Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,15 @@ import { type Page } from "@playwright/test";
import { rejectToast } from "@element-hq/element-web-playwright-common";

import { expect, test } from "../../../element-web-test";
import { assertRoomInSection, dragRoomToSection, getRoomList, getRoomListHeader, getSectionHeader } from "./utils";
import {
assertRoomInSection,
assertSectionsOrder,
dragRoomToSection,
dragSectionToSection,
getRoomList,
getRoomListHeader,
getSectionHeader,
} from "./utils";

test.describe("Room list custom sections", () => {
test.use({
Expand Down Expand Up @@ -284,6 +292,21 @@ test.describe("Room list custom sections", () => {
});
});

test.describe("Section reordering via dnd", () => {
test("should reorder custom sections via dnd", async ({ page, app }) => {
await app.client.createRoom({ name: "my room" });
await createCustomSection(page, "Work");
await createCustomSection(page, "Personal");

// Default placement: custom sections sit at the top of Chats
await assertSectionsOrder(page, ["Work", "Personal", "Chats"]);

// Moves Work after Chats
await dragSectionToSection(page, "Work", "Chats");
await assertSectionsOrder(page, ["Personal", "Chats", "Work"]);
});
});

test.describe("Adding a room to a custom section", () => {
test("should add a room to a custom section via the More Options menu", async ({ page, app }) => {
await app.client.createRoom({ name: "my room" });
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,15 @@
import { rejectToast } from "@element-hq/element-web-playwright-common";

import { expect, test } from "../../../element-web-test";
import { assertRoomInSection, dragRoomToSection, getPrimaryFilters, getRoomList, getSectionHeader } from "./utils";
import {
assertRoomInSection,
assertSectionsOrder,
dragRoomToSection,
dragSectionToSection,
getPrimaryFilters,
getRoomList,
getSectionHeader,
} from "./utils";

test.describe("Room list sections", () => {
test.use({
Expand Down Expand Up @@ -197,6 +205,29 @@ test.describe("Room list sections", () => {
await assertRoomInSection(page, "Favourites", "my room");
});

test("should reorder default sections via dnd", async ({ page, app }) => {
// Populate each default section so all three headers are visible
const favouriteId = await app.client.createRoom({ name: "fav room" });
await app.client.evaluate(async (client, roomId) => {
await client.setRoomTag(roomId, "m.favourite");
}, favouriteId);

await app.client.createRoom({ name: "regular room" });

const lowPrioId = await app.client.createRoom({ name: "low prio room" });
await app.client.evaluate(async (client, roomId) => {
await client.setRoomTag(roomId, "m.lowpriority");
}, lowPrioId);

// Initial order
await assertSectionsOrder(page, ["Favourites", "Chats", "Low Priority"]);

// Moves Favourites to immediately after Low Priority
await dragSectionToSection(page, "Favourites", "Low Priority");

await assertSectionsOrder(page, ["Chats", "Low Priority", "Favourites"]);
});

test("should move a room from Favourites to Chats when using dnd", async ({ page, app }) => {
const favouriteId = await app.client.createRoom({ name: "my room" });
await app.client.evaluate(async (client, roomId) => {
Expand Down
53 changes: 53 additions & 0 deletions apps/web/playwright/e2e/left-panel/room-list-panel/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,59 @@ export async function dragRoomToSection(page: Page, roomName: string, sectionNam
await page.mouse.up();
}

/**
* Drag and drop a section header onto another section header. The dragged section is moved
* to the position immediately after the target section. Because the dnd start handler collapses
* every section, the layout changes once the drag activates — so the target position is
* recomputed after activation rather than cached up-front.
*/
export async function dragSectionToSection(
page: Page,
sourceSectionName: string,
targetSectionName: string,
): Promise<void> {
const source = getSectionHeader(page, sourceSectionName);
const sourceBox = await source.boundingBox();
if (!sourceBox) throw new Error(`Source section ${sourceSectionName} has no bounding box`);

const sourceX = sourceBox.x + sourceBox.width / 2;
const sourceY = sourceBox.y + sourceBox.height / 2;

// Grab the section header
await page.mouse.move(sourceX, sourceY);
await page.mouse.down();
// Move past the 5px PointerSensor activation threshold so the drag actually starts.
// This triggers onSectionDragStart, which collapses all sections.
await page.mouse.move(sourceX, sourceY + 10, { steps: 5 });

// Re-query the target now that the layout has reflowed.
const target = getSectionHeader(page, targetSectionName);
const targetBox = await target.boundingBox();
if (!targetBox) throw new Error(`Target section ${targetSectionName} has no bounding box`);
const targetY = targetBox.y + targetBox.height / 2;

// Move onto the (possibly relocated) target section header and drop.
await page.mouse.move(sourceX, targetY, { steps: 10 });
await page.mouse.up();
}

/**
* Assert the displayed section headers appear in the given top-to-bottom order.
*/
export async function assertSectionsOrder(page: Page, expectedOrder: string[]): Promise<void> {
const positions: Array<{ name: string; y: number }> = [];
for (const name of expectedOrder) {
const header = getSectionHeader(page, name);
await expect(header).toBeVisible();
const box = await header.boundingBox();
if (!box) throw new Error(`Section ${name} has no bounding box`);
positions.push({ name, y: box.y });
}
for (let i = 1; i < positions.length; i++) {
expect(positions[i].y).toBeGreaterThan(positions[i - 1].y);
}
}

/**
* Get the primary filters container
* @param page
Expand Down
4 changes: 2 additions & 2 deletions apps/web/src/settings/Settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 CustomSections, type CustomSectionsData } from "../stores/room-list-v3/section.ts";
import { type NotificationSound } from "../Notifier.ts";

export const defaultWatchManager = new WatchManager();
Expand Down Expand Up @@ -368,7 +368,7 @@ export interface Settings {
"blockInvites": IBaseSetting<boolean>;
"Developer.elementCallUrl": IBaseSetting<string>;
"RoomList.CustomSectionData": IBaseSetting<CustomSectionsData>;
"RoomList.OrderedCustomSections": IBaseSetting<OrderedCustomSections>;
"RoomList.OrderedCustomSections": IBaseSetting<CustomSections>;
}

export type SettingKey = keyof Settings;
Expand Down
15 changes: 11 additions & 4 deletions apps/web/src/stores/room-list-v3/RoomListStoreV3.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,11 +36,10 @@ 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, getOrderedSections, reorderSection } from "./section";

/**
* These are the filters passed to the room skip list.
Expand Down Expand Up @@ -511,6 +510,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.
*/
Expand All @@ -522,8 +530,7 @@ 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];
this.sortedTags = getOrderedSections();
}
}

Expand Down
69 changes: 65 additions & 4 deletions apps/web/src/stores/room-list-v3/section.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,9 +83,14 @@ function isValidCustomSection(value: unknown): value is CustomSection {
*/
export type CustomSectionsData = Record<CustomTag, CustomSection>;
/**
* Ordered list of custom section tags.
* Union of all valid section tags (default + custom).
*/
export type OrderedCustomSections = CustomTag[];
export type SectionTag = CustomTag | DefaultTagID.Favourite | DefaultTagID.LowPriority | typeof CHATS_TAG;

/**
* Ordered list of section tags (default + custom).
*/
export type CustomSections = SectionTag[];

/**
* Returns true if the given space key corresponds to an enabled meta-space or a known top-level space room.
Expand Down Expand Up @@ -122,13 +127,51 @@ 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.
*/
export function getOrderedCustomSections(): OrderedCustomSections {
export function getOrderedCustomSections(): CustomSections {
const sectionData = getCustomSectionData();
const rawValue = SettingsStore.getValue("RoomList.OrderedCustomSections");
const orderedSections: OrderedCustomSections = Array.isArray(rawValue) ? rawValue : [];
const orderedSections: CustomSections = Array.isArray(rawValue) ? rawValue : [];
return orderedSections.filter((tag) => tag in sectionData);
}

const DEFAULT_SECTION_TAGS = new Set<string>([DefaultTagID.Favourite, CHATS_TAG, DefaultTagID.LowPriority]);

/**
* Returns the full ordered list of all sections (default + custom).
* If the stored order includes all three default tags, it is used as-is (minus deleted custom sections),
* with any new custom sections inserted before LowPriority.
* Falls back to the canonical order when the stored value is empty or contains no default tags.
*/
export function getOrderedSections(): SectionTag[] {
const customData = getCustomSectionData();
const availableCustomTags = Object.keys(customData).filter(isCustomSectionTag) as CustomTag[];

const rawValue = SettingsStore.getValue("RoomList.OrderedCustomSections");
const tags: SectionTag[] = Array.isArray(rawValue) ? (rawValue as SectionTag[]) : [];

// Keep only valid tags (remove deleted custom sections, keep all defaults)
const result = tags.filter((t) => DEFAULT_SECTION_TAGS.has(t) || (isCustomSectionTag(t) && t in customData));

// Ensure all 3 default tags are present
for (const tag of [DefaultTagID.Favourite, CHATS_TAG, DefaultTagID.LowPriority] as SectionTag[]) {
if (!result.includes(tag)) result.push(tag);
}

// Append any new custom tags not yet in the list, before LowPriority
for (const tag of availableCustomTags) {
if (!result.includes(tag)) {
const lpIndex = result.indexOf(DefaultTagID.LowPriority);
if (lpIndex === -1) {
result.push(tag);
} else {
result.splice(lpIndex, 0, tag);
}
}
}

return result;
}

/**
* Creates a new custom section by showing a dialog to the user to enter the section name.
* If the user confirms, it generates a unique tag for the section, saves the section data in the settings, and updates the ordered list of sections.
Expand Down Expand Up @@ -212,3 +255,21 @@ export async function deleteSection(tag: string, isEmpty: boolean): Promise<void
delete sectionData[tag];
await SettingsStore.setValue("RoomList.CustomSectionData", null, SettingLevel.ACCOUNT, sectionData);
}

/**
* Reorders sections by moving sourceTag after targetTag.
* Works for both default and custom sections.
* @param sourceTag - The tag of the section to move.
* @param targetTag - The tag of the section to move after.
*/
export async function reorderSection(sourceTag: string, targetTag: string): Promise<void> {
const ordered = getOrderedSections();
const fromIndex = ordered.indexOf(sourceTag as SectionTag);

if (fromIndex === -1 || !ordered.includes(targetTag as SectionTag) || sourceTag === targetTag) return;

ordered.splice(fromIndex, 1);
const newToIndex = ordered.indexOf(targetTag as SectionTag);
ordered.splice(newToIndex + 1, 0, sourceTag as SectionTag);
await SettingsStore.setValue("RoomList.OrderedCustomSections", null, SettingLevel.ACCOUNT, ordered);
}
27 changes: 27 additions & 0 deletions apps/web/src/viewmodels/room-list/RoomListViewModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -673,6 +678,28 @@ export class RoomListViewModel
}, 15 * 1000);
}

public changeSectionOrder = (sourceTag: string, targetTag: string): void => {
RoomListStoreV3.instance.reorderSection(sourceTag, targetTag);
};

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;
Expand Down
Loading
Loading