Skip to content
Open
Show file tree
Hide file tree
Changes from 12 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 @@ -195,6 +195,68 @@ test.describe("Room list custom sections", () => {
});
});

test.describe("Section editing", () => {
test("should edit a custom section name via the section header menu", async ({ page, app }) => {
await app.client.createRoom({ name: "my room" });
await createCustomSection(page, "Work");

// Open the section header menu
const sectionHeader = getSectionHeader(page, "Work");
await sectionHeader.hover();
await sectionHeader.getByRole("button", { name: "More options" }).click();

// Click "Edit section"
await page.getByRole("menuitem", { name: "Edit section" }).click();

// The edit dialog should appear pre-filled with the current name
const dialog = page.getByRole("dialog", { name: "Edit a section" });
await expect(dialog).toBeVisible();
await expect(dialog.getByRole("textbox", { name: "Section name" })).toHaveValue("Work");

// Change the name and confirm
await dialog.getByRole("textbox", { name: "Section name" }).fill("Personal");
await dialog.getByRole("button", { name: "Edit section" }).click();

// Dialog should close
await expect(dialog).not.toBeVisible();

// Section should have the new name
await expect(getSectionHeader(page, "Personal")).toBeVisible();
await expect(getSectionHeader(page, "Work")).not.toBeVisible();
});
});

test.describe("Section removal", () => {
test("should move rooms back to Chats when their section is removed", async ({ page, app }) => {
await app.client.createRoom({ name: "my room" });
await createCustomSection(page, "Work");
await createCustomSection(page, "Personal");

const roomList = getRoomList(page);

// Move room to Work section
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();
await assertRoomInSection(page, "Work", "my room");

// Remove the Work section
const sectionHeader = getSectionHeader(page, "Work");
await sectionHeader.hover();
await sectionHeader.getByRole("button", { name: "More options" }).click();
await page.getByRole("menuitem", { name: "Remove section" }).click();
const dialog = page.getByRole("dialog", { name: "Remove section?" });
await dialog.getByRole("button", { name: "Remove section" }).click();

// Section should be gone
await expect(getSectionHeader(page, "Work")).not.toBeVisible();
// Room should now be in the Chats section
await assertRoomInSection(page, "Chats", "my room");
});
});

