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
@@ -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);
}
}
/**