Skip to content
Merged
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 @@ -5,7 +5,7 @@
* Please see LICENSE files in the repository root for full details.
*/

import { rejectToast } from "@element-hq/element-web-playwright-common";
import { rejectToast, rejectToastIfExists } from "@element-hq/element-web-playwright-common";

import { expect, test } from "../../../element-web-test";
import { SettingLevel } from "../../../../src/settings/SettingLevel";
Expand Down Expand Up @@ -229,6 +229,63 @@ test.describe("Room list sections", () => {
});
});

test.describe("Section collapse state persistence", () => {
test.beforeEach(async ({ app }) => {
// A favourite room (so we get a Favourites section) and a regular room in Chats,
// giving us two independent sections whose expansion state we can assert.
const favouriteId = await app.client.createRoom({ name: "favourite room" });
await app.client.evaluate(async (client, roomId) => {
await client.setRoomTag(roomId, "m.favourite");
}, favouriteId);
await app.client.createRoom({ name: "regular room" });
});

test("persists the collapsed/expanded state across reloads", async ({ page }) => {
const roomList = getRoomList(page);
const favouritesHeader = getSectionHeader(page, "Favourites");
const chatsHeader = getSectionHeader(page, "Chats");
const favRoom = roomList.getByRole("row", { name: "Open room favourite room" });
const regularRoom = roomList.getByRole("row", { name: "Open room regular room" });

// Collapse both the Favourites and Chats sections
await expect(favouritesHeader).toHaveAttribute("aria-expanded", "true");
await favouritesHeader.click();
await expect(favouritesHeader).toHaveAttribute("aria-expanded", "false");
await expect(favRoom).not.toBeVisible();

await expect(chatsHeader).toHaveAttribute("aria-expanded", "true");
await chatsHeader.click();
await expect(chatsHeader).toHaveAttribute("aria-expanded", "false");
await expect(regularRoom).not.toBeVisible();

// Reload the page: the collapsed state is persisted at the device level and should survive
await page.reload();
await rejectToastIfExists(page, "Verify this device");
await rejectToastIfExists(page, "Notifications");

// Both sections are still collapsed and their rooms stay hidden
await expect(getSectionHeader(page, "Favourites")).toHaveAttribute("aria-expanded", "false");
await expect(getRoomList(page).getByRole("row", { name: "Open room favourite room" })).not.toBeVisible();
await expect(getSectionHeader(page, "Chats")).toHaveAttribute("aria-expanded", "false");
await expect(getRoomList(page).getByRole("row", { name: "Open room regular room" })).not.toBeVisible();

// Expand them again and reload: the expanded state is likewise persisted
await getSectionHeader(page, "Favourites").click();
await expect(getSectionHeader(page, "Favourites")).toHaveAttribute("aria-expanded", "true");
await getSectionHeader(page, "Chats").click();
await expect(getSectionHeader(page, "Chats")).toHaveAttribute("aria-expanded", "true");

await page.reload();
await rejectToastIfExists(page, "Verify this device");
await rejectToastIfExists(page, "Notifications");

await expect(getSectionHeader(page, "Favourites")).toHaveAttribute("aria-expanded", "true");
await expect(getRoomList(page).getByRole("row", { name: "Open room favourite room" })).toBeVisible();
await expect(getSectionHeader(page, "Chats")).toHaveAttribute("aria-expanded", "true");
await expect(getRoomList(page).getByRole("row", { name: "Open room regular room" })).toBeVisible();
});
});