test.describe("Adding a room to a custom section", () => {
test("should add a room to a custom section via the More Options menu", async ({ page, app }) => {
await app.client.createRoom({ name: "my room" });
Expand Down
1 change: 1 addition & 0 deletions apps/web/res/css/_components.pcss
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,7 @@
@import "./views/dialogs/_ModalWidgetDialog.pcss";
@import "./views/dialogs/_PollCreateDialog.pcss";
@import "./views/dialogs/_RegistrationEmailPromptDialog.pcss";
@import "./views/dialogs/_RemoveSectionDialog.pcss";
@import "./views/dialogs/_ReportRoomDialog.pcss";
@import "./views/dialogs/_RoomSettingsDialog.pcss";
@import "./views/dialogs/_RoomSettingsDialogBridges.pcss";
Expand Down
10 changes: 10 additions & 0 deletions apps/web/res/css/views/dialogs/_RemoveSectionDialog.pcss
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
/*
* Copyright 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/

.mx_RemoveSectionDialog {
color: var(--cpd-color-text-primary);
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.

You don't have one for CreateSectionDialog. Presumably this is an archaic bug we're fixing, can you leave a comment?

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.

}
24 changes: 18 additions & 6 deletions apps/web/src/components/views/dialogs/CreateSectionDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@
import { _t } from "../../../languageHandler";
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.

(Since you don't have a screenshot, design feedback will end up here 😄 )

Image

https://www.figma.com/design/qurBlLqjf3mRNpyZ1ffamm/ER-213---Sections?node-id=214-25270&t=pLsVx9P23XBqQBY6-4

The padding looks off for the middle text.

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.

Padding around the title is EW default. Compound and EW have different dialog designs and until we decide to update the dialog styles this kind of differences will exist imo


interface CreateSectionDialogProps {
/**
* The name of the section being edited if defined. Otherwise, create a new section.
*/
sectionToEdit?: string;

/**
* Callback called when the dialog is closed.
* @param shouldCreateSection Whether a section should be created or not. This will be false if the user cancels the dialog.
Expand All @@ -25,15 +30,16 @@
/**
* Dialog shown to the user to create a new section in the room list.
*/
export function CreateSectionDialog({ onFinished }: CreateSectionDialogProps): JSX.Element {
const [value, setValue] = useState("");
export function CreateSectionDialog({ onFinished, sectionToEdit }: CreateSectionDialogProps): JSX.Element {

Check warning on line 33 in apps/web/src/components/views/dialogs/CreateSectionDialog.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Mark the props of the component as read-only.

See more on https://sonarcloud.io/project/issues?id=element-web&issues=AZ26m1cDwYC1c_QJ1chX&open=AZ26m1cDwYC1c_QJ1chX&pullRequest=33283
const isEdition = Boolean(sectionToEdit);
const [value, setValue] = useState(sectionToEdit ?? "");
const isInvalid = Boolean(value.trim().length === 0);

return (
<BaseDialog
className="mx_CreateSectionDialog"
onFinished={() => onFinished(false, value)}
title={_t("create_section_dialog|title")}
title={isEdition ? _t("create_section_dialog|title_edition") : _t("create_section_dialog|title")}
hasCancel={true}
>
<Flex gap="var(--cpd-space-6x)" direction="column" className="mx_CreateSectionDialog_content">
Expand All @@ -43,18 +49,24 @@
<Form.Root
className="mx_CreateSectionDialog_form"
onSubmit={(e) => {
onFinished(true, value);
e.preventDefault();
if (!isInvalid) onFinished(true, value);
}}
>
<Form.Field name="sectionName">
<Form.Label> {_t("create_section_dialog|label")}</Form.Label>
<Form.TextControl onChange={(evt) => setValue(evt.target.value)} required={true} />
<Form.TextControl
value={value}
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.

fwiw, in my testing I could insert whitetext before or after my section name and it would not be rendered in the list but accepted by this dialog

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.

Good catch

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.

onChange={(evt) => setValue(evt.target.value)}
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.

Also noting it's possible to have duplicate section names

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.

Yep that is totally possible

required={true}
/>
</Form.Field>
</Form.Root>
</Flex>
<DialogButtons
primaryButton={_t("create_section_dialog|create_section")}
primaryButton={
isEdition ? _t("create_section_dialog|edit_section") : _t("create_section_dialog|create_section")
}
primaryDisabled={isInvalid}
hasCancel={true}
onCancel={() => onFinished(false, "")}
Expand Down
41 changes: 41 additions & 0 deletions apps/web/src/components/views/dialogs/RemoveSectionDialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
/*
* Copyright 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/

import React from "react";
import { type JSX } from "react";
import { Text } from "@vector-im/compound-web";

import { _t } from "../../../languageHandler";
import BaseDialog from "./BaseDialog";
import DialogButtons from "../elements/DialogButtons";

interface RemoveSectionDialogProps {
onFinished: (shouldRemoveSection: boolean) => void;
}

/**
* Dialog shown to the user to remove section in the room list.
*/
export function RemoveSectionDialog({ onFinished }: RemoveSectionDialogProps): JSX.Element {

Check warning on line 23 in apps/web/src/components/views/dialogs/RemoveSectionDialog.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Mark the props of the component as read-only.

See more on https://sonarcloud.io/project/issues?id=element-web&issues=AZ26m1WIwYC1c_QJ1chW&open=AZ26m1WIwYC1c_QJ1chW&pullRequest=33283
return (
<BaseDialog
className="mx_RemoveSectionDialog"
onFinished={() => onFinished(false)}
title={_t("remove_section_dialog|title")}
hasCancel={true}
>
<Text as="span">{_t("remove_section_dialog|confirmation")}</Text>
<Text as="span">{_t("remove_section_dialog|description")}</Text>
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.

ooi, is there a reason for the two spans rather than combining into one?

Copy link
Copy Markdown
Member Author

@florianduros florianduros Apr 24, 2026

Choose a reason for hiding this comment

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

To have the same formatting than in figma. You prefer to put a <br /> in the translation?

<DialogButtons
primaryButton={_t("remove_section_dialog|remove_section")}
hasCancel={true}
onCancel={() => onFinished(false)}
onPrimaryButtonClick={() => onFinished(true)}
/>
</BaseDialog>
);
}
10 changes: 9 additions & 1 deletion apps/web/src/i18n/strings/en_EN.json
Original file line number Diff line number Diff line change
Expand Up @@ -684,8 +684,10 @@
"create_section_dialog": {
"create_section": "Create section",
"description": "Sections are only for you",
"edit_section": "Edit section",
"label": "Section name",
"title": "Create a section"
"title": "Create a section",
"title_edition": "Edit a section"
},
"create_space": {
"add_details_prompt": "Add some details to help people recognise it.",
Expand Down Expand Up @@ -1855,6 +1857,12 @@
"ongoing": "Removing…",
"reason_label": "Reason (optional)"
},
"remove_section_dialog": {
"confirmation": "Are you sure you want to remove this section? ",
"description": "The chats in this section will still be available in your chats list.",
"remove_section": "Remove section",
"title": "Remove section?"
},
"report_content": {
"description": "Reporting this message will send its unique 'event ID' to the administrator of your homeserver. If messages in this room are encrypted, your homeserver administrator will not be able to read the message text or view any files or images.",
"disagree": "Disagree",
Expand Down
20 changes: 19 additions & 1 deletion apps/web/src/stores/room-list-v3/RoomListStoreV3.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ import { DefaultTagID } from "./skip-list/tag";
import { ExcludeTagsFilter } from "./skip-list/filters/ExcludeTagsFilter";
import { TagFilter } from "./skip-list/filters/TagFilter";
import { filterBoolean } from "../../utils/arrays";
import { createSection } from "./section";
import { createSection, deleteSection, editSection } from "./section";

/**
* These are the filters passed to the room skip list.
Expand Down Expand Up @@ -498,6 +498,24 @@ export class RoomListStoreV3Class extends AsyncStoreWithClient<EmptyObject> {
return tag;
}

/**
* Edit a section's name.
* @param tag The tag of the section to edit
*/
public async editSection(tag: string): Promise<void> {
await editSection(tag);
}

/**
* Remove a section
* Emits {@link LISTS_UPDATE_EVENT} if the section was successfully removed.
* @param tag The tag of the section to remove
*/
public async removeSection(tag: string): Promise<void> {
await deleteSection(tag);
this.scheduleEmit();
}

/**
* Returns the ordered section tags.
*/
Expand Down
51 changes: 51 additions & 0 deletions apps/web/src/stores/room-list-v3/section.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,13 @@
* Please see LICENSE files in the repository root for full details.
*/

import { logger } from "matrix-js-sdk/src/logger";

import { SettingLevel } from "../../settings/SettingLevel";
import SettingsStore from "../../settings/SettingsStore";
import Modal from "../../Modal";
import { CreateSectionDialog } from "../../components/views/dialogs/CreateSectionDialog";
import { RemoveSectionDialog } from "../../components/views/dialogs/RemoveSectionDialog";

type Tag = string;

Expand Down Expand Up @@ -69,3 +72,51 @@ export async function createSection(): Promise<string | undefined> {
await SettingsStore.setValue("RoomList.OrderedCustomSections", null, SettingLevel.ACCOUNT, orderedSections);
return tag;
}

/**
* Edits an existing custom section by showing a dialog to the user to enter the new section name. If the user confirms, it updates the section data in the settings.
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.

Holy line length batman (it would be helpful if this was kept to around 120 chars per line so it was easier to read)

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.

There is no currently formating rules enforcing it

* @param tag - The tag of the section to edit.
*/
export async function editSection(tag: string): Promise<void> {
const sectionData = SettingsStore.getValue("RoomList.CustomSectionData") || {};
const section = sectionData[tag];
if (!section) {
logger.info("Unknown section tag, cannot edit section", tag);
return;
}

const modal = Modal.createDialog(CreateSectionDialog, { sectionToEdit: section.name });

const [shouldEditSection, newName] = await modal.finished;
const isSameName = newName === section.name;
if (!shouldEditSection || !newName || isSameName) return;

// Save the new name
sectionData[tag].name = newName;
await SettingsStore.setValue("RoomList.CustomSectionData", null, SettingLevel.ACCOUNT, sectionData);
}

/**
* Deletes a custom section by showing a confirmation dialog to the user. If the user confirms, it removes the section data from the settings and updates the ordered list of sections.
* @param tag - The tag of the section to delete.
*/
export async function deleteSection(tag: string): Promise<void> {
const sectionData = SettingsStore.getValue("RoomList.CustomSectionData");
if (!sectionData[tag]) {
logger.info("Unknown section tag, cannot delete section", tag);
return;
}

const modal = Modal.createDialog(RemoveSectionDialog);
const [shouldRemoveSection] = await modal.finished;
if (!shouldRemoveSection) return;

// Remove the section from the ordered list of sections
const orderedSections = SettingsStore.getValue("RoomList.OrderedCustomSections");
const newOrderedSections = orderedSections.filter((sectionTag) => sectionTag !== tag);
await SettingsStore.setValue("RoomList.OrderedCustomSections", null, SettingLevel.ACCOUNT, newOrderedSections);

// Remove the section data
delete sectionData[tag];
await SettingsStore.setValue("RoomList.CustomSectionData", null, SettingLevel.ACCOUNT, sectionData);
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ import {
import { RoomNotificationStateStore } from "../../stores/notifications/RoomNotificationStateStore";
import { NotificationStateEvents } from "../../stores/notifications/NotificationState";
import { type RoomNotificationState } from "../../stores/notifications/RoomNotificationState";
import SettingsStore from "../../settings/SettingsStore";
import { DefaultTagID } from "../../stores/room-list-v3/skip-list/tag";
import RoomListStoreV3, { CHATS_TAG } from "../../stores/room-list-v3/RoomListStoreV3";

interface RoomListSectionHeaderViewModelProps {
tag: string;
Expand Down Expand Up @@ -42,7 +45,19 @@ export class RoomListSectionHeaderViewModel
private readonly expandedBySpace = new Map<string, boolean>();

public constructor(props: RoomListSectionHeaderViewModelProps) {
super(props, { id: props.tag, title: props.title, isExpanded: true, isUnread: false });
const isDefaultSection =
props.tag === DefaultTagID.Favourite || props.tag === DefaultTagID.LowPriority || props.tag === CHATS_TAG;
super(props, {
id: props.tag,
title: props.title,
isExpanded: true,
isUnread: false,
displaySectionMenu: !isDefaultSection,
});
const sectionWatherRef = SettingsStore.watchSetting("RoomList.CustomSectionData", null, () =>
this.onCustomSectionDataChange(),
);
this.disposables.track(() => SettingsStore.unwatchSetting(sectionWatherRef));
}

public onClick = (): void => {
Expand Down Expand Up @@ -120,4 +135,23 @@ export class RoomListSectionHeaderViewModel
this.roomNotificationStates.clear();
super.dispose();
}

/**
* Handle changes to custom section data.
*/
private onCustomSectionDataChange(): void {
const customSectionData = SettingsStore.getValue("RoomList.CustomSectionData") || {};
const sectionData = customSectionData[this.props.tag];
if (sectionData) {
this.snapshot.merge({ title: sectionData.name });
}
}

public editSection = async (): Promise<void> => {
await RoomListStoreV3.instance.editSection(this.props.tag);
};

public removeSection = async (): Promise<void> => {
await RoomListStoreV3.instance.removeSection(this.props.tag);
};
}
Loading
Loading