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 a390d3e6102..01146ef0dd5 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 @@ -175,4 +175,93 @@ test.describe("Room list custom sections", () => { await expect(getSectionHeader(page, "Low Priority")).toBeVisible(); }); }); + + test.describe("Adding a room to a custom section", () => { + /** + * Asserts a room is nested under a specific section using the treegrid aria-level hierarchy. + * Section header rows sit at aria-level=1; room rows nested within a section sit at aria-level=2. + * Verifies that the closest preceding aria-level=1 row is the expected section header. + */ + async function assertRoomInSection(page: Page, sectionName: string, roomName: string): Promise { + const roomList = getRoomList(page); + const roomRow = roomList.getByRole("row", { name: `Open room ${roomName}` }); + // Room row must be at aria-level=2 (i.e. inside a section) + await expect(roomRow).toHaveAttribute("aria-level", "2"); + // The closest preceding aria-level=1 row must be the expected section header. + // XPath preceding:: axis returns nodes before the context in document order; [1] picks the nearest one. + const closestSectionHeader = roomRow.locator(`xpath=preceding::*[@role="row" and @aria-level="1"][1]`); + await expect(closestSectionHeader).toContainText(sectionName); + } + + test("should add a room to a custom section via the More Options menu", async ({ page, app }) => { + await app.client.createRoom({ name: "my room" }); + await createCustomSection(page, "Work"); + + const roomList = getRoomList(page); + + // Room starts in Chats section (aria-level=2) + const roomItem = roomList.getByRole("row", { name: "Open room my room" }); + await expect(roomItem).toBeVisible(); + + // Open More Options and move to the Work section + await roomItem.hover(); + await roomItem.getByRole("button", { name: "More Options" }).click(); + await page.getByRole("menuitem", { name: "Move to" }).hover(); + await page.getByRole("menuitem", { name: "Work" }).click(); + + // Room should now be nested under the Work section header (aria-level=1 → aria-level=2) + await assertRoomInSection(page, "Work", "my room"); + }); + + test( + "should show 'Chat moved' toast when adding a room to a custom section", + { tag: "@screenshot" }, + async ({ page, app }) => { + await app.client.createRoom({ name: "my room" }); + await createCustomSection(page, "Work"); + + const roomList = getRoomList(page); + const roomItem = roomList.getByRole("row", { name: "Open room my room" }); + + await roomItem.hover(); + await roomItem.getByRole("button", { name: "More Options" }).click(); + await page.getByRole("menuitem", { name: "Move to" }).hover(); + await page.getByRole("menuitem", { name: "Work" }).click(); + + // The "Chat moved" toast should appear + await expect(page.getByText("Chat moved")).toBeVisible(); + + // Remove focus outline from the room item before taking the screenshot + await page.getByRole("button", { name: "User menu" }).focus(); + + await expect(roomList).toMatchScreenshot("room-list-sections-chat-moved-toast.png"); + }, + ); + + 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"); + + const roomList = getRoomList(page); + + // Move to Work section and verify placement via aria-level + let roomItem = roomList.getByRole("row", { name: "Open room my room" }); + await roomItem.hover(); + await roomItem.getByRole("button", { name: "More Options" }).click(); + await page.getByRole("menuitem", { name: "Move to" }).hover(); + await page.getByRole("menuitem", { name: "Work" }).click(); + + await assertRoomInSection(page, "Work", "my room"); + + // Toggle off by selecting the same section again + roomItem = roomList.getByRole("row", { name: "Open room my room" }); + await roomItem.hover(); + await roomItem.getByRole("button", { name: "More Options" }).click(); + await page.getByRole("menuitem", { name: "Move to" }).hover(); + await page.getByRole("menuitem", { name: "Work" }).click(); + + // Room is back in the Chats section + await assertRoomInSection(page, "Chats", "my room"); + }); + }); }); diff --git a/apps/web/playwright/snapshots/left-panel/room-list-panel/room-list-custom-sections.spec.ts/room-list-sections-chat-moved-toast-linux.png b/apps/web/playwright/snapshots/left-panel/room-list-panel/room-list-custom-sections.spec.ts/room-list-sections-chat-moved-toast-linux.png new file mode 100644 index 00000000000..4da4571cbcb Binary files /dev/null and b/apps/web/playwright/snapshots/left-panel/room-list-panel/room-list-custom-sections.spec.ts/room-list-sections-chat-moved-toast-linux.png differ diff --git a/apps/web/playwright/snapshots/left-panel/room-list-panel/room-list-sections.spec.ts/room-list-sections-collapsed-linux.png b/apps/web/playwright/snapshots/left-panel/room-list-panel/room-list-sections.spec.ts/room-list-sections-collapsed-linux.png index 6ebcef85327..1f5246ceaf1 100644 Binary files a/apps/web/playwright/snapshots/left-panel/room-list-panel/room-list-sections.spec.ts/room-list-sections-collapsed-linux.png and b/apps/web/playwright/snapshots/left-panel/room-list-panel/room-list-sections.spec.ts/room-list-sections-collapsed-linux.png differ diff --git a/apps/web/playwright/snapshots/left-panel/room-list-panel/room-list-sections.spec.ts/room-list-sections-linux.png b/apps/web/playwright/snapshots/left-panel/room-list-panel/room-list-sections.spec.ts/room-list-sections-linux.png index bd61342d605..28f00a9614d 100644 Binary files a/apps/web/playwright/snapshots/left-panel/room-list-panel/room-list-sections.spec.ts/room-list-sections-linux.png and b/apps/web/playwright/snapshots/left-panel/room-list-panel/room-list-sections.spec.ts/room-list-sections-linux.png differ diff --git a/apps/web/src/stores/room-list-v3/RoomListStoreV3.ts b/apps/web/src/stores/room-list-v3/RoomListStoreV3.ts index 7615d3ec67d..f2527c99711 100644 --- a/apps/web/src/stores/room-list-v3/RoomListStoreV3.ts +++ b/apps/web/src/stores/room-list-v3/RoomListStoreV3.ts @@ -62,6 +62,8 @@ export enum RoomListStoreV3Event { ListsLoaded = "lists_loaded", /** Fired when a new section is created in the room list. */ SectionCreated = "section_created", + /** Fired when a room's tags change. */ + RoomTagged = "room_tagged", } // The result object for returning rooms from the store @@ -93,6 +95,7 @@ export const CHATS_TAG = "chats"; export const LISTS_UPDATE_EVENT = RoomListStoreV3Event.ListsUpdate; export const LISTS_LOADED_EVENT = RoomListStoreV3Event.ListsLoaded; export const SECTION_CREATED_EVENT = RoomListStoreV3Event.SectionCreated; +export const ROOM_TAGGED_EVENT = RoomListStoreV3Event.RoomTagged; /** * This store allows for fast retrieval of the room list in a sorted and filtered manner. @@ -243,6 +246,7 @@ export class RoomListStoreV3Class extends AsyncStoreWithClient { case "MatrixActions.Room.tags": { const room = payload.room; this.addRoomAndEmit(room); + this.emit(ROOM_TAGGED_EVENT); break; } @@ -493,6 +497,13 @@ export class RoomListStoreV3Class extends AsyncStoreWithClient { this.emit(SECTION_CREATED_EVENT, tag); } + /** + * Returns the ordered section tags. + */ + public get orderedSectionTags(): string[] { + return this.sortedTags; + } + /** * Load the custom sections from the settings store and update the sorted tags. */ diff --git a/apps/web/src/stores/room-list-v3/section.ts b/apps/web/src/stores/room-list-v3/section.ts index 49454918900..389ec56a915 100644 --- a/apps/web/src/stores/room-list-v3/section.ts +++ b/apps/web/src/stores/room-list-v3/section.ts @@ -12,6 +12,20 @@ import { CreateSectionDialog } from "../../components/views/dialogs/CreateSectio type Tag = string; +/** + * Prefix for custom section tags. + */ +export const CUSTOM_SECTION_TAG_PREFIX = "element.io.section."; + +/** + * Checks if a given tag is a custom section tag. + * @param tag - The tag to check. + * @returns True if the tag is a custom section tag, false otherwise. + */ +export function isCustomSectionTag(tag: string): boolean { + return tag.startsWith(CUSTOM_SECTION_TAG_PREFIX); +} + /** * Structure of the custom section stored in the settings. The tag is used as a unique identifier for the section, and the name is given by the user. */ @@ -41,7 +55,7 @@ export async function createSection(): Promise { const [shouldCreateSection, sectionName] = await modal.finished; if (!shouldCreateSection || !sectionName) return undefined; - const tag = `element.io.section.${window.crypto.randomUUID()}`; + const tag = `${CUSTOM_SECTION_TAG_PREFIX}${window.crypto.randomUUID()}`; const newSection: CustomSection = { tag, name: sectionName }; // Save the new section data diff --git a/apps/web/src/utils/room/tagRoom.ts b/apps/web/src/utils/room/tagRoom.ts index ae9b52a174c..62bac2ffca0 100644 --- a/apps/web/src/utils/room/tagRoom.ts +++ b/apps/web/src/utils/room/tagRoom.ts @@ -13,20 +13,29 @@ import { DefaultTagID, type TagID } from "../../stores/room-list-v3/skip-list/ta import RoomListActions from "../../actions/RoomListActions"; import dis from "../../dispatcher/dispatcher"; import { getTagsForRoom } from "./getTagsForRoom"; +import { isCustomSectionTag } from "../../stores/room-list-v3/section"; /** - * Toggle tag for a given room + * Toggle tag for a given room. + * A room can only be in one section: either a custom section, Favourite, or LowPriority. + * Applying any of these will atomically replace the current section tag. * @param room The room to tag * @param tagId The tag to invert */ export function tagRoom(room: Room, tagId: TagID): void { - if (tagId === DefaultTagID.Favourite || tagId === DefaultTagID.LowPriority) { - const inverseTag = tagId === DefaultTagID.Favourite ? DefaultTagID.LowPriority : DefaultTagID.Favourite; - const isApplied = getTagsForRoom(room).includes(tagId); - const removeTag = isApplied ? tagId : inverseTag; - const addTag = isApplied ? null : tagId; - dis.dispatch(RoomListActions.tagRoom(room.client, room, removeTag, addTag)); - } else { + if (tagId !== DefaultTagID.Favourite && tagId !== DefaultTagID.LowPriority && !isCustomSectionTag(tagId)) { logger.warn(`Unexpected tag ${tagId} applied to ${room.roomId}`); + return; } + + // Find the section tag currently applied (Fav, LowPriority, or custom) — at most one exists + const currentSectionTag = + getTagsForRoom(room).find( + (t) => t === DefaultTagID.Favourite || t === DefaultTagID.LowPriority || isCustomSectionTag(t), + ) ?? null; + + const isApplied = currentSectionTag === tagId; + const removeTag = currentSectionTag; + const addTag = isApplied ? null : tagId; + dis.dispatch(RoomListActions.tagRoom(room.client, room, removeTag, addTag)); } diff --git a/apps/web/src/viewmodels/room-list/RoomListItemViewModel.ts b/apps/web/src/viewmodels/room-list/RoomListItemViewModel.ts index f40b60eebda..a87e69f0fb6 100644 --- a/apps/web/src/viewmodels/room-list/RoomListItemViewModel.ts +++ b/apps/web/src/viewmodels/room-list/RoomListItemViewModel.ts @@ -10,6 +10,7 @@ import { RoomNotifState, type RoomListItemViewSnapshot, type RoomListItemViewActions, + type Section, } from "@element-hq/web-shared-components"; import { RoomEvent } from "matrix-js-sdk/src/matrix"; import { CallType } from "matrix-js-sdk/src/webrtc/call"; @@ -37,7 +38,8 @@ import { Action } from "../../dispatcher/actions"; import type { ViewRoomPayload } from "../../dispatcher/payloads/ViewRoomPayload"; import PosthogTrackers from "../../PosthogTrackers"; import { type Call, CallEvent } from "../../models/Call"; -import RoomListStoreV3 from "../../stores/room-list-v3/RoomListStoreV3"; +import RoomListStoreV3, { CHATS_TAG } from "../../stores/room-list-v3/RoomListStoreV3"; +import { _t } from "../../languageHandler"; interface RoomItemProps { room: Room; @@ -96,6 +98,13 @@ export class RoomListItemViewModel this.disposables.trackListener(props.room, RoomEvent.Name, this.onRoomChanged); this.disposables.trackListener(props.room, RoomEvent.Tags, this.onRoomChanged); + const orderSectionsRef = SettingsStore.watchSetting("RoomList.OrderedCustomSections", null, () => + this.onOrderedCustomSectionsChange(), + ); + this.disposables.track(() => { + SettingsStore.unwatchSetting(orderSectionsRef); + }); + // Load message preview asynchronously (sync data is already complete) void this.loadAndSetMessagePreview(); } @@ -181,6 +190,7 @@ export class RoomListItemViewModel this.snapshot.merge({ ...newItem, notification: keepIfSame(this.snapshot.current.notification, newItem.notification), + sections: keepIfSame(this.snapshot.current.sections, newItem.sections), // Preserve message preview - it's managed separately by loadAndSetMessagePreview messagePreview: this.snapshot.current.messagePreview, }); @@ -279,6 +289,9 @@ export class RoomListItemViewModel const canMoveToSection = SettingsStore.getValue("feature_room_list_sections"); + // Build sections list for the "Move to section" submenu + const sections: Section[] = canMoveToSection ? RoomListItemViewModel.buildSections(roomTags) : []; + return { id: room.roomId, room, @@ -307,6 +320,7 @@ export class RoomListItemViewModel canMarkAsUnread, roomNotifState, canMoveToSection, + sections, }; } @@ -389,4 +403,42 @@ export class RoomListItemViewModel public onCreateSection = (): void => { RoomListStoreV3.instance.createSection(); }; + + public onToggleSection = (tag: string): void => { + tagRoom(this.props.room, tag); + }; + + private onOrderedCustomSectionsChange = (): void => { + // Rebuild sections list to reflect new order + const sections = RoomListItemViewModel.buildSections(this.props.room.tags); + this.snapshot.merge({ sections: keepIfSame(this.snapshot.current.sections, sections) }); + }; + + /** + * Build the list of available sections for the "Move to section" submenu. + * Order follows the canonical section order from RoomListStoreV3. + */ + private static buildSections(roomTags: Room["tags"]): Section[] { + const customSectionData = SettingsStore.getValue("RoomList.CustomSectionData") || {}; + + return ( + RoomListStoreV3.instance.orderedSectionTags + // Exclude the Chats section because the user toggle the other sections to move rooms in and out of the Chats section. + .filter((tag) => tag !== CHATS_TAG) + .map((tag) => ({ + tag, + name: RoomListItemViewModel.getSectionName(tag, customSectionData), + isSelected: Boolean(roomTags[tag]), + })) + ); + } + + /** + * Get the display name for a section based on its tag. + */ + private static getSectionName(tag: string, customSectionData: Record): string { + if (tag === DefaultTagID.Favourite) return _t("room_list|section|favourites"); + if (tag === DefaultTagID.LowPriority) return _t("room_list|section|low_priority"); + return customSectionData[tag]?.name || tag; + } } diff --git a/apps/web/src/viewmodels/room-list/RoomListViewModel.ts b/apps/web/src/viewmodels/room-list/RoomListViewModel.ts index eeac64a3c2c..e711e340b0f 100644 --- a/apps/web/src/viewmodels/room-list/RoomListViewModel.ts +++ b/apps/web/src/viewmodels/room-list/RoomListViewModel.ts @@ -13,6 +13,7 @@ import { type RoomListViewState, type RoomListSection, _t, + type ToastType, } from "@element-hq/web-shared-components"; import { type MatrixClient, type Room } from "matrix-js-sdk/src/matrix"; @@ -156,6 +157,13 @@ export class RoomListViewModel this.onSectionCreated as (...args: unknown[]) => void, ); + // Subscribe to room tagging + this.disposables.trackListener( + RoomListStoreV3.instance, + RoomListStoreV3Event.RoomTagged as any, + this.onRoomTagged, + ); + // Subscribe to active room changes to update selected room const dispatcherRef = dispatcher.register(this.onDispatch); this.disposables.track(() => { @@ -595,15 +603,11 @@ export class RoomListViewModel public onSectionCreated = (tag: string): void => { this.updateRoomListData(false, null, tag); + this.showToast("section_created"); + }; - clearTimeout(this.toastRef); - this.snapshot.merge({ - toast: "section_created", - }); - // Automatically close the toast after 15 seconds - this.toastRef = setTimeout(() => { - this.closeToast(); - }, 15 * 1000); + public onRoomTagged = (): void => { + this.showToast("chat_moved"); }; public closeToast: () => void = () => { @@ -612,6 +616,15 @@ export class RoomListViewModel toast: undefined, }); }; + + private showToast(toast: ToastType): void { + clearTimeout(this.toastRef); + this.snapshot.merge({ toast }); + // Automatically close the toast after 15 seconds + this.toastRef = setTimeout(() => { + this.closeToast(); + }, 15 * 1000); + } } /** diff --git a/apps/web/test/unit-tests/utils/room/tagRoom-test.ts b/apps/web/test/unit-tests/utils/room/tagRoom-test.ts index 20e5931a875..cec9b805a21 100644 --- a/apps/web/test/unit-tests/utils/room/tagRoom-test.ts +++ b/apps/web/test/unit-tests/utils/room/tagRoom-test.ts @@ -11,6 +11,7 @@ import { Room } from "matrix-js-sdk/src/matrix"; import RoomListActions from "../../../../src/actions/RoomListActions"; import defaultDispatcher from "../../../../src/dispatcher/dispatcher"; import { DefaultTagID, type TagID } from "../../../../src/stores/room-list-v3/skip-list/tag"; +import { CUSTOM_SECTION_TAG_PREFIX } from "../../../../src/stores/room-list-v3/section"; import { tagRoom } from "../../../../src/utils/room/tagRoom"; import { getMockClientWithEventEmitter } from "../../../test-utils"; import * as getTagsForRoomUtils from "../../../../src/utils/room/getTagsForRoom"; @@ -18,6 +19,7 @@ import * as getTagsForRoomUtils from "../../../../src/utils/room/getTagsForRoom" describe("tagRoom()", () => { const userId = "@alice:server.org"; const roomId = "!room:server.org"; + const customTag = `${CUSTOM_SECTION_TAG_PREFIX}my-section`; const makeRoom = (tags: TagID[] = []): Room => { const client = getMockClientWithEventEmitter({ @@ -59,7 +61,7 @@ describe("tagRoom()", () => { expect(RoomListActions.tagRoom).toHaveBeenCalledWith( room.client, room, - DefaultTagID.LowPriority, // remove + null, // remove DefaultTagID.Favourite, // add ); }); @@ -73,10 +75,24 @@ describe("tagRoom()", () => { expect(RoomListActions.tagRoom).toHaveBeenCalledWith( room.client, room, - DefaultTagID.Favourite, // remove + null, // remove DefaultTagID.LowPriority, // add ); }); + + it("should tag a room with a custom section", () => { + const room = makeRoom(); + + tagRoom(room, customTag); + + expect(defaultDispatcher.dispatch).toHaveBeenCalled(); + expect(RoomListActions.tagRoom).toHaveBeenCalledWith( + room.client, + room, + null, // remove + customTag, // add + ); + }); }); describe("when a room is tagged as favourite", () => { @@ -137,4 +153,26 @@ describe("tagRoom()", () => { ); }); }); + + describe("when a room is tagged with a custom section", () => { + const otherCustomTag = `${CUSTOM_SECTION_TAG_PREFIX}other-section`; + + it.each([ + { label: "untag the custom section", applyTag: customTag, expectedAdd: null }, + { label: "replace with favourite", applyTag: DefaultTagID.Favourite, expectedAdd: DefaultTagID.Favourite }, + { label: "replace with another custom section", applyTag: otherCustomTag, expectedAdd: otherCustomTag }, + ])("should $label", ({ applyTag, expectedAdd }) => { + const room = makeRoom([customTag]); + + tagRoom(room, applyTag); + + expect(defaultDispatcher.dispatch).toHaveBeenCalled(); + expect(RoomListActions.tagRoom).toHaveBeenCalledWith( + room.client, + room, + customTag, // remove + expectedAdd, // add + ); + }); + }); }); diff --git a/apps/web/test/viewmodels/room-list/RoomListItemViewModel-test.tsx b/apps/web/test/viewmodels/room-list/RoomListItemViewModel-test.tsx index 294476d075f..35ee53efdb7 100644 --- a/apps/web/test/viewmodels/room-list/RoomListItemViewModel-test.tsx +++ b/apps/web/test/viewmodels/room-list/RoomListItemViewModel-test.tsx @@ -21,7 +21,7 @@ import { RoomNotificationState } from "../../../src/stores/notifications/RoomNot import { RoomNotificationStateStore } from "../../../src/stores/notifications/RoomNotificationStateStore"; import { NotificationStateEvents } from "../../../src/stores/notifications/NotificationState"; import { type MessagePreview, MessagePreviewStore } from "../../../src/stores/message-preview"; -import SettingsStore from "../../../src/settings/SettingsStore"; +import SettingsStore, { type CallbackFn } from "../../../src/settings/SettingsStore"; import DMRoomMap from "../../../src/utils/DMRoomMap"; import { DefaultTagID } from "../../../src/stores/room-list-v3/skip-list/tag"; import dispatcher from "../../../src/dispatcher/dispatcher"; @@ -29,7 +29,8 @@ import { Action } from "../../../src/dispatcher/actions"; import { CallStore } from "../../../src/stores/CallStore"; import { CallEvent, type Call } from "../../../src/models/Call"; import { RoomListItemViewModel } from "../../../src/viewmodels/room-list/RoomListItemViewModel"; -import RoomListStoreV3 from "../../../src/stores/room-list-v3/RoomListStoreV3"; +import RoomListStoreV3, { CHATS_TAG } from "../../../src/stores/room-list-v3/RoomListStoreV3"; +import * as tagRoomModule from "../../../src/utils/room/tagRoom"; jest.mock("../../../src/viewmodels/room-list/utils", () => ({ hasAccessToOptionsMenu: jest.fn().mockReturnValue(true), @@ -83,6 +84,7 @@ describe("RoomListItemViewModel", () => { jest.spyOn(MessagePreviewStore.instance, "getPreviewForRoom").mockResolvedValue(null); jest.spyOn(CallStore.instance, "getCall").mockReturnValue(null); + jest.spyOn(RoomListStoreV3.instance, "orderedSectionTags", "get").mockReturnValue([]); }); afterEach(() => { @@ -213,8 +215,8 @@ describe("RoomListItemViewModel", () => { let watchCallback: any; jest.spyOn(SettingsStore, "getValue").mockImplementation(() => showPreview); - jest.spyOn(SettingsStore, "watchSetting").mockImplementation((_setting, _room, callback) => { - watchCallback = callback; + jest.spyOn(SettingsStore, "watchSetting").mockImplementation((setting, _room, callback) => { + if (setting === "RoomList.showMessagePreview") watchCallback = callback; return "watcher-id"; }); jest.spyOn(MessagePreviewStore.instance, "getPreviewForRoom").mockResolvedValue({ @@ -595,6 +597,93 @@ describe("RoomListItemViewModel", () => { viewModel.onCreateSection(); expect(createSectionSpy).toHaveBeenCalled(); }); + + it("should call tagRoom when onToggleSection is called", () => { + const tagRoomSpy = jest.spyOn(tagRoomModule, "tagRoom").mockImplementation(() => {}); + viewModel = new RoomListItemViewModel({ room, client: matrixClient }); + + viewModel.onToggleSection(DefaultTagID.Favourite); + + expect(tagRoomSpy).toHaveBeenCalledWith(room, DefaultTagID.Favourite); + }); + }); + + describe("Sections", () => { + const customTag = "element.io.section.custom1"; + + beforeEach(() => { + jest.spyOn(RoomListStoreV3.instance, "orderedSectionTags", "get").mockReturnValue([ + DefaultTagID.Favourite, + customTag, + CHATS_TAG, + DefaultTagID.LowPriority, + ]); + }); + + it("should include sections from orderedSectionTags excluding CHATS_TAG", () => { + jest.spyOn(SettingsStore, "getValue").mockImplementation((setting) => { + if (setting === "feature_room_list_sections") return true; + return false; + }); + viewModel = new RoomListItemViewModel({ room, client: matrixClient }); + + const sections = viewModel.getSnapshot().sections; + expect(sections.map((s) => s.tag)).toEqual([DefaultTagID.Favourite, customTag, DefaultTagID.LowPriority]); + }); + + it("should mark the room current section as selected", () => { + room.tags = { [DefaultTagID.Favourite]: { order: 0 } }; + jest.spyOn(SettingsStore, "getValue").mockImplementation((setting) => { + if (setting === "feature_room_list_sections") return true; + return false; + }); + viewModel = new RoomListItemViewModel({ room, client: matrixClient }); + + const sections = viewModel.getSnapshot().sections; + expect(sections.find((s) => s.tag === DefaultTagID.Favourite)?.isSelected).toBe(true); + expect(sections.find((s) => s.tag === DefaultTagID.LowPriority)?.isSelected).toBe(false); + }); + + it("should use custom section name from CustomSectionData", () => { + jest.spyOn(SettingsStore, "getValue").mockImplementation((setting) => { + if (setting === "feature_room_list_sections") return true; + if (setting === "RoomList.CustomSectionData") + return { [customTag]: { name: "My Custom Section", tag: customTag } }; + return false; + }); + viewModel = new RoomListItemViewModel({ room, client: matrixClient }); + + const section = viewModel.getSnapshot().sections.find((s) => s.tag === customTag); + expect(section?.name).toBe("My Custom Section"); + }); + + it("should update sections when OrderedCustomSections setting changes", () => { + let watchCallback: CallbackFn = () => {}; + jest.spyOn(SettingsStore, "watchSetting").mockImplementation((setting, _room, callback) => { + if (setting === "RoomList.OrderedCustomSections") watchCallback = callback; + return "watcher-id"; + }); + jest.spyOn(SettingsStore, "getValue").mockImplementation((setting) => { + if (setting === "feature_room_list_sections") return true; + return false; + }); + + viewModel = new RoomListItemViewModel({ room, client: matrixClient }); + expect(viewModel.getSnapshot().sections).toHaveLength(3); // Favourite, custom, LowPriority + + // Simulate reordering: custom section removed + jest.spyOn(RoomListStoreV3.instance, "orderedSectionTags", "get").mockReturnValue([ + DefaultTagID.Favourite, + CHATS_TAG, + DefaultTagID.LowPriority, + ]); + watchCallback("RoomList.OrderedCustomSections", null, null as any, null, null); + + expect(viewModel.getSnapshot().sections.map((s) => s.tag)).toEqual([ + DefaultTagID.Favourite, + DefaultTagID.LowPriority, + ]); + }); }); describe("Cleanup", () => { diff --git a/apps/web/test/viewmodels/room-list/RoomListViewModel-test.tsx b/apps/web/test/viewmodels/room-list/RoomListViewModel-test.tsx index 4ae01433ceb..c0eacbdf35d 100644 --- a/apps/web/test/viewmodels/room-list/RoomListViewModel-test.tsx +++ b/apps/web/test/viewmodels/room-list/RoomListViewModel-test.tsx @@ -619,6 +619,12 @@ describe("RoomListViewModel", () => { expect(viewModel.getSnapshot().toast).toBe("section_created"); }); + it("should show toast when RoomTagged event fires", () => { + viewModel = new RoomListViewModel({ client: matrixClient }); + RoomListStoreV3.instance.emit(RoomListStoreV3Event.RoomTagged); + expect(viewModel.getSnapshot().toast).toBe("chat_moved"); + }); + it("should clear toast when closeToast is called", () => { viewModel = new RoomListViewModel({ client: matrixClient }); diff --git a/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListView/RoomListToast/RoomListToast.stories.tsx/chat-moved-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListView/RoomListToast/RoomListToast.stories.tsx/chat-moved-auto.png new file mode 100644 index 00000000000..0d4111e53b6 Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListView/RoomListToast/RoomListToast.stories.tsx/chat-moved-auto.png differ diff --git a/packages/shared-components/src/i18n/strings/en_EN.json b/packages/shared-components/src/i18n/strings/en_EN.json index a9eb6712d44..1296591759a 100644 --- a/packages/shared-components/src/i18n/strings/en_EN.json +++ b/packages/shared-components/src/i18n/strings/en_EN.json @@ -99,6 +99,7 @@ "voice_call": "Open room %(roomName)s with a voice call." }, "appearance": "Appearance", + "chat_moved": "Chat moved", "collapse_filters": "Collapse filter list", "empty": { "no_chats": "No chats yet", diff --git a/packages/shared-components/src/room-list/RoomListView/RoomListToast/RoomListToast.stories.tsx b/packages/shared-components/src/room-list/RoomListView/RoomListToast/RoomListToast.stories.tsx index 15a89a2e472..7f8b4ed9ae1 100644 --- a/packages/shared-components/src/room-list/RoomListView/RoomListToast/RoomListToast.stories.tsx +++ b/packages/shared-components/src/room-list/RoomListView/RoomListToast/RoomListToast.stories.tsx @@ -38,3 +38,9 @@ export default meta; type Story = StoryObj; export const SectionCreated: Story = {}; + +export const ChatMoved: Story = { + args: { + type: "chat_moved", + }, +}; diff --git a/packages/shared-components/src/room-list/RoomListView/RoomListToast/RoomListToast.test.tsx b/packages/shared-components/src/room-list/RoomListView/RoomListToast/RoomListToast.test.tsx index e64d17ffb69..e9f4303e52d 100644 --- a/packages/shared-components/src/room-list/RoomListView/RoomListToast/RoomListToast.test.tsx +++ b/packages/shared-components/src/room-list/RoomListView/RoomListToast/RoomListToast.test.tsx @@ -13,7 +13,7 @@ import userEvent from "@testing-library/user-event"; import * as stories from "./RoomListToast.stories"; -const { SectionCreated } = composeStories(stories); +const { SectionCreated, ChatMoved } = composeStories(stories); describe("", () => { it("renders SectionCreated story", () => { @@ -21,6 +21,11 @@ describe("", () => { expect(container).toMatchSnapshot(); }); + it("renders ChatMoved story", () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); + it("calls onClose when the close button is clicked", async () => { const user = userEvent.setup(); render(); diff --git a/packages/shared-components/src/room-list/RoomListView/RoomListToast/RoomListToast.tsx b/packages/shared-components/src/room-list/RoomListView/RoomListToast/RoomListToast.tsx index 9388cd93f55..e46b000fd4b 100644 --- a/packages/shared-components/src/room-list/RoomListView/RoomListToast/RoomListToast.tsx +++ b/packages/shared-components/src/room-list/RoomListView/RoomListToast/RoomListToast.tsx @@ -12,7 +12,7 @@ import CheckIcon from "@vector-im/compound-design-tokens/assets/web/icons/check" import styles from "./RoomListToast.module.css"; import { useI18n } from "../../../core/i18n/i18nContext"; -export type ToastType = "section_created"; +export type ToastType = "section_created" | "chat_moved"; interface RoomListToastProps { /** The type of toast to display */ @@ -37,6 +37,9 @@ export function RoomListToast({ type, onClose }: Readonly): case "section_created": content = { text: _t("room_list|section_created"), icon: CheckIcon }; break; + case "chat_moved": + content = { text: _t("room_list|chat_moved"), icon: CheckIcon }; + break; } return ( diff --git a/packages/shared-components/src/room-list/RoomListView/RoomListToast/__snapshots__/RoomListToast.test.tsx.snap b/packages/shared-components/src/room-list/RoomListView/RoomListToast/__snapshots__/RoomListToast.test.tsx.snap index b0e8906e3bc..05d35de67a0 100644 --- a/packages/shared-components/src/room-list/RoomListView/RoomListToast/__snapshots__/RoomListToast.test.tsx.snap +++ b/packages/shared-components/src/room-list/RoomListView/RoomListToast/__snapshots__/RoomListToast.test.tsx.snap @@ -1,5 +1,61 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html +exports[` > renders ChatMoved story 1`] = ` +
+
+
+
+ + Chat moved +
+ +
+
+
+`; + exports[` > renders SectionCreated story 1`] = `
", () => { onLeaveRoom: vi.fn(), onSetRoomNotifState: vi.fn(), onCreateSection: vi.fn(), + onToggleSection: vi.fn(), }; const renderMenu = (overrides: Partial = {}): ReturnType => { @@ -240,4 +241,59 @@ describe("", () => { expect(mockCallbacks.onCreateSection).toHaveBeenCalled(); }); + + it("should render section items in move to section submenu", () => { + const sections = [ + { tag: "m.favourite", name: "Favourites", isSelected: false }, + { tag: "element.io.section.custom1", name: "Work", isSelected: true }, + { tag: "element.io.section.custom2", name: "Personal", isSelected: false }, + ]; + + const TestComponent = (): JSX.Element => { + const vm = useMockedViewModel({ ...defaultSnapshot, sections }, mockCallbacks); + return ; + }; + render(); + + const favouriteItem = screen.getByRole("menuitem", { name: "Favourites" }); + expect(favouriteItem).toBeInTheDocument(); + expect(favouriteItem).toHaveAttribute("aria-checked", "false"); + + const workItem = screen.getByRole("menuitem", { name: "Work" }); + expect(workItem).toBeInTheDocument(); + expect(workItem).toHaveAttribute("aria-checked", "true"); + + const personalItem = screen.getByRole("menuitem", { name: "Personal" }); + expect(personalItem).toBeInTheDocument(); + expect(personalItem).toHaveAttribute("aria-checked", "false"); + }); + + it("should call onToggleSection when a section item is clicked", async () => { + const user = userEvent.setup(); + const sections = [ + { tag: "m.favourite", name: "Favourites", isSelected: false }, + { tag: "element.io.section.custom1", name: "Work", isSelected: false }, + ]; + + const TestComponent = (): JSX.Element => { + const vm = useMockedViewModel({ ...defaultSnapshot, sections }, mockCallbacks); + return ; + }; + render(); + + const workItem = screen.getByRole("menuitem", { name: "Work" }); + await user.click(workItem); + + expect(mockCallbacks.onToggleSection).toHaveBeenCalledWith("element.io.section.custom1"); + }); + + it("should not render section items when sections array is empty", () => { + const TestComponent = (): JSX.Element => { + const vm = useMockedViewModel({ ...defaultSnapshot, sections: [] }, mockCallbacks); + return ; + }; + render(); + + expect(screen.getByRole("menuitem", { name: "New section" })).toBeInTheDocument(); + }); }); diff --git a/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListItemAccessibilityWrapper/RoomListItemView/RoomListItemMoreOptionsMenu.tsx b/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListItemAccessibilityWrapper/RoomListItemView/RoomListItemMoreOptionsMenu.tsx index e03504ad01c..00690a445bc 100644 --- a/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListItemAccessibilityWrapper/RoomListItemView/RoomListItemMoreOptionsMenu.tsx +++ b/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListItemAccessibilityWrapper/RoomListItemView/RoomListItemMoreOptionsMenu.tsx @@ -17,6 +17,7 @@ import { LeaveIcon, OverflowHorizontalIcon, ArrowRightIcon, + CheckIcon, } from "@vector-im/compound-design-tokens/assets/web/icons"; import { _t } from "../../../../core/i18n/i18n"; @@ -136,6 +137,21 @@ export function MoreOptionContent({ vm }: MoreOptionContentProps): JSX.Element { /> } > + {snapshot.sections.map((section) => ( + vm.onToggleSection(section.tag)} + onClick={(evt) => evt.stopPropagation()} + hideChevron={true} + aria-checked={section.isSelected} + > + {section.isSelected && ( + + )} + + ))} + )} diff --git a/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListItemAccessibilityWrapper/RoomListItemView/RoomListItemNotificationMenu.test.tsx b/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListItemAccessibilityWrapper/RoomListItemView/RoomListItemNotificationMenu.test.tsx index 81db73e03fc..f7fb8570770 100644 --- a/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListItemAccessibilityWrapper/RoomListItemView/RoomListItemNotificationMenu.test.tsx +++ b/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListItemAccessibilityWrapper/RoomListItemView/RoomListItemNotificationMenu.test.tsx @@ -28,6 +28,7 @@ describe("", () => { onLeaveRoom: vi.fn(), onSetRoomNotifState: vi.fn(), onCreateSection: vi.fn(), + onToggleSection: vi.fn(), }; const renderMenu = (roomNotifState: RoomNotifState = RoomNotifState.AllMessages): ReturnType => { diff --git a/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListItemAccessibilityWrapper/RoomListItemView/RoomListItemView.stories.tsx b/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListItemAccessibilityWrapper/RoomListItemView/RoomListItemView.stories.tsx index 165d38d2673..21bf2806d2e 100644 --- a/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListItemAccessibilityWrapper/RoomListItemView/RoomListItemView.stories.tsx +++ b/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListItemAccessibilityWrapper/RoomListItemView/RoomListItemView.stories.tsx @@ -39,6 +39,7 @@ const RoomListItemWrapperImpl = ({ onLeaveRoom, onSetRoomNotifState, onCreateSection, + onToggleSection, isSelected, isFocused, onFocus, @@ -58,6 +59,7 @@ const RoomListItemWrapperImpl = ({ onLeaveRoom, onSetRoomNotifState, onCreateSection, + onToggleSection, }); return ( void; /** Called when creating a new section */ onCreateSection: () => void; + /** Called when toggling a room's membership in a section */ + onToggleSection: (tag: string) => void; } /** diff --git a/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListItemAccessibilityWrapper/RoomListItemView/default-snapshot.ts b/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListItemAccessibilityWrapper/RoomListItemView/default-snapshot.ts index f01243eb095..bf0cb0189e7 100644 --- a/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListItemAccessibilityWrapper/RoomListItemView/default-snapshot.ts +++ b/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListItemAccessibilityWrapper/RoomListItemView/default-snapshot.ts @@ -37,4 +37,21 @@ export const defaultSnapshot: RoomListItemViewSnapshot = { canMarkAsUnread: true, roomNotifState: RoomNotifState.AllMessages, canMoveToSection: true, + sections: [ + { + tag: "m.favourite", + name: "Favourites", + isSelected: false, + }, + { + tag: "element.io.section.work", + name: "Work", + isSelected: true, + }, + { + tag: "m.lowpriority", + name: "Low Priority", + isSelected: false, + }, + ], }; diff --git a/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListItemAccessibilityWrapper/RoomListItemView/index.ts b/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListItemAccessibilityWrapper/RoomListItemView/index.ts index 13db309fd0f..72f2f98119d 100644 --- a/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListItemAccessibilityWrapper/RoomListItemView/index.ts +++ b/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListItemAccessibilityWrapper/RoomListItemView/index.ts @@ -12,6 +12,7 @@ export type { RoomListItemViewModel, RoomListItemViewActions, RoomListItemViewProps, + Section, } from "./RoomListItemView"; export { RoomListItemNotificationMenu } from "./RoomListItemNotificationMenu"; export type { RoomListItemNotificationMenuProps } from "./RoomListItemNotificationMenu"; diff --git a/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListItemAccessibilityWrapper/RoomListItemView/mocked-actions.ts b/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListItemAccessibilityWrapper/RoomListItemView/mocked-actions.ts index 806e2e76ff2..8e79c52cc51 100644 --- a/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListItemAccessibilityWrapper/RoomListItemView/mocked-actions.ts +++ b/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListItemAccessibilityWrapper/RoomListItemView/mocked-actions.ts @@ -20,4 +20,5 @@ export const mockedActions: RoomListItemViewActions = { onLeaveRoom: fn(), onSetRoomNotifState: fn(), onCreateSection: fn(), + onToggleSection: fn(), }; diff --git a/packages/shared-components/src/room-list/story-mocks.tsx b/packages/shared-components/src/room-list/story-mocks.tsx index 148baec2a18..5d2e615859a 100644 --- a/packages/shared-components/src/room-list/story-mocks.tsx +++ b/packages/shared-components/src/room-list/story-mocks.tsx @@ -106,6 +106,7 @@ export const createMockRoomSnapshot = (id: string, name: string, index: number): canMarkAsUnread: true, roomNotifState: RoomNotifState.AllMessages, canMoveToSection: true, + sections: [], }); export function createMockRoomItemViewModel(roomId: string, name: string, index: number): RoomListItemViewModel { @@ -123,6 +124,7 @@ export function createMockRoomItemViewModel(roomId: string, name: string, index: onLeaveRoom: fn(), onSetRoomNotifState: fn(), onCreateSection: fn(), + onToggleSection: fn(), }; }