Room list: add release announcement for sections (#33800)

* feat: add release announcement for sections

* test: update screenshot

* test: update tests

* test: add release annoucement utils to close it

* test: close release announcement in custom section tests

* test: update release announcement e2e test

* test: add more tests

* feat: update Ra labels

* test: update screenshot

* chore: remove stale screenshot
This commit is contained in:
Florian Duros
2026-06-19 12:06:58 +00:00
committed by GitHub
parent 43b9bc9458
commit 338c18df16
17 changed files with 185 additions and 47 deletions
@@ -6,7 +6,7 @@
*/ */
import { type Page } from "@playwright/test"; import { type Page } from "@playwright/test";
import { rejectToast } from "@element-hq/element-web-playwright-common"; import { closeReleaseAnnouncement, rejectToast } from "@element-hq/element-web-playwright-common";
import { expect, test } from "../../../element-web-test"; import { expect, test } from "../../../element-web-test";
import { assertRoomInSection, dragRoomToSection, getRoomList, getRoomListHeader, getSectionHeader } from "./utils"; import { assertRoomInSection, dragRoomToSection, getRoomList, getRoomListHeader, getSectionHeader } from "./utils";
@@ -46,6 +46,9 @@ test.describe("Room list custom sections", () => {
await rejectToast(page, "Verify this device"); await rejectToast(page, "Verify this device");
await rejectToast(page, "Notifications"); await rejectToast(page, "Notifications");
// Close the release announcement about the new room list sections
await closeReleaseAnnouncement(page, "Introducing Sections");
// Focus the user menu to avoid hover decoration // Focus the user menu to avoid hover decoration
await page.getByRole("button", { name: "User menu" }).focus(); await page.getByRole("button", { name: "User menu" }).focus();
}); });
@@ -6,15 +6,12 @@
* Please see LICENSE files in the repository root for full details. * Please see LICENSE files in the repository root for full details.
*/ */
import { test, expect } from "./"; import { rejectToast } from "@element-hq/element-web-playwright-common";
import { test } from "./";
test.describe("Release announcement", () => { test.describe("Release announcement", () => {
test.use({ test.use({
config: {
features: {
feature_release_announcement: true,
},
},
room: async ({ app, user }, use) => { room: async ({ app, user }, use) => {
const roomId = await app.client.createRoom({ const roomId = await app.client.createRoom({
name: "Test room", name: "Test room",
@@ -22,37 +19,31 @@ test.describe("Release announcement", () => {
await app.viewRoomById(roomId); await app.viewRoomById(roomId);
await use({ roomId }); await use({ roomId });
}, },
labsFlags: ["feature_new_room_list"], labsFlags: ["feature_room_list_sections"],
});
test.beforeEach(async ({ page, app, user }) => {
// The toasts are displayed above the search section
await rejectToast(page, "Verify this device");
await rejectToast(page, "Notifications");
}); });
// There is no release announcement currently live // There is no release announcement currently live
test.skip( test(
"should display the new room list release announcement", "should display the room list section release announcement",
{ tag: "@screenshot" }, { tag: "@screenshot" },
async ({ page, app, room, util }) => { async ({ page, app, room, util }) => {
// dismiss the toast so the announcement appears const sectionName = "Introducing Sections";
await page.getByRole("button", { name: "Dismiss" }).click(); // The section release announcement should be displayed
await util.assertReleaseAnnouncementIsVisible(sectionName);
// Hide the section release announcement
const dialog = util.getReleaseAnnouncement(sectionName);
await dialog.getByRole("button", { name: "Ok" }).click();
const newSoundsName = "Weve refreshed your sounds"; await util.assertReleaseAnnouncementIsNotVisible(sectionName);
// The new sounds release announcement should be displayed
await util.assertReleaseAnnouncementIsVisible(newSoundsName);
// Hide the new sounds release announcement
const newSoundsDialog = util.getReleaseAnnouncement(newSoundsName);
await newSoundsDialog.getByRole("button", { name: "OK" }).click();
const newRoomListName = "Chats has a new look!";
// The new room list release announcement should be displayed
await util.assertReleaseAnnouncementIsVisible(newRoomListName);
// Hide the new room list release announcement
const dialog = util.getReleaseAnnouncement(newRoomListName);
await dialog.getByRole("button", { name: "Next" }).click();
await util.assertReleaseAnnouncementIsNotVisible(newRoomListName);
await page.reload(); await page.reload();
await expect(page.getByRole("button", { name: "Room options" })).toBeVisible(); await util.assertReleaseAnnouncementIsNotVisible(sectionName);
// Check that once the release announcements has been marked as viewed, it does not appear again
await util.assertReleaseAnnouncementIsNotVisible(newRoomListName);
}, },
); );
}); });
@@ -19,7 +19,7 @@ import ToastStore from "./ToastStore";
* We include a `_test_dummy` value to enable tests to function even where there are no running release announcements. * We include a `_test_dummy` value to enable tests to function even where there are no running release announcements.
* This value must be at the end of the list. * This value must be at the end of the list.
*/ */
const FEATURES = ["_test_dummy1", "_test_dummy2"] as const; const FEATURES = ["room_list_section", "_test_dummy1", "_test_dummy2"] as const;
/** /**
* All the features that can be shown in the release announcements. * All the features that can be shown in the release announcements.
*/ */
@@ -32,6 +32,7 @@ import RoomListStoreV3 from "../../stores/room-list-v3/RoomListStoreV3";
import { SortingAlgorithm } from "../../stores/room-list-v3/skip-list/sorters"; import { SortingAlgorithm } from "../../stores/room-list-v3/skip-list/sorters";
import { SettingLevel } from "../../settings/SettingLevel"; import { SettingLevel } from "../../settings/SettingLevel";
import { createRoom, hasCreateRoomRights } from "./utils"; import { createRoom, hasCreateRoomRights } from "./utils";
import { ReleaseAnnouncementStore } from "../../stores/ReleaseAnnouncementStore";
export interface Props { export interface Props {
/** /**
@@ -82,6 +83,12 @@ export class RoomListHeaderViewModel
// Listen for section collapse state changes from RoomListViewModel // Listen for section collapse state changes from RoomListViewModel
const dispatcherRef = defaultDispatcher.register(this.onDispatch); const dispatcherRef = defaultDispatcher.register(this.onDispatch);
this.disposables.track(() => defaultDispatcher.unregister(dispatcherRef)); this.disposables.track(() => defaultDispatcher.unregister(dispatcherRef));
this.disposables.trackListener(
ReleaseAnnouncementStore.instance,
"releaseAnnouncementChanged",
this.onReleaseAnnouncementChanged,
);
} }
/** /**
@@ -229,7 +236,21 @@ export class RoomListHeaderViewModel
}); });
} }
}; };
public closeSectionReleaseAnnouncement = (): void => {
ReleaseAnnouncementStore.instance.nextReleaseAnnouncement();
this.snapshot.merge({ displaySectionReleaseAnnouncement: false });
};
public onReleaseAnnouncementChanged = (): void => {
const isSectionFeatureEnabled = SettingsStore.getValue("feature_room_list_sections");
const displaySectionReleaseAnnouncement =
isSectionFeatureEnabled &&
ReleaseAnnouncementStore.instance.getReleaseAnnouncement() === "room_list_section";
this.snapshot.merge({ displaySectionReleaseAnnouncement });
};
} }
/** /**
* Get the initial snapshot for the RoomListHeaderViewModel. * Get the initial snapshot for the RoomListHeaderViewModel.
* @param spaceStore - The space store instance. * @param spaceStore - The space store instance.
@@ -290,6 +311,8 @@ function computeHeaderSpaceState(
matrixClient: MatrixClient, matrixClient: MatrixClient,
): Omit<RoomListHeaderViewSnapshot, "activeSortOption" | "isMessagePreviewEnabled"> { ): Omit<RoomListHeaderViewSnapshot, "activeSortOption" | "isMessagePreviewEnabled"> {
const isSectionFeatureEnabled = SettingsStore.getValue("feature_room_list_sections"); const isSectionFeatureEnabled = SettingsStore.getValue("feature_room_list_sections");
const displaySectionReleaseAnnouncement =
isSectionFeatureEnabled && ReleaseAnnouncementStore.instance.getReleaseAnnouncement() === "room_list_section";
const activeSpace = spaceStore.activeSpaceRoom; const activeSpace = spaceStore.activeSpaceRoom;
const title = getHeaderTitle(spaceStore); const title = getHeaderTitle(spaceStore);
@@ -316,5 +339,6 @@ function computeHeaderSpaceState(
canAccessSpaceSettings, canAccessSpaceSettings,
canCreateSection, canCreateSection,
useComposeIcon, useComposeIcon,
displaySectionReleaseAnnouncement,
}; };
} }
@@ -22,7 +22,12 @@ describe("ReleaseAnnouncement", () => {
function renderReleaseAnnouncement() { function renderReleaseAnnouncement() {
return render( return render(
<ReleaseAnnouncement feature="_test_dummy1" header="header" description="description" closeLabel="close"> <ReleaseAnnouncement
feature="room_list_section"
header="header"
description="description"
closeLabel="close"
>
<div>content</div> <div>content</div>
</ReleaseAnnouncement>, </ReleaseAnnouncement>,
); );
@@ -108,13 +108,13 @@ describe("ReleaseAnnouncementStore", () => {
it("should listen to release announcement data changes in the store", async () => { it("should listen to release announcement data changes in the store", async () => {
const secondStore = new ReleaseAnnouncementStore(); const secondStore = new ReleaseAnnouncementStore();
expect(secondStore.getReleaseAnnouncement()).toBe("_test_dummy1"); expect(secondStore.getReleaseAnnouncement()).toBe("room_list_section");
const promise = listenReleaseAnnouncementChanged(); const promise = listenReleaseAnnouncementChanged();
await secondStore.nextReleaseAnnouncement(); await secondStore.nextReleaseAnnouncement();
expect(await promise).toBe("_test_dummy2"); expect(await promise).toBe("_test_dummy1");
expect(releaseAnnouncementStore.getReleaseAnnouncement()).toBe("_test_dummy2"); expect(releaseAnnouncementStore.getReleaseAnnouncement()).toBe("_test_dummy1");
}); });
it("should return null when there are toasts on screen", async () => { it("should return null when there are toasts on screen", async () => {
@@ -26,6 +26,7 @@ import {
import { createTestClient, mkSpace } from "../../test-utils"; import { createTestClient, mkSpace } from "../../test-utils";
import { createRoom, hasCreateRoomRights } from "../../../src/viewmodels/room-list/utils"; import { createRoom, hasCreateRoomRights } from "../../../src/viewmodels/room-list/utils";
import PosthogTrackers from "../../../src/PosthogTrackers"; import PosthogTrackers from "../../../src/PosthogTrackers";
import { ReleaseAnnouncementStore } from "../../../src/stores/ReleaseAnnouncementStore";
jest.mock("../../../src/PosthogTrackers", () => ({ jest.mock("../../../src/PosthogTrackers", () => ({
trackInteraction: jest.fn(), trackInteraction: jest.fn(),
@@ -59,6 +60,9 @@ describe("RoomListHeaderViewModel", () => {
mocked(hasCreateRoomRights).mockReturnValue(true); mocked(hasCreateRoomRights).mockReturnValue(true);
mocked(shouldShowSpaceSettings).mockReturnValue(true); mocked(shouldShowSpaceSettings).mockReturnValue(true);
jest.spyOn(ReleaseAnnouncementStore.instance, "getReleaseAnnouncement").mockReturnValue(null);
jest.spyOn(ReleaseAnnouncementStore.instance, "nextReleaseAnnouncement").mockResolvedValue(undefined);
jest.spyOn(SettingsStore, "getValue").mockImplementation((settingName: string) => { jest.spyOn(SettingsStore, "getValue").mockImplementation((settingName: string) => {
if (settingName === "RoomList.preferredSorting") return SortingAlgorithm.Recency; if (settingName === "RoomList.preferredSorting") return SortingAlgorithm.Recency;
if (settingName === "feature_video_rooms") return true; if (settingName === "feature_video_rooms") return true;
@@ -191,6 +195,28 @@ describe("RoomListHeaderViewModel", () => {
expect(vm.getSnapshot().useComposeIcon).toBe(expectedUseComposeIcon); expect(vm.getSnapshot().useComposeIcon).toBe(expectedUseComposeIcon);
}, },
); );
it("should set displaySectionReleaseAnnouncement to true when sections feature is enabled and announcement is active", () => {
jest.spyOn(SettingsStore, "getValue").mockImplementation((settingName: string) => {
if (settingName === "feature_room_list_sections") return true;
return false;
});
jest.spyOn(ReleaseAnnouncementStore.instance, "getReleaseAnnouncement").mockReturnValue(
"room_list_section",
);
vm = new RoomListHeaderViewModel({ matrixClient, spaceStore: SpaceStore.instance });
expect(vm.getSnapshot().displaySectionReleaseAnnouncement).toBe(true);
});
it("should set displaySectionReleaseAnnouncement to false when sections feature is disabled", () => {
jest.spyOn(ReleaseAnnouncementStore.instance, "getReleaseAnnouncement").mockReturnValue(
"room_list_section",
);
vm = new RoomListHeaderViewModel({ matrixClient, spaceStore: SpaceStore.instance });
expect(vm.getSnapshot().displaySectionReleaseAnnouncement).toBe(false);
});
}); });
describe("event listeners", () => { describe("event listeners", () => {
@@ -435,5 +461,14 @@ describe("RoomListHeaderViewModel", () => {
expect(setValueSpy).toHaveBeenCalledWith("RoomList.showMessagePreview", null, expect.anything(), false); expect(setValueSpy).toHaveBeenCalledWith("RoomList.showMessagePreview", null, expect.anything(), false);
expect(vm.getSnapshot().isMessagePreviewEnabled).toBe(false); expect(vm.getSnapshot().isMessagePreviewEnabled).toBe(false);
}); });
it("should call nextReleaseAnnouncement and set displaySectionReleaseAnnouncement to false when closeSectionReleaseAnnouncement is called", () => {
vm = new RoomListHeaderViewModel({ matrixClient, spaceStore: SpaceStore.instance });
vm.closeSectionReleaseAnnouncement();
expect(ReleaseAnnouncementStore.instance.nextReleaseAnnouncement).toHaveBeenCalled();
expect(vm.getSnapshot().displaySectionReleaseAnnouncement).toBe(false);
});
}); });
}); });
+1
View File
@@ -13,6 +13,7 @@ import { routeConfigJson } from "./utils/config_json.js";
export * from "./utils/config_json.js"; export * from "./utils/config_json.js";
export * from "./utils/context.js"; export * from "./utils/context.js";
export * from "./utils/release_accouncement.js";
export * from "./utils/toasts.js"; export * from "./utils/toasts.js";
export { populateLocalStorageWithCredentials } from "./fixtures/user.js"; export { populateLocalStorageWithCredentials } from "./fixtures/user.js";
@@ -0,0 +1,17 @@
/*
* 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 { Page } from "playwright-core";
/**
* Close the release announcement with the given name.
* @param page The Playwright page instance.
* @param name The name of the release announcement dialog.
*/
export async function closeReleaseAnnouncement(page: Page, name: string): Promise<void> {
await page.getByRole("dialog", { name }).getByRole("button", { name: "OK" }).click();
}
@@ -67,6 +67,11 @@
"mentions_keywords": "Mentions and keywords", "mentions_keywords": "Mentions and keywords",
"mute_room": "Mute room" "mute_room": "Mute room"
}, },
"release_announcement": {
"room_list_section_close": "Ok",
"room_list_section_description": " Group your chats into sections to keep conversations organised and easy to navigate.",
"room_list_section_title": "Introducing Sections"
},
"room": { "room": {
"context_menu": { "context_menu": {
"title": "Room options" "title": "Room options"
@@ -32,6 +32,7 @@ const RoomListHeaderViewWrapperImpl = ({
toggleMessagePreview, toggleMessagePreview,
createSection, createSection,
collapseOrExpandSections, collapseOrExpandSections,
closeSectionReleaseAnnouncement,
...rest ...rest
}: RoomListHeaderProps): JSX.Element => { }: RoomListHeaderProps): JSX.Element => {
const vm = useMockedViewModel(rest, { const vm = useMockedViewModel(rest, {
@@ -46,6 +47,7 @@ const RoomListHeaderViewWrapperImpl = ({
toggleMessagePreview, toggleMessagePreview,
createSection, createSection,
collapseOrExpandSections, collapseOrExpandSections,
closeSectionReleaseAnnouncement,
}); });
return <RoomListHeaderView vm={vm} />; return <RoomListHeaderView vm={vm} />;
}; };
@@ -68,6 +70,7 @@ const meta = {
toggleMessagePreview: fn(), toggleMessagePreview: fn(),
createSection: fn(), createSection: fn(),
collapseOrExpandSections: fn(), collapseOrExpandSections: fn(),
closeSectionReleaseAnnouncement: fn(),
}, },
parameters: { parameters: {
design: { design: {
@@ -124,3 +127,30 @@ export const ExpandSections: Story = {
collapseSections: "expand", collapseSections: "expand",
}, },
}; };
export const DisplaySectionReleaseAnnouncement: Story = {
decorators: [
(Story) => (
<div style={{ width: "300px" }}>
<Story />
</div>
),
],
args: {
displaySectionReleaseAnnouncement: true,
},
parameters: {
a11y: {
config: {
rules: [
{
// compound-web's ReleaseAnnouncement renders its header as <h3>,
// which jumps from RoomListHeaderView's <h1> ("Rooms").
id: "heading-order",
enabled: false,
},
],
},
},
},
};
@@ -80,6 +80,10 @@ export interface RoomListHeaderViewSnapshot {
* If undefined, no icon are shown. * If undefined, no icon are shown.
*/ */
collapseSections?: CollapseSectionsOption; collapseSections?: CollapseSectionsOption;
/**
* Whether to display the section release announcement
*/
displaySectionReleaseAnnouncement: boolean;
} }
export interface RoomListHeaderViewActions { export interface RoomListHeaderViewActions {
@@ -127,6 +131,10 @@ export interface RoomListHeaderViewActions {
* Collapse or expand all sections in the room list depending on the current state. * Collapse or expand all sections in the room list depending on the current state.
*/ */
collapseOrExpandSections: () => void; collapseOrExpandSections: () => void;
/**
* Close the section release announcement
*/
closeSectionReleaseAnnouncement: () => void;
} }
/** /**
@@ -19,4 +19,5 @@ export const defaultSnapshot: RoomListHeaderViewSnapshot = {
isMessagePreviewEnabled: true, isMessagePreviewEnabled: true,
useComposeIcon: true, useComposeIcon: true,
canCreateSection: true, canCreateSection: true,
displaySectionReleaseAnnouncement: false,
}; };
@@ -6,7 +6,7 @@
*/ */
import React, { useState, type JSX } from "react"; import React, { useState, type JSX } from "react";
import { IconButton, Menu, MenuItem } from "@vector-im/compound-web"; import { IconButton, Menu, MenuItem, ReleaseAnnouncement } from "@vector-im/compound-web";
import ComposeIcon from "@vector-im/compound-design-tokens/assets/web/icons/compose"; import ComposeIcon from "@vector-im/compound-design-tokens/assets/web/icons/compose";
import VideoCallIcon from "@vector-im/compound-design-tokens/assets/web/icons/video-call"; import VideoCallIcon from "@vector-im/compound-design-tokens/assets/web/icons/video-call";
import ChatIcon from "@vector-im/compound-design-tokens/assets/web/icons/chat"; import ChatIcon from "@vector-im/compound-design-tokens/assets/web/icons/chat";
@@ -37,7 +37,33 @@ interface ComposeMenuViewProps {
export function ComposeMenuView({ vm }: ComposeMenuViewProps): JSX.Element { export function ComposeMenuView({ vm }: ComposeMenuViewProps): JSX.Element {
const { translate: _t } = useI18n(); const { translate: _t } = useI18n();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const { canCreateRoom, canCreateVideoRoom, canCreateSection, useComposeIcon } = useViewModel(vm); const { canCreateRoom, canCreateVideoRoom, canCreateSection, useComposeIcon, displaySectionReleaseAnnouncement } =
useViewModel(vm);
// 28px button with a 20px icon
const button = (
<IconButton size="28px" style={{ padding: "4px" }} tooltip={_t("action|new_conversation")}>
{useComposeIcon ? (
<ComposeIcon color="var(--cpd-color-icon-secondary)" aria-hidden />
) : (
<PlusIcon color="var(--cpd-color-icon-secondary)" aria-hidden />
)}
</IconButton>
);
if (displaySectionReleaseAnnouncement) {
return (
<ReleaseAnnouncement
open={displaySectionReleaseAnnouncement}
onClick={vm.closeSectionReleaseAnnouncement}
header={_t("release_announcement|room_list_section_title")}
description={_t("release_announcement|room_list_section_description")}
closeLabel={_t("release_announcement|room_list_section_close")}
>
{button}
</ReleaseAnnouncement>
);
}
return ( return (
<Menu <Menu
@@ -46,16 +72,7 @@ export function ComposeMenuView({ vm }: ComposeMenuViewProps): JSX.Element {
showTitle={false} showTitle={false}
title={_t("action|open_menu")} title={_t("action|open_menu")}
align="start" align="start"
trigger={ trigger={button}
// 28px button with a 20px icon
<IconButton size="28px" style={{ padding: "4px" }} tooltip={_t("action|new_conversation")}>
{useComposeIcon ? (
<ComposeIcon color="var(--cpd-color-icon-secondary)" aria-hidden />
) : (
<PlusIcon color="var(--cpd-color-icon-secondary)" aria-hidden />
)}
</IconButton>
}
> >
<MenuItem Icon={ChatIcon} label={_t("action|start_chat")} onSelect={vm.createChatRoom} hideChevron /> <MenuItem Icon={ChatIcon} label={_t("action|start_chat")} onSelect={vm.createChatRoom} hideChevron />
{canCreateRoom && ( {canCreateRoom && (
@@ -25,6 +25,7 @@ export class MockedViewModel extends MockViewModel<RoomListHeaderViewSnapshot> i
public toggleMessagePreview = vi.fn<() => void>(); public toggleMessagePreview = vi.fn<() => void>();
public createSection = vi.fn<() => void>(); public createSection = vi.fn<() => void>();
public collapseOrExpandSections = vi.fn<() => void>(); public collapseOrExpandSections = vi.fn<() => void>();
public closeSectionReleaseAnnouncement = vi.fn<() => void>();
} }
export { defaultSnapshot } from "./default-snapshot"; export { defaultSnapshot } from "./default-snapshot";