Room list: edit or remove custom sections (#33283)

* feat(sc): add section menu to section header

* feat(rls): add edit and remove sections

* feat(dialog): add editing mode to CreateSectionDialog

* feat(dialog): add remove section dialog

* feat(vm): wire up vm and stores

* test: update existing snapshots

* test(e2e): add playwright tests to edit and remove a section

* chore: fix remove section i18n key

* fix: able to send empty sections

* chore: update create section editing docs

* chore: remove useless fallback

* chore: add logs when section is unknown

* feat: use different wording when removing an empty section

* fix: only animate the chevron icon in the section header

* fix: change dialog subtitle weight to medium
This commit is contained in:
Florian Duros
2026-04-28 10:16:34 +00:00
committed by GitHub
parent 1dd5748d6f
commit c363d2eb82
22 changed files with 1090 additions and 160 deletions
@@ -14,6 +14,11 @@ import DialogButtons from "../elements/DialogButtons";
import { _t } from "../../../languageHandler";
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.
@@ -25,36 +30,43 @@ interface CreateSectionDialogProps {
/**
* 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 {
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">
<Text as="span" weight="semibold">
<Text as="span" weight="medium">
{_t("create_section_dialog|description")}
</Text>
<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}
onChange={(evt) => setValue(evt.target.value)}
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, "")}
@@ -0,0 +1,48 @@
/*
* 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;
/** Whether the section is empty */
isEmpty: boolean;
}
/**
* Dialog shown to the user to remove section in the room list.
*/
export function RemoveSectionDialog({ onFinished, isEmpty }: RemoveSectionDialogProps): JSX.Element {
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>
{!isEmpty && (
<>
<br />
<Text as="span">{_t("remove_section_dialog|description")}</Text>
</>
)}
<DialogButtons
primaryButton={_t("remove_section_dialog|remove_section")}
hasCancel={true}
onCancel={() => onFinished(false)}
onPrimaryButtonClick={() => onFinished(true)}
/>
</BaseDialog>
);
}
+9 -1
View File
@@ -683,8 +683,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.",
@@ -1851,6 +1853,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",
@@ -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.
@@ -498,6 +498,25 @@ 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
* @param isEmpty Whether the section is empty
*/
public async removeSection(tag: string, isEmpty: boolean): Promise<void> {
await deleteSection(tag, isEmpty);
this.scheduleEmit();
}
/**
* Returns the ordered section tags.
*/
@@ -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;
@@ -69,3 +72,52 @@ 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.
* @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.
* @param isEmpty - Whether the section is empty (has no rooms). If the section is not empty, the confirmation dialog will show a warning message.
*/
export async function deleteSection(tag: string, isEmpty: boolean): 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, { isEmpty });
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);
}
@@ -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;
@@ -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 => {
@@ -120,4 +135,25 @@ 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> => {
// There is one notification state per room in the section
const isEmpty = this.roomNotificationStates.size === 0;
await RoomListStoreV3.instance.removeSection(this.props.tag, isEmpty);
};
}