diff --git a/apps/web/playwright/e2e/left-panel/room-list-panel/room-list-sections.spec.ts b/apps/web/playwright/e2e/left-panel/room-list-panel/room-list-sections.spec.ts new file mode 100644 index 00000000000..fdc35137923 --- /dev/null +++ b/apps/web/playwright/e2e/left-panel/room-list-panel/room-list-sections.spec.ts @@ -0,0 +1,260 @@ +/* + * 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 { type Locator, type Page } from "@playwright/test"; + +import { expect, test } from "../../../element-web-test"; + +test.describe("Room list sections", () => { + test.use({ + displayName: "Alice", + labsFlags: ["feature_new_room_list", "feature_room_list_sections"], + botCreateOpts: { + displayName: "BotBob", + autoAcceptInvites: true, + }, + }); + + /** + * Get the room list + * @param page + */ + function getRoomList(page: Page): Locator { + return page.getByTestId("room-list"); + } + + /** + * Get the primary filters + * @param page + */ + function getPrimaryFilters(page: Page): Locator { + return page.getByTestId("primary-filters"); + } + + /** + * Get a section header toggle button by section name + * @param page + * @param sectionName The display name of the section (e.g. "Favourites", "Chats", "Low Priority") + */ + function getSectionHeader(page: Page, sectionName: string): Locator { + return getRoomList(page).getByRole("gridcell", { name: `Toggle ${sectionName} section` }); + } + + test.beforeEach(async ({ page, app, user }) => { + // The notification toast is displayed above the search section + await app.closeNotificationToast(); + + // focus the user menu to avoid to have hover decoration + await page.getByRole("button", { name: "User menu" }).focus(); + }); + + test.describe("Section rendering", () => { + test.beforeEach(async ({ app, user }) => { + // Create regular rooms + for (let i = 0; i < 3; i++) { + await app.client.createRoom({ name: `room${i}` }); + } + }); + + test("should render sections with correct rooms in each", { tag: "@screenshot" }, async ({ page, app }) => { + // Create a favourite room + const favouriteId = await app.client.createRoom({ name: "favourite room" }); + await app.client.evaluate(async (client, roomId) => { + await client.setRoomTag(roomId, "m.favourite"); + }, favouriteId); + + // Create a low priority room + const lowPrioId = await app.client.createRoom({ name: "low prio room" }); + await app.client.evaluate(async (client, roomId) => { + await client.setRoomTag(roomId, "m.lowpriority"); + }, lowPrioId); + + const roomList = getRoomList(page); + + // All three section headers should be visible + await expect(getSectionHeader(page, "Favourites")).toBeVisible(); + await expect(getSectionHeader(page, "Chats")).toBeVisible(); + await expect(getSectionHeader(page, "Low Priority")).toBeVisible(); + + // Ensure all rooms are visible + await expect(roomList.getByRole("row", { name: "Open room favourite room" })).toBeVisible(); + await expect(roomList.getByRole("row", { name: "Open room low prio room" })).toBeVisible(); + await expect(roomList.getByRole("row", { name: "Open room room0" })).toBeVisible(); + + await expect(roomList).toMatchScreenshot("room-list-sections.png"); + }); + + test("should only show non-empty sections", async ({ page, app }) => { + // No low priority rooms created, only regular and favourite rooms + const favouriteId = await app.client.createRoom({ name: "favourite room" }); + await app.client.evaluate(async (client, roomId) => { + await client.setRoomTag(roomId, "m.favourite"); + }, favouriteId); + + // Chats and Favourites sections should still be visible + await expect(getSectionHeader(page, "Chats")).toBeVisible(); + await expect(getSectionHeader(page, "Favourites")).toBeVisible(); + // Low Priority sections should not be visible + await expect(getSectionHeader(page, "Low Priority")).not.toBeVisible(); + }); + + test("should render a flat list when there is only rooms in Chats section", async ({ page, app }) => { + // All sections should not be visible + await expect(getSectionHeader(page, "Chats")).not.toBeVisible(); + await expect(getSectionHeader(page, "Favourites")).not.toBeVisible(); + await expect(getSectionHeader(page, "Low Priority")).not.toBeVisible(); + // It should be a flat list (using listbox a11y role) + await expect(page.getByRole("listbox", { name: "Room list", exact: true })).toBeVisible(); + await expect(getRoomList(page).getByRole("option", { name: "Open room room0" })).toBeVisible(); + }); + }); + + test.describe("Section collapse and expand", () => { + [ + { section: "Favourites", roomName: "favourite room", tag: "m.favourite" }, + { section: "Low Priority", roomName: "low prio room", tag: "m.lowpriority" }, + ].forEach(({ section, roomName, tag }) => { + test(`should collapse and expand the ${section} section`, async ({ page, app }) => { + const roomId = await app.client.createRoom({ name: roomName }); + if (tag) { + await app.client.evaluate( + async (client, { roomId, tag }) => { + await client.setRoomTag(roomId, tag); + }, + { roomId, tag }, + ); + } + + const roomList = getRoomList(page); + const sectionHeader = getSectionHeader(page, section); + + // The room should be visible + await expect(roomList.getByRole("row", { name: `Open room ${roomName}` })).toBeVisible(); + + // Collapse the section + await sectionHeader.click(); + + // The room should no longer be visible + await expect(roomList.getByRole("row", { name: `Open room ${roomName}` })).not.toBeVisible(); + + // The section header should still be visible + await expect(sectionHeader).toBeVisible(); + + // Expand the section again + await sectionHeader.click(); + + // The room should be visible again + await expect(roomList.getByRole("row", { name: `Open room ${roomName}` })).toBeVisible(); + }); + }); + + test("should render collapsed section", { tag: "@screenshot" }, async ({ page, app }) => { + const favouriteId = await app.client.createRoom({ name: "favourite room" }); + await app.client.evaluate(async (client, roomId) => { + await client.setRoomTag(roomId, "m.favourite"); + }, favouriteId); + + await app.client.createRoom({ name: "regular room" }); + + const roomList = getRoomList(page); + + // Collapse the Favourites section + await getSectionHeader(page, "Favourites").click(); + + // Verify favourite room is hidden but regular room is still visible + await expect(roomList.getByRole("row", { name: "Open room favourite room" })).not.toBeVisible(); + await expect(roomList.getByRole("row", { name: "Open room regular room" })).toBeVisible(); + + await expect(roomList).toMatchScreenshot("room-list-sections-collapsed.png"); + }); + }); + + test.describe("Rooms placement in sections", () => { + test("should move a room between sections when tags change", async ({ page, app }) => { + await app.client.createRoom({ name: "my room" }); + + const roomList = getRoomList(page); + + // Flat list because there is only rooms in the Chats section + let roomItem = roomList.getByRole("option", { name: "Open room my room" }); + await expect(roomItem).toBeVisible(); + + // Favourite the room via context menu + await roomItem.click({ button: "right" }); + await page.getByRole("menuitemcheckbox", { name: "Favourited" }).click(); + + // The Favourites section header should now be visible and the room should be under it + await expect(getSectionHeader(page, "Favourites")).toBeVisible(); + roomItem = roomList.getByRole("row", { name: "Open room my room" }); + await expect(roomItem).toBeVisible(); + + // Unfavourite the room + await roomItem.hover(); + await roomItem.getByRole("button", { name: "More Options" }).click(); + await page.getByRole("menuitemcheckbox", { name: "Favourited" }).click(); + + // Mark the room as low priority via context menu + roomItem = roomList.getByRole("option", { name: "Open room my room" }); + await roomItem.click({ button: "right" }); + await page.getByRole("menuitemcheckbox", { name: "Low priority" }).click(); + + // The Low Priority section header should now be visible and the room should be under it + await expect(getSectionHeader(page, "Low Priority")).toBeVisible(); + roomItem = roomList.getByRole("row", { name: "Open room my room" }); + await expect(roomItem).toBeVisible(); + }); + }); + + test.describe("Sections and filters interaction", () => { + test("should not show Favourite and Low Priority filters when sections are enabled", async ({ page, app }) => { + const primaryFilters = getPrimaryFilters(page); + + // Expand the filter list to see all filters + const expandButton = primaryFilters.getByRole("button", { name: "Expand filter list" }); + await expandButton.click(); + + // Favourite and Low Priority filters should NOT be visible since sections handle them + await expect(primaryFilters.getByRole("option", { name: "Favourite" })).not.toBeVisible(); + + // Other filters should still be present + await expect(primaryFilters.getByRole("option", { name: "People" })).toBeVisible(); + await expect(primaryFilters.getByRole("option", { name: "Rooms" })).toBeVisible(); + await expect(primaryFilters.getByRole("option", { name: "Unread" })).toBeVisible(); + }); + + test("should maintain sections when a filter is applied", async ({ page, app, bot }) => { + // Create a favourite room with unread messages + const favouriteId = await app.client.createRoom({ name: "fav with unread" }); + await app.client.evaluate(async (client, roomId) => { + await client.setRoomTag(roomId, "m.favourite"); + }, favouriteId); + await app.client.inviteUser(favouriteId, bot.credentials.userId); + await bot.joinRoom(favouriteId); + await bot.sendMessage(favouriteId, "Hello from favourite!"); + + // Create a regular room with unread messages + const regularId = await app.client.createRoom({ name: "regular with unread" }); + await app.client.inviteUser(regularId, bot.credentials.userId); + await bot.joinRoom(regularId); + await bot.sendMessage(regularId, "Hello from regular!"); + + // Create a room without unread + await app.client.createRoom({ name: "no unread room" }); + + const roomList = getRoomList(page); + const primaryFilters = getPrimaryFilters(page); + + // Apply the Unread filter + await primaryFilters.getByRole("option", { name: "Unread" }).click(); + + // Only rooms with unreads should be visible + await expect(roomList.getByRole("row", { name: "fav with unread" })).toBeVisible(); + await expect(roomList.getByRole("row", { name: "regular with unread" })).toBeVisible(); + await expect(roomList.getByRole("row", { name: "no unread room" })).not.toBeVisible(); + }); + }); +}); diff --git a/apps/web/playwright/snapshots/left-panel/room-list-panel/room-list-sections.spec.ts/room-list-sections-collapsed-linux.png b/apps/web/playwright/snapshots/left-panel/room-list-panel/room-list-sections.spec.ts/room-list-sections-collapsed-linux.png new file mode 100644 index 00000000000..6ebcef85327 Binary files /dev/null and b/apps/web/playwright/snapshots/left-panel/room-list-panel/room-list-sections.spec.ts/room-list-sections-collapsed-linux.png differ diff --git a/apps/web/playwright/snapshots/left-panel/room-list-panel/room-list-sections.spec.ts/room-list-sections-linux.png b/apps/web/playwright/snapshots/left-panel/room-list-panel/room-list-sections.spec.ts/room-list-sections-linux.png new file mode 100644 index 00000000000..bd61342d605 Binary files /dev/null and b/apps/web/playwright/snapshots/left-panel/room-list-panel/room-list-sections.spec.ts/room-list-sections-linux.png differ diff --git a/apps/web/src/i18n/strings/en_EN.json b/apps/web/src/i18n/strings/en_EN.json index d56399dda48..283e58fcb6a 100644 --- a/apps/web/src/i18n/strings/en_EN.json +++ b/apps/web/src/i18n/strings/en_EN.json @@ -1567,6 +1567,7 @@ "render_reaction_images_description": "Sometimes referred to as \"custom emojis\".", "report_to_moderators": "Report to moderators", "report_to_moderators_description": "In rooms that support moderation, the “Report” button will let you report abuse to room moderators.", + "room_list_sections": "Room list sections", "share_history_on_invite": "Share encrypted history with new members", "share_history_on_invite_description": "When inviting a user to an encrypted room that has history visibility set to \"shared\", share encrypted history with that user, and accept encrypted history when you are invited to such a room.", "share_history_on_invite_warning": "This feature is EXPERIMENTAL and not all security precautions are implemented. Do not enable on production accounts.", @@ -2164,6 +2165,11 @@ "one": "Currently removing messages in %(count)s room", "other": "Currently removing messages in %(count)s rooms" }, + "section": { + "chats": "Chats", + "favourites": "Favourites", + "low_priority": "Low Priority" + }, "show_less": "Show less", "show_n_more": { "one": "Show %(count)s more", diff --git a/apps/web/src/settings/Settings.tsx b/apps/web/src/settings/Settings.tsx index 02a692a2091..a3c1565bc80 100644 --- a/apps/web/src/settings/Settings.tsx +++ b/apps/web/src/settings/Settings.tsx @@ -223,6 +223,7 @@ export interface Settings { "feature_dynamic_room_predecessors": IFeature; "feature_render_reaction_images": IFeature; "feature_new_room_list": IFeature; + "feature_room_list_sections": IFeature; "feature_ask_to_join": IFeature; "feature_notifications": IFeature; "feature_msc4362_encrypted_state_events": IFeature; @@ -695,6 +696,15 @@ export const SETTINGS: Settings = { default: true, controller: new ReloadOnChangeController(), }, + "feature_room_list_sections": { + supportedLevels: LEVELS_DEVICE_ONLY_SETTINGS_WITH_CONFIG_PRIORITISED, + labsGroup: LabGroup.Ui, + displayName: _td("labs|room_list_sections"), + description: _td("labs|under_active_development"), + isFeature: true, + default: false, + controller: new ReloadOnChangeController(), + }, /** * With the transition to Compound we are moving to a base font size * of 16px. We're taking the opportunity to move away from the `baseFontSize` diff --git a/apps/web/src/stores/room-list-v3/RoomListStoreV3.ts b/apps/web/src/stores/room-list-v3/RoomListStoreV3.ts index d8b9dd5f8f1..71c5a1a9cb7 100644 --- a/apps/web/src/stores/room-list-v3/RoomListStoreV3.ts +++ b/apps/web/src/stores/room-list-v3/RoomListStoreV3.ts @@ -11,11 +11,10 @@ import { EventType } from "matrix-js-sdk/src/matrix"; import type { EmptyObject, Room } from "matrix-js-sdk/src/matrix"; import type { MatrixDispatcher } from "../../dispatcher/dispatcher"; import type { ActionPayload } from "../../dispatcher/payloads"; -import type { FilterKey } from "./skip-list/filters"; +import type { Filter, FilterKey } from "./skip-list/filters"; import { AsyncStoreWithClient } from "../AsyncStoreWithClient"; import SettingsStore from "../../settings/SettingsStore"; import defaultDispatcher from "../../dispatcher/dispatcher"; -import { RoomSkipList } from "./skip-list/RoomSkipList"; import { RecencySorter } from "./skip-list/sorters/RecencySorter"; import { AlphabeticSorter } from "./skip-list/sorters/AlphabeticSorter"; import { readReceiptChangeIsFor } from "../../utils/read-receipts"; @@ -36,6 +35,11 @@ import { Action } from "../../dispatcher/actions"; import { UnreadSorter } from "./skip-list/sorters/UnreadSorter"; import { getChangedOverrideRoomMutePushRules } from "./utils"; import { isRoomVisible } from "./isRoomVisible"; +import { RoomSkipList } from "./skip-list/RoomSkipList"; +import { DefaultTagID } from "./skip-list/tag"; +import { ExcludeTagsFilter } from "./skip-list/filters/ExcludeTagsFilter"; +import { TagFilter } from "./skip-list/filters/TagFilter"; +import { filterBoolean } from "../../utils/arrays"; /** * These are the filters passed to the room skip list. @@ -64,9 +68,25 @@ export type RoomsResult = { // The filter queried filterKeys?: FilterKey[]; // The resulting list of rooms - rooms: Room[]; + sections: Section[]; }; +/** + * Represents a named section of rooms in the room list, identified by a tag. + */ +export interface Section { + /** The tag that identifies this section. */ + tag: string; + /** The ordered list of rooms belonging to this section. */ + rooms: Room[]; +} + +/** + * A synthetic tag used to represent the "Chats" section, which contains + * every room that does not belong to any other explicit tag section. + */ +export const CHATS_TAG = "chats"; + export const LISTS_UPDATE_EVENT = RoomListStoreV3Event.ListsUpdate; export const LISTS_LOADED_EVENT = RoomListStoreV3Event.ListsLoaded; /** @@ -75,7 +95,21 @@ export const LISTS_LOADED_EVENT = RoomListStoreV3Event.ListsLoaded; * This store is being actively developed so expect the methods to change in future. */ export class RoomListStoreV3Class extends AsyncStoreWithClient { + /** + * Contains all the rooms in the active space + */ private roomSkipList?: RoomSkipList; + + /** + * Maps section tags to their corresponding tag filters, used to determine which rooms belong in which sections. + */ + private readonly filterByTag: Map = new Map(); + + /** + * Defines the display order of sections. + */ + private readonly sortedTags: string[] = [DefaultTagID.Favourite, CHATS_TAG, DefaultTagID.LowPriority]; + private readonly msc3946ProcessDynamicPredecessor: boolean; /** @@ -126,13 +160,17 @@ export class RoomListStoreV3Class extends AsyncStoreWithClient { */ public getSortedRoomsInActiveSpace(filterKeys?: FilterKey[]): RoomsResult { const spaceId = SpaceStore.instance.activeSpace; - if (this.roomSkipList?.initialized) - return { - spaceId: spaceId, - filterKeys, - rooms: Array.from(this.roomSkipList.getRoomsInActiveSpace(filterKeys)), - }; - else return { spaceId: spaceId, filterKeys, rooms: [] }; + + const areSectionsEnabled = SettingsStore.getValue("feature_room_list_sections"); + const sections = areSectionsEnabled + ? this.getSections(filterKeys) + : [{ tag: CHATS_TAG, rooms: Array.from(this.roomSkipList?.getRoomsInActiveSpace(filterKeys) ?? []) }]; + + return { + spaceId: spaceId, + filterKeys, + sections, + }; } /** @@ -159,7 +197,9 @@ export class RoomListStoreV3Class extends AsyncStoreWithClient { protected async onReady(): Promise { if (this.roomSkipList?.initialized || !this.matrixClient) return; const sorter = this.getPreferredSorter(this.matrixClient.getSafeUserId()); - this.roomSkipList = new RoomSkipList(sorter, FILTERS); + + this.roomSkipList = new RoomSkipList(sorter, this.getSkipListFilters()); + await SpaceStore.instance.storeReadyPromise; const rooms = this.getRooms(); this.roomSkipList.seed(rooms); @@ -276,7 +316,6 @@ export class RoomListStoreV3Class extends AsyncStoreWithClient { const room = payload.room; this.roomSkipList.removeRoom(room); this.scheduleEmit(); - break; } } @@ -300,7 +339,7 @@ export class RoomListStoreV3Class extends AsyncStoreWithClient { logger.warn(`${roomId} was found in DMs but the room is not in the store`); continue; } - this.roomSkipList!.reInsertRoom(room); + this.roomSkipList?.reInsertRoom(room); needsEmit = true; } } @@ -314,7 +353,7 @@ export class RoomListStoreV3Class extends AsyncStoreWithClient { .map((id) => this.matrixClient?.getRoom(id)) .filter((room) => !!room); for (const room of rooms) { - this.roomSkipList!.reInsertRoom(room); + this.roomSkipList?.reInsertRoom(room); needsEmit = true; } break; @@ -395,6 +434,35 @@ export class RoomListStoreV3Class extends AsyncStoreWithClient { this.roomSkipList.calculateActiveSpaceForNodes(); this.scheduleEmit(); } + + /** + * Get the list of filters to be used in the skip list, including the tag filters for sectioning. + */ + private getSkipListFilters(): Filter[] { + const tagsToExclude = this.sortedTags.filter((tag) => tag !== CHATS_TAG); + const tagFilters = this.sortedTags.map((tag) => + tag === CHATS_TAG ? new ExcludeTagsFilter(tagsToExclude) : new TagFilter(tag), + ); + this.sortedTags.forEach((tag, index) => this.filterByTag.set(tag, tagFilters[index])); + + return [...FILTERS, ...tagFilters]; + } + + /** + * Get the sections to display in the room list, based on the current active space and the provided filters. + * @param filterKeys - Optional array of filters that the rooms must match against to be included in the sections. + * @returns An array of sections + */ + private getSections(filterKeys?: FilterKey[]): Section[] { + return this.sortedTags.map((tag) => { + const filters = filterBoolean([this.filterByTag.get(tag)?.key, ...(filterKeys || [])]); + + return { + tag, + rooms: Array.from(this.roomSkipList?.getRoomsInActiveSpace(filters) || []), + }; + }); + } } export default class RoomListStoreV3 { diff --git a/apps/web/src/stores/room-list-v3/skip-list/filters/ExcludeTagsFilter.ts b/apps/web/src/stores/room-list-v3/skip-list/filters/ExcludeTagsFilter.ts new file mode 100644 index 00000000000..b62cabd93be --- /dev/null +++ b/apps/web/src/stores/room-list-v3/skip-list/filters/ExcludeTagsFilter.ts @@ -0,0 +1,22 @@ +/* + * Copyright 2026 Element Creations 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 { type Room } from "matrix-js-sdk/src/matrix"; + +import { type Filter, FilterEnum } from "."; + +export class ExcludeTagsFilter implements Filter { + public constructor(private readonly tags: string[]) {} + + public matches(room: Room): boolean { + return !this.tags.some((tag) => room.tags[tag]); + } + + public get key(): FilterEnum.ExcludeTagsFilter { + return FilterEnum.ExcludeTagsFilter; + } +} diff --git a/apps/web/src/stores/room-list-v3/skip-list/filters/FavouriteFilter.ts b/apps/web/src/stores/room-list-v3/skip-list/filters/FavouriteFilter.ts index 4dafcb21d8b..e3490543190 100644 --- a/apps/web/src/stores/room-list-v3/skip-list/filters/FavouriteFilter.ts +++ b/apps/web/src/stores/room-list-v3/skip-list/filters/FavouriteFilter.ts @@ -5,8 +5,7 @@ Please see LICENSE files in the repository root for full details. */ import type { Room } from "matrix-js-sdk/src/matrix"; -import type { Filter } from "."; -import { FilterKey } from "."; +import { FilterEnum, type Filter } from "."; import { DefaultTagID } from "../tag"; export class FavouriteFilter implements Filter { @@ -14,7 +13,7 @@ export class FavouriteFilter implements Filter { return !!room.tags[DefaultTagID.Favourite]; } - public get key(): FilterKey.FavouriteFilter { - return FilterKey.FavouriteFilter; + public get key(): FilterEnum.FavouriteFilter { + return FilterEnum.FavouriteFilter; } } diff --git a/apps/web/src/stores/room-list-v3/skip-list/filters/InvitesFilter.ts b/apps/web/src/stores/room-list-v3/skip-list/filters/InvitesFilter.ts index fb9fff9b441..fd767b40c8f 100644 --- a/apps/web/src/stores/room-list-v3/skip-list/filters/InvitesFilter.ts +++ b/apps/web/src/stores/room-list-v3/skip-list/filters/InvitesFilter.ts @@ -6,15 +6,14 @@ Please see LICENSE files in the repository root for full details. import { type Room, KnownMembership } from "matrix-js-sdk/src/matrix"; -import type { Filter } from "."; -import { FilterKey } from "."; +import { type Filter, FilterEnum } from "."; export class InvitesFilter implements Filter { public matches(room: Room): boolean { return room.getMyMembership() === KnownMembership.Invite; } - public get key(): FilterKey.InvitesFilter { - return FilterKey.InvitesFilter; + public get key(): FilterEnum.InvitesFilter { + return FilterEnum.InvitesFilter; } } diff --git a/apps/web/src/stores/room-list-v3/skip-list/filters/LowPriorityFilter.ts b/apps/web/src/stores/room-list-v3/skip-list/filters/LowPriorityFilter.ts index 861d18fa50a..cee870087dd 100644 --- a/apps/web/src/stores/room-list-v3/skip-list/filters/LowPriorityFilter.ts +++ b/apps/web/src/stores/room-list-v3/skip-list/filters/LowPriorityFilter.ts @@ -5,8 +5,7 @@ Please see LICENSE files in the repository root for full details. */ import type { Room } from "matrix-js-sdk/src/matrix"; -import type { Filter } from "."; -import { FilterKey } from "."; +import { type Filter, FilterEnum } from "."; import { DefaultTagID } from "../tag"; export class LowPriorityFilter implements Filter { @@ -14,7 +13,7 @@ export class LowPriorityFilter implements Filter { return !!room.tags[DefaultTagID.LowPriority]; } - public get key(): FilterKey.LowPriorityFilter { - return FilterKey.LowPriorityFilter; + public get key(): FilterEnum.LowPriorityFilter { + return FilterEnum.LowPriorityFilter; } } diff --git a/apps/web/src/stores/room-list-v3/skip-list/filters/MentionsFilter.ts b/apps/web/src/stores/room-list-v3/skip-list/filters/MentionsFilter.ts index fb6e45b9228..3f14978d5eb 100644 --- a/apps/web/src/stores/room-list-v3/skip-list/filters/MentionsFilter.ts +++ b/apps/web/src/stores/room-list-v3/skip-list/filters/MentionsFilter.ts @@ -5,8 +5,7 @@ Please see LICENSE files in the repository root for full details. */ import type { Room } from "matrix-js-sdk/src/matrix"; -import type { Filter } from "."; -import { FilterKey } from "."; +import { type Filter, FilterEnum } from "."; import { RoomNotificationStateStore } from "../../../notifications/RoomNotificationStateStore"; export class MentionsFilter implements Filter { @@ -14,7 +13,7 @@ export class MentionsFilter implements Filter { return RoomNotificationStateStore.instance.getRoomState(room).isMention; } - public get key(): FilterKey.MentionsFilter { - return FilterKey.MentionsFilter; + public get key(): FilterEnum.MentionsFilter { + return FilterEnum.MentionsFilter; } } diff --git a/apps/web/src/stores/room-list-v3/skip-list/filters/PeopleFilter.ts b/apps/web/src/stores/room-list-v3/skip-list/filters/PeopleFilter.ts index 742eb40abe1..a0dd922bdb8 100644 --- a/apps/web/src/stores/room-list-v3/skip-list/filters/PeopleFilter.ts +++ b/apps/web/src/stores/room-list-v3/skip-list/filters/PeopleFilter.ts @@ -5,8 +5,7 @@ Please see LICENSE files in the repository root for full details. */ import type { Room } from "matrix-js-sdk/src/matrix"; -import type { Filter } from "."; -import { FilterKey } from "."; +import { type Filter, FilterEnum } from "."; import DMRoomMap from "../../../../utils/DMRoomMap"; export class PeopleFilter implements Filter { @@ -15,7 +14,7 @@ export class PeopleFilter implements Filter { return !!DMRoomMap.shared().getUserIdForRoomId(room.roomId); } - public get key(): FilterKey.PeopleFilter { - return FilterKey.PeopleFilter; + public get key(): FilterEnum.PeopleFilter { + return FilterEnum.PeopleFilter; } } diff --git a/apps/web/src/stores/room-list-v3/skip-list/filters/RoomsFilter.ts b/apps/web/src/stores/room-list-v3/skip-list/filters/RoomsFilter.ts index 58349dcea21..dc49cde05b5 100644 --- a/apps/web/src/stores/room-list-v3/skip-list/filters/RoomsFilter.ts +++ b/apps/web/src/stores/room-list-v3/skip-list/filters/RoomsFilter.ts @@ -5,8 +5,7 @@ Please see LICENSE files in the repository root for full details. */ import type { Room } from "matrix-js-sdk/src/matrix"; -import type { Filter } from "."; -import { FilterKey } from "."; +import { type Filter, FilterEnum } from "."; import DMRoomMap from "../../../../utils/DMRoomMap"; export class RoomsFilter implements Filter { @@ -15,7 +14,7 @@ export class RoomsFilter implements Filter { return !DMRoomMap.shared().getUserIdForRoomId(room.roomId); } - public get key(): FilterKey.RoomsFilter { - return FilterKey.RoomsFilter; + public get key(): FilterEnum.RoomsFilter { + return FilterEnum.RoomsFilter; } } diff --git a/apps/web/src/stores/room-list-v3/skip-list/filters/TagFilter.ts b/apps/web/src/stores/room-list-v3/skip-list/filters/TagFilter.ts new file mode 100644 index 00000000000..9947f8be51e --- /dev/null +++ b/apps/web/src/stores/room-list-v3/skip-list/filters/TagFilter.ts @@ -0,0 +1,22 @@ +/* + * Copyright 2026 Element Creations 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 { type Room } from "matrix-js-sdk/src/matrix"; + +import { type Filter } from "."; + +export class TagFilter implements Filter { + public constructor(private readonly tag: string) {} + + public matches(room: Room): boolean { + return !!room.tags[this.tag]; + } + + public get key(): string { + return this.tag; + } +} diff --git a/apps/web/src/stores/room-list-v3/skip-list/filters/UnreadFilter.ts b/apps/web/src/stores/room-list-v3/skip-list/filters/UnreadFilter.ts index db29861cd1e..0c6100eb131 100644 --- a/apps/web/src/stores/room-list-v3/skip-list/filters/UnreadFilter.ts +++ b/apps/web/src/stores/room-list-v3/skip-list/filters/UnreadFilter.ts @@ -5,8 +5,7 @@ Please see LICENSE files in the repository root for full details. */ import type { Room } from "matrix-js-sdk/src/matrix"; -import type { Filter } from "."; -import { FilterKey } from "."; +import { type Filter, FilterEnum } from "."; import { RoomNotificationStateStore } from "../../../notifications/RoomNotificationStateStore"; import { getMarkedUnreadState } from "../../../../utils/notifications"; @@ -15,7 +14,7 @@ export class UnreadFilter implements Filter { return RoomNotificationStateStore.instance.getRoomState(room).hasUnreadCount || !!getMarkedUnreadState(room); } - public get key(): FilterKey.UnreadFilter { - return FilterKey.UnreadFilter; + public get key(): FilterEnum.UnreadFilter { + return FilterEnum.UnreadFilter; } } diff --git a/apps/web/src/stores/room-list-v3/skip-list/filters/index.ts b/apps/web/src/stores/room-list-v3/skip-list/filters/index.ts index e4c65167b3c..b66c2dd0ad8 100644 --- a/apps/web/src/stores/room-list-v3/skip-list/filters/index.ts +++ b/apps/web/src/stores/room-list-v3/skip-list/filters/index.ts @@ -6,16 +6,19 @@ Please see LICENSE files in the repository root for full details. import type { Room } from "matrix-js-sdk/src/matrix"; -export const enum FilterKey { - FavouriteFilter, - UnreadFilter, - PeopleFilter, - RoomsFilter, - LowPriorityFilter, - MentionsFilter, - InvitesFilter, +export const enum FilterEnum { + FavouriteFilter = "favourite", + UnreadFilter = "unread", + PeopleFilter = "people", + RoomsFilter = "rooms", + LowPriorityFilter = "low_priority", + MentionsFilter = "mentions", + InvitesFilter = "invites", + ExcludeTagsFilter = "exclude_tags", } +export type FilterKey = FilterEnum | string; + export interface Filter { /** * Boolean return value indicates whether this room satisfies diff --git a/apps/web/src/viewmodels/room-list/RoomListSectionHeaderViewModel.ts b/apps/web/src/viewmodels/room-list/RoomListSectionHeaderViewModel.ts new file mode 100644 index 00000000000..5b8006e2529 --- /dev/null +++ b/apps/web/src/viewmodels/room-list/RoomListSectionHeaderViewModel.ts @@ -0,0 +1,40 @@ +/* + * Copyright 2026 Element Creations 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 { + BaseViewModel, + type RoomListSectionHeaderActions, + type RoomListSectionHeaderViewSnapshot, +} from "@element-hq/web-shared-components"; + +interface RoomListSectionHeaderViewModelProps { + tag: string; + title: string; + onToggleExpanded: (isExpanded: boolean) => void; +} + +export class RoomListSectionHeaderViewModel + extends BaseViewModel + implements RoomListSectionHeaderActions +{ + public constructor(props: RoomListSectionHeaderViewModelProps) { + super(props, { id: props.tag, title: props.title, isExpanded: true }); + } + + public onClick = (): void => { + const isExpanded = !this.snapshot.current.isExpanded; + this.snapshot.merge({ isExpanded }); + this.props.onToggleExpanded(isExpanded); + }; + + /** + * Whether the section is currently expanded or not. + */ + public get isExpanded(): boolean { + return this.snapshot.current.isExpanded; + } +} diff --git a/apps/web/src/viewmodels/room-list/RoomListViewModel.ts b/apps/web/src/viewmodels/room-list/RoomListViewModel.ts index 3ed45fc663b..ffe30748d81 100644 --- a/apps/web/src/viewmodels/room-list/RoomListViewModel.ts +++ b/apps/web/src/viewmodels/room-list/RoomListViewModel.ts @@ -11,6 +11,8 @@ import { type FilterId, type RoomListViewActions, type RoomListViewState, + type RoomListSection, + _t, } from "@element-hq/web-shared-components"; import { type MatrixClient, type Room } from "matrix-js-sdk/src/matrix"; @@ -19,43 +21,75 @@ import dispatcher from "../../dispatcher/dispatcher"; import { type ViewRoomDeltaPayload } from "../../dispatcher/payloads/ViewRoomDeltaPayload"; import { type ViewRoomPayload } from "../../dispatcher/payloads/ViewRoomPayload"; import SpaceStore from "../../stores/spaces/SpaceStore"; -import RoomListStoreV3, { RoomListStoreV3Event, type RoomsResult } from "../../stores/room-list-v3/RoomListStoreV3"; -import { FilterKey } from "../../stores/room-list-v3/skip-list/filters"; +import RoomListStoreV3, { + CHATS_TAG, + RoomListStoreV3Event, + type RoomsResult, + type Section, +} from "../../stores/room-list-v3/RoomListStoreV3"; +import { FilterEnum } from "../../stores/room-list-v3/skip-list/filters"; import { RoomNotificationStateStore } from "../../stores/notifications/RoomNotificationStateStore"; import { RoomListItemViewModel } from "./RoomListItemViewModel"; import { SdkContextClass } from "../../contexts/SDKContext"; import { hasCreateRoomRights } from "./utils"; import { keepIfSame } from "../../utils/keepIfSame"; +import { DefaultTagID } from "../../stores/room-list-v3/skip-list/tag"; +import { RoomListSectionHeaderViewModel } from "./RoomListSectionHeaderViewModel"; +import SettingsStore from "../../settings/SettingsStore"; + +/** + * Tracks the position of the active room within a specific section. + * Used to implement sticky room behaviour so the selected room doesn't + * jump around when the room list is re-sorted. + */ +interface StickyRoomPosition { + /** The tag of the section the room belongs to. */ + sectionTag: string; + /** The index of the room within that section. */ + indexInSection: number; +} interface RoomListViewModelProps { client: MatrixClient; } -const filterKeyToIdMap: Map = new Map([ - [FilterKey.UnreadFilter, "unread"], - [FilterKey.PeopleFilter, "people"], - [FilterKey.RoomsFilter, "rooms"], - [FilterKey.FavouriteFilter, "favourite"], - [FilterKey.MentionsFilter, "mentions"], - [FilterKey.InvitesFilter, "invites"], - [FilterKey.LowPriorityFilter, "low_priority"], +const filterKeyToIdMap: Map = new Map([ + [FilterEnum.UnreadFilter, "unread"], + [FilterEnum.PeopleFilter, "people"], + [FilterEnum.RoomsFilter, "rooms"], + [FilterEnum.FavouriteFilter, "favourite"], + [FilterEnum.MentionsFilter, "mentions"], + [FilterEnum.InvitesFilter, "invites"], + [FilterEnum.LowPriorityFilter, "low_priority"], ]); +const TAG_TO_TITLE_MAP: Record = { + [DefaultTagID.Favourite]: _t("room_list|section|favourites"), + [CHATS_TAG]: _t("room_list|section|chats"), + [DefaultTagID.LowPriority]: _t("room_list|section|low_priority"), +}; + export class RoomListViewModel extends BaseViewModel implements RoomListViewActions { // State tracking - private activeFilter: FilterKey | undefined = undefined; + private activeFilter: FilterEnum | undefined = undefined; private roomsResult: RoomsResult; - private lastActiveRoomIndex: number | undefined = undefined; + /** + * List of sections to display in the room list, derived from roomsResult and section header view model expansion state. + */ + private sections: Section[] = []; + private lastActiveRoomPosition: StickyRoomPosition | undefined = undefined; // Child view model management - private roomItemViewModels = new Map(); + private readonly roomItemViewModels = new Map(); // This map is intentionally additive (never cleared except on space changes) to avoid a race condition: // a list update can refresh roomsResult and roomsMap before the view re-renders, so the view may still // request a view model for a room that was removed from the latest list. Keeping old entries prevents a crash. private roomsMap = new Map(); + // Don't clear section vm because we want to keep the expand/collapse state even during space changes. + private readonly roomSectionHeaderViewModels = new Map(); public constructor(props: RoomListViewModelProps) { const activeSpace = SpaceStore.instance.activeSpaceRoom; @@ -63,14 +97,21 @@ export class RoomListViewModel // Get initial rooms const roomsResult = RoomListStoreV3.instance.getSortedRoomsInActiveSpace(undefined); const canCreateRoom = hasCreateRoomRights(props.client, activeSpace); - const filterIds = [...filterKeyToIdMap.values()]; - const roomIds = roomsResult.rooms.map((room) => room.roomId); - const sections = [{ id: "all", roomIds }]; + + // Remove favourite and low priority filters if sections are enabled, as they are redundant with the sections + const areSectionsEnabled = SettingsStore.getValue("feature_room_list_sections"); + const filterIds = [...filterKeyToIdMap.values()].filter( + (id) => !areSectionsEnabled || (id !== "favourite" && id !== "low_priority"), + ); + + // By default, all sections are expanded + const { sections, isFlatList } = computeSections(roomsResult, (tag) => true); + const isRoomListEmpty = roomsResult.sections.every((section) => section.rooms.length === 0); super(props, { // Initial view state - start with empty, will populate in async init isLoadingRooms: RoomListStoreV3.instance.isLoadingRooms, - isRoomListEmpty: roomsResult.rooms.length === 0, + isRoomListEmpty, filterIds, activeFilterId: undefined, roomListState: { @@ -78,13 +119,13 @@ export class RoomListViewModel spaceId: roomsResult.spaceId, filterKeys: undefined, }, - // Until we implement sections, this view model only supports the flat list mode - isFlatList: true, - sections, + isFlatList, + sections: toRoomListSection(sections), canCreateRoom, }); this.roomsResult = roomsResult; + this.sections = sections; // Build initial roomsMap from roomsResult this.updateRoomsMap(roomsResult); @@ -120,7 +161,7 @@ export class RoomListViewModel public onToggleFilter = (filterId: FilterId): void => { // Find the FilterKey by matching the filter ID - let filterKey: FilterKey | undefined = undefined; + let filterKey: FilterEnum | undefined = undefined; for (const [key, id] of filterKeyToIdMap.entries()) { if (id === filterId) { filterKey = key; @@ -150,7 +191,7 @@ export class RoomListViewModel * This maintains a quick lookup for room objects. */ private updateRoomsMap(roomsResult: RoomsResult): void { - for (const room of roomsResult.rooms) { + for (const room of roomsResult.sections.flatMap((section) => section.rooms)) { this.roomsMap.set(room.roomId, room); } } @@ -170,7 +211,7 @@ export class RoomListViewModel * Get the ordered list of room IDs. */ public get roomIds(): string[] { - return this.roomsResult.rooms.map((room) => room.roomId); + return this.roomsResult.sections.flatMap((section) => section.rooms).map((room) => room.roomId); } /** @@ -179,7 +220,7 @@ export class RoomListViewModel * The view should call this only for visible rooms from the roomIds list. * @throws Error if room is not found in roomsMap (indicates a programming error) */ - public getRoomItemViewModel(roomId: string): RoomListItemViewModel { + public getRoomItemViewModel(roomId: string): RoomListItemViewModel | undefined { // Check if we have a view model for this room let viewModel = this.roomItemViewModels.get(roomId); @@ -191,7 +232,11 @@ export class RoomListViewModel room = this.roomsMap.get(roomId); } - if (!room) throw new Error(`Room ${roomId} not found in roomsMap`); + if (!room) { + // Race condition: the room list has changed but the view hasn't re-rendered yet. + // Return undefined so the view can skip rendering this item. + return undefined; + } // Create new view model viewModel = new RoomListItemViewModel({ @@ -206,13 +251,17 @@ export class RoomListViewModel return viewModel; } - /** - * Not implemented - this view model does not support sections. - * Flat list mode is forced so this method is never be called. - * @throw Error if called - */ - public getSectionHeaderViewModel(): never { - throw new Error("Sections are not supported in this room list"); + public getSectionHeaderViewModel(tag: string): RoomListSectionHeaderViewModel { + if (this.roomSectionHeaderViewModels.has(tag)) return this.roomSectionHeaderViewModels.get(tag)!; + + const title = TAG_TO_TITLE_MAP[tag] || tag; + const viewModel = new RoomListSectionHeaderViewModel({ + tag, + title, + onToggleExpanded: () => this.updateRoomListData(), + }); + this.roomSectionHeaderViewModels.set(tag, viewModel); + return viewModel; } /** @@ -257,7 +306,7 @@ export class RoomListViewModel if (!currentRoomId) return; const { delta, unread } = payload; - const rooms = this.roomsResult.rooms; + const rooms = this.sections.flatMap((section) => section.rooms); const filteredRooms = unread ? // Filter the rooms to only include unread ones and the active room @@ -349,58 +398,74 @@ export class RoomListViewModel return undefined; } - const index = this.roomsResult.rooms.findIndex((room) => room.roomId === roomId); + const index = this.sections.flatMap((section) => section.rooms).findIndex((room) => room.roomId === roomId); return index >= 0 ? index : undefined; } /** - * Apply sticky room logic to keep the active room at the same index position. + * Find the position of a room within the sections list. + * Returns undefined if the room is not found. + */ + private findRoomPosition(sections: Section[], roomId: string): StickyRoomPosition | undefined { + for (const section of sections) { + const idx = section.rooms.findIndex((room) => room.roomId === roomId); + if (idx !== -1) return { sectionTag: section.tag, indexInSection: idx }; + } + return undefined; + } + + /** + * Apply sticky room logic to keep the active room at the same position within its section. * When the room list updates, this prevents the selected room from jumping around in the UI. * * @param isRoomChange - Whether this update is due to a room change (not a list update) * @param roomId - The room ID to apply sticky logic for (can be null/undefined) - * @returns The modified rooms array with sticky positioning applied + * @returns The modified sections array with sticky positioning applied */ - private applyStickyRoom(isRoomChange: boolean, roomId: string | null | undefined): Room[] { - const rooms = this.roomsResult.rooms; + private applyStickyRoom(isRoomChange: boolean, roomId: string | null | undefined): Section[] { + const sections = this.roomsResult.sections; - if (!roomId) { - return rooms; - } + // When opening another room, the index should obviously change + if (!roomId || isRoomChange) return sections; - const newIndex = rooms.findIndex((room) => room.roomId === roomId); - const oldIndex = this.lastActiveRoomIndex; + // If there was no previously tracked position, nothing to stick to + const oldPosition = this.lastActiveRoomPosition; + if (!oldPosition) return sections; - // When opening another room, the index should obviously change - if (isRoomChange) { - return rooms; - } + const newPosition = this.findRoomPosition(sections, roomId); - // If oldIndex is undefined, then there was no active room before - // Similarly, if newIndex is -1, the active room is not in the current list - if (newIndex === -1 || oldIndex === undefined) { - return rooms; - } + // If the room is no longer in the list, nothing to do + if (!newPosition) return sections; - // If the index hasn't changed, we have nothing to do - if (newIndex === oldIndex) { - return rooms; - } + // If the room moved to a different section, this is an intentional structural + // change (e.g. favourited/unfavourited), so don't apply sticky logic + if (newPosition.sectionTag !== oldPosition.sectionTag) return sections; - // If the old index falls out of the bounds of the rooms array - // (usually because rooms were removed), we can no longer place - // the active room in the same old index - if (oldIndex > rooms.length - 1) { - return rooms; - } + // If the index within the section hasn't changed, nothing to do + if (newPosition.indexInSection === oldPosition.indexInSection) return sections; - // Making the active room sticky is as simple as removing it from - // its new index and placing it in the old index - const newRooms = [...rooms]; - const [stickyRoom] = newRooms.splice(newIndex, 1); - newRooms.splice(oldIndex, 0, stickyRoom); + // Find the target section and apply the sticky swap within it + return sections.map((section) => { + // Different section - no change + if (section.tag !== oldPosition.sectionTag) return section; - return newRooms; + const sectionRooms = section.rooms; + + // If the old index falls out of the bounds of the section + // (usually because rooms were removed), we can no longer place + // the active room in the same old position + if (oldPosition.indexInSection > sectionRooms.length - 1) { + return section; + } + + // Making the active room sticky is as simple as removing it from + // its new index and placing it in the old index within the section + const newRooms = [...sectionRooms]; + const [stickyRoom] = newRooms.splice(newPosition.indexInSection, 1); + newRooms.splice(oldPosition.indexInSection, 0, stickyRoom); + + return { ...section, rooms: newRooms }; + }); } private async updateRoomListData( @@ -411,28 +476,30 @@ export class RoomListViewModel // Use override if provided (e.g., during space changes), otherwise fall back to RoomViewStore const roomId = roomIdOverride ?? SdkContextClass.instance.roomViewStore.getRoomId(); - // Apply sticky room logic to keep selected room at same position - const stickyRooms = this.applyStickyRoom(isRoomChange, roomId); + // Apply sticky room logic to keep selected room at same position within its section + const stickySections = this.applyStickyRoom(isRoomChange, roomId); - // Update roomsResult with sticky rooms + // Update roomsResult with the sticky-adjusted sections this.roomsResult = { ...this.roomsResult, - rooms: stickyRooms, + sections: stickySections, }; // Rebuild roomsMap with the reordered rooms this.updateRoomsMap(this.roomsResult); - // Calculate the active room index after applying sticky logic - const activeRoomIndex = this.getActiveRoomIndex(roomId); - - // Track the current active room index for future sticky calculations - this.lastActiveRoomIndex = activeRoomIndex; + // Track the current active room position for future sticky calculations + this.lastActiveRoomPosition = roomId ? this.findRoomPosition(this.roomsResult.sections, roomId) : undefined; // Build the complete state atomically to ensure consistency - // roomIds and roomListState must always be in sync - const roomIds = this.roomIds; - const sections = [{ id: "all", roomIds }]; + const { sections, isFlatList } = computeSections( + this.roomsResult, + (tag) => this.roomSectionHeaderViewModels.get(tag)?.isExpanded ?? true, + ); + this.sections = sections; + + // Calculate the active room index from the computed sections (which exclude collapsed sections' rooms) + const activeRoomIndex = this.getActiveRoomIndex(roomId); // 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; @@ -444,16 +511,20 @@ export class RoomListViewModel }; const activeFilterId = this.activeFilter !== undefined ? filterKeyToIdMap.get(this.activeFilter) : undefined; - const isRoomListEmpty = roomIds.length === 0; + 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 this.snapshot.merge({ isLoadingRooms, isRoomListEmpty, activeFilterId, roomListState: keepIfSame(this.snapshot.current.roomListState, roomListState), - sections: keepIfSame(this.snapshot.current.sections, sections), + sections: keepIfSame(previousSections, viewSections), + isFlatList, }); } @@ -475,3 +546,36 @@ export class RoomListViewModel } }; } + +/** + * Compute the sections to display in the room list based on the rooms result and section expansion state. + * @param roomsResult - The current rooms result containing sections and rooms + * @param isSectionExpanded - A function that takes a section tag and returns whether that section is currently expanded + * @returns An object containing the computed sections (with rooms removed for collapsed sections) and a boolean indicating if this is a flat list (only one section with all rooms) + */ +function computeSections( + roomsResult: RoomsResult, + isSectionExpanded: (tag: string) => boolean, +): { sections: Section[]; isFlatList: boolean } { + const sections = roomsResult.sections + // Only include sections that have rooms + .filter((section) => section.rooms.length > 0) + // Remove roomIds for sections that are currently collapsed according to their section header view model + .map((section) => ({ + ...section, + rooms: isSectionExpanded(section.tag) ? section.rooms : [], + })); + const isFlatList = sections.length === 1 && sections[0].tag === CHATS_TAG; + + return { sections, isFlatList }; +} + +/** + * Convert from the internal Section type used in the view model to the RoomListSection type used in the snapshot. + */ +function toRoomListSection(sections: Section[]): RoomListSection[] { + return sections.map(({ tag, rooms }) => ({ + id: tag, + roomIds: rooms.map((room) => room.roomId), + })); +} 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 09d8f5ded04..1944efe7e6c 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 @@ -11,7 +11,12 @@ import { mocked } from "jest-mock"; import type { MatrixClient } from "matrix-js-sdk/src/matrix"; import type { RoomNotificationState } from "../../../../src/stores/notifications/RoomNotificationState"; -import { LISTS_UPDATE_EVENT, RoomListStoreV3Class } from "../../../../src/stores/room-list-v3/RoomListStoreV3"; +import { + CHATS_TAG, + LISTS_UPDATE_EVENT, + RoomListStoreV3Class, + type Section, +} from "../../../../src/stores/room-list-v3/RoomListStoreV3"; import { AsyncStoreWithClient } from "../../../../src/stores/AsyncStoreWithClient"; import { RecencySorter } from "../../../../src/stores/room-list-v3/skip-list/sorters/RecencySorter"; import { mkEvent, mkMessage, mkSpace, mkStubRoom, stubClient, upsertRoomStateEvents } from "../../../test-utils"; @@ -21,7 +26,7 @@ import dispatcher from "../../../../src/dispatcher/dispatcher"; import SpaceStore from "../../../../src/stores/spaces/SpaceStore"; import { MetaSpace, UPDATE_SELECTED_SPACE } from "../../../../src/stores/spaces"; import { DefaultTagID } from "../../../../src/stores/room-list-v3/skip-list/tag"; -import { FilterKey } from "../../../../src/stores/room-list-v3/skip-list/filters"; +import { FilterEnum } from "../../../../src/stores/room-list-v3/skip-list/filters"; import { RoomNotificationStateStore } from "../../../../src/stores/notifications/RoomNotificationStateStore"; import DMRoomMap from "../../../../src/utils/DMRoomMap"; import { SortingAlgorithm } from "../../../../src/stores/room-list-v3/skip-list/sorters"; @@ -502,7 +507,10 @@ describe("RoomListStoreV3", () => { store.on(LISTS_UPDATE_EVENT, fn); // The rooms which belong to the space should not be shown - const result = store.getSortedRoomsInActiveSpace().rooms.map((r) => r.roomId); + const result = store + .getSortedRoomsInActiveSpace() + .sections.flatMap((s) => s.rooms) + .map((r) => r.roomId); for (const id of roomIds) { expect(result).not.toContain(id); } @@ -511,7 +519,10 @@ describe("RoomListStoreV3", () => { jest.spyOn(SpaceStore.instance, "activeSpace", "get").mockImplementation(() => spaceRoom.roomId); SpaceStore.instance.emit(UPDATE_SELECTED_SPACE); expect(fn).toHaveBeenCalled(); - const result2 = store.getSortedRoomsInActiveSpace().rooms.map((r) => r.roomId); + const result2 = store + .getSortedRoomsInActiveSpace() + .sections.flatMap((s) => s.rooms) + .map((r) => r.roomId); for (const id of roomIds) { expect(result2).toContain(id); } @@ -534,7 +545,9 @@ describe("RoomListStoreV3", () => { await store.start(); // Sorted, filtered rooms should be 8, 27 and 75 - const result = store.getSortedRoomsInActiveSpace([FilterKey.FavouriteFilter]).rooms; + const result = store + .getSortedRoomsInActiveSpace([FilterEnum.FavouriteFilter]) + .sections.flatMap((s) => s.rooms); expect(result).toHaveLength(3); for (const i of [8, 27, 75]) { expect(result).toContain(rooms[i]); @@ -569,7 +582,9 @@ describe("RoomListStoreV3", () => { expect(fn).toHaveBeenCalled(); // Sorted, filtered rooms should be 27 and 75 - const result = store.getSortedRoomsInActiveSpace([FilterKey.FavouriteFilter]).rooms; + const result = store + .getSortedRoomsInActiveSpace([FilterEnum.FavouriteFilter]) + .sections.flatMap((s) => s.rooms); expect(result).toHaveLength(2); for (const i of [8, 75]) { expect(result).toContain(rooms[i]); @@ -594,7 +609,9 @@ describe("RoomListStoreV3", () => { await store.start(); // Should only give us rooms at index 8 and 27 - const result = store.getSortedRoomsInActiveSpace([FilterKey.UnreadFilter]).rooms; + const result = store + .getSortedRoomsInActiveSpace([FilterEnum.UnreadFilter]) + .sections.flatMap((s) => s.rooms); expect(result).toHaveLength(2); for (const i of [8, 27]) { expect(result).toContain(rooms[i]); @@ -611,7 +628,9 @@ describe("RoomListStoreV3", () => { await store.start(); // Since there's no unread yet, we expect zero results - let result = store.getSortedRoomsInActiveSpace([FilterKey.UnreadFilter]).rooms; + let result = store + .getSortedRoomsInActiveSpace([FilterEnum.UnreadFilter]) + .sections.flatMap((s) => s.rooms); expect(result).toHaveLength(0); // Mock so that room at index 8 is marked as unread @@ -626,7 +645,7 @@ describe("RoomListStoreV3", () => { ); // Now we expect room at index 8 to show as unread - result = store.getSortedRoomsInActiveSpace([FilterKey.UnreadFilter]).rooms; + result = store.getSortedRoomsInActiveSpace([FilterEnum.UnreadFilter]).sections.flatMap((s) => s.rooms); expect(result).toHaveLength(1); expect(result).toContain(rooms[8]); }); @@ -649,14 +668,18 @@ describe("RoomListStoreV3", () => { await store.start(); // Should only give us rooms at index 8 and 27 - const peopleRooms = store.getSortedRoomsInActiveSpace([FilterKey.PeopleFilter]).rooms; + const peopleRooms = store + .getSortedRoomsInActiveSpace([FilterEnum.PeopleFilter]) + .sections.flatMap((s) => s.rooms); expect(peopleRooms).toHaveLength(2); for (const i of [8, 27]) { expect(peopleRooms).toContain(rooms[i]); } // Rest are normal rooms - const nonDms = store.getSortedRoomsInActiveSpace([FilterKey.RoomsFilter]).rooms; + const nonDms = store + .getSortedRoomsInActiveSpace([FilterEnum.RoomsFilter]) + .sections.flatMap((s) => s.rooms); expect(nonDms).toHaveLength(3); for (const i of [6, 13, 75]) { expect(nonDms).toContain(rooms[i]); @@ -680,7 +703,9 @@ describe("RoomListStoreV3", () => { const store = new RoomListStoreV3Class(dispatcher); await store.start(); - const result = store.getSortedRoomsInActiveSpace([FilterKey.InvitesFilter]).rooms; + const result = store + .getSortedRoomsInActiveSpace([FilterEnum.InvitesFilter]) + .sections.flatMap((s) => s.rooms); expect(result).toHaveLength(5); for (const room of invitedRooms) { expect(result).toContain(room); @@ -705,7 +730,9 @@ describe("RoomListStoreV3", () => { await store.start(); // Should only give us rooms at index 8 and 27 - const result = store.getSortedRoomsInActiveSpace([FilterKey.MentionsFilter]).rooms; + const result = store + .getSortedRoomsInActiveSpace([FilterEnum.MentionsFilter]) + .sections.flatMap((s) => s.rooms); expect(result).toHaveLength(2); for (const i of [8, 27]) { expect(result).toContain(rooms[i]); @@ -727,7 +754,9 @@ describe("RoomListStoreV3", () => { await store.start(); // Sorted, filtered rooms should be 8, 27 and 75 - const result = store.getSortedRoomsInActiveSpace([FilterKey.LowPriorityFilter]).rooms; + const result = store + .getSortedRoomsInActiveSpace([FilterEnum.LowPriorityFilter]) + .sections.flatMap((s) => s.rooms); expect(result).toHaveLength(3); for (const i of [8, 27, 75]) { expect(result).toContain(rooms[i]); @@ -755,10 +784,9 @@ describe("RoomListStoreV3", () => { await store.start(); // Should give us only room at 8 since that's the only room which matches both filters - const result = store.getSortedRoomsInActiveSpace([ - FilterKey.UnreadFilter, - FilterKey.FavouriteFilter, - ]).rooms; + const result = store + .getSortedRoomsInActiveSpace([FilterEnum.UnreadFilter, FilterEnum.FavouriteFilter]) + .sections.flatMap((s) => s.rooms); expect(result).toHaveLength(1); expect(result).toContain(rooms[8]); }); @@ -777,7 +805,9 @@ describe("RoomListStoreV3", () => { }, true, ); - expect(store.getSortedRoomsInActiveSpace([FilterKey.InvitesFilter]).rooms).not.toContain(room); + expect( + store.getSortedRoomsInActiveSpace([FilterEnum.InvitesFilter]).sections.flatMap((s) => s.rooms), + ).not.toContain(room); room.getMyMembership = jest.fn().mockReturnValue(KnownMembership.Invite); dispatcher.dispatch( @@ -789,11 +819,196 @@ describe("RoomListStoreV3", () => { }, true, ); - expect(store.getSortedRoomsInActiveSpace([FilterKey.InvitesFilter]).rooms).toContain(room); + expect( + store.getSortedRoomsInActiveSpace([FilterEnum.InvitesFilter]).sections.flatMap((s) => s.rooms), + ).toContain(room); }); }); }); + describe("Sections", () => { + function enableSections(): void { + jest.spyOn(SettingsStore, "getValue").mockImplementation((setting: string) => { + if (setting === "feature_room_list_sections") return true; + return false; + }); + } + + function findSection(sections: Section[], tag: string): Section | undefined { + return sections.find((s) => s.tag === tag); + } + + function getClientAndRooms() { + const client = stubClient(); + const rooms = getMockedRooms(client); + client.getVisibleRooms = jest.fn().mockReturnValue(rooms); + jest.spyOn(AsyncStoreWithClient.prototype, "matrixClient", "get").mockReturnValue(client); + return { client, rooms }; + } + + it("returns a single chats section when sections feature is disabled", async () => { + const { rooms } = getClientAndRooms(); + // Mark some rooms as favourite so we can verify they are NOT split out + [0, 1, 2].forEach((i) => { + rooms[i].tags[DefaultTagID.Favourite] = {}; + }); + + const store = new RoomListStoreV3Class(dispatcher); + await store.start(); + + const result = store.getSortedRoomsInActiveSpace(); + expect(result.sections).toHaveLength(1); + expect(result.sections[0].tag).toBe(CHATS_TAG); + // All rooms, including favourites, are in the single section + for (const i of [0, 1, 2]) { + expect(result.sections[0].rooms).toContain(rooms[i]); + } + }); + + it("returns three sections in the correct order when enabled", async () => { + enableSections(); + getClientAndRooms(); + + const store = new RoomListStoreV3Class(dispatcher); + await store.start(); + + const result = store.getSortedRoomsInActiveSpace(); + expect(result.sections).toHaveLength(3); + expect(result.sections[0].tag).toBe(DefaultTagID.Favourite); + expect(result.sections[1].tag).toBe(CHATS_TAG); + expect(result.sections[2].tag).toBe(DefaultTagID.LowPriority); + }); + + it.each([ + { tag: DefaultTagID.Favourite, label: "Favourite" }, + { tag: DefaultTagID.LowPriority, label: "LowPriority" }, + ])("places tagged rooms only in the $label section", async ({ tag }) => { + enableSections(); + const { rooms } = getClientAndRooms(); + + // Mark rooms 3, 7 with the given tag + [3, 7].forEach((i) => { + rooms[i].tags[tag] = {}; + }); + + const store = new RoomListStoreV3Class(dispatcher); + await store.start(); + + const { sections } = store.getSortedRoomsInActiveSpace(); + const targetSection = findSection(sections, tag)!; + const chatsSection = findSection(sections, CHATS_TAG)!; + + for (const i of [3, 7]) { + expect(targetSection.rooms).toContain(rooms[i]); + expect(chatsSection.rooms).not.toContain(rooms[i]); + } + }); + + it("places regular rooms only in the Chats section", async () => { + enableSections(); + const { rooms } = getClientAndRooms(); + + // Mark some rooms as favourite / low priority so the rest are regular + rooms[0].tags[DefaultTagID.Favourite] = {}; + rooms[1].tags[DefaultTagID.LowPriority] = {}; + + const store = new RoomListStoreV3Class(dispatcher); + await store.start(); + + const { sections } = store.getSortedRoomsInActiveSpace(); + const favSection = findSection(sections, DefaultTagID.Favourite)!; + const chatsSection = findSection(sections, CHATS_TAG)!; + const lowPrioritySection = findSection(sections, DefaultTagID.LowPriority)!; + + // A regular room (index 5) should be in chats only + expect(chatsSection.rooms).toContain(rooms[5]); + expect(favSection.rooms).not.toContain(rooms[5]); + expect(lowPrioritySection.rooms).not.toContain(rooms[5]); + }); + + it("all rooms are accounted for across all sections", async () => { + enableSections(); + const { rooms } = getClientAndRooms(); + + [2, 5].forEach((i) => { + rooms[i].tags[DefaultTagID.Favourite] = {}; + }); + [11].forEach((i) => { + rooms[i].tags[DefaultTagID.LowPriority] = {}; + }); + + const store = new RoomListStoreV3Class(dispatcher); + await store.start(); + + const { sections } = store.getSortedRoomsInActiveSpace(); + const totalRooms = sections.flatMap((s) => s.rooms).length; + // All 100 rooms should be distributed across the three sections + expect(totalRooms).toBe(rooms.length); + }); + + it("applies additional filter keys within each section", async () => { + enableSections(); + const { rooms } = getClientAndRooms(); + + // Rooms 3 and 7 are favourites; room 7 is also unread + [3, 7].forEach((i) => { + rooms[i].tags[DefaultTagID.Favourite] = {}; + }); + jest.spyOn(RoomNotificationStateStore.instance, "getRoomState").mockImplementation((room) => { + const state = { + hasUnreadCount: room === rooms[7], + } as unknown as RoomNotificationState; + return state; + }); + + const store = new RoomListStoreV3Class(dispatcher); + await store.start(); + + const { sections } = store.getSortedRoomsInActiveSpace([FilterEnum.UnreadFilter]); + const favSection = findSection(sections, DefaultTagID.Favourite)!; + + // Only room 7 is both favourite AND unread + expect(favSection.rooms).toHaveLength(1); + expect(favSection.rooms).toContain(rooms[7]); + }); + + it("sections respect space filtering", async () => { + enableSections(); + const { rooms } = getClientAndRooms(); + + // Room 3 is a favourite room in the space + rooms[3].tags[DefaultTagID.Favourite] = {}; + + const spaceRoomId = "!space1:matrix.org"; + const inSpaceIds = [3, 10, 20].map((i) => rooms[i].roomId); + jest.spyOn(SpaceStore.instance, "isRoomInSpace").mockImplementation((space, id) => { + if (space === spaceRoomId && inSpaceIds.includes(id)) return true; + return false; + }); + jest.spyOn(SpaceStore.instance, "activeSpace", "get").mockImplementation(() => spaceRoomId); + + const store = new RoomListStoreV3Class(dispatcher); + await store.start(); + + const { sections, spaceId } = store.getSortedRoomsInActiveSpace(); + expect(spaceId).toBe(spaceRoomId); + + const allRooms = sections.flatMap((s) => s.rooms); + const allRoomIds = allRooms.map((r) => r.roomId); + + // Only rooms in the space should appear + for (const id of inSpaceIds) { + expect(allRoomIds).toContain(id); + } + // Rooms not in the space should not appear + expect(allRoomIds).not.toContain(rooms[50].roomId); + + // Room 3 should be in the Favourite section specifically + const favSection = findSection(sections, DefaultTagID.Favourite)!; + expect(favSection.rooms).toContain(rooms[3]); + }); + }); + describe("Muted rooms", () => { async function getRoomListStoreWithMutedRooms() { const client = stubClient(); diff --git a/apps/web/test/viewmodels/room-list/RoomListSectionHeaderViewModel-test.ts b/apps/web/test/viewmodels/room-list/RoomListSectionHeaderViewModel-test.ts new file mode 100644 index 00000000000..1f2cd4ebb0b --- /dev/null +++ b/apps/web/test/viewmodels/room-list/RoomListSectionHeaderViewModel-test.ts @@ -0,0 +1,48 @@ +/* + * Copyright 2026 Element Creations 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 { RoomListSectionHeaderViewModel } from "../../../src/viewmodels/room-list/RoomListSectionHeaderViewModel"; + +describe("RoomListSectionHeaderViewModel", () => { + let onToggleExpanded: jest.Mock; + + beforeEach(() => { + onToggleExpanded = jest.fn(); + }); + + it("should initialize snapshot from props", () => { + const vm = new RoomListSectionHeaderViewModel({ + tag: "m.favourite", + title: "Favourites", + onToggleExpanded, + }); + + const snapshot = vm.getSnapshot(); + expect(snapshot.id).toBe("m.favourite"); + expect(snapshot.title).toBe("Favourites"); + expect(snapshot.isExpanded).toBe(true); + }); + + it("should toggle expanded state on click", () => { + const vm = new RoomListSectionHeaderViewModel({ + tag: "m.favourite", + title: "Favourites", + onToggleExpanded, + }); + expect(vm.isExpanded).toBe(true); + + vm.onClick(); + expect(vm.isExpanded).toBe(false); + expect(vm.getSnapshot().isExpanded).toBe(false); + expect(onToggleExpanded).toHaveBeenCalledWith(false); + + vm.onClick(); + expect(vm.isExpanded).toBe(true); + expect(vm.getSnapshot().isExpanded).toBe(true); + expect(onToggleExpanded).toHaveBeenCalledWith(true); + }); +}); diff --git a/apps/web/test/viewmodels/room-list/RoomListViewModel-test.tsx b/apps/web/test/viewmodels/room-list/RoomListViewModel-test.tsx index ce5c9ce6b47..c2ced861f00 100644 --- a/apps/web/test/viewmodels/room-list/RoomListViewModel-test.tsx +++ b/apps/web/test/viewmodels/room-list/RoomListViewModel-test.tsx @@ -7,17 +7,20 @@ import { type MatrixClient, type Room } from "matrix-js-sdk/src/matrix"; import { mocked } from "jest-mock"; +import { waitFor } from "jest-matrix-react"; import { createTestClient, flushPromises, mkStubRoom, stubClient } from "../../test-utils"; -import RoomListStoreV3, { RoomListStoreV3Event } from "../../../src/stores/room-list-v3/RoomListStoreV3"; +import RoomListStoreV3, { CHATS_TAG, RoomListStoreV3Event } from "../../../src/stores/room-list-v3/RoomListStoreV3"; import SpaceStore from "../../../src/stores/spaces/SpaceStore"; -import { FilterKey } from "../../../src/stores/room-list-v3/skip-list/filters"; +import { FilterEnum } from "../../../src/stores/room-list-v3/skip-list/filters"; import dispatcher from "../../../src/dispatcher/dispatcher"; import { Action } from "../../../src/dispatcher/actions"; import { SdkContextClass } from "../../../src/contexts/SDKContext"; import DMRoomMap from "../../../src/utils/DMRoomMap"; import { RoomListViewModel } from "../../../src/viewmodels/room-list/RoomListViewModel"; import { hasCreateRoomRights } from "../../../src/viewmodels/room-list/utils"; +import { DefaultTagID } from "../../../src/stores/room-list-v3/skip-list/tag"; +import SettingsStore from "../../../src/settings/SettingsStore"; jest.mock("../../../src/viewmodels/room-list/utils", () => ({ hasCreateRoomRights: jest.fn().mockReturnValue(false), @@ -46,7 +49,7 @@ describe("RoomListViewModel", () => { jest.spyOn(RoomListStoreV3.instance, "getSortedRoomsInActiveSpace").mockReturnValue({ spaceId: "home", - rooms: [room1, room2, room3], + sections: [{ tag: CHATS_TAG, rooms: [room1, room2, room3] }], }); jest.spyOn(RoomListStoreV3.instance, "isLoadingRooms", "get").mockReturnValue(false); @@ -77,12 +80,12 @@ describe("RoomListViewModel", () => { it("should initialize with empty room list", () => { jest.spyOn(RoomListStoreV3.instance, "getSortedRoomsInActiveSpace").mockReturnValue({ spaceId: "home", - rooms: [], + sections: [{ tag: CHATS_TAG, rooms: [] }], }); viewModel = new RoomListViewModel({ client: matrixClient }); - expect(viewModel.getSnapshot().sections[0].roomIds).toEqual([]); + expect(viewModel.getSnapshot().sections).toEqual([]); expect(viewModel.getSnapshot().isRoomListEmpty).toBe(true); }); @@ -101,7 +104,7 @@ describe("RoomListViewModel", () => { const newRoom = mkStubRoom("!room4:server", "Room 4", matrixClient); jest.spyOn(RoomListStoreV3.instance, "getSortedRoomsInActiveSpace").mockReturnValue({ spaceId: "home", - rooms: [room1, room2, room3, newRoom], + sections: [{ tag: CHATS_TAG, rooms: [room1, room2, room3, newRoom] }], }); RoomListStoreV3.instance.emit(RoomListStoreV3Event.ListsUpdate); @@ -136,7 +139,7 @@ describe("RoomListViewModel", () => { RoomListStoreV3.instance.emit(RoomListStoreV3Event.ListsUpdate); // View model should be still valid - expect(room1VM.isDisposed).toBe(false); + expect(room1VM!.isDisposed).toBe(false); }); }); @@ -148,7 +151,7 @@ describe("RoomListViewModel", () => { jest.spyOn(RoomListStoreV3.instance, "getSortedRoomsInActiveSpace").mockReturnValue({ spaceId: "!space:server", - rooms: spaceRoomList, + sections: [{ tag: CHATS_TAG, rooms: spaceRoomList }], }); jest.spyOn(SpaceStore.instance, "getLastSelectedRoomIdForSpace").mockReturnValue("!room1:server"); @@ -163,8 +166,8 @@ describe("RoomListViewModel", () => { viewModel = new RoomListViewModel({ client: matrixClient }); // Get view models for visible rooms - const vm1 = viewModel.getRoomItemViewModel("!room1:server"); - const vm2 = viewModel.getRoomItemViewModel("!room2:server"); + const vm1 = viewModel.getRoomItemViewModel("!room1:server")!; + const vm2 = viewModel.getRoomItemViewModel("!room2:server")!; const disposeSpy1 = jest.spyOn(vm1, "dispose"); const disposeSpy2 = jest.spyOn(vm2, "dispose"); @@ -172,7 +175,7 @@ describe("RoomListViewModel", () => { // Change space jest.spyOn(RoomListStoreV3.instance, "getSortedRoomsInActiveSpace").mockReturnValue({ spaceId: "!space:server", - rooms: [room3], + sections: [{ tag: CHATS_TAG, rooms: [room3] }], }); RoomListStoreV3.instance.emit(RoomListStoreV3Event.ListsUpdate); @@ -188,7 +191,7 @@ describe("RoomListViewModel", () => { jest.spyOn(RoomListStoreV3.instance, "getSortedRoomsInActiveSpace").mockReturnValue({ spaceId: "!space:server", - rooms: [newSpaceRoom], + sections: [{ tag: CHATS_TAG, rooms: [newSpaceRoom] }], }); jest.spyOn(SpaceStore.instance, "getLastSelectedRoomIdForSpace").mockReturnValue(null); @@ -197,7 +200,7 @@ describe("RoomListViewModel", () => { // New space room should be accessible expect(() => viewModel.getRoomItemViewModel("!spaceroom:server")).not.toThrow(); // Old rooms from the home space should not be accessible - expect(() => viewModel.getRoomItemViewModel("!room1:server")).toThrow(); + expect(viewModel.getRoomItemViewModel("!room1:server")).toBeUndefined(); }); }); @@ -252,7 +255,7 @@ describe("RoomListViewModel", () => { // Simulate room list update that would move room2 to front jest.spyOn(RoomListStoreV3.instance, "getSortedRoomsInActiveSpace").mockReturnValue({ spaceId: "home", - rooms: [room2, room1, room3], // room2 moved to front + sections: [{ tag: CHATS_TAG, rooms: [room2, room1, room3] }], // room2 moved to front }); RoomListStoreV3.instance.emit(RoomListStoreV3Event.ListsUpdate); @@ -295,8 +298,8 @@ describe("RoomListViewModel", () => { jest.spyOn(RoomListStoreV3.instance, "getSortedRoomsInActiveSpace").mockReturnValue({ spaceId: "home", - rooms: [room1], - filterKeys: [FilterKey.UnreadFilter], + sections: [{ tag: CHATS_TAG, rooms: [room1] }], + filterKeys: [FilterEnum.UnreadFilter], }); viewModel.onToggleFilter("unread"); @@ -311,8 +314,8 @@ describe("RoomListViewModel", () => { // Turn filter on jest.spyOn(RoomListStoreV3.instance, "getSortedRoomsInActiveSpace").mockReturnValue({ spaceId: "home", - rooms: [room1], - filterKeys: [FilterKey.UnreadFilter], + sections: [{ tag: CHATS_TAG, rooms: [room1] }], + filterKeys: [FilterEnum.UnreadFilter], }); viewModel.onToggleFilter("unread"); @@ -321,7 +324,7 @@ describe("RoomListViewModel", () => { // Turn filter off jest.spyOn(RoomListStoreV3.instance, "getSortedRoomsInActiveSpace").mockReturnValue({ spaceId: "home", - rooms: [room1, room2, room3], + sections: [{ tag: CHATS_TAG, rooms: [room1, room2, room3] }], }); viewModel.onToggleFilter("unread"); @@ -341,7 +344,7 @@ describe("RoomListViewModel", () => { const itemViewModel = viewModel.getRoomItemViewModel("!room1:server"); expect(itemViewModel).toBeDefined(); - expect(itemViewModel.getSnapshot().room).toBe(room1); + expect(itemViewModel!.getSnapshot().room).toBe(room1); }); it("should reuse existing room item view model", () => { @@ -353,12 +356,10 @@ describe("RoomListViewModel", () => { expect(itemViewModel1).toBe(itemViewModel2); }); - it("should throw error when requesting view model for non-existent room", () => { + it("should return undefined for non-existent room", () => { viewModel = new RoomListViewModel({ client: matrixClient }); - expect(() => { - viewModel.getRoomItemViewModel("!nonexistent:server"); - }).toThrow(); + expect(viewModel.getRoomItemViewModel("!nonexistent:server")).toBeUndefined(); }); it("should not throw when requesting view model for a room removed from the list but still in roomsMap", () => { @@ -367,7 +368,7 @@ describe("RoomListViewModel", () => { // Normal list update removes room2 from the list jest.spyOn(RoomListStoreV3.instance, "getSortedRoomsInActiveSpace").mockReturnValue({ spaceId: "home", - rooms: [room1, room3], + sections: [{ tag: CHATS_TAG, rooms: [room1, room3] }], }); RoomListStoreV3.instance.emit(RoomListStoreV3Event.ListsUpdate); @@ -375,7 +376,7 @@ describe("RoomListViewModel", () => { expect(() => viewModel.getRoomItemViewModel("!room2:server")).not.toThrow(); }); - it("should throw when requesting view model for a room from old space after space change", () => { + it("should return undefined for a room from old space after space change", () => { viewModel = new RoomListViewModel({ client: matrixClient }); const spaceRoom = mkStubRoom("!newroom:server", "New Room", matrixClient); @@ -383,15 +384,13 @@ describe("RoomListViewModel", () => { // Space change: new space only has spaceRoom jest.spyOn(RoomListStoreV3.instance, "getSortedRoomsInActiveSpace").mockReturnValue({ spaceId: "!space:server", - rooms: [spaceRoom], + sections: [{ tag: CHATS_TAG, rooms: [spaceRoom] }], }); jest.spyOn(SpaceStore.instance, "getLastSelectedRoomIdForSpace").mockReturnValue(null); RoomListStoreV3.instance.emit(RoomListStoreV3Event.ListsUpdate); - expect(() => viewModel.getRoomItemViewModel("!room1:server")).toThrow( - "Room !room1:server not found in roomsMap", - ); + expect(viewModel.getRoomItemViewModel("!room1:server")).toBeUndefined(); }); it("should recover when roomsMap is stale but roomsResult has the room", () => { @@ -407,9 +406,9 @@ describe("RoomListViewModel", () => { it("should dispose view models for rooms no longer visible", () => { viewModel = new RoomListViewModel({ client: matrixClient }); - const vm1 = viewModel.getRoomItemViewModel("!room1:server"); - const vm2 = viewModel.getRoomItemViewModel("!room2:server"); - const vm3 = viewModel.getRoomItemViewModel("!room3:server"); + const vm1 = viewModel.getRoomItemViewModel("!room1:server")!; + const vm2 = viewModel.getRoomItemViewModel("!room2:server")!; + const vm3 = viewModel.getRoomItemViewModel("!room3:server")!; const disposeSpy1 = jest.spyOn(vm1, "dispose"); const disposeSpy3 = jest.spyOn(vm3, "dispose"); @@ -593,8 +592,8 @@ describe("RoomListViewModel", () => { it("should dispose all room item view models on dispose", () => { viewModel = new RoomListViewModel({ client: matrixClient }); - const vm1 = viewModel.getRoomItemViewModel("!room1:server"); - const vm2 = viewModel.getRoomItemViewModel("!room2:server"); + const vm1 = viewModel.getRoomItemViewModel("!room1:server")!; + const vm2 = viewModel.getRoomItemViewModel("!room2:server")!; const disposeSpy1 = jest.spyOn(vm1, "dispose"); const disposeSpy2 = jest.spyOn(vm2, "dispose"); @@ -604,5 +603,297 @@ describe("RoomListViewModel", () => { expect(disposeSpy1).toHaveBeenCalled(); expect(disposeSpy2).toHaveBeenCalled(); }); + + describe("Sections (feature_room_list_sections)", () => { + let favRoom1: Room; + let favRoom2: Room; + let lowPriorityRoom: Room; + let regularRoom1: Room; + let regularRoom2: Room; + + beforeEach(() => { + jest.spyOn(SettingsStore, "getValue").mockImplementation((setting: string) => { + if (setting === "feature_room_list_sections") return true; + return false; + }); + + favRoom1 = mkStubRoom("!fav1:server", "Fav 1", matrixClient); + favRoom2 = mkStubRoom("!fav2:server", "Fav 2", matrixClient); + lowPriorityRoom = mkStubRoom("!low1:server", "Low 1", matrixClient); + regularRoom1 = mkStubRoom("!reg1:server", "Reg 1", matrixClient); + regularRoom2 = mkStubRoom("!reg2:server", "Reg 2", matrixClient); + + jest.spyOn(RoomListStoreV3.instance, "getSortedRoomsInActiveSpace").mockReturnValue({ + spaceId: "home", + sections: [ + { tag: DefaultTagID.Favourite, rooms: [favRoom1, favRoom2] }, + { tag: CHATS_TAG, rooms: [regularRoom1, regularRoom2] }, + { tag: DefaultTagID.LowPriority, rooms: [lowPriorityRoom] }, + ], + }); + }); + + it("should initialize with multiple sections", () => { + viewModel = new RoomListViewModel({ client: matrixClient }); + + const snapshot = viewModel.getSnapshot(); + expect(snapshot.sections).toHaveLength(3); + expect(snapshot.sections[0].id).toBe(DefaultTagID.Favourite); + expect(snapshot.sections[0].roomIds).toEqual(["!fav1:server", "!fav2:server"]); + expect(snapshot.sections[1].id).toBe(CHATS_TAG); + expect(snapshot.sections[1].roomIds).toEqual(["!reg1:server", "!reg2:server"]); + expect(snapshot.sections[2].id).toBe(DefaultTagID.LowPriority); + expect(snapshot.sections[2].roomIds).toEqual(["!low1:server"]); + }); + + it("should not be a flat list when multiple sections exist", () => { + viewModel = new RoomListViewModel({ client: matrixClient }); + + expect(viewModel.getSnapshot().isFlatList).toBe(false); + }); + + it("should be a flat list when only chats section has rooms", () => { + jest.spyOn(RoomListStoreV3.instance, "getSortedRoomsInActiveSpace").mockReturnValue({ + spaceId: "home", + sections: [ + { tag: DefaultTagID.Favourite, rooms: [] }, + { tag: CHATS_TAG, rooms: [regularRoom1] }, + { tag: DefaultTagID.LowPriority, rooms: [] }, + ], + }); + + viewModel = new RoomListViewModel({ client: matrixClient }); + + expect(viewModel.getSnapshot().isFlatList).toBe(true); + expect(viewModel.getSnapshot().sections).toHaveLength(1); + expect(viewModel.getSnapshot().sections[0].id).toBe(CHATS_TAG); + }); + + it("should exclude favourite and low_priority from filter list", () => { + viewModel = new RoomListViewModel({ client: matrixClient }); + + const snapshot = viewModel.getSnapshot(); + expect(snapshot.filterIds).not.toContain("favourite"); + expect(snapshot.filterIds).not.toContain("low_priority"); + // Other filters should still be present + expect(snapshot.filterIds).toContain("unread"); + expect(snapshot.filterIds).toContain("people"); + }); + + it("should omit empty sections from snapshot", () => { + jest.spyOn(RoomListStoreV3.instance, "getSortedRoomsInActiveSpace").mockReturnValue({ + spaceId: "home", + sections: [ + { tag: DefaultTagID.Favourite, rooms: [] }, + { tag: CHATS_TAG, rooms: [regularRoom1] }, + { tag: DefaultTagID.LowPriority, rooms: [] }, + ], + }); + + viewModel = new RoomListViewModel({ client: matrixClient }); + + const snapshot = viewModel.getSnapshot(); + expect(snapshot.sections).toHaveLength(1); + expect(snapshot.sections[0].id).toBe(CHATS_TAG); + }); + + it("should create section header view models on demand", () => { + viewModel = new RoomListViewModel({ client: matrixClient }); + + const headerVM = viewModel.getSectionHeaderViewModel(DefaultTagID.Favourite); + expect(headerVM).toBeDefined(); + expect(headerVM.getSnapshot().id).toBe(DefaultTagID.Favourite); + expect(headerVM.getSnapshot().isExpanded).toBe(true); + }); + + it("should reuse section header view models", () => { + viewModel = new RoomListViewModel({ client: matrixClient }); + + const headerVM1 = viewModel.getSectionHeaderViewModel(DefaultTagID.Favourite); + const headerVM2 = viewModel.getSectionHeaderViewModel(DefaultTagID.Favourite); + expect(headerVM1).toBe(headerVM2); + }); + + it("should hide room IDs when a section is collapsed", () => { + viewModel = new RoomListViewModel({ client: matrixClient }); + + // Collapse the favourite section + const favHeader = viewModel.getSectionHeaderViewModel(DefaultTagID.Favourite); + favHeader.onClick(); + expect(favHeader.isExpanded).toBe(false); + + const snapshot = viewModel.getSnapshot(); + const favSection = snapshot.sections.find((s) => s.id === DefaultTagID.Favourite); + expect(favSection).toBeDefined(); + // Collapsed sections have an empty roomIds list + expect(favSection!.roomIds).toEqual([]); + + // Other sections remain unaffected + const chatsSection = snapshot.sections.find((s) => s.id === CHATS_TAG); + expect(chatsSection!.roomIds).toEqual(["!reg1:server", "!reg2:server"]); + }); + + it("should compute activeRoomIndex relative to visible rooms when a section is collapsed", async () => { + viewModel = new RoomListViewModel({ client: matrixClient }); + + // Collapse the favourite section (which has 2 rooms: fav1, fav2) + const favHeader = viewModel.getSectionHeaderViewModel(DefaultTagID.Favourite); + favHeader.onClick(); + expect(favHeader.isExpanded).toBe(false); + + // Select regularRoom1, which is the first room in the chats section + jest.spyOn(SdkContextClass.instance.roomViewStore, "getRoomId").mockReturnValue("!reg1:server"); + dispatcher.dispatch({ + action: Action.ActiveRoomChanged, + newRoomId: "!reg1:server", + }); + + await waitFor(() => { + const snapshot = viewModel.getSnapshot(); + // The favourite section is collapsed so its 2 rooms are not visible. + // regularRoom1 should be at index 0 in the visible list, not index 2. + expect(snapshot.roomListState.activeRoomIndex).toBe(0); + }); + }); + + it("should restore room IDs when a section is re-expanded", () => { + viewModel = new RoomListViewModel({ client: matrixClient }); + + const favHeader = viewModel.getSectionHeaderViewModel(DefaultTagID.Favourite); + + // Collapse then re-expand + favHeader.onClick(); + favHeader.onClick(); + expect(favHeader.isExpanded).toBe(true); + + const snapshot = viewModel.getSnapshot(); + const favSection = snapshot.sections.find((s) => s.id === DefaultTagID.Favourite); + expect(favSection!.roomIds).toEqual(["!fav1:server", "!fav2:server"]); + }); + + it("should update sections when room list changes", () => { + viewModel = new RoomListViewModel({ client: matrixClient }); + + const newFav = mkStubRoom("!fav3:server", "Fav 3", matrixClient); + + jest.spyOn(RoomListStoreV3.instance, "getSortedRoomsInActiveSpace").mockReturnValue({ + spaceId: "home", + sections: [ + { tag: DefaultTagID.Favourite, rooms: [favRoom1, favRoom2, newFav] }, + { tag: CHATS_TAG, rooms: [regularRoom1, regularRoom2] }, + { tag: DefaultTagID.LowPriority, rooms: [lowPriorityRoom] }, + ], + }); + + RoomListStoreV3.instance.emit(RoomListStoreV3Event.ListsUpdate); + + const snapshot = viewModel.getSnapshot(); + expect(snapshot.sections[0].roomIds).toEqual(["!fav1:server", "!fav2:server", "!fav3:server"]); + }); + + it("should preserve section collapse state across list updates", () => { + viewModel = new RoomListViewModel({ client: matrixClient }); + + // Collapse favourites + const favHeader = viewModel.getSectionHeaderViewModel(DefaultTagID.Favourite); + favHeader.onClick(); + + // Trigger a list update + RoomListStoreV3.instance.emit(RoomListStoreV3Event.ListsUpdate); + + const snapshot = viewModel.getSnapshot(); + const favSection = snapshot.sections.find((s) => s.id === DefaultTagID.Favourite); + expect(favSection!.roomIds).toEqual([]); + }); + + it("should preserve section collapse state across space changes", () => { + viewModel = new RoomListViewModel({ client: matrixClient }); + + // Collapse favourites + const favHeader = viewModel.getSectionHeaderViewModel(DefaultTagID.Favourite); + favHeader.onClick(); + + // Switch to a different space with its own rooms + const spaceFav = mkStubRoom("!spacefav:server", "Space Fav", matrixClient); + const spaceReg = mkStubRoom("!spacereg:server", "Space Reg", matrixClient); + jest.spyOn(RoomListStoreV3.instance, "getSortedRoomsInActiveSpace").mockReturnValue({ + spaceId: "!space:server", + sections: [ + { tag: DefaultTagID.Favourite, rooms: [spaceFav] }, + { tag: CHATS_TAG, rooms: [spaceReg] }, + { tag: DefaultTagID.LowPriority, rooms: [] }, + ], + }); + jest.spyOn(SpaceStore.instance, "getLastSelectedRoomIdForSpace").mockReturnValue(null); + + RoomListStoreV3.instance.emit(RoomListStoreV3Event.ListsUpdate); + + const snapshot = viewModel.getSnapshot(); + // Favourites should still be collapsed even after the space change + const favSection = snapshot.sections.find((s) => s.id === DefaultTagID.Favourite); + expect(favSection).toBeDefined(); + expect(favSection!.roomIds).toEqual([]); + + // Other sections should remain expanded + const chatsSection = snapshot.sections.find((s) => s.id === CHATS_TAG); + expect(chatsSection!.roomIds).toEqual(["!spacereg:server"]); + }); + + it("should apply filters across all sections", () => { + viewModel = new RoomListViewModel({ client: matrixClient }); + + // Only favRoom1 is unread + jest.spyOn(RoomListStoreV3.instance, "getSortedRoomsInActiveSpace").mockReturnValue({ + spaceId: "home", + sections: [ + { tag: DefaultTagID.Favourite, rooms: [favRoom1] }, + { tag: CHATS_TAG, rooms: [] }, + { tag: DefaultTagID.LowPriority, rooms: [] }, + ], + filterKeys: [FilterEnum.UnreadFilter], + }); + + viewModel.onToggleFilter("unread"); + + const snapshot = viewModel.getSnapshot(); + expect(snapshot.activeFilterId).toBe("unread"); + // Only the favourite section should remain (chats and low priority are empty) + expect(snapshot.sections).toHaveLength(1); + expect(snapshot.sections[0].id).toBe(DefaultTagID.Favourite); + expect(snapshot.sections[0].roomIds).toEqual(["!fav1:server"]); + }); + + it("should apply sticky room within the correct section", async () => { + stubClient(); + viewModel = new RoomListViewModel({ client: matrixClient }); + + // Select favRoom1 (index 0 globally, index 0 in favourites section) + jest.spyOn(SdkContextClass.instance.roomViewStore, "getRoomId").mockReturnValue("!fav1:server"); + dispatcher.dispatch({ + action: Action.ActiveRoomChanged, + newRoomId: "!fav1:server", + }); + await flushPromises(); + + expect(viewModel.getSnapshot().roomListState.activeRoomIndex).toBe(0); + + // Room list update moves favRoom1 to second position within favourites + jest.spyOn(RoomListStoreV3.instance, "getSortedRoomsInActiveSpace").mockReturnValue({ + spaceId: "home", + sections: [ + { tag: DefaultTagID.Favourite, rooms: [favRoom2, favRoom1] }, + { tag: CHATS_TAG, rooms: [regularRoom1, regularRoom2] }, + { tag: DefaultTagID.LowPriority, rooms: [lowPriorityRoom] }, + ], + }); + + RoomListStoreV3.instance.emit(RoomListStoreV3Event.ListsUpdate); + + // Sticky room should keep favRoom1 at index 0 within the favourites section + const snapshot = viewModel.getSnapshot(); + expect(snapshot.sections[0].roomIds[0]).toBe("!fav1:server"); + expect(snapshot.roomListState.activeRoomIndex).toBe(0); + }); + }); }); }); diff --git a/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListView/RoomListView.stories.tsx/large-section-list-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListView/RoomListView.stories.tsx/large-section-list-auto.png index 9d43af46193..402b680fcf2 100644 Binary files a/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListView/RoomListView.stories.tsx/large-section-list-auto.png and b/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListView/RoomListView.stories.tsx/large-section-list-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListView/RoomListView.stories.tsx/section-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListView/RoomListView.stories.tsx/section-auto.png index d8992ad6e64..15159194a0d 100644 Binary files a/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListView/RoomListView.stories.tsx/section-auto.png and b/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListView/RoomListView.stories.tsx/section-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListView/RoomListView.stories.tsx/small-section-list-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListView/RoomListView.stories.tsx/small-section-list-auto.png index 97dcad563f7..df6a0d002bf 100644 Binary files a/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListView/RoomListView.stories.tsx/small-section-list-auto.png and b/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListView/RoomListView.stories.tsx/small-section-list-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/room-list/VirtualizedRoomListView/VirtualizedRoomListView.stories.tsx/sections-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room-list/VirtualizedRoomListView/VirtualizedRoomListView.stories.tsx/sections-auto.png index 36b07b87ba5..dfda0e57b71 100644 Binary files a/packages/shared-components/__vis__/linux/__baselines__/room-list/VirtualizedRoomListView/VirtualizedRoomListView.stories.tsx/sections-auto.png and b/packages/shared-components/__vis__/linux/__baselines__/room-list/VirtualizedRoomListView/VirtualizedRoomListView.stories.tsx/sections-auto.png differ diff --git a/packages/shared-components/src/room-list/RoomListView/RoomListView.tsx b/packages/shared-components/src/room-list/RoomListView/RoomListView.tsx index 8405c566af0..784f60f99b7 100644 --- a/packages/shared-components/src/room-list/RoomListView/RoomListView.tsx +++ b/packages/shared-components/src/room-list/RoomListView/RoomListView.tsx @@ -58,8 +58,11 @@ export interface RoomListViewActions { createChatRoom: () => void; /** Called to create a new room */ createRoom: () => void; - /** Get view model for a specific room (virtualization API) */ - getRoomItemViewModel: (roomId: string) => RoomListItemViewModel; + /** + * Get view model for a specific room (virtualization API) + * 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) */ updateVisibleRooms: (startIndex: number, endIndex: number) => void; /** Get view model for a specific section header (virtualization API) */ diff --git a/packages/shared-components/src/room-list/RoomListView/__snapshots__/RoomListView.test.tsx.snap b/packages/shared-components/src/room-list/RoomListView/__snapshots__/RoomListView.test.tsx.snap index 8b500701779..222cdbf7e4f 100644 --- a/packages/shared-components/src/room-list/RoomListView/__snapshots__/RoomListView.test.tsx.snap +++ b/packages/shared-components/src/room-list/RoomListView/__snapshots__/RoomListView.test.tsx.snap @@ -8352,7 +8352,7 @@ exports[` > renders LargeSectionList story 1`] = ` aria-haspopup="menu" aria-label="Open room General" aria-selected="false" - class="flex roomListItem mx_RoomListItemView bold firstItem" + class="flex roomListItem mx_RoomListItemView bold" data-state="closed" role="gridcell" style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: stretch; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-3x); --mx-flex-wrap: nowrap;" @@ -13652,7 +13652,7 @@ exports[` > renders SmallSectionList story 1`] = ` aria-haspopup="menu" aria-label="Open room General" aria-selected="false" - class="flex roomListItem mx_RoomListItemView bold firstItem" + class="flex roomListItem mx_RoomListItemView bold" data-state="closed" role="gridcell" style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: stretch; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-3x); --mx-flex-wrap: nowrap;" @@ -13806,7 +13806,7 @@ exports[` > renders SmallSectionList story 1`] = ` aria-haspopup="menu" aria-label="Open room Random" aria-selected="false" - class="flex roomListItem mx_RoomListItemView lastItem" + class="flex roomListItem mx_RoomListItemView" data-state="closed" role="gridcell" style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: stretch; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-3x); --mx-flex-wrap: nowrap;" diff --git a/packages/shared-components/src/room-list/VirtualizedRoomListView/VirtualizedRoomListView.tsx b/packages/shared-components/src/room-list/VirtualizedRoomListView/VirtualizedRoomListView.tsx index 3bc484ae035..fbfb3d617af 100644 --- a/packages/shared-components/src/room-list/VirtualizedRoomListView/VirtualizedRoomListView.tsx +++ b/packages/shared-components/src/room-list/VirtualizedRoomListView/VirtualizedRoomListView.tsx @@ -139,6 +139,13 @@ export function VirtualizedRoomListView({ vm, renderAvatar, onKeyDown }: Virtual /** * Get the item component for a specific index * Gets the room's view model and passes it to RoomListItemView + * + * @param index - The index of the item in the list + * @param roomId - The ID of the room for this item + * @param context - The virtualization context containing list state + * @param onFocus - Callback to call when the item is focused + * @param isInLastSection - Whether this item is in the last section + * @param roomIndexInSection - The index of this room within its section */ const getItemComponent = useCallback( ( @@ -146,18 +153,24 @@ export function VirtualizedRoomListView({ vm, renderAvatar, onKeyDown }: Virtual roomId: string, context: VirtualizedListContext, onFocus: (item: string, e: React.FocusEvent) => void, - roomIndexInSection: number, + isInLastSection?: boolean, + roomIndexInSection?: number, ): JSX.Element => { const { activeRoomIndex, roomCount, vm, isFlatList } = context.context; const isSelected = activeRoomIndex === index; const roomItemVM = vm.getRoomItemViewModel(roomId); + // If we don't have a view model for this room, it means the room has been removed since the list was rendered - return an empty placeholder + if (!roomItemVM) { + return ; + } + // Item is focused when the list has focus AND this item's key matches tabIndexKey // This matches the old RoomList implementation's roving tabindex pattern const isFocused = context.focused && context.tabIndexKey === roomId; - const isFirstItem = index === 0; - const isLastItem = index === roomCount - 1; + const isFirstItem = isFlatList && index === 0; + const isLastItem = Boolean((isFlatList || isInLastSection) && index === roomCount - 1); return ( { const { sections } = context.context; const roomIndexInSection = sections[groupIndex].roomIds.findIndex((id) => id === roomId); - return getItemComponent(index, roomId, context, onFocus, roomIndexInSection); + const isInLastSection = groupIndex === sections.length - 1; + return getItemComponent(index, roomId, context, onFocus, isInLastSection, roomIndexInSection); }, [getItemComponent], ); /** * Get the item component for a specific index in a flat list - * Since we don't have sections, we can pass 0 for the room's index within its section to getItemComponent * Gets the room's view model and passes it to RoomListItemView */ const getItemComponentForFlatList = useCallback( @@ -211,8 +224,7 @@ export function VirtualizedRoomListView({ vm, renderAvatar, onKeyDown }: Virtual context: VirtualizedListContext, onFocus: (item: string, e: React.FocusEvent) => void, ): JSX.Element => { - // For a flat list, we don't have sections, so roomIndexInSection is unused and can be set to 0 - return getItemComponent(index, roomId, context, onFocus, 0); + return getItemComponent(index, roomId, context, onFocus); }, [getItemComponent], );