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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user