Room list: assign room to custom section (#33238)

* feat(sc): add new toast type for room list

* feat(sc): add section entries in room list item menu

* feat(rls): expose util functions

* feat: allows to tag room with custom sections

* feat(vm): add new Chat moved toast to room list vm

* feat(vm): add section selection to room list item vm

* feat(e2e): add tests for adding room in a custom section

* test(e2e): update existing screenshots

* chore: fix lint after merge

* chore: remove outline in test
This commit is contained in:
Florian Duros 2026-04-22 21:50:54 +02:00 committed by GitHub
parent 73e1b87075
commit f4c62abbcd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
28 changed files with 532 additions and 26 deletions

View File

@ -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<void> {
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");
});
});
});

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.1 KiB

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 18 KiB

View File

@ -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<EmptyObject> {
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<EmptyObject> {
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.
*/

View File

@ -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<string | undefined> {
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

View File

@ -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));
}

View File

@ -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, { name: string }>): 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;
}
}

View File

@ -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);
}
}
/**

View File

@ -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
);
});
});
});

View File

@ -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", () => {

View File

@ -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 });

View File

@ -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",

View File

@ -38,3 +38,9 @@ export default meta;
type Story = StoryObj<typeof meta>;
export const SectionCreated: Story = {};
export const ChatMoved: Story = {
args: {
type: "chat_moved",
},
};

View File

@ -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("<RoomListToast />", () => {
it("renders SectionCreated story", () => {
@ -21,6 +21,11 @@ describe("<RoomListToast />", () => {
expect(container).toMatchSnapshot();
});
it("renders ChatMoved story", () => {
const { container } = render(<ChatMoved />);
expect(container).toMatchSnapshot();
});
it("calls onClose when the close button is clicked", async () => {
const user = userEvent.setup();
render(<SectionCreated />);

View File

@ -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<RoomListToastProps>):
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 (

View File

@ -1,5 +1,61 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`<RoomListToast /> > renders ChatMoved story 1`] = `
<div>
<div
style="position: relative; width: 320px; height: 100px; background-color: grey;"
>
<div
class="_typography_6v6n8_153 _font-body-sm-medium_6v6n8_41 _toast-container_1ysb3_8 RoomListToast-module_toast _has-close_1ysb3_30"
>
<div
class="_content_1ysb3_34"
>
<svg
aria-hidden="true"
class="_icon_1ysb3_26"
fill="currentColor"
height="20"
viewBox="0 0 24 24"
width="20"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M9.55 17.575q-.2 0-.375-.062a.9.9 0 0 1-.325-.213L4.55 13q-.274-.274-.262-.713.012-.437.287-.712a.95.95 0 0 1 .7-.275q.425 0 .7.275L9.55 15.15l8.475-8.475q.274-.275.713-.275.437 0 .712.275.275.274.275.713 0 .437-.275.712l-9.2 9.2q-.15.15-.325.212a1.1 1.1 0 0 1-.375.063"
/>
</svg>
Chat moved
</div>
<button
aria-labelledby="_r_6_"
class="_icon-button_1215g_8 _close_1ysb3_41 _no-background_1215g_42"
data-kind="secondary"
role="button"
style="--cpd-icon-button-size: 24px;"
tabindex="0"
>
<div
class="_indicator-icon_147l5_17"
style="--cpd-icon-button-size: 100%;"
>
<svg
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M6.293 6.293a1 1 0 0 1 1.414 0L12 10.586l4.293-4.293a1 1 0 1 1 1.414 1.414L13.414 12l4.293 4.293a1 1 0 0 1-1.414 1.414L12 13.414l-4.293 4.293a1 1 0 0 1-1.414-1.414L10.586 12 6.293 7.707a1 1 0 0 1 0-1.414"
/>
</svg>
</div>
</button>
</div>
</div>
</div>
`;
exports[`<RoomListToast /> > renders SectionCreated story 1`] = `
<div>
<div

View File

@ -17,3 +17,4 @@ export { RoomListLoadingSkeleton } from "./RoomListLoadingSkeleton";
export { RoomListEmptyStateView } from "./RoomListEmptyStateView";
export type { RoomListEmptyStateViewProps } from "./RoomListEmptyStateView";
export * from "../VirtualizedRoomListView/RoomListItemAccessibilityWrapper/RoomListItemView";
export * from "./RoomListToast";

View File

@ -27,6 +27,7 @@ describe("<RoomListItemMoreOptionsMenu />", () => {
onLeaveRoom: vi.fn(),
onSetRoomNotifState: vi.fn(),
onCreateSection: vi.fn(),
onToggleSection: vi.fn(),
};
const renderMenu = (overrides: Partial<RoomListItemViewSnapshot> = {}): ReturnType<typeof render> => {
@ -240,4 +241,59 @@ describe("<RoomListItemMoreOptionsMenu />", () => {
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 <MoreOptionContent vm={vm} />;
};
render(<TestComponent />);
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 <MoreOptionContent vm={vm} />;
};
render(<TestComponent />);
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 <MoreOptionContent vm={vm} />;
};
render(<TestComponent />);
expect(screen.getByRole("menuitem", { name: "New section" })).toBeInTheDocument();
});
});

View File

@ -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) => (
<MenuItem
key={section.tag}
label={section.name}
onSelect={() => vm.onToggleSection(section.tag)}
onClick={(evt) => evt.stopPropagation()}
hideChevron={true}
aria-checked={section.isSelected}
>
{section.isSelected && (
<CheckIcon color="var(--cpd-color-icon-tertiary)" width="24px" height="24px" />
)}
</MenuItem>
))}
<Separator />
<MenuItem label={_t("action|new_section")} onSelect={vm.onCreateSection} hideChevron={true} />
</SubMenu>
)}

View File

@ -28,6 +28,7 @@ describe("<RoomListItemNotificationMenu />", () => {
onLeaveRoom: vi.fn(),
onSetRoomNotifState: vi.fn(),
onCreateSection: vi.fn(),
onToggleSection: vi.fn(),
};
const renderMenu = (roomNotifState: RoomNotifState = RoomNotifState.AllMessages): ReturnType<typeof render> => {

View File

@ -39,6 +39,7 @@ const RoomListItemWrapperImpl = ({
onLeaveRoom,
onSetRoomNotifState,
onCreateSection,
onToggleSection,
isSelected,
isFocused,
onFocus,
@ -58,6 +59,7 @@ const RoomListItemWrapperImpl = ({
onLeaveRoom,
onSetRoomNotifState,
onCreateSection,
onToggleSection,
});
return (
<RoomListItemView

View File

@ -44,6 +44,19 @@ function getA11yLabel(roomName: string, notification: NotificationDecorationData
}
}
/**
* Describes a section that a room can be assigned to.
* Used to render toggle items in the "Move to section" submenu.
*/
export interface Section {
/** The tag that identifies this section (e.g. `m.favourite`, custom tag) */
tag: string;
/** The human-readable display name of the section */
name: string;
/** Whether the room currently belongs to this section */
isSelected: boolean;
}
/**
* Snapshot for a room list item.
* Contains all the data needed to render a room in the list.
@ -81,6 +94,8 @@ export interface RoomListItemViewSnapshot {
roomNotifState: RoomNotifState;
/** Whether the room can be moved to a section */
canMoveToSection: boolean;
/** Available sections the room can be assigned to */
sections: Section[];
}
/**
@ -108,6 +123,8 @@ export interface RoomListItemViewActions {
onSetRoomNotifState: (state: RoomNotifState) => void;
/** Called when creating a new section */
onCreateSection: () => void;
/** Called when toggling a room's membership in a section */
onToggleSection: (tag: string) => void;
}
/**

View File

@ -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,
},
],
};

View File

@ -12,6 +12,7 @@ export type {
RoomListItemViewModel,
RoomListItemViewActions,
RoomListItemViewProps,
Section,
} from "./RoomListItemView";
export { RoomListItemNotificationMenu } from "./RoomListItemNotificationMenu";
export type { RoomListItemNotificationMenuProps } from "./RoomListItemNotificationMenu";

View File

@ -20,4 +20,5 @@ export const mockedActions: RoomListItemViewActions = {
onLeaveRoom: fn(),
onSetRoomNotifState: fn(),
onCreateSection: fn(),
onToggleSection: fn(),
};

View File

@ -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(),
};
}