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