diff --git a/apps/web/playwright/e2e/left-panel/room-list-panel/room-list-custom-sections.spec.ts b/apps/web/playwright/e2e/left-panel/room-list-panel/room-list-custom-sections.spec.ts index e7ed355832..356c129e72 100644 --- a/apps/web/playwright/e2e/left-panel/room-list-panel/room-list-custom-sections.spec.ts +++ b/apps/web/playwright/e2e/left-panel/room-list-panel/room-list-custom-sections.spec.ts @@ -9,7 +9,7 @@ import { type Page } from "@playwright/test"; import { rejectToast } from "@element-hq/element-web-playwright-common"; import { expect, test } from "../../../element-web-test"; -import { assertRoomInSection, getRoomList, getRoomListHeader, getSectionHeader } from "./utils"; +import { assertRoomInSection, dragRoomToSection, getRoomList, getRoomListHeader, getSectionHeader } from "./utils"; test.describe("Room list custom sections", () => { test.use({ @@ -330,6 +330,22 @@ test.describe("Room list custom sections", () => { }, ); + test("should accept drag and drop into a section created after another section exists", async ({ + page, + app, + }) => { + await app.client.createRoom({ name: "room A" }); + await app.client.createRoom({ name: "room B" }); + await createCustomSection(page, "Work"); + await createCustomSection(page, "Personal"); + + await dragRoomToSection(page, "room A", "Personal"); + await assertRoomInSection(page, "Personal", "room A"); + + await dragRoomToSection(page, "room B", "Work"); + await assertRoomInSection(page, "Work", "room B"); + }); + test("should remove a room from a custom section when toggling the same section", async ({ page, app }) => { await app.client.createRoom({ name: "my room" }); await createCustomSection(page, "Work"); diff --git a/apps/web/src/stores/room-list-v3/RoomListStoreV3.ts b/apps/web/src/stores/room-list-v3/RoomListStoreV3.ts index 50feaa83df..7004efc090 100644 --- a/apps/web/src/stores/room-list-v3/RoomListStoreV3.ts +++ b/apps/web/src/stores/room-list-v3/RoomListStoreV3.ts @@ -486,7 +486,7 @@ export class RoomListStoreV3Class extends AsyncStoreWithClient { * Emits {@link SECTION_CREATED_EVENT} if the section was successfully created. */ public async createSection(): Promise { - const tag = await createSection(); + const tag = await createSection(SpaceStore.instance.activeSpace); if (!tag) return; this.emit(SECTION_CREATED_EVENT, tag); return tag; diff --git a/apps/web/src/stores/room-list-v3/section.ts b/apps/web/src/stores/room-list-v3/section.ts index 16e1b9a374..1cde7e6a04 100644 --- a/apps/web/src/stores/room-list-v3/section.ts +++ b/apps/web/src/stores/room-list-v3/section.ts @@ -13,6 +13,8 @@ import Modal from "../../Modal"; import { CreateSectionDialog } from "../../components/views/dialogs/CreateSectionDialog"; import { RemoveSectionDialog } from "../../components/views/dialogs/RemoveSectionDialog"; import { DefaultTagID, type TagID } from "./skip-list/tag"; +import { isMetaSpace, MetaSpace, type SpaceKey } from "../spaces"; +import SpaceStore from "../spaces/SpaceStore"; /** * A synthetic tag used to represent the "Chats" section, which contains @@ -60,6 +62,8 @@ export function isSectionTag(tagId: TagID): boolean { type CustomSection = { tag: CustomTag; name: string; + /** The space in which this section was created. Used to control visibility of empty sections. */ + spaceId?: SpaceKey; }; /** @@ -83,6 +87,14 @@ export type CustomSectionsData = Record; */ export type OrderedCustomSections = CustomTag[]; +/** + * Returns true if the given space key corresponds to an enabled meta-space or a known top-level space room. + */ +function doesSpaceExist(spaceId: SpaceKey): boolean { + if (isMetaSpace(spaceId)) return SpaceStore.instance.enabledMetaSpaces.includes(spaceId); + return SpaceStore.instance.spacePanelSpaces.some((room) => room.roomId === spaceId); +} + /** * Retrieves the custom sections data from the settings. * Invalid or malformed entries are dropped and the cleaned data is persisted back to settings. @@ -92,9 +104,17 @@ export function getCustomSectionData(): CustomSectionsData { // Data are malformed if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return {}; - // Filter out invalid entries return Object.fromEntries( - Object.entries(raw).filter(([key, value]) => isValidCustomSection(value) && value.tag === key), + Object.entries(raw) + .filter(([key, value]) => isValidCustomSection(value) && value.tag === key) + .map(([key, value]) => [ + key, + { + ...value, + // Default to MetaSpace.Home for legacy sections (no spaceId) or if the stored space no longer exists + spaceId: value.spaceId && doesSpaceExist(value.spaceId) ? value.spaceId : MetaSpace.Home, + }, + ]), ) as CustomSectionsData; } @@ -113,16 +133,17 @@ export function getOrderedCustomSections(): OrderedCustomSections { * Creates a new custom section by showing a dialog to the user to enter the section name. * If the user confirms, it generates a unique tag for the section, saves the section data in the settings, and updates the ordered list of sections. * + * @param spaceId The space in which the section is being created. Used to control visibility of the empty section. * @return A promise that resolves to the new section tag if created, or undefined if cancelled. */ -export async function createSection(): Promise { +export async function createSection(spaceId: SpaceKey): Promise { const modal = Modal.createDialog(CreateSectionDialog); const [shouldCreateSection, sectionName] = await modal.finished; if (!shouldCreateSection || !sectionName) return undefined; const tag: CustomTag = `${CUSTOM_SECTION_TAG_PREFIX}${window.crypto.randomUUID()}`; - const newSection: CustomSection = { tag, name: sectionName }; + const newSection: CustomSection = { tag, name: sectionName, spaceId }; // Save the new section data const sectionData = getCustomSectionData(); diff --git a/apps/web/src/viewmodels/room-list/RoomListViewModel.ts b/apps/web/src/viewmodels/room-list/RoomListViewModel.ts index 55a2708e36..c988e9e668 100644 --- a/apps/web/src/viewmodels/room-list/RoomListViewModel.ts +++ b/apps/web/src/viewmodels/room-list/RoomListViewModel.ts @@ -645,6 +645,10 @@ export class RoomListViewModel }; public onSectionCreated = (tag: string): void => { + // Refresh roomsResult so the new section lands in the same snapshot as the scroll-to. + const filterKeys = this.activeFilter !== undefined ? [this.activeFilter] : undefined; + this.roomsResult = RoomListStoreV3.instance.getSortedRoomsInActiveSpace(filterKeys); + this.updateRoomsMap(this.roomsResult); this.updateRoomListData(false, null, tag); this.showToast("section_created"); }; @@ -694,9 +698,11 @@ function computeSections( const customSections = getCustomSectionData(); const sections = roomsResult.sections - // Only include sections that have rooms or are custom sections (which may be empty but should still be shown) + // Only include sections that have rooms, or custom sections that were created in the current space. .filter( - (section) => section.rooms.length > 0 || (isCustomSectionTag(section.tag) && customSections[section.tag]), + (section) => + section.rooms.length > 0 || + (isCustomSectionTag(section.tag) && customSections[section.tag]?.spaceId === roomsResult.spaceId), ) // Remove roomIds for sections that are currently collapsed according to their section header view model .map((section) => ({ diff --git a/apps/web/test/unit-tests/stores/room-list-v3/section-test.ts b/apps/web/test/unit-tests/stores/room-list-v3/section-test.ts index 8ba9da11c0..b1fe1a2fb8 100644 --- a/apps/web/test/unit-tests/stores/room-list-v3/section-test.ts +++ b/apps/web/test/unit-tests/stores/room-list-v3/section-test.ts @@ -5,6 +5,8 @@ * Please see LICENSE files in the repository root for full details. */ +import { type Room } from "matrix-js-sdk/src/matrix"; + import Modal from "../../../../src/Modal"; import SettingsStore from "../../../../src/settings/SettingsStore"; import { @@ -21,6 +23,8 @@ import { import { CreateSectionDialog } from "../../../../src/components/views/dialogs/CreateSectionDialog"; import { RemoveSectionDialog } from "../../../../src/components/views/dialogs/RemoveSectionDialog"; import { DefaultTagID } from "../../../../src/stores/room-list-v3/skip-list/tag"; +import { MetaSpace } from "../../../../src/stores/spaces"; +import SpaceStore from "../../../../src/stores/spaces/SpaceStore"; describe("section", () => { afterEach(() => { @@ -33,7 +37,9 @@ describe("section", () => { const validEntry = { tag: validTag, name: "Valid" }; beforeEach(() => { - jest.spyOn(SettingsStore, "setValue").mockResolvedValue(undefined); + // Default: no known spaces + jest.spyOn(SpaceStore.instance, "enabledMetaSpaces", "get").mockReturnValue([]); + jest.spyOn(SpaceStore.instance, "spacePanelSpaces", "get").mockReturnValue([]); }); it.each([null, false, 42, "string", []] as const)("returns an empty object when the raw value is %p", (raw) => { @@ -41,12 +47,12 @@ describe("section", () => { expect(getCustomSectionData()).toEqual({}); }); - it("returns valid entries and drops invalid ones", () => { + it("returns valid entries and drops invalid ones, defaulting spaceId to MetaSpace.Home", () => { jest.spyOn(SettingsStore, "getValue").mockReturnValue({ [validTag]: validEntry, [invalidTag]: { tag: "element.io.section.mismatch", name: "Bad" }, }); - expect(getCustomSectionData()).toEqual({ [validTag]: validEntry }); + expect(getCustomSectionData()).toEqual({ [validTag]: { ...validEntry, spaceId: MetaSpace.Home } }); }); it("drops entries that fail the isValidCustomSection check", () => { @@ -58,13 +64,44 @@ describe("section", () => { }); expect(getCustomSectionData()).toEqual({}); }); + + it("defaults spaceId to MetaSpace.Home when spaceId is missing", () => { + jest.spyOn(SettingsStore, "getValue").mockReturnValue({ [validTag]: validEntry }); + expect(getCustomSectionData()[validTag].spaceId).toBe(MetaSpace.Home); + }); + + it("defaults spaceId to MetaSpace.Home when the stored space does not exist", () => { + jest.spyOn(SettingsStore, "getValue").mockReturnValue({ + [validTag]: { ...validEntry, spaceId: "!gone:server" }, + }); + // spacePanelSpaces is empty (default mock), so !gone:server is unknown + expect(getCustomSectionData()[validTag].spaceId).toBe(MetaSpace.Home); + }); + + it("keeps spaceId when the meta-space is enabled", () => { + jest.spyOn(SpaceStore.instance, "enabledMetaSpaces", "get").mockReturnValue([MetaSpace.Home]); + jest.spyOn(SettingsStore, "getValue").mockReturnValue({ + [validTag]: { ...validEntry, spaceId: MetaSpace.Home }, + }); + expect(getCustomSectionData()[validTag].spaceId).toBe(MetaSpace.Home); + }); + + it("keeps spaceId when the real space room exists", () => { + const spaceId = "!space:server"; + jest.spyOn(SpaceStore.instance, "spacePanelSpaces", "get").mockReturnValue([{ roomId: spaceId } as Room]); + jest.spyOn(SettingsStore, "getValue").mockReturnValue({ + [validTag]: { ...validEntry, spaceId }, + }); + expect(getCustomSectionData()[validTag].spaceId).toBe(spaceId); + }); }); describe("getOrderedCustomSections", () => { const tag = "element.io.section.abc"; beforeEach(() => { - jest.spyOn(SettingsStore, "setValue").mockResolvedValue(undefined); + jest.spyOn(SpaceStore.instance, "enabledMetaSpaces", "get").mockReturnValue([]); + jest.spyOn(SpaceStore.instance, "spacePanelSpaces", "get").mockReturnValue([]); }); it("returns an empty array when the raw value is not an array", () => { @@ -93,6 +130,8 @@ describe("section", () => { beforeEach(() => { jest.spyOn(SettingsStore, "getValue").mockReturnValue(null); jest.spyOn(SettingsStore, "setValue").mockResolvedValue(undefined); + jest.spyOn(SpaceStore.instance, "enabledMetaSpaces", "get").mockReturnValue([]); + jest.spyOn(SpaceStore.instance, "spacePanelSpaces", "get").mockReturnValue([]); }); it.each([ @@ -105,7 +144,7 @@ describe("section", () => { close: jest.fn(), } as any); - const result = await createSection(); + const result = await createSection(MetaSpace.Home); expect(result).toEqual(expected); }); @@ -115,7 +154,7 @@ describe("section", () => { close: jest.fn(), } as any); - const result = await createSection(); + const result = await createSection(MetaSpace.Home); expect(result).toMatch(/^element\.io\.section\./); }); @@ -125,7 +164,7 @@ describe("section", () => { close: jest.fn(), } as any); - await createSection(); + await createSection(MetaSpace.Home); expect(createDialogSpy).toHaveBeenCalledWith(CreateSectionDialog); }); @@ -143,7 +182,7 @@ describe("section", () => { } as any); const setValueSpy = jest.spyOn(SettingsStore, "setValue").mockResolvedValue(undefined); - await createSection(); + await createSection(MetaSpace.Home); const orderedCall = setValueSpy.mock.calls.find(([name]) => name === "RoomList.OrderedCustomSections"); const savedOrder = orderedCall![3] as string[]; @@ -152,9 +191,12 @@ describe("section", () => { const newTag = savedOrder[1]; const customDataCall = setValueSpy.mock.calls.find(([name]) => name === "RoomList.CustomSectionData"); - const savedSection = (customDataCall![3] as Record)[newTag]; + const savedSection = (customDataCall![3] as Record)[ + newTag + ]; expect(savedSection.name).toBe("My Section"); expect(savedSection.tag).toBe(newTag); + expect(savedSection.spaceId).toBe(MetaSpace.Home); }); }); @@ -219,7 +261,7 @@ describe("section", () => { "RoomList.CustomSectionData", null, expect.anything(), - expect.objectContaining({ [tag]: { tag, name: "New Name" } }), + expect.objectContaining({ [tag]: expect.objectContaining({ tag, name: "New Name" }) }), ); }); }); diff --git a/apps/web/test/viewmodels/room-list/RoomListViewModel-test.tsx b/apps/web/test/viewmodels/room-list/RoomListViewModel-test.tsx index 0631b94631..026fd4c74c 100644 --- a/apps/web/test/viewmodels/room-list/RoomListViewModel-test.tsx +++ b/apps/web/test/viewmodels/room-list/RoomListViewModel-test.tsx @@ -23,7 +23,8 @@ import { DefaultTagID } from "../../../src/stores/room-list-v3/skip-list/tag"; import SettingsStore from "../../../src/settings/SettingsStore"; import { tagRoom } from "../../../src/utils/room/tagRoom"; import { getSectionTagForRoom } from "../../../src/utils/room/getSectionTagForRoom"; -import { CHATS_TAG } from "../../../src/stores/room-list-v3/section"; +import { CHATS_TAG, CUSTOM_SECTION_TAG_PREFIX } from "../../../src/stores/room-list-v3/section"; +import { MetaSpace } from "../../../src/stores/spaces"; jest.mock("../../../src/utils/room/tagRoom", () => ({ tagRoom: jest.fn(), @@ -996,6 +997,73 @@ describe("RoomListViewModel", () => { expect(favSection!.roomIds).toEqual(["!fav1:server"]); }); + describe("custom section visibility by originating space", () => { + const customTag = `${CUSTOM_SECTION_TAG_PREFIX}test-uuid` as const; + + beforeEach(() => { + jest.spyOn(SpaceStore.instance, "enabledMetaSpaces", "get").mockReturnValue([MetaSpace.Home]); + jest.spyOn(SpaceStore.instance, "spacePanelSpaces", "get").mockReturnValue([ + mkStubRoom("!space:server", "My Space", matrixClient), + ]); + jest.spyOn(SettingsStore, "getValue").mockImplementation((setting: string) => { + if (setting === "feature_room_list_sections") return true; + if (setting === "RoomList.CustomSectionData") + return { + [customTag]: { tag: customTag, name: "My Section", spaceId: "!space:server" }, + }; + return false; + }); + }); + + it("shows an empty custom section when viewing its originating space", () => { + jest.spyOn(SettingsStore, "getValue").mockImplementation((setting: string) => { + if (setting === "feature_room_list_sections") return true; + if (setting === "RoomList.CustomSectionData") + return { [customTag]: { tag: customTag, name: "My Section", spaceId: MetaSpace.Home } }; + return false; + }); + jest.spyOn(RoomListStoreV3.instance, "getSortedRoomsInActiveSpace").mockReturnValue({ + spaceId: MetaSpace.Home, + sections: [ + { tag: customTag, rooms: [] }, + { tag: CHATS_TAG, rooms: [regularRoom1] }, + ], + }); + + viewModel = new RoomListViewModel({ client: matrixClient }); + + expect(viewModel.getSnapshot().sections.some((s) => s.id === customTag)).toBe(true); + }); + + it("hides an empty custom section in a different space", () => { + jest.spyOn(RoomListStoreV3.instance, "getSortedRoomsInActiveSpace").mockReturnValue({ + spaceId: MetaSpace.Home, + sections: [ + { tag: customTag, rooms: [] }, + { tag: CHATS_TAG, rooms: [regularRoom1] }, + ], + }); + + viewModel = new RoomListViewModel({ client: matrixClient }); + + expect(viewModel.getSnapshot().sections.some((s) => s.id === customTag)).toBe(false); + }); + + it("shows a non-empty custom section regardless of originating space", () => { + jest.spyOn(RoomListStoreV3.instance, "getSortedRoomsInActiveSpace").mockReturnValue({ + spaceId: MetaSpace.Home, + sections: [ + { tag: customTag, rooms: [regularRoom1] }, + { tag: CHATS_TAG, rooms: [regularRoom2] }, + ], + }); + + viewModel = new RoomListViewModel({ client: matrixClient }); + + expect(viewModel.getSnapshot().sections.some((s) => s.id === customTag)).toBe(true); + }); + }); + describe("Collapse/expand all sections", () => { it("should collapse all sections when Action.RoomListCollapseAllSections is dispatched", async () => { viewModel = new RoomListViewModel({ client: matrixClient }); diff --git a/packages/shared-components/src/room-list/VirtualizedRoomListView/VirtualizedRoomListView.tsx b/packages/shared-components/src/room-list/VirtualizedRoomListView/VirtualizedRoomListView.tsx index 1d01f35f0c..2549984090 100644 --- a/packages/shared-components/src/room-list/VirtualizedRoomListView/VirtualizedRoomListView.tsx +++ b/packages/shared-components/src/room-list/VirtualizedRoomListView/VirtualizedRoomListView.tsx @@ -264,6 +264,8 @@ export function VirtualizedRoomListView({ vm, renderAvatar, onKeyDown }: Virtual return (