Room list: drag and drop rooms into sections (#33366)

* chore: add dnd kit deps

* chore: patch dnd kit to fix ts error

* feat(sc): add drag-and-drop to room list item and wrapper

* feat(sc): make the room list header a droppable element

* feat(sc): add dnd to room list view

* feat(tags): can tag room as CHAT

* feat(vm): implement `changeRoomSection`

* feat(sc): disable dragging in flat list

* fix: disable keyboard navigation when dragging element

* test(sc): update snapshots

* test(sc): add dnd test

* test(e2e): add e2e tests for room drag and drop

* test(vm): add tests for changeRoomSection

* fix: remove focus visible when dropping with the mouse

* test(playwright): update existing screenshots

* chore(sc): move numbers out of main build

The Ew RecorderWorklet imports shared component bundle. However if the
bundle uses some deps using document/window which, the worklet will not
work.

The solution is to put the used functions into a separate bundle.

* doc(sc): add subpath import into README

* doc: typo barrel/bundle

* test: improve test expect

* refactor: add utils to section tag

* fix: incorrect check in tagRoom

* fix: add doc about dndkit tunning
This commit is contained in:
Florian Duros
2026-05-13 09:06:22 +00:00
committed by GitHub
parent 97da3be67a
commit 85aca65a81
50 changed files with 4845 additions and 3871 deletions
@@ -38,8 +38,9 @@ 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, { CHATS_TAG } from "../../stores/room-list-v3/RoomListStoreV3";
import RoomListStoreV3 from "../../stores/room-list-v3/RoomListStoreV3";
import { _t } from "../../languageHandler";
import { isDefaultSectionTag } from "../../stores/room-list-v3/section";
interface RoomItemProps {
room: Room;
@@ -429,9 +430,7 @@ export class RoomListItemViewModel
RoomListStoreV3.instance.orderedSectionTags
// Exclude the Chats because the user toggle the other sections to move rooms in and out of the Chats section.
// Also exclude the default sections because they are available as toggles in the main context menu, and we don't want them to be duplicated in the "Move to section" submenu.
.filter(
(tag) => tag !== CHATS_TAG && tag !== DefaultTagID.Favourite && tag !== DefaultTagID.LowPriority,
)
.filter((tag) => !isDefaultSectionTag(tag))
.map((tag) => ({
tag,
name: RoomListItemViewModel.getSectionName(tag, customSectionData),
@@ -16,8 +16,8 @@ import { RoomNotificationStateStore } from "../../stores/notifications/RoomNotif
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";
import RoomListStoreV3 from "../../stores/room-list-v3/RoomListStoreV3";
import { isDefaultSectionTag } from "../../stores/room-list-v3/section";
interface RoomListSectionHeaderViewModelProps {
tag: string;
@@ -45,8 +45,7 @@ export class RoomListSectionHeaderViewModel
private readonly expandedBySpace = new Map<string, boolean>();
public constructor(props: RoomListSectionHeaderViewModelProps) {
const isDefaultSection =
props.tag === DefaultTagID.Favourite || props.tag === DefaultTagID.LowPriority || props.tag === CHATS_TAG;
const isDefaultSection = isDefaultSectionTag(props.tag);
super(props, {
id: props.tag,
title: props.title,
@@ -15,7 +15,7 @@ import {
_t,
type ToastType,
} from "@element-hq/web-shared-components";
import { type MatrixClient, type Room } from "matrix-js-sdk/src/matrix";
import { type Room, type MatrixClient } from "matrix-js-sdk/src/matrix";
import { Action } from "../../dispatcher/actions";
import dispatcher from "../../dispatcher/dispatcher";
@@ -24,7 +24,6 @@ import { type ViewRoomPayload } from "../../dispatcher/payloads/ViewRoomPayload"
import { type RoomListSectionsCollapseStateChangedPayload } from "../../dispatcher/payloads/RoomListSectionsCollapseStateChangedPayload";
import SpaceStore from "../../stores/spaces/SpaceStore";
import RoomListStoreV3, {
CHATS_TAG,
RoomListStoreV3Event,
type RoomsResult,
type Section,
@@ -38,6 +37,9 @@ import { keepIfSame } from "../../utils/keepIfSame";
import { DefaultTagID } from "../../stores/room-list-v3/skip-list/tag";
import { RoomListSectionHeaderViewModel } from "./RoomListSectionHeaderViewModel";
import SettingsStore from "../../settings/SettingsStore";
import { tagRoom } from "../../utils/room/tagRoom";
import { getSectionTagForRoom } from "../../utils/room/getSectionTagForRoom";
import { CHATS_TAG } from "../../stores/room-list-v3/section";
/**
* Tracks the position of the active room within a specific section.
@@ -667,6 +669,17 @@ export class RoomListViewModel
this.closeToast();
}, 15 * 1000);
}
public changeRoomSection = (roomId: string, tag: string): void => {
const room = this.props.client.getRoom(roomId);
if (!room) return;
const currentTag = getSectionTagForRoom(room);
// Room is already in the section
if (currentTag === tag) return;
tagRoom(room, tag);
};
}
/**