Skip to content

Commit 7853701

Browse files
authored
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
1 parent 73e1b87 commit 7853701

28 files changed

Lines changed: 532 additions & 26 deletions

File tree

apps/web/playwright/e2e/left-panel/room-list-panel/room-list-custom-sections.spec.ts

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,4 +175,93 @@ test.describe("Room list custom sections", () => {
175175
await expect(getSectionHeader(page, "Low Priority")).toBeVisible();
176176
});
177177
});
178+
179+
test.describe("Adding a room to a custom section", () => {
180+
/**
181+
* Asserts a room is nested under a specific section using the treegrid aria-level hierarchy.
182+
* Section header rows sit at aria-level=1; room rows nested within a section sit at aria-level=2.
183+
* Verifies that the closest preceding aria-level=1 row is the expected section header.
184+
*/
185+
async function assertRoomInSection(page: Page, sectionName: string, roomName: string): Promise<void> {
186+
const roomList = getRoomList(page);
187+
const roomRow = roomList.getByRole("row", { name: `Open room ${roomName}` });
188+
// Room row must be at aria-level=2 (i.e. inside a section)
189+
await expect(roomRow).toHaveAttribute("aria-level", "2");
190+
// The closest preceding aria-level=1 row must be the expected section header.
191+
// XPath preceding:: axis returns nodes before the context in document order; [1] picks the nearest one.
192+
const closestSectionHeader = roomRow.locator(`xpath=preceding::*[@role="row" and @aria-level="1"][1]`);
193+
await expect(closestSectionHeader).toContainText(sectionName);
194+
}
195+
196+
test("should add a room to a custom section via the More Options menu", async ({ page, app }) => {
197+
await app.client.createRoom({ name: "my room" });
198+
await createCustomSection(page, "Work");
199+
200+
const roomList = getRoomList(page);
201+
202+
// Room starts in Chats section (aria-level=2)
203+
const roomItem = roomList.getByRole("row", { name: "Open room my room" });
204+
await expect(roomItem).toBeVisible();
205+
206+
// Open More Options and move to the Work section
207+
await roomItem.hover();
208+
await roomItem.getByRole("button", { name: "More Options" }).click();
209+
await page.getByRole("menuitem", { name: "Move to" }).hover();
210+
await page.getByRole("menuitem", { name: "Work" }).click();
211+
212+
// Room should now be nested under the Work section header (aria-level=1 → aria-level=2)
213+
await assertRoomInSection(page, "Work", "my room");
214+
});
215+
216+
test(
217+
"should show 'Chat moved' toast when adding a room to a custom section",
218+
{ tag: "@screenshot" },
219+
async ({ page, app }) => {
220+
await app.client.createRoom({ name: "my room" });
221+
await createCustomSection(page, "Work");
222+
223+
const roomList = getRoomList(page);
224+
const roomItem = roomList.getByRole("row", { name: "Open room my room" });
225+
226+
await roomItem.hover();
227+
await roomItem.getByRole("button", { name: "More Options" }).click();
228+
await page.getByRole("menuitem", { name: "Move to" }).hover();
229+
await page.getByRole("menuitem", { name: "Work" }).click();
230+
231+
// The "Chat moved" toast should appear
232+
await expect(page.getByText("Chat moved")).toBeVisible();
233+
234+
// Remove focus outline from the room item before taking the screenshot
235+
await page.getByRole("button", { name: "User menu" }).focus();
236+
237+
await expect(roomList).toMatchScreenshot("room-list-sections-chat-moved-toast.png");
238+
},
239+
);
240+
241+
test("should remove a room from a custom section when toggling the same section", async ({ page, app }) => {
242+
await app.client.createRoom({ name: "my room" });
243+
await createCustomSection(page, "Work");
244+
245+
const roomList = getRoomList(page);
246+
247+
// Move to Work section and verify placement via aria-level
248+
let roomItem = roomList.getByRole("row", { name: "Open room my room" });
249+
await roomItem.hover();
250+
await roomItem.getByRole("button", { name: "More Options" }).click();
251+
await page.getByRole("menuitem", { name: "Move to" }).hover();
252+
await page.getByRole("menuitem", { name: "Work" }).click();
253+
254+
await assertRoomInSection(page, "Work", "my room");
255+
256+
// Toggle off by selecting the same section again
257+
roomItem = roomList.getByRole("row", { name: "Open room my room" });
258+
await roomItem.hover();
259+
await roomItem.getByRole("button", { name: "More Options" }).click();
260+
await page.getByRole("menuitem", { name: "Move to" }).hover();
261+
await page.getByRole("menuitem", { name: "Work" }).click();
262+
263+
// Room is back in the Chats section
264+
await assertRoomInSection(page, "Chats", "my room");
265+
});
266+
});
178267
});
Loading
2.49 KB
Loading
2.47 KB
Loading

apps/web/src/stores/room-list-v3/RoomListStoreV3.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,8 @@ export enum RoomListStoreV3Event {
6262
ListsLoaded = "lists_loaded",
6363
/** Fired when a new section is created in the room list. */
6464
SectionCreated = "section_created",
65+
/** Fired when a room's tags change. */
66+
RoomTagged = "room_tagged",
6567
}
6668

6769
// The result object for returning rooms from the store
@@ -93,6 +95,7 @@ export const CHATS_TAG = "chats";
9395
export const LISTS_UPDATE_EVENT = RoomListStoreV3Event.ListsUpdate;
9496
export const LISTS_LOADED_EVENT = RoomListStoreV3Event.ListsLoaded;
9597
export const SECTION_CREATED_EVENT = RoomListStoreV3Event.SectionCreated;
98+
export const ROOM_TAGGED_EVENT = RoomListStoreV3Event.RoomTagged;
9699

97100
/**
98101
* 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> {
243246
case "MatrixActions.Room.tags": {
244247
const room = payload.room;
245248
this.addRoomAndEmit(room);
249+
this.emit(ROOM_TAGGED_EVENT);
246250
break;
247251
}
248252

@@ -493,6 +497,13 @@ export class RoomListStoreV3Class extends AsyncStoreWithClient<EmptyObject> {
493497
this.emit(SECTION_CREATED_EVENT, tag);
494498
}
495499

500+
/**
501+
* Returns the ordered section tags.
502+
*/
503+
public get orderedSectionTags(): string[] {
504+
return this.sortedTags;
505+
}
506+
496507
/**
497508
* Load the custom sections from the settings store and update the sorted tags.
498509
*/

apps/web/src/stores/room-list-v3/section.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,20 @@ import { CreateSectionDialog } from "../../components/views/dialogs/CreateSectio
1212

1313
type Tag = string;
1414

15+
/**
16+
* Prefix for custom section tags.
17+
*/
18+
export const CUSTOM_SECTION_TAG_PREFIX = "element.io.section.";
19+
20+
/**
21+
* Checks if a given tag is a custom section tag.
22+
* @param tag - The tag to check.
23+
* @returns True if the tag is a custom section tag, false otherwise.
24+
*/
25+
export function isCustomSectionTag(tag: string): boolean {
26+
return tag.startsWith(CUSTOM_SECTION_TAG_PREFIX);
27+
}
28+
1529
/**
1630
* 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.
1731
*/
@@ -41,7 +55,7 @@ export async function createSection(): Promise<string | undefined> {
4155
const [shouldCreateSection, sectionName] = await modal.finished;
4256
if (!shouldCreateSection || !sectionName) return undefined;
4357

44-
const tag = `element.io.section.${window.crypto.randomUUID()}`;
58+
const tag = `${CUSTOM_SECTION_TAG_PREFIX}${window.crypto.randomUUID()}`;
4559
const newSection: CustomSection = { tag, name: sectionName };
4660

4761
// Save the new section data

apps/web/src/utils/room/tagRoom.ts

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -13,20 +13,29 @@ import { DefaultTagID, type TagID } from "../../stores/room-list-v3/skip-list/ta
1313
import RoomListActions from "../../actions/RoomListActions";
1414
import dis from "../../dispatcher/dispatcher";
1515
import { getTagsForRoom } from "./getTagsForRoom";
16+
import { isCustomSectionTag } from "../../stores/room-list-v3/section";
1617

1718
/**
18-
* Toggle tag for a given room
19+
* Toggle tag for a given room.
20+
* A room can only be in one section: either a custom section, Favourite, or LowPriority.
21+
* Applying any of these will atomically replace the current section tag.
1922
* @param room The room to tag
2023
* @param tagId The tag to invert
2124
*/
2225
export function tagRoom(room: Room, tagId: TagID): void {
23-
if (tagId === DefaultTagID.Favourite || tagId === DefaultTagID.LowPriority) {
24-
const inverseTag = tagId === DefaultTagID.Favourite ? DefaultTagID.LowPriority : DefaultTagID.Favourite;
25-
const isApplied = getTagsForRoom(room).includes(tagId);
26-
const removeTag = isApplied ? tagId : inverseTag;
27-
const addTag = isApplied ? null : tagId;
28-
dis.dispatch(RoomListActions.tagRoom(room.client, room, removeTag, addTag));
29-
} else {
26+
if (tagId !== DefaultTagID.Favourite && tagId !== DefaultTagID.LowPriority && !isCustomSectionTag(tagId)) {
3027
logger.warn(`Unexpected tag ${tagId} applied to ${room.roomId}`);
28+
return;
3129
}
30+
31+
// Find the section tag currently applied (Fav, LowPriority, or custom) — at most one exists
32+
const currentSectionTag =
33+
getTagsForRoom(room).find(
34+
(t) => t === DefaultTagID.Favourite || t === DefaultTagID.LowPriority || isCustomSectionTag(t),
35+
) ?? null;
36+
37+
const isApplied = currentSectionTag === tagId;
38+
const removeTag = currentSectionTag;
39+
const addTag = isApplied ? null : tagId;
40+
dis.dispatch(RoomListActions.tagRoom(room.client, room, removeTag, addTag));
3241
}

apps/web/src/viewmodels/room-list/RoomListItemViewModel.ts

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
RoomNotifState,
1111
type RoomListItemViewSnapshot,
1212
type RoomListItemViewActions,
13+
type Section,
1314
} from "@element-hq/web-shared-components";
1415
import { RoomEvent } from "matrix-js-sdk/src/matrix";
1516
import { CallType } from "matrix-js-sdk/src/webrtc/call";
@@ -37,7 +38,8 @@ import { Action } from "../../dispatcher/actions";
3738
import type { ViewRoomPayload } from "../../dispatcher/payloads/ViewRoomPayload";
3839
import PosthogTrackers from "../../PosthogTrackers";
3940
import { type Call, CallEvent } from "../../models/Call";
40-
import RoomListStoreV3 from "../../stores/room-list-v3/RoomListStoreV3";
41+
import RoomListStoreV3, { CHATS_TAG } from "../../stores/room-list-v3/RoomListStoreV3";
42+
import { _t } from "../../languageHandler";
4143

4244
interface RoomItemProps {
4345
room: Room;
@@ -96,6 +98,13 @@ export class RoomListItemViewModel
9698
this.disposables.trackListener(props.room, RoomEvent.Name, this.onRoomChanged);
9799
this.disposables.trackListener(props.room, RoomEvent.Tags, this.onRoomChanged);
98100

101+
const orderSectionsRef = SettingsStore.watchSetting("RoomList.OrderedCustomSections", null, () =>
102+
this.onOrderedCustomSectionsChange(),
103+
);
104+
this.disposables.track(() => {
105+
SettingsStore.unwatchSetting(orderSectionsRef);
106+
});
107+
99108
// Load message preview asynchronously (sync data is already complete)
100109
void this.loadAndSetMessagePreview();
101110
}
@@ -181,6 +190,7 @@ export class RoomListItemViewModel
181190
this.snapshot.merge({
182191
...newItem,
183192
notification: keepIfSame(this.snapshot.current.notification, newItem.notification),
193+
sections: keepIfSame(this.snapshot.current.sections, newItem.sections),
184194
// Preserve message preview - it's managed separately by loadAndSetMessagePreview
185195
messagePreview: this.snapshot.current.messagePreview,
186196
});
@@ -279,6 +289,9 @@ export class RoomListItemViewModel
279289

280290
const canMoveToSection = SettingsStore.getValue("feature_room_list_sections");
281291

292+
// Build sections list for the "Move to section" submenu
293+
const sections: Section[] = canMoveToSection ? RoomListItemViewModel.buildSections(roomTags) : [];
294+
282295
return {
283296
id: room.roomId,
284297
room,
@@ -307,6 +320,7 @@ export class RoomListItemViewModel
307320
canMarkAsUnread,
308321
roomNotifState,
309322
canMoveToSection,
323+
sections,
310324
};
311325
}
312326

@@ -389,4 +403,42 @@ export class RoomListItemViewModel
389403
public onCreateSection = (): void => {
390404
RoomListStoreV3.instance.createSection();
391405
};
406+
407+
public onToggleSection = (tag: string): void => {
408+
tagRoom(this.props.room, tag);
409+
};
410+
411+
private onOrderedCustomSectionsChange = (): void => {
412+
// Rebuild sections list to reflect new order
413+
const sections = RoomListItemViewModel.buildSections(this.props.room.tags);
414+
this.snapshot.merge({ sections: keepIfSame(this.snapshot.current.sections, sections) });
415+
};
416+
417+
/**
418+
* Build the list of available sections for the "Move to section" submenu.
419+
* Order follows the canonical section order from RoomListStoreV3.
420+
*/
421+
private static buildSections(roomTags: Room["tags"]): Section[] {
422+
const customSectionData = SettingsStore.getValue("RoomList.CustomSectionData") || {};
423+
424+
return (
425+
RoomListStoreV3.instance.orderedSectionTags
426+
// Exclude the Chats section because the user toggle the other sections to move rooms in and out of the Chats section.
427+
.filter((tag) => tag !== CHATS_TAG)
428+
.map((tag) => ({
429+
tag,
430+
name: RoomListItemViewModel.getSectionName(tag, customSectionData),
431+
isSelected: Boolean(roomTags[tag]),
432+
}))
433+
);
434+
}
435+
436+
/**
437+
* Get the display name for a section based on its tag.
438+
*/
439+
private static getSectionName(tag: string, customSectionData: Record<string, { name: string }>): string {
440+
if (tag === DefaultTagID.Favourite) return _t("room_list|section|favourites");
441+
if (tag === DefaultTagID.LowPriority) return _t("room_list|section|low_priority");
442+
return customSectionData[tag]?.name || tag;
443+
}
392444
}

apps/web/src/viewmodels/room-list/RoomListViewModel.ts

Lines changed: 21 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
type RoomListViewState,
1414
type RoomListSection,
1515
_t,
16+
type ToastType,
1617
} from "@element-hq/web-shared-components";
1718
import { type MatrixClient, type Room } from "matrix-js-sdk/src/matrix";
1819

@@ -156,6 +157,13 @@ export class RoomListViewModel
156157
this.onSectionCreated as (...args: unknown[]) => void,
157158
);
158159

160+
// Subscribe to room tagging
161+
this.disposables.trackListener(
162+
RoomListStoreV3.instance,
163+
RoomListStoreV3Event.RoomTagged as any,
164+
this.onRoomTagged,
165+
);
166+
159167
// Subscribe to active room changes to update selected room
160168
const dispatcherRef = dispatcher.register(this.onDispatch);
161169
this.disposables.track(() => {
@@ -595,15 +603,11 @@ export class RoomListViewModel
595603

596604
public onSectionCreated = (tag: string): void => {
597605
this.updateRoomListData(false, null, tag);
606+
this.showToast("section_created");
607+
};
598608

599-
clearTimeout(this.toastRef);
600-
this.snapshot.merge({
601-
toast: "section_created",
602-
});
603-
// Automatically close the toast after 15 seconds
604-
this.toastRef = setTimeout(() => {
605-
this.closeToast();
606-
}, 15 * 1000);
609+
public onRoomTagged = (): void => {
610+
this.showToast("chat_moved");
607611
};
608612

609613
public closeToast: () => void = () => {
@@ -612,6 +616,15 @@ export class RoomListViewModel
612616
toast: undefined,
613617
});
614618
};
619+
620+
private showToast(toast: ToastType): void {
621+
clearTimeout(this.toastRef);
622+
this.snapshot.merge({ toast });
623+
// Automatically close the toast after 15 seconds
624+
this.toastRef = setTimeout(() => {
625+
this.closeToast();
626+
}, 15 * 1000);
627+
}
615628
}
616629

617630
/**

0 commit comments

Comments
 (0)