Room list: add drag and drop of sections to reorder them (#33606)

* feat(rls): centralize room-list section ordering via getOrderedSections

Introduce a single source of truth for the ordered list of section
tags (defaults + custom) in section.ts. RoomListStoreV3 no longer
hard-codes the default tag order — it asks section.ts.

* feat(vm): add section reorder logic to RoomListStoreV3 and view model

* refactor(sc): extract RoomListSectionHeaderContent from RoomListSectionHeaderView

* feat(sc): add drag-and-drop reordering for room-list sections

* test(sc): update existing tests

* test: add new tests

* test(sc): add snapshot test for overlay

* test(e2e): add playright test for dnd of sections

* feat: tweak opacity

* fix: section detection when dragged

* feat: use relative position in section order

* chore: move style to room list section header view

* test: add e2e test for moving section before another

* feat: make favourite and low priority no draggable

* test: remove deprecated e2e test

* fix: type correctly dnd provider and hooks

* fix: use String instead of casting to string

* fix: wrong a11y attributes on section header

* test: fix keyboard navigation e2e tests

* feat: use custom a11y announcement for dnd

* fix: add aria-hidden to the overlay

* chore: remove duplicated code in a11Y announcement

* fix: increase keyboard drag offset

* fix: tests

* fix: virtuoso computed item key

* fix: aria-expanded double annoncement why dragging

* fix: re-add screen reader instructions

* chore: remove unused import

* fix: reduce keyboard drag offset

* fix: improve text readback on unread section

* feat: use a custom a11y plugin instead of buitin a11y plugin

* chore: formatting

* test: fix incorrect tests

* chore: use randomUUID

* fix: try to make test working

* fix: put back mock

* fix: circular import

* Revert "fix: circular import"

This reverts commit 6c69313ade9ed2ab17a4622c692b435fbdb94ad2.

* chore: add @dnd-kit/abstract to optimizeDeps.include

* test: fix e2e tests

* fix: disable interaction with section when dragging

* fix: be more explicit if the section will be dropped before or after

* fix: add info that space is dropping too

* fix: scroll to dropped section

* test: fix virtualized room list test

* chore: update lang

* chore: fix dead code analyze

* test: add provider section story

* fix: lang

* fix: again lang...

* fix: improve voice over on chrome

* test: upate snapshot

* fix: add readback when a section is over a non droppable element
This commit is contained in:
Florian Duros
2026-06-26 10:00:44 +00:00
committed by GitHub
parent 4e3f47b948
commit 1f83ba4bbb
40 changed files with 2064 additions and 342 deletions
+2 -2
View File
@@ -53,7 +53,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";
import { type ReorderableSection, type CustomSectionsData } from "../stores/room-list-v3/section.ts";
import { type NotificationSound } from "../Notifier.ts";
import VideoRoomsBetaImage from "../../res/img/betas/video_rooms.png";
@@ -371,7 +371,7 @@ export interface Settings {
"blockInvites": IBaseSetting<boolean>;
"Developer.elementCallUrl": IBaseSetting<string>;
"RoomList.CustomSectionData": IBaseSetting<CustomSectionsData>;
"RoomList.OrderedCustomSections": IBaseSetting<OrderedCustomSections>;
"RoomList.OrderedCustomSections": IBaseSetting<ReorderableSection[]>;
}
export type SettingKey = keyof Settings;
@@ -36,11 +36,18 @@ import { UnreadSorter } from "./skip-list/sorters/UnreadSorter";
import { getChangedOverrideRoomMutePushRules } from "./utils";
import { isRoomVisible } from "./isRoomVisible";
import { RoomSkipList } from "./skip-list/RoomSkipList";
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 { CHATS_TAG, createSection, deleteSection, editSection, getOrderedCustomSections } from "./section";
import {
CHATS_TAG,
createSection,
deleteSection,
editSection,
getOrderedReorderableSections,
reorderSection,
} from "./section";
import { DefaultTagID } from "./skip-list/tag";
/**
* These are the filters passed to the room skip list.
@@ -513,6 +520,15 @@ export class RoomListStoreV3Class extends AsyncStoreWithClient<EmptyObject> {
this.scheduleEmit();
}
/**
* Reorder custom sections by moving sourceTag to the position of targetTag.
* @param sourceTag The tag of the section to move
* @param targetTag The tag of the section to move to
*/
public async reorderSection(sourceTag: string, targetTag: string): Promise<void> {
await reorderSection(sourceTag, targetTag);
}
/**
* Returns the ordered section tags.
*/
@@ -524,8 +540,10 @@ export class RoomListStoreV3Class extends AsyncStoreWithClient<EmptyObject> {
* Load the custom sections from the settings store and update the sorted tags.
*/
private loadCustomSections(): void {
const orderedCustomSections = getOrderedCustomSections();
this.sortedTags = [DefaultTagID.Favourite, ...orderedCustomSections, CHATS_TAG, DefaultTagID.LowPriority];
// Favourite is pinned to the top and LowPriority to the bottom. Everything in between
// (custom sections + Chats) is user-reorderable.
const reorderable = getOrderedReorderableSections();
this.sortedTags = [DefaultTagID.Favourite, ...reorderable, DefaultTagID.LowPriority];
}
}
+67 -8
View File
@@ -82,11 +82,25 @@ function isValidCustomSection(value: unknown): value is CustomSection {
* 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<CustomTag, CustomSection>;
/**
* Ordered list of custom section tags.
*/
export type OrderedCustomSections = CustomTag[];
/**
* Tags that can be reordered relative to each other (everything except Favourite and LowPriority,
* which are pinned to the top and bottom respectively).
*/
export type ReorderableSection = CustomTag | typeof CHATS_TAG;
/**
* Returns true if the given tag is a tag that can be reordered (custom section or the Chats tag).
*/
function isReorderableSection(tag: string, customData: CustomSectionsData): tag is ReorderableSection {
return tag === CHATS_TAG || (isCustomSectionTag(tag) && tag in customData);
}
/**
* Returns true if the given space key corresponds to an enabled meta-space or a known top-level space room.
*/
@@ -121,12 +135,31 @@ export function getCustomSectionData(): CustomSectionsData {
/**
* Retrieves the ordered list of custom section tags from the settings.
* If the settings contain tags that are not present in the custom section data, they will be filtered out and the settings will be updated to remove the unknown tags.
*
* @knipignore - exported for tests
*/
export function getOrderedCustomSections(): OrderedCustomSections {
const sectionData = getCustomSectionData();
const rawValue = SettingsStore.getValue("RoomList.OrderedCustomSections");
const orderedSections: OrderedCustomSections = Array.isArray(rawValue) ? rawValue : [];
return orderedSections.filter((tag) => tag in sectionData);
const orderedSections = Array.isArray(rawValue) ? rawValue : [];
return orderedSections.filter((tag): tag is CustomTag => isCustomSectionTag(tag) && tag in sectionData);
}
/**
* Returns the ordered list of reorderable section tags (custom sections + the Chats tag).
* Favourite and LowPriority are not included they are pinned at the top and bottom respectively.
*
* If `CHATS_TAG` is missing from the stored order (e.g. legacy data or a freshly created custom
* section), it is appended at the end so that custom sections sit above Chats by default.
*/
export function getOrderedReorderableSections(): ReorderableSection[] {
const sectionData = getCustomSectionData();
const rawValue = SettingsStore.getValue("RoomList.OrderedCustomSections");
const stored = Array.isArray(rawValue) ? rawValue : [];
const result = stored.filter((tag): tag is ReorderableSection => isReorderableSection(tag, sectionData));
if (!result.includes(CHATS_TAG)) result.push(CHATS_TAG);
return result;
}
/**
@@ -150,10 +183,12 @@ export async function createSection(spaceId: SpaceKey): Promise<string | undefin
sectionData[tag] = newSection;
await SettingsStore.setValue("RoomList.CustomSectionData", null, SettingLevel.ACCOUNT, sectionData);
// Add the new section to the ordered list of sections
const orderedSections = getOrderedCustomSections();
orderedSections.push(tag);
await SettingsStore.setValue("RoomList.OrderedCustomSections", null, SettingLevel.ACCOUNT, orderedSections);
// Add the new section to the ordered list of reorderable sections, just before CHATS_TAG
// so that newly-created sections appear above Chats by default.
const reorderable = getOrderedReorderableSections();
const chatsIndex = reorderable.indexOf(CHATS_TAG);
reorderable.splice(chatsIndex === -1 ? reorderable.length : chatsIndex, 0, tag);
await SettingsStore.setValue("RoomList.OrderedCustomSections", null, SettingLevel.ACCOUNT, reorderable);
return tag;
}
@@ -204,11 +239,35 @@ export async function deleteSection(tag: string, isEmpty: boolean): Promise<void
const [shouldRemoveSection] = await modal.finished;
if (!shouldRemoveSection) return;
// Remove the section from the ordered list of sections
const newOrderedSections = getOrderedCustomSections().filter((sectionTag) => sectionTag !== tag);
// Remove the section from the ordered list of reorderable sections (preserves CHATS_TAG position)
const newOrderedSections = getOrderedReorderableSections().filter((sectionTag) => sectionTag !== tag);
await SettingsStore.setValue("RoomList.OrderedCustomSections", null, SettingLevel.ACCOUNT, newOrderedSections);
// Remove the section data
delete sectionData[tag];
await SettingsStore.setValue("RoomList.CustomSectionData", null, SettingLevel.ACCOUNT, sectionData);
}
/**
* Reorders sections by moving sourceTag relative to targetTag within the set of reorderable
* sections (custom sections and the Chats tag). Favourite and LowPriority are not reorderable
* and are rejected as either source or target.
*
* If the source was below the target, it is inserted before the target; otherwise after.
* @param sourceTag - The tag of the section to move.
* @param targetTag - The tag of the section to move relative to.
*/
export async function reorderSection(sourceTag: string, targetTag: string): Promise<void> {
const ordered = getOrderedReorderableSections();
const fromIndex = ordered.indexOf(sourceTag as ReorderableSection);
if (fromIndex === -1 || !ordered.includes(targetTag as ReorderableSection) || sourceTag === targetTag) return;
const toIndex = ordered.indexOf(targetTag as ReorderableSection);
const insertBefore = fromIndex > toIndex;
ordered.splice(fromIndex, 1);
const newToIndex = ordered.indexOf(targetTag as ReorderableSection);
ordered.splice(insertBefore ? newToIndex : newToIndex + 1, 0, sourceTag as ReorderableSection);
await SettingsStore.setValue("RoomList.OrderedCustomSections", null, SettingLevel.ACCOUNT, ordered);
}
@@ -19,7 +19,12 @@ import { NotificationStateEvents } from "../../stores/notifications/Notification
import { type RoomNotificationState } from "../../stores/notifications/RoomNotificationState";
import SettingsStore from "../../settings/SettingsStore";
import RoomListStoreV3 from "../../stores/room-list-v3/RoomListStoreV3";
import { getCustomSectionData, isCustomSectionTag, isDefaultSectionTag } from "../../stores/room-list-v3/section";
import {
CHATS_TAG,
getCustomSectionData,
isCustomSectionTag,
isDefaultSectionTag,
} from "../../stores/room-list-v3/section";
import PosthogTrackers from "../../PosthogTrackers";
import { CallStore, CallStoreEvent } from "../../stores/CallStore";
import { type Call, CallEvent } from "../../models/Call";
@@ -62,6 +67,7 @@ export class RoomListSectionHeaderViewModel
isExpanded: true,
isUnread: false,
displaySectionMenu: !isDefaultSection,
canBeReordered: !isDefaultSection || props.tag === CHATS_TAG,
});
const sectionWatherRef = SettingsStore.watchSetting("RoomList.CustomSectionData", null, () =>
this.onCustomSectionDataChange(),
@@ -94,6 +94,11 @@ export class RoomListViewModel
private roomsMap = new Map<string, Room>();
// 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>();
/**
* When dragging sections, we want to temporarily expand all sections to make it easier to move rooms between sections.
* This map stores the original expansion state of each section before the drag starts, so we can restore it after the drag ends.
*/
private readonly savedExpansionStates = new Map<string, boolean>();
/**
* Reference to the currently displayed toast, used to automatically close the toast after a timeout.
@@ -663,6 +668,33 @@ export class RoomListViewModel
}, 15 * 1000);
}
public changeSectionOrder = async (sourceTag: string, targetTag: string): Promise<void> => {
await RoomListStoreV3.instance.reorderSection(sourceTag, targetTag);
// Scroll to the section after it moved
const filterKeys = this.activeFilter !== undefined ? [this.activeFilter] : undefined;
this.roomsResult = RoomListStoreV3.instance.getSortedRoomsInActiveSpace(filterKeys);
this.updateRoomsMap(this.roomsResult);
this.updateRoomListData(false, null, sourceTag);
};
public onSectionDragStart = (): void => {
this.savedExpansionStates.clear();
for (const [tag, sectionVM] of this.roomSectionHeaderViewModels) {
this.savedExpansionStates.set(tag, sectionVM.isExpanded);
sectionVM.isExpanded = false;
}
this.updateRoomListData();
};
public onSectionDragEnd = (): void => {
for (const [tag, expanded] of this.savedExpansionStates) {
const sectionVM = this.roomSectionHeaderViewModels.get(tag);
if (sectionVM) sectionVM.isExpanded = expanded;
}
this.savedExpansionStates.clear();
this.updateRoomListData();
};
public changeRoomSection = (roomId: string, tag: string): void => {
const room = this.props.client.getRoom(roomId);
if (!room) return;