Room list: assign room to custom section (#33238)

* feat(sc): add new toast type for room list

* feat(sc): add section entries in room list item menu

* feat(rls): expose util functions

* feat: allows to tag room with custom sections

* feat(vm): add new Chat moved toast to room list vm

* feat(vm): add section selection to room list item vm

* feat(e2e): add tests for adding room in a custom section

* test(e2e): update existing screenshots

* chore: fix lint after merge

* chore: remove outline in test
This commit is contained in:
Florian Duros
2026-04-22 19:50:54 +00:00
committed by GitHub
parent 73e1b87075
commit f4c62abbcd
28 changed files with 532 additions and 26 deletions
@@ -62,6 +62,8 @@ export enum RoomListStoreV3Event {
ListsLoaded = "lists_loaded",
/** Fired when a new section is created in the room list. */
SectionCreated = "section_created",
/** Fired when a room's tags change. */
RoomTagged = "room_tagged",
}
// The result object for returning rooms from the store
@@ -93,6 +95,7 @@ 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;
export const ROOM_TAGGED_EVENT = RoomListStoreV3Event.RoomTagged;
/**
* This store allows for fast retrieval of the room list in a sorted and filtered manner.
@@ -243,6 +246,7 @@ export class RoomListStoreV3Class extends AsyncStoreWithClient<EmptyObject> {
case "MatrixActions.Room.tags": {
const room = payload.room;
this.addRoomAndEmit(room);
this.emit(ROOM_TAGGED_EVENT);
break;
}
@@ -493,6 +497,13 @@ export class RoomListStoreV3Class extends AsyncStoreWithClient<EmptyObject> {
this.emit(SECTION_CREATED_EVENT, tag);
}
/**
* Returns the ordered section tags.
*/
public get orderedSectionTags(): string[] {
return this.sortedTags;
}
/**
* Load the custom sections from the settings store and update the sorted tags.
*/
+15 -1
View File
@@ -12,6 +12,20 @@ import { CreateSectionDialog } from "../../components/views/dialogs/CreateSectio
type Tag = string;
/**
* Prefix for custom section tags.
*/
export const CUSTOM_SECTION_TAG_PREFIX = "element.io.section.";
/**
* Checks if a given tag is a custom section tag.
* @param tag - The tag to check.
* @returns True if the tag is a custom section tag, false otherwise.
*/
export function isCustomSectionTag(tag: string): boolean {
return tag.startsWith(CUSTOM_SECTION_TAG_PREFIX);
}
/**
* 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.
*/
@@ -41,7 +55,7 @@ export async function createSection(): Promise<string | undefined> {
const [shouldCreateSection, sectionName] = await modal.finished;
if (!shouldCreateSection || !sectionName) return undefined;
const tag = `element.io.section.${window.crypto.randomUUID()}`;
const tag = `${CUSTOM_SECTION_TAG_PREFIX}${window.crypto.randomUUID()}`;
const newSection: CustomSection = { tag, name: sectionName };
// Save the new section data
+17 -8
View File
@@ -13,20 +13,29 @@ import { DefaultTagID, type TagID } from "../../stores/room-list-v3/skip-list/ta
import RoomListActions from "../../actions/RoomListActions";
import dis from "../../dispatcher/dispatcher";
import { getTagsForRoom } from "./getTagsForRoom";
import { isCustomSectionTag } from "../../stores/room-list-v3/section";
/**
* Toggle tag for a given room
* Toggle tag for a given room.
* A room can only be in one section: either a custom section, Favourite, or LowPriority.
* Applying any of these will atomically replace the current section tag.
* @param room The room to tag
* @param tagId The tag to invert
*/
export function tagRoom(room: Room, tagId: TagID): void {
if (tagId === DefaultTagID.Favourite || tagId === DefaultTagID.LowPriority) {
const inverseTag = tagId === DefaultTagID.Favourite ? DefaultTagID.LowPriority : DefaultTagID.Favourite;
const isApplied = getTagsForRoom(room).includes(tagId);
const removeTag = isApplied ? tagId : inverseTag;
const addTag = isApplied ? null : tagId;
dis.dispatch(RoomListActions.tagRoom(room.client, room, removeTag, addTag));
} else {
if (tagId !== DefaultTagID.Favourite && tagId !== DefaultTagID.LowPriority && !isCustomSectionTag(tagId)) {
logger.warn(`Unexpected tag ${tagId} applied to ${room.roomId}`);
return;
}
// Find the section tag currently applied (Fav, LowPriority, or custom) — at most one exists
const currentSectionTag =
getTagsForRoom(room).find(
(t) => t === DefaultTagID.Favourite || t === DefaultTagID.LowPriority || isCustomSectionTag(t),
) ?? null;
const isApplied = currentSectionTag === tagId;
const removeTag = currentSectionTag;
const addTag = isApplied ? null : tagId;
dis.dispatch(RoomListActions.tagRoom(room.client, room, removeTag, addTag));
}
@@ -10,6 +10,7 @@ import {
RoomNotifState,
type RoomListItemViewSnapshot,
type RoomListItemViewActions,
type Section,
} from "@element-hq/web-shared-components";
import { RoomEvent } from "matrix-js-sdk/src/matrix";
import { CallType } from "matrix-js-sdk/src/webrtc/call";
@@ -37,7 +38,8 @@ 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";
import RoomListStoreV3, { CHATS_TAG } from "../../stores/room-list-v3/RoomListStoreV3";
import { _t } from "../../languageHandler";
interface RoomItemProps {
room: Room;
@@ -96,6 +98,13 @@ export class RoomListItemViewModel
this.disposables.trackListener(props.room, RoomEvent.Name, this.onRoomChanged);
this.disposables.trackListener(props.room, RoomEvent.Tags, this.onRoomChanged);
const orderSectionsRef = SettingsStore.watchSetting("RoomList.OrderedCustomSections", null, () =>
this.onOrderedCustomSectionsChange(),
);
this.disposables.track(() => {
SettingsStore.unwatchSetting(orderSectionsRef);
});
// Load message preview asynchronously (sync data is already complete)
void this.loadAndSetMessagePreview();
}
@@ -181,6 +190,7 @@ export class RoomListItemViewModel
this.snapshot.merge({
...newItem,
notification: keepIfSame(this.snapshot.current.notification, newItem.notification),
sections: keepIfSame(this.snapshot.current.sections, newItem.sections),
// Preserve message preview - it's managed separately by loadAndSetMessagePreview
messagePreview: this.snapshot.current.messagePreview,
});
@@ -279,6 +289,9 @@ export class RoomListItemViewModel
const canMoveToSection = SettingsStore.getValue("feature_room_list_sections");
// Build sections list for the "Move to section" submenu
const sections: Section[] = canMoveToSection ? RoomListItemViewModel.buildSections(roomTags) : [];
return {
id: room.roomId,
room,
@@ -307,6 +320,7 @@ export class RoomListItemViewModel
canMarkAsUnread,
roomNotifState,
canMoveToSection,
sections,
};
}
@@ -389,4 +403,42 @@ export class RoomListItemViewModel
public onCreateSection = (): void => {
RoomListStoreV3.instance.createSection();
};
public onToggleSection = (tag: string): void => {
tagRoom(this.props.room, tag);
};
private onOrderedCustomSectionsChange = (): void => {
// Rebuild sections list to reflect new order
const sections = RoomListItemViewModel.buildSections(this.props.room.tags);
this.snapshot.merge({ sections: keepIfSame(this.snapshot.current.sections, sections) });
};
/**
* Build the list of available sections for the "Move to section" submenu.
* Order follows the canonical section order from RoomListStoreV3.
*/
private static buildSections(roomTags: Room["tags"]): Section[] {
const customSectionData = SettingsStore.getValue("RoomList.CustomSectionData") || {};
return (
RoomListStoreV3.instance.orderedSectionTags
// Exclude the Chats section because the user toggle the other sections to move rooms in and out of the Chats section.
.filter((tag) => tag !== CHATS_TAG)
.map((tag) => ({
tag,
name: RoomListItemViewModel.getSectionName(tag, customSectionData),
isSelected: Boolean(roomTags[tag]),
}))
);
}
/**
* Get the display name for a section based on its tag.
*/
private static getSectionName(tag: string, customSectionData: Record<string, { name: string }>): string {
if (tag === DefaultTagID.Favourite) return _t("room_list|section|favourites");
if (tag === DefaultTagID.LowPriority) return _t("room_list|section|low_priority");
return customSectionData[tag]?.name || tag;
}
}
@@ -13,6 +13,7 @@ import {
type RoomListViewState,
type RoomListSection,
_t,
type ToastType,
} from "@element-hq/web-shared-components";
import { type MatrixClient, type Room } from "matrix-js-sdk/src/matrix";
@@ -156,6 +157,13 @@ export class RoomListViewModel
this.onSectionCreated as (...args: unknown[]) => void,
);
// Subscribe to room tagging
this.disposables.trackListener(
RoomListStoreV3.instance,
RoomListStoreV3Event.RoomTagged as any,
this.onRoomTagged,
);
// Subscribe to active room changes to update selected room
const dispatcherRef = dispatcher.register(this.onDispatch);
this.disposables.track(() => {
@@ -595,15 +603,11 @@ export class RoomListViewModel
public onSectionCreated = (tag: string): void => {
this.updateRoomListData(false, null, tag);
this.showToast("section_created");
};
clearTimeout(this.toastRef);
this.snapshot.merge({
toast: "section_created",
});
// Automatically close the toast after 15 seconds
this.toastRef = setTimeout(() => {
this.closeToast();
}, 15 * 1000);
public onRoomTagged = (): void => {
this.showToast("chat_moved");
};
public closeToast: () => void = () => {
@@ -612,6 +616,15 @@ export class RoomListViewModel
toast: undefined,
});
};
private showToast(toast: ToastType): void {
clearTimeout(this.toastRef);
this.snapshot.merge({ toast });
// Automatically close the toast after 15 seconds
this.toastRef = setTimeout(() => {
this.closeToast();
}, 15 * 1000);
}
}
/**