Room list: add default sections (#32785)
* feat: add sections to RLSV3 * feat: add sections in vms * feat: add room list section labs flag * fix: wrong margin for room list item when in sections * feat: hide favourites and low priority filters * fix: crash when changing filter * feat: support sticky room in sections * test: update SC snapshot * test: update SC screenshot * test: update RLS tests * test: add tests to RoomListSectionHeaderViewModel * test: fix existing test in RoomListViewModel * test: add sections tests for RoomListViewModel * test: add e2e tests for sections * fix: incorrect selected room when expanding/collasping a section * fix: typo in `roomSkipList` * feat: use one skip list with all filters instead of one list by tag * chore: put back comment about `roomIndexInSection` * chore: add missing `readonly` * chore: add doc about possible undefined value for room item vm
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
After Width: | Height: | Size: 7.1 KiB |
|
After Width: | Height: | Size: 15 KiB |
@ -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",
|
||||
|
||||
@ -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`
|
||||
|
||||
@ -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<EmptyObject> {
|
||||
/**
|
||||
* 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<string, Filter> = 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<EmptyObject> {
|
||||
*/
|
||||
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<EmptyObject> {
|
||||
protected async onReady(): Promise<any> {
|
||||
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<EmptyObject> {
|
||||
const room = payload.room;
|
||||
this.roomSkipList.removeRoom(room);
|
||||
this.scheduleEmit();
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
@ -300,7 +339,7 @@ export class RoomListStoreV3Class extends AsyncStoreWithClient<EmptyObject> {
|
||||
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<EmptyObject> {
|
||||
.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<EmptyObject> {
|
||||
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 {
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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<RoomListSectionHeaderViewSnapshot, RoomListSectionHeaderViewModelProps>
|
||||
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;
|
||||
}
|
||||
}
|
||||
@ -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<FilterKey, FilterId> = 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<FilterEnum, FilterId> = 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<string, string> = {
|
||||
[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<RoomListViewSnapshot, RoomListViewModelProps>
|
||||
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<string, RoomListItemViewModel>();
|
||||
private readonly roomItemViewModels = new Map<string, RoomListItemViewModel>();
|
||||
// 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<string, Room>();
|
||||
// Don't clear section vm because we want to keep the expand/collapse state even during space changes.
|
||||
private readonly roomSectionHeaderViewModels = new Map<string, RoomListSectionHeaderViewModel>();
|
||||
|
||||
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;
|
||||
|
||||
if (!roomId) {
|
||||
return rooms;
|
||||
}
|
||||
|
||||
const newIndex = rooms.findIndex((room) => room.roomId === roomId);
|
||||
const oldIndex = this.lastActiveRoomIndex;
|
||||
private applyStickyRoom(isRoomChange: boolean, roomId: string | null | undefined): Section[] {
|
||||
const sections = this.roomsResult.sections;
|
||||
|
||||
// When opening another room, the index should obviously change
|
||||
if (isRoomChange) {
|
||||
return rooms;
|
||||
}
|
||||
if (!roomId || isRoomChange) return sections;
|
||||
|
||||
// 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 there was no previously tracked position, nothing to stick to
|
||||
const oldPosition = this.lastActiveRoomPosition;
|
||||
if (!oldPosition) return sections;
|
||||
|
||||
// If the index hasn't changed, we have nothing to do
|
||||
if (newIndex === oldIndex) {
|
||||
return rooms;
|
||||
}
|
||||
const newPosition = this.findRoomPosition(sections, roomId);
|
||||
|
||||
// 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 room is no longer in the list, nothing to do
|
||||
if (!newPosition) 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);
|
||||
// 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;
|
||||
|
||||
return newRooms;
|
||||
// If the index within the section hasn't changed, nothing to do
|
||||
if (newPosition.indexInSection === oldPosition.indexInSection) return sections;
|
||||
|
||||
// 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;
|
||||
|
||||
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),
|
||||
}));
|
||||
}
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 57 KiB |
|
Before Width: | Height: | Size: 37 KiB After Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 39 KiB |
@ -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) */
|
||||
|
||||
@ -8352,7 +8352,7 @@ exports[`<RoomListView /> > 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[`<RoomListView /> > 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[`<RoomListView /> > 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;"
|
||||
|
||||
@ -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<Context>,
|
||||
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 <React.Fragment key={`stale-${index}`} />;
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<RoomListItemAccessibilityWrapper
|
||||
@ -168,7 +181,8 @@ export function VirtualizedRoomListView({ vm, renderAvatar, onKeyDown }: Virtual
|
||||
isFocused={isFocused}
|
||||
onFocus={onFocus}
|
||||
roomIndex={index}
|
||||
roomIndexInSection={roomIndexInSection}
|
||||
// For a flat list, we don't have sections, so roomIndexInSection is unused and can be set to 0
|
||||
roomIndexInSection={roomIndexInSection || 0}
|
||||
roomCount={roomCount}
|
||||
isFirstItem={isFirstItem}
|
||||
isLastItem={isLastItem}
|
||||
@ -181,7 +195,6 @@ export function VirtualizedRoomListView({ vm, renderAvatar, onKeyDown }: Virtual
|
||||
|
||||
/**
|
||||
* Get the item component for a specific index in a grouped list
|
||||
* Since we have sections, we can calculate the room's index within its section and pass it to getItemComponent
|
||||
* Gets the room's view model and passes it to RoomListItemView
|
||||
*/
|
||||
const getItemComponentForGroupedList = useCallback(
|
||||
@ -194,14 +207,14 @@ export function VirtualizedRoomListView({ vm, renderAvatar, onKeyDown }: Virtual
|
||||
): JSX.Element => {
|
||||
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<Context>,
|
||||
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],
|
||||
);
|
||||
|
||||