test.describe("Rooms placement in sections", () => {
test("should move a room between sections when tags change", async ({ page, app }) => {
await app.client.createRoom({ name: "my room" });
Expand Down
15 changes: 14 additions & 1 deletion apps/web/src/settings/Settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,11 @@ 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 ReorderableSection, type CustomSectionsData } from "../stores/room-list-v3/section.ts";
import {
type ReorderableSection,
type CustomSectionsData,
type SectionExpansionState,
} from "../stores/room-list-v3/section.ts";
import { type NotificationSound } from "../Notifier.ts";
import VideoRoomsBetaImage from "../../res/img/betas/video_rooms.png";

Expand Down Expand Up @@ -364,6 +368,7 @@ export interface Settings {
"Developer.elementCallUrl": IBaseSetting<string>;
"RoomList.CustomSectionData": IBaseSetting<CustomSectionsData>;
"RoomList.OrderedCustomSections": IBaseSetting<ReorderableSection[]>;
"RoomList.SectionExpansionState": IBaseSetting<SectionExpansionState>;
"RoomList.showSections": IBaseSetting<boolean>;
}

Expand Down Expand Up @@ -1349,6 +1354,14 @@ export const SETTINGS: Settings = {
supportedLevels: LEVELS_ACCOUNT_SETTINGS,
default: [],
},
/**
* Managed by the {@link RoomListSectionHeaderViewModel}
* Store the expanded/collapsed state of the room list sections, per space and per section tag
*/
"RoomList.SectionExpansionState": {
supportedLevels: LEVELS_DEVICE_ONLY_SETTINGS,
default: {},
},
[UIFeature.RoomHistorySettings]: {
supportedLevels: LEVELS_UI_FEATURE,
default: true,
Expand Down
30 changes: 30 additions & 0 deletions apps/web/src/stores/room-list-v3/section.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,36 @@ export function getCustomSectionData(): CustomSectionsData {
) as CustomSectionsData;
}

/**
* Persisted expanded/collapsed state of the room list sections, stored per space then per section tag.
*/
export type SectionExpansionState = { [spaceId: string]: { [sectionTag: string]: boolean } };

/**
* Returns whether the section with the given tag is expanded in the given space.
* Defaults to expanded when no state has been persisted.
* @param spaceId - The id of the space.
* @param tag - The tag of the section.
*/
export function isSectionExpanded(spaceId: string, tag: string): boolean {
return SettingsStore.getValue("RoomList.SectionExpansionState")[spaceId]?.[tag] ?? true;
}
Comment on lines +146 to +148

/**
* Persists the expanded/collapsed state of a section for a given space at the device level.
* @param spaceId - The id of the space.
* @param tag - The tag of the section.
* @param expanded - Whether the section is expanded.
*/
export async function setSectionExpanded(spaceId: string, tag: string, expanded: boolean): Promise<void> {
const state = SettingsStore.getValue("RoomList.SectionExpansionState");
const newState: SectionExpansionState = {
...state,
[spaceId]: { ...state[spaceId], [tag]: expanded },
};
await SettingsStore.setValue("RoomList.SectionExpansionState", null, SettingLevel.DEVICE, newState);
}
Comment on lines +156 to +163

/**
* 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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@
getCustomSectionData,
isCustomSectionTag,
isDefaultSectionTag,
isSectionExpanded,
setSectionExpanded,
} from "../../stores/room-list-v3/section";
import PosthogTrackers from "../../PosthogTrackers";
import { CallStore, CallStoreEvent } from "../../stores/CallStore";
Expand All @@ -48,12 +50,6 @@
*/
private roomNotificationStates = new Set<RoomNotificationState>();

/**
* Tracks the expanded/collapsed state per space.
* Key is spaceId. Defaults to expanded if not set.
*/
private readonly expandedBySpace = new Map<string, boolean>();

/**
* The calls of the rooms currently in this section that we are listening to, used to aggregate the call decoration.
*/
Expand All @@ -64,7 +60,7 @@
super(props, {
id: props.tag,
title: props.title,
isExpanded: true,
isExpanded: isSectionExpanded(props.spaceId, props.tag),
isUnread: false,
displaySectionMenu: !isDefaultSection,
canBeReordered: !isDefaultSection || props.tag === CHATS_TAG,
Expand All @@ -78,9 +74,10 @@
this.disposables.trackListener(CallStore.instance, CallStoreEvent.Call, this.onCallChanged);
}

public onClick = (): void => {
public onClick = async (): Promise<void> => {
const isExpanded = !this.snapshot.current.isExpanded;
this.expandedBySpace.set(this.props.spaceId, isExpanded);
// We don't wait to persist the expanded state to storage, as it is not critical and we want the UI to update immediately
void setSectionExpanded(this.props.spaceId, this.props.tag, isExpanded);
this.snapshot.merge({ isExpanded });
Comment on lines +77 to 81
this.props.onToggleExpanded(isExpanded);
};
Expand All @@ -97,7 +94,8 @@
* This will not trigger the onToggleExpanded callback.
*/
public set isExpanded(value: boolean) {
this.expandedBySpace.set(this.props.spaceId, value);
// We don't wait to persist the expanded state to storage, as it is not critical and we want the UI to update immediately
void setSectionExpanded(this.props.spaceId, this.props.tag, value);
this.snapshot.merge({ isExpanded: value });
Comment on lines 96 to 99

const kind = value ? "Expand" : "Collapse";
Expand All @@ -110,7 +108,7 @@
*/
public setSpace(spaceId: string): void {
this.props.spaceId = spaceId;
const isExpanded = this.expandedBySpace.get(this.props.spaceId) ?? true;
const isExpanded = isSectionExpanded(this.props.spaceId, this.props.tag);
this.snapshot.merge({ isExpanded });
}

Expand Down Expand Up @@ -182,7 +180,7 @@
* Computes both the unread (bold) state and a merged notification decoration that aggregates
* the rooms' notifications. The activity "dot" is intentionally excluded from the decoration.
*/
private updateNotificationState = (): void => {

Check failure on line 183 in apps/web/src/viewmodels/room-list/RoomListSectionHeaderViewModel.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this function to reduce its Cognitive Complexity from 24 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=element-web&issues=AZ-EJLcEjnNQy-GW7oeO&open=AZ-EJLcEjnNQy-GW7oeO&pullRequest=34351
let isUnread = false;
let isMention = false;
let isNotification = false;
Expand Down
27 changes: 13 additions & 14 deletions apps/web/src/viewmodels/room-list/RoomListViewModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -753,6 +753,9 @@ export class RoomListViewModel
roomIdOverride: string | null = null,
scrollToSectionTag: string | undefined = undefined,
): Promise<void> {
// Store is still loading rooms - don't update the list yet, we'll get another update when loading finishes
if (RoomListStoreV3.instance.isLoadingRooms) return;

// Determine the room ID to use for calculations
// Use override if provided (e.g., during space changes), otherwise fall back to RoomViewStore
const roomId = roomIdOverride ?? this.props.roomViewStore.getRoomId();
Expand Down Expand Up @@ -782,12 +785,6 @@ export class RoomListViewModel
this.roomsResult,
(tag) => this.roomSectionHeaderViewModels.get(tag)?.isExpanded ?? true,
);
// If it's a flat list, we need to make sure the single section is expanded and has all rooms, otherwise the room list will be empty
if (isFlatList) {
const chatSections = this.roomSectionHeaderViewModels.get(CHATS_TAG);
if (chatSections) chatSections.isExpanded = true;
chatSections?.setRooms(this.roomsResult.sections.flatMap((section) => section.rooms));
}
this.sections = sections;

// Calculate the active room index from the computed sections (which exclude collapsed sections' rooms)
Expand Down Expand Up @@ -970,19 +967,21 @@ function computeSections(
): { sections: Section[]; isFlatList: boolean } {
const customSections = getCustomSectionData();

const sections = roomsResult.sections
const filtered = roomsResult.sections
// Only include sections that have rooms, or custom sections that were created in the current space.
.filter(
(section) =>
section.rooms.length > 0 ||
(isCustomSectionTag(section.tag) && customSections[section.tag]?.spaceId === roomsResult.spaceId),
)
// Remove roomIds for sections that are currently collapsed according to their section header view model
.map((section) => ({
...section,
rooms: isSectionExpanded(section.tag) ? section.rooms : [],
}));
const isFlatList = sections.length === 0 || (sections.length === 1 && sections[0].tag === CHATS_TAG);
);
const isFlatList = filtered.length === 0 || (filtered.length === 1 && filtered[0].tag === CHATS_TAG);

const sections = filtered.map((section) => ({
...section,
// A flat list has no section header to toggle, so always render its rooms.
// Otherwise, remove roomIds for sections that are currently collapsed.
rooms: isFlatList || isSectionExpanded(section.tag) ? section.rooms : [],
}));

return { sections, isFlatList };
}
Expand Down
60 changes: 60 additions & 0 deletions apps/web/test/unit-tests/stores/room-list-v3/section-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,14 @@ import {
getCustomSectionData,
getOrderedCustomSections,
isDefaultSectionTag,
isSectionExpanded,
setSectionExpanded,
CHATS_TAG,
CUSTOM_SECTION_TAG_PREFIX,
isSectionTag,
reorderSection,
} from "../../../../src/stores/room-list-v3/section";
import { SettingLevel } from "../../../../src/settings/SettingLevel";
import { CreateSectionDialog } from "../../../../src/components/views/dialogs/CreateSectionDialog";
import { RemoveSectionDialog } from "../../../../src/components/views/dialogs/RemoveSectionDialog";
import { DefaultTagID } from "../../../../src/stores/room-list-v3/skip-list/tag";
Expand Down Expand Up @@ -131,6 +134,63 @@ describe("section", () => {
});
});

describe("isSectionExpanded", () => {
const spaceId = "!space:server";
const tag = "element.io.section.abc";

it.each([
{ value: {}, result: true },
{ value: { "!other:server": { [tag]: false } }, result: true },
{ value: { [spaceId]: { "other.tag": false } }, result: true },
{ value: { [spaceId]: { [tag]: false } }, result: false },
])("returns the persisted state=$result when value=$value", ({ value, result }) => {
jest.spyOn(SettingsStore, "getValue").mockReturnValue(value);
expect(isSectionExpanded(spaceId, tag)).toBe(result);
});
});

describe("setSectionExpanded", () => {
const spaceId = "!space:server";
const tag = "element.io.section.abc";

it("persists the state at the device level", async () => {
jest.spyOn(SettingsStore, "getValue").mockReturnValue({});
const setValueSpy = jest.spyOn(SettingsStore, "setValue").mockResolvedValue(undefined);

await setSectionExpanded(spaceId, tag, false);

expect(setValueSpy).toHaveBeenCalledWith("RoomList.SectionExpansionState", null, SettingLevel.DEVICE, {
[spaceId]: { [tag]: false },
});
});

it("merges with existing state for other spaces and tags", async () => {
jest.spyOn(SettingsStore, "getValue").mockReturnValue({
"!other:server": { "other.tag": false },
[spaceId]: { "existing.tag": true },
});
const setValueSpy = jest.spyOn(SettingsStore, "setValue").mockResolvedValue(undefined);

await setSectionExpanded(spaceId, tag, false);

expect(setValueSpy).toHaveBeenCalledWith("RoomList.SectionExpansionState", null, SettingLevel.DEVICE, {
"!other:server": { "other.tag": false },
[spaceId]: { "existing.tag": true, [tag]: false },
});
});

it("overwrites the previous state for the same space and tag", async () => {
jest.spyOn(SettingsStore, "getValue").mockReturnValue({ [spaceId]: { [tag]: false } });
const setValueSpy = jest.spyOn(SettingsStore, "setValue").mockResolvedValue(undefined);

await setSectionExpanded(spaceId, tag, true);

expect(setValueSpy).toHaveBeenCalledWith("RoomList.SectionExpansionState", null, SettingLevel.DEVICE, {
[spaceId]: { [tag]: true },
});
});
});

describe("createSection", () => {
beforeEach(() => {
jest.spyOn(SettingsStore, "getValue").mockReturnValue(null);
Expand Down
Loading
Loading