Room list: add custom section creation (#33155)

* feat: add creation section dialog

* feat: add in skip list a method to change filters

* feat: add helper to creation section

* feat: add custom sections data to Settings

* feat: add custom section to room list store v3

* feat: update header and room list item vms

* feat: add toast to room list vm

* feat: add new translation

* chore: move util functions of room list specs

* test: add custom section playwright tests

* chore: call loadCustomSections in RoomListStoreV3 ctor
This commit is contained in:
Florian Duros
2026-04-17 12:02:42 +00:00
committed by GitHub
parent 73d4b63ada
commit 6b67b24254
27 changed files with 985 additions and 97 deletions
@@ -0,0 +1,65 @@
/*
* 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, { useState, type JSX } from "react";
import { Flex } from "@element-hq/web-shared-components";
import { Form, Text } from "@vector-im/compound-web";
import BaseDialog from "./BaseDialog";
import DialogButtons from "../elements/DialogButtons";
import { _t } from "../../../languageHandler";
interface CreateSectionDialogProps {
/**
* 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.
* @param sectionName The name of the section to create.
*/
onFinished: (shouldCreateSection: boolean, sectionName: string) => void;
}
/**
* 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("");
const isInvalid = Boolean(value.trim().length === 0);
return (
<BaseDialog
className="mx_CreateSectionDialog"
onFinished={() => onFinished(false, value)}
title={_t("create_section_dialog|title")}
hasCancel={true}
>
<Flex gap="var(--cpd-space-6x)" direction="column" className="mx_CreateSectionDialog_content">
<Text as="span" weight="semibold">
{_t("create_section_dialog|description")}
</Text>
<Form.Root
className="mx_CreateSectionDialog_form"
onSubmit={(e) => {
onFinished(true, value);
e.preventDefault();
}}
>
<Form.Field name="sectionName">
<Form.Label> {_t("create_section_dialog|label")}</Form.Label>
<Form.TextControl onChange={(evt) => setValue(evt.target.value)} required={true} />
</Form.Field>
</Form.Root>
</Flex>
<DialogButtons
primaryButton={_t("create_section_dialog|create_section")}
primaryDisabled={isInvalid}
hasCancel={true}
onCancel={() => onFinished(false, "")}
onPrimaryButtonClick={() => onFinished(true, value)}
/>
</BaseDialog>
);
}
+6
View File
@@ -681,6 +681,12 @@
"unfederated_label_default_on": "You might disable this if the room will be used for collaborating with external teams who have their own homeserver. This cannot be changed later.",
"unsupported_version": "The server does not support the room version specified."
},
"create_section_dialog": {
"create_section": "Create section",
"description": "Sections are only for you",
"label": "Section name",
"title": "Create a section"
},
"create_space": {
"add_details_prompt": "Add some details to help people recognise it.",
"add_details_prompt_2": "You can change these anytime.",
+19
View File
@@ -52,6 +52,7 @@ import InviteRulesConfigController from "./controllers/InviteRulesConfigControll
import { type ComputedInviteConfig } from "../@types/invite-rules.ts";
import BlockInvitesConfigController from "./controllers/BlockInvitesConfigController.ts";
import RequiresSettingsController from "./controllers/RequiresSettingsController.ts";
import { type OrderedCustomSections, type CustomSectionsData } from "../stores/room-list-v3/section.ts";
export const defaultWatchManager = new WatchManager();
@@ -373,6 +374,8 @@ export interface Settings {
"inviteRules": IBaseSetting<ComputedInviteConfig>;
"blockInvites": IBaseSetting<boolean>;
"Developer.elementCallUrl": IBaseSetting<string>;
"RoomList.CustomSectionData": IBaseSetting<CustomSectionsData>;
"RoomList.OrderedCustomSections": IBaseSetting<OrderedCustomSections>;
}
export type SettingKey = keyof Settings;
@@ -1371,6 +1374,22 @@ export const SETTINGS: Settings = {
supportedLevels: LEVELS_ACCOUNT_SETTINGS,
default: {},
},
/**
* Managed by the {@link RoomListStoreV3}
* Store the custom section data for the room list
*/
"RoomList.CustomSectionData": {
supportedLevels: LEVELS_ACCOUNT_SETTINGS,
default: {},
},
/**
* Managed by the {@link RoomListStoreV3}
* Store the ordering of the custom sections for the room list
*/
"RoomList.OrderedCustomSections": {
supportedLevels: LEVELS_ACCOUNT_SETTINGS,
default: [],
},
[UIFeature.RoomHistorySettings]: {
supportedLevels: LEVELS_UI_FEATURE,
default: true,
@@ -40,6 +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";
/**
* These are the filters passed to the room skip list.
@@ -59,6 +60,8 @@ export enum RoomListStoreV3Event {
ListsUpdate = "lists_update",
// The event which is called when the room list is loaded.
ListsLoaded = "lists_loaded",
/** Fired when a new section is created in the room list. */
SectionCreated = "section_created",
}
// The result object for returning rooms from the store
@@ -89,6 +92,8 @@ 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;
/**
* This store allows for fast retrieval of the room list in a sorted and filtered manner.
* This is the third such implementation hence the "V3".
@@ -108,7 +113,7 @@ export class RoomListStoreV3Class extends AsyncStoreWithClient<EmptyObject> {
/**
* Defines the display order of sections.
*/
private readonly sortedTags: string[] = [DefaultTagID.Favourite, CHATS_TAG, DefaultTagID.LowPriority];
private sortedTags: string[] = [];
private readonly msc3946ProcessDynamicPredecessor: boolean;
@@ -125,6 +130,8 @@ export class RoomListStoreV3Class extends AsyncStoreWithClient<EmptyObject> {
this.onActiveSpaceChanged();
});
SpaceStore.instance.on(UPDATE_HOME_BEHAVIOUR, () => this.onActiveSpaceChanged());
SettingsStore.watchSetting("RoomList.OrderedCustomSections", null, () => this.onOrderedCustomSectionsChange());
this.loadCustomSections();
}
/**
@@ -463,6 +470,37 @@ export class RoomListStoreV3Class extends AsyncStoreWithClient<EmptyObject> {
};
});
}
/**
* Handle changes to the order of custom sections.
* Reloads the custom sections, updates the skip list filters to reflect the new order and emits an update.
* Emit {@link LISTS_UPDATE_EVENT}.
*/
private onOrderedCustomSectionsChange(): void {
this.loadCustomSections();
if (!this.roomSkipList) return;
this.roomSkipList.useNewFilters(this.getSkipListFilters());
this.scheduleEmit();
}
/**
* Create a new section.
* Emits {@link SECTION_CREATED_EVENT} and {@link LISTS_UPDATE_EVENT} if the section was successfully created.
*/
public async createSection(): Promise<void> {
const sectionIsCreated = await createSection();
if (!sectionIsCreated) return;
this.emit(SECTION_CREATED_EVENT);
this.scheduleEmit();
}
/**
* Load the custom sections from the settings store and update the sorted tags.
*/
private loadCustomSections(): void {
const orderedCustomSections = SettingsStore.getValue("RoomList.OrderedCustomSections");
this.sortedTags = [DefaultTagID.Favourite, ...orderedCustomSections, CHATS_TAG, DefaultTagID.LowPriority];
}
}
export default class RoomListStoreV3 {
@@ -0,0 +1,59 @@
/*
* 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 { v4 as uuidv4 } from "uuid";
import { SettingLevel } from "../../settings/SettingLevel";
import SettingsStore from "../../settings/SettingsStore";
import Modal from "../../Modal";
import { CreateSectionDialog } from "../../components/views/dialogs/CreateSectionDialog";
type Tag = string;
/**
* 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.
*/
type CustomSection = {
tag: Tag;
name: string;
};
/**
* The custom sections data is stored as a record in the settings, where the key is the section tag and the value is the section data (name and tag).
*/
export type CustomSectionsData = Record<Tag, CustomSection>;
/**
* Ordered list of custom section tags.
*/
export type OrderedCustomSections = Tag[];
/**
* Creates a new custom section by showing a dialog to the user to enter the section name.
* If the user confirms, it generates a unique tag for the section, saves the section data in the settings, and updates the ordered list of sections.
*
* @return A promise that resolves to true if the section was created, or false if the user cancelled the creation or if there was an error.
*/
export async function createSection(): Promise<boolean> {
const modal = Modal.createDialog(CreateSectionDialog);
const [shouldCreateSection, sectionName] = await modal.finished;
if (!shouldCreateSection || !sectionName) return false;
const tag = `element.io.section.${uuidv4()}`;
const newSection: CustomSection = { tag, name: sectionName };
// Save the new section data
const sectionData = SettingsStore.getValue("RoomList.CustomSectionData") || {};
sectionData[tag] = newSection;
await SettingsStore.setValue("RoomList.CustomSectionData", null, SettingLevel.ACCOUNT, sectionData);
// Add the new section to the ordered list of sections
const orderedSections = SettingsStore.getValue("RoomList.OrderedCustomSections") || [];
orderedSections.push(tag);
await SettingsStore.setValue("RoomList.OrderedCustomSections", null, SettingLevel.ACCOUNT, orderedSections);
return true;
}
@@ -76,6 +76,17 @@ export class RoomSkipList implements Iterable<Room> {
this.seed(rooms);
}
/**
* Change the filters used by the skip list.
* This will apply the new filters to all existing nodes.
*/
public useNewFilters(filters: Filter[]): void {
this.filters = filters;
for (const node of this.roomNodeMap.values()) {
node.applyFilters(this.filters);
}
}
/**
* Removes a given room from the skip list.
*/
@@ -201,7 +201,7 @@ export class RoomListHeaderViewModel
};
public createSection = (): void => {
// To be implemented when custom section creation is added in vms
RoomListStoreV3.instance.createSection();
};
}
/**
@@ -275,6 +275,10 @@ function computeHeaderSpaceState(
);
const canAccessSpaceSettings = Boolean(activeSpace && shouldShowSpaceSettings(activeSpace));
const isSectionFeatureEnabled = SettingsStore.getValue("feature_room_list_sections");
const useComposeIcon = !isSectionFeatureEnabled;
const canCreateSection = isSectionFeatureEnabled;
return {
title,
canCreateRoom,
@@ -283,8 +287,7 @@ function computeHeaderSpaceState(
displaySpaceMenu,
canInviteInSpace,
canAccessSpaceSettings,
// To be implemented when custom section creation is added in vms
canCreateSection: false,
useComposeIcon: true,
canCreateSection,
useComposeIcon,
};
}
@@ -37,6 +37,7 @@ import { Action } from "../../dispatcher/actions";
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";
interface RoomItemProps {
room: Room;
@@ -276,6 +277,8 @@ export class RoomListItemViewModel
const callType =
call?.callType === CallType.Voice ? "voice" : call?.callType === CallType.Video ? "video" : undefined;
const canMoveToSection = SettingsStore.getValue("feature_room_list_sections");
return {
id: room.roomId,
room,
@@ -303,8 +306,7 @@ export class RoomListItemViewModel
canMarkAsRead,
canMarkAsUnread,
roomNotifState,
// To be implemented when custom section creation is added in vms
canMoveToSection: false,
canMoveToSection,
};
}
@@ -385,6 +387,6 @@ export class RoomListItemViewModel
};
public onCreateSection = (): void => {
// To be implemented when custom section creation is added in vms
RoomListStoreV3.instance.createSection();
};
}
@@ -91,6 +91,11 @@ export class RoomListViewModel
// Don't clear section vm because we want to keep the expand/collapse state even during space changes.
private readonly roomSectionHeaderViewModels = new Map<string, RoomListSectionHeaderViewModel>();
/**
* Reference to the currently displayed toast, used to automatically close the toast after a timeout.
*/
private toastRef?: number;
public constructor(props: RoomListViewModelProps) {
const activeSpace = SpaceStore.instance.activeSpaceRoom;
@@ -144,6 +149,13 @@ export class RoomListViewModel
this.onListsLoaded,
);
// Subscribe to section creation
this.disposables.trackListener(
RoomListStoreV3.instance,
RoomListStoreV3Event.SectionCreated as any,
this.onSectionCreated,
);
// Subscribe to active room changes to update selected room
const dispatcherRef = dispatcher.register(this.onDispatch);
this.disposables.track(() => {
@@ -264,7 +276,8 @@ export class RoomListViewModel
public getSectionHeaderViewModel(tag: string): RoomListSectionHeaderViewModel {
if (this.roomSectionHeaderViewModels.has(tag)) return this.roomSectionHeaderViewModels.get(tag)!;
const title = TAG_TO_TITLE_MAP[tag] || tag;
const customSections = SettingsStore.getValue("RoomList.CustomSectionData");
const title = TAG_TO_TITLE_MAP[tag] || customSections[tag]?.name || tag;
const viewModel = new RoomListSectionHeaderViewModel({
tag,
title,
@@ -573,7 +586,19 @@ export class RoomListViewModel
}
};
public onSectionCreated = (): void => {
clearTimeout(this.toastRef);
this.snapshot.merge({
toast: "section_created",
});
// Automatically close the toast after 15 seconds
this.toastRef = setTimeout(() => {
this.closeToast();
}, 15 * 1000);
};
public closeToast: () => void = () => {
clearTimeout(this.toastRef);
this.snapshot.merge({
toast: undefined,
});
@@ -590,9 +615,11 @@ function computeSections(
roomsResult: RoomsResult,
isSectionExpanded: (tag: string) => boolean,
): { sections: Section[]; isFlatList: boolean } {
const customSections = SettingsStore.getValue("RoomList.CustomSectionData");
const sections = roomsResult.sections
// Only include sections that have rooms
.filter((section) => section.rooms.length > 0)
// Only include sections that have rooms or are custom sections (which may be empty but should still be shown)
.filter((section) => section.rooms.length > 0 || customSections[section.tag])
// Remove roomIds for sections that are currently collapsed according to their section header view model
.map((section) => ({
...section,