Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -175,4 +175,93 @@
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> {

Check warning on line 185 in apps/web/playwright/e2e/left-panel/room-list-panel/room-list-custom-sections.spec.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Move async function 'assertRoomInSection' to the outer scope.

See more on https://sonarcloud.io/project/issues?id=element-web&issues=AZ2ww9CcknsXlosZMvoY&open=AZ2ww9CcknsXlosZMvoY&pullRequest=33238
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");
});
});
});
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this screenshot have the focus outline?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The focus stays on the room list item when moved. Or do you mean to remove the outline in the screenshot?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it'd be sane to remove it

Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
11 changes: 11 additions & 0 deletions apps/web/src/stores/room-list-v3/RoomListStoreV3.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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.
*/
Expand Down
16 changes: 15 additions & 1 deletion apps/web/src/stores/room-list-v3/section.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,20 @@

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.
*/
Expand Down Expand Up @@ -41,7 +55,7 @@
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()}`;

Check warning on line 58 in apps/web/src/stores/room-list-v3/section.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer `globalThis` over `window`.

See more on https://sonarcloud.io/project/issues?id=element-web&issues=AZ21lJ6pJQi_iJBa9QRl&open=AZ21lJ6pJQi_iJBa9QRl&pullRequest=33238
const newSection: CustomSection = { tag, name: sectionName };

// Save the new section data
Expand Down
25 changes: 17 additions & 8 deletions apps/web/src/utils/room/tagRoom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}
54 changes: 53 additions & 1 deletion apps/web/src/viewmodels/room-list/RoomListItemViewModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
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";
Expand Down Expand Up @@ -37,7 +38,8 @@
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;
Expand Down Expand Up @@ -96,6 +98,13 @@
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();
}
Expand Down Expand Up @@ -181,6 +190,7 @@
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,
});
Expand Down Expand Up @@ -279,6 +289,9 @@

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,
Expand Down Expand Up @@ -307,6 +320,7 @@
canMarkAsUnread,
roomNotifState,
canMoveToSection,
sections,
};
}

Expand Down Expand Up @@ -389,4 +403,42 @@
public onCreateSection = (): void => {
RoomListStoreV3.instance.createSection();
};

public onToggleSection = (tag: string): void => {
tagRoom(this.props.room, tag);
};

private onOrderedCustomSectionsChange = (): void => {

Check warning on line 411 in apps/web/src/viewmodels/room-list/RoomListItemViewModel.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Member 'onOrderedCustomSectionsChange' is never reassigned; mark it as `readonly`.

See more on https://sonarcloud.io/project/issues?id=element-web&issues=AZ2wh_GQO0nayQPjgYsL&open=AZ2wh_GQO0nayQPjgYsL&pullRequest=33238
// 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;
}
}
29 changes: 21 additions & 8 deletions apps/web/src/viewmodels/room-list/RoomListViewModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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(() => {
Expand Down Expand Up @@ -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 = () => {
Expand All @@ -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);
}
}

/**
Expand Down
Loading
Loading