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:
@@ -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];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user