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
+1 -1
View File
@@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
Please see LICENSE files in the repository root for full details.
*/
import { percentageOf } from "@element-hq/web-shared-components";
import { percentageOf } from "@element-hq/web-shared-components/numbers";
import { type IAmplitudePayload, type ITimingPayload, PayloadEvent, WORKLET_NAME } from "./consts";
@@ -40,7 +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, deleteSection, editSection } from "./section";
import { CHATS_TAG, createSection, deleteSection, editSection } from "./section";
/**
* These are the filters passed to the room skip list.
@@ -86,12 +86,6 @@ export interface Section {
rooms: Room[];
}
/**
* A synthetic tag used to represent the "Chats" section, which contains
* every room that does not belong to any other explicit tag section.
*/
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;
@@ -12,9 +12,16 @@ import SettingsStore from "../../settings/SettingsStore";
import Modal from "../../Modal";
import { CreateSectionDialog } from "../../components/views/dialogs/CreateSectionDialog";
import { RemoveSectionDialog } from "../../components/views/dialogs/RemoveSectionDialog";
import { DefaultTagID, type TagID } from "./skip-list/tag";
type Tag = string;
/**
* A synthetic tag used to represent the "Chats" section, which contains
* every room that does not belong to any other explicit tag section.
*/
export const CHATS_TAG = "chats";
/**
* Prefix for custom section tags.
*/
@@ -29,6 +36,24 @@ export function isCustomSectionTag(tag: string): boolean {
return tag.startsWith(CUSTOM_SECTION_TAG_PREFIX);
}
/**
* Checks if a given tag is a default section tag.
* @param tagId - The tag to check.
* @returns True if the tag is a default section tag, false otherwise.
*/
export function isDefaultSectionTag(tagId: TagID): boolean {
return tagId === DefaultTagID.Favourite || tagId === DefaultTagID.LowPriority || tagId === CHATS_TAG;
}
/**
* Checks if a given tag is a section tag.
* @param tagId - The tag to check.
* @returns True if the tag is a section tag, false otherwise.
*/
export function isSectionTag(tagId: TagID): boolean {
return isCustomSectionTag(tagId) || isDefaultSectionTag(tagId);
}
/**
* 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.
*/
+1 -1
View File
@@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
Please see LICENSE files in the repository root for full details.
*/
import { percentageOf, percentageWithin } from "@element-hq/web-shared-components";
import { percentageOf, percentageWithin } from "@element-hq/web-shared-components/numbers";
/**
* Quickly resample an array to have less/more data points. If an input which is larger
@@ -0,0 +1,21 @@
/*
* 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 { type Room } from "matrix-js-sdk/src/matrix";
import { type TagID } from "../../stores/room-list-v3/skip-list/tag";
import { getTagsForRoom } from "./getTagsForRoom";
import { isSectionTag } from "../../stores/room-list-v3/section";
/**
* Get the section tag for a given room.
* @param room The room to get the section tag for.
* @returns The section tag ID or null if none found.
*/
export function getSectionTagForRoom(room: Room): TagID | null {
return getTagsForRoom(room).find((t) => isSectionTag(t)) ?? null;
}
+11 -11
View File
@@ -9,11 +9,11 @@ Please see LICENSE files in the repository root for full details.
import { type Room } from "matrix-js-sdk/src/matrix";
import { logger } from "matrix-js-sdk/src/logger";
import { DefaultTagID, type TagID } from "../../stores/room-list-v3/skip-list/tag";
import { type TagID } from "../../stores/room-list-v3/skip-list/tag";
import RoomListActions from "../../actions/RoomListActions";
import dis from "../../dispatcher/dispatcher";
import { getTagsForRoom } from "./getTagsForRoom";
import { isCustomSectionTag } from "../../stores/room-list-v3/section";
import { CHATS_TAG, isSectionTag } from "../../stores/room-list-v3/section";
import { getSectionTagForRoom } from "./getSectionTagForRoom";
/**
* Toggle tag for a given room.
@@ -23,19 +23,19 @@ import { isCustomSectionTag } from "../../stores/room-list-v3/section";
* @param tagId The tag to invert
*/
export function tagRoom(room: Room, tagId: TagID): void {
if (tagId !== DefaultTagID.Favourite && tagId !== DefaultTagID.LowPriority && !isCustomSectionTag(tagId)) {
logger.warn(`Unexpected tag ${tagId} applied to ${room.roomId}`);
const isChatTag = tagId === CHATS_TAG;
const tag = isChatTag ? null : tagId;
if (!isSectionTag(tagId)) {
logger.warn(`Unexpected tag ${tag} 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 currentSectionTag = getSectionTagForRoom(room);
const isApplied = currentSectionTag === tagId;
const isApplied = currentSectionTag === tag;
const removeTag = currentSectionTag;
const addTag = isApplied ? null : tagId;
const addTag = isApplied ? null : tag;
dis.dispatch(RoomListActions.tagRoom(room.client, room, removeTag, addTag));
}
@@ -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);
};
}
/**