TAC: Release Announcement (#12380)

* WIP

* Store the release announcements in the account settings

* Update TAC release announcement description

* Fix settings content comparison

* Add logging in case of failure

* Watch settings changes

* I add release announcement settings to disable it

* Disable release announcement in e2e test

* Add release announcement in e2e test

* Add tests for ReleaseAnnouncementStore.ts

* Update compound-web to `3.3.0`

* Update TAC tests

* Update Labs tests

* Nits

* Add test for ReleaseAnnouncement.tsx

* Update `@vector-im/compound-web`

* Add playwright snapshot

* Delete false playwright screenshot

* Wait for EW to be displayed after reload

* Add screenshot

* Clean util file

* Renaming and comments fixing

* Use second store instead of looking in the store.

---------

Co-authored-by: R Midhun Suresh <hi@midhun.dev>
This commit is contained in:
Florian Duros 2024-04-08 10:43:59 +02:00 committed by GitHub
parent 156f2fa50a
commit 5815e70b76
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 805 additions and 45 deletions

View file

@ -76,7 +76,7 @@
"@sentry/browser": "^7.0.0", "@sentry/browser": "^7.0.0",
"@testing-library/react-hooks": "^8.0.1", "@testing-library/react-hooks": "^8.0.1",
"@vector-im/compound-design-tokens": "^1.2.0", "@vector-im/compound-design-tokens": "^1.2.0",
"@vector-im/compound-web": "^3.1.1", "@vector-im/compound-web": "^3.3.1",
"@zxcvbn-ts/core": "^3.0.4", "@zxcvbn-ts/core": "^3.0.4",
"@zxcvbn-ts/language-common": "^3.0.4", "@zxcvbn-ts/language-common": "^3.0.4",
"@zxcvbn-ts/language-en": "^3.0.2", "@zxcvbn-ts/language-en": "^3.0.2",

View file

@ -0,0 +1,77 @@
/*
*
* Copyright 2024 The Matrix.org Foundation C.I.C.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
* /
*/
import { Page } from "@playwright/test";
import { test as base, expect } from "../../element-web-test";
/**
* Set up for release announcement tests.
*/
export const test = base.extend<{
util: Helpers;
}>({
displayName: "Alice",
botCreateOpts: { displayName: "Other User" },
util: async ({ page, app, bot }, use) => {
await use(new Helpers(page));
},
});
export class Helpers {
constructor(private page: Page) {}
/**
* Get the release announcement with the given name.
* @param name
* @private
*/
private getReleaseAnnouncement(name: string) {
return this.page.getByRole("dialog", { name });
}
/**
* Assert that the release announcement with the given name is visible.
* @param name
*/
async assertReleaseAnnouncementIsVisible(name: string) {
await expect(this.getReleaseAnnouncement(name)).toBeVisible();
await expect(this.page).toMatchScreenshot(`release-announcement-${name}.png`);
}
/**
* Assert that the release announcement with the given name is not visible.
* @param name
*/
assertReleaseAnnouncementIsNotVisible(name: string) {
return expect(this.getReleaseAnnouncement(name)).not.toBeVisible();
}
/**
* Mark the release announcement with the given name as read.
* If the release announcement is not visible, this will throw an error.
* @param name
*/
async markReleaseAnnouncementAsRead(name: string) {
const dialog = this.getReleaseAnnouncement(name);
await dialog.getByRole("button", { name: "Ok" }).click();
}
}
export { expect };

View file

@ -0,0 +1,44 @@
/*
*
* Copyright 2024 The Matrix.org Foundation C.I.C.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
* /
*/
import { test, expect } from "./";
test.describe("Release announcement", () => {
test.use({
config: {
features: {
feature_release_announcement: true,
},
},
labsFlags: ["threadsActivityCentre"],
});
test("should display the release announcement process", async ({ page, app, util }) => {
// The TAC release announcement should be displayed
await util.assertReleaseAnnouncementIsVisible("Threads Activity Centre");
// Hide the release announcement
await util.markReleaseAnnouncementAsRead("Threads Activity Centre");
await util.assertReleaseAnnouncementIsNotVisible("Threads Activity Centre");
await page.reload();
// Wait for EW to load
await expect(page.getByRole("navigation", { name: "Spaces" })).toBeVisible();
// Check that once the release announcement has been marked as viewed, it does not appear again
await util.assertReleaseAnnouncementIsNotVisible("Threads Activity Centre");
});
});

View file

@ -52,6 +52,11 @@ const CONFIG_JSON: Partial<IConfigOptions> = {
// the location tests want a map style url. // the location tests want a map style url.
map_style_url: "https://api.maptiler.com/maps/streets/style.json?key=fU3vlMsMn4Jb6dnEIFsx", map_style_url: "https://api.maptiler.com/maps/streets/style.json?key=fU3vlMsMn4Jb6dnEIFsx",
features: {
// We don't want to go through the feature announcement during the e2e test
feature_release_announcement: false,
},
}; };
export type TestOptions = { export type TestOptions = {

View file

@ -0,0 +1,54 @@
/*
*
* Copyright 2024 The Matrix.org Foundation C.I.C.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
* /
*/
import React, { ComponentProps, JSX, PropsWithChildren } from "react";
import { ReleaseAnnouncement as ReleaseAnnouncementCompound } from "@vector-im/compound-web";
import { ReleaseAnnouncementStore, Feature } from "../../stores/ReleaseAnnouncementStore";
import { useIsReleaseAnnouncementOpen } from "../../hooks/useIsReleaseAnnouncementOpen";
interface ReleaseAnnouncementProps
extends Omit<ComponentProps<typeof ReleaseAnnouncementCompound>, "open" | "onClick"> {
feature: Feature;
}
/**
* Display a release announcement component around the children
* Wrapper gluing the release announcement compound and the ReleaseAnnouncementStore
* @param feature - the feature to announce, should be listed in {@link Feature}
* @param children
* @param props
* @constructor
*/
export function ReleaseAnnouncement({
feature,
children,
...props
}: PropsWithChildren<ReleaseAnnouncementProps>): JSX.Element {
const enabled = useIsReleaseAnnouncementOpen(feature);
return (
<ReleaseAnnouncementCompound
open={enabled}
onClick={() => ReleaseAnnouncementStore.instance.nextReleaseAnnouncement()}
{...props}
>
{children}
</ReleaseAnnouncementCompound>
);
}

View file

@ -34,6 +34,8 @@ import { NotificationLevel } from "../../../../stores/notifications/Notification
import PosthogTrackers from "../../../../PosthogTrackers"; import PosthogTrackers from "../../../../PosthogTrackers";
import { getKeyBindingsManager } from "../../../../KeyBindingsManager"; import { getKeyBindingsManager } from "../../../../KeyBindingsManager";
import { KeyBindingAction } from "../../../../accessibility/KeyboardShortcuts"; import { KeyBindingAction } from "../../../../accessibility/KeyboardShortcuts";
import { ReleaseAnnouncement } from "../../../structures/ReleaseAnnouncement";
import { useIsReleaseAnnouncementOpen } from "../../../../hooks/useIsReleaseAnnouncementOpen";
interface ThreadsActivityCentreProps { interface ThreadsActivityCentreProps {
/** /**
@ -49,6 +51,7 @@ interface ThreadsActivityCentreProps {
export function ThreadsActivityCentre({ displayButtonLabel }: ThreadsActivityCentreProps): JSX.Element { export function ThreadsActivityCentre({ displayButtonLabel }: ThreadsActivityCentreProps): JSX.Element {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const roomsAndNotifications = useUnreadThreadRooms(open); const roomsAndNotifications = useUnreadThreadRooms(open);
const isReleaseAnnouncementOpen = useIsReleaseAnnouncementOpen("threadsActivityCentre");
return ( return (
<div <div
@ -65,41 +68,55 @@ export function ThreadsActivityCentre({ displayButtonLabel }: ThreadsActivityCen
} }
}} }}
> >
<Menu {isReleaseAnnouncementOpen ? (
align="end" <ReleaseAnnouncement
open={open} feature="threadsActivityCentre"
onOpenChange={(newOpen) => { header={_t("threads_activity_centre|release_announcement_header")}
// Track only when the Threads Activity Centre is opened description={_t("threads_activity_centre|release_announcement_description")}
if (newOpen) PosthogTrackers.trackInteraction("WebThreadsActivityCentreButton"); closeLabel={_t("action|ok")}
>
setOpen(newOpen);
}}
side="right"
title={_t("threads_activity_centre|header")}
trigger={
<ThreadsActivityCentreButton <ThreadsActivityCentreButton
displayLabel={displayButtonLabel} displayLabel={displayButtonLabel}
notificationLevel={roomsAndNotifications.greatestNotificationLevel} notificationLevel={roomsAndNotifications.greatestNotificationLevel}
/> />
} </ReleaseAnnouncement>
> ) : (
{/* Make the content of the pop-up scrollable */} <Menu
<div className="mx_ThreadsActivityCentre_rows"> align="end"
{roomsAndNotifications.rooms.map(({ room, notificationLevel }) => ( open={open}
<ThreadsActivityCentreRow onOpenChange={(newOpen) => {
key={room.roomId} // Track only when the Threads Activity Centre is opened
room={room} if (newOpen) PosthogTrackers.trackInteraction("WebThreadsActivityCentreButton");
notificationLevel={notificationLevel}
onClick={() => setOpen(false)} setOpen(newOpen);
}}
side="right"
title={_t("threads_activity_centre|header")}
trigger={
<ThreadsActivityCentreButton
displayLabel={displayButtonLabel}
notificationLevel={roomsAndNotifications.greatestNotificationLevel}
/> />
))} }
{roomsAndNotifications.rooms.length === 0 && ( >
<div className="mx_ThreadsActivityCentre_emptyCaption"> {/* Make the content of the pop-up scrollable */}
{_t("threads_activity_centre|no_rooms_with_unreads_threads")} <div className="mx_ThreadsActivityCentre_rows">
</div> {roomsAndNotifications.rooms.map(({ room, notificationLevel }) => (
)} <ThreadsActivityCentreRow
</div> key={room.roomId}
</Menu> room={room}
notificationLevel={notificationLevel}
onClick={() => setOpen(false)}
/>
))}
{roomsAndNotifications.rooms.length === 0 && (
<div className="mx_ThreadsActivityCentre_emptyCaption">
{_t("threads_activity_centre|no_rooms_with_unreads_threads")}
</div>
)}
</div>
</Menu>
)}
</div> </div>
); );
} }

View file

@ -0,0 +1,32 @@
/*
*
* Copyright 2024 The Matrix.org Foundation C.I.C.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
* /
*/
import { useTypedEventEmitterState } from "./useEventEmitter";
import { Feature, ReleaseAnnouncementStore } from "../stores/ReleaseAnnouncementStore";
/**
* Return true if the release announcement of the given feature is enabled
* @param feature
*/
export function useIsReleaseAnnouncementOpen(feature: Feature): boolean {
return useTypedEventEmitterState(
ReleaseAnnouncementStore.instance,
"releaseAnnouncementChanged",
() => ReleaseAnnouncementStore.instance.getReleaseAnnouncement() === feature,
);
}

View file

@ -1417,6 +1417,7 @@
"group_spaces": "Spaces", "group_spaces": "Spaces",
"group_themes": "Themes", "group_themes": "Themes",
"group_threads": "Threads", "group_threads": "Threads",
"group_ui": "User interface",
"group_voip": "Voice & Video", "group_voip": "Voice & Video",
"group_widgets": "Widgets", "group_widgets": "Widgets",
"hidebold": "Hide notification dot (only display counters badges)", "hidebold": "Hide notification dot (only display counters badges)",
@ -1440,6 +1441,7 @@
"oidc_native_flow": "OIDC native authentication", "oidc_native_flow": "OIDC native authentication",
"oidc_native_flow_description": "⚠ WARNING: Experimental. Use OIDC native authentication when supported by the server.", "oidc_native_flow_description": "⚠ WARNING: Experimental. Use OIDC native authentication when supported by the server.",
"pinning": "Message Pinning", "pinning": "Message Pinning",
"release_announcement": "Release announcement",
"render_reaction_images": "Render custom images in reactions", "render_reaction_images": "Render custom images in reactions",
"render_reaction_images_description": "Sometimes referred to as \"custom emojis\".", "render_reaction_images_description": "Sometimes referred to as \"custom emojis\".",
"report_to_moderators": "Report to moderators", "report_to_moderators": "Report to moderators",
@ -3161,7 +3163,9 @@
}, },
"threads_activity_centre": { "threads_activity_centre": {
"header": "Threads activity", "header": "Threads activity",
"no_rooms_with_unreads_threads": "You don't have rooms with unread threads yet." "no_rooms_with_unreads_threads": "You don't have rooms with unread threads yet.",
"release_announcement_description": "Threads notifications have moved, find them here from now on.",
"release_announcement_header": "Threads Activity Centre"
}, },
"time": { "time": {
"about_day_ago": "about a day ago", "about_day_ago": "about a day ago",

View file

@ -89,6 +89,7 @@ export enum LabGroup {
Encryption, Encryption,
Experimental, Experimental,
Developer, Developer,
Ui,
} }
export enum Features { export enum Features {
@ -98,6 +99,7 @@ export enum Features {
OidcNativeFlow = "feature_oidc_native_flow", OidcNativeFlow = "feature_oidc_native_flow",
// If true, every new login will use the new rust crypto implementation // If true, every new login will use the new rust crypto implementation
RustCrypto = "feature_rust_crypto", RustCrypto = "feature_rust_crypto",
ReleaseAnnouncement = "feature_release_announcement",
} }
export const labGroupNames: Record<LabGroup, TranslationKey> = { export const labGroupNames: Record<LabGroup, TranslationKey> = {
@ -114,6 +116,7 @@ export const labGroupNames: Record<LabGroup, TranslationKey> = {
[LabGroup.Encryption]: _td("labs|group_encryption"), [LabGroup.Encryption]: _td("labs|group_encryption"),
[LabGroup.Experimental]: _td("labs|group_experimental"), [LabGroup.Experimental]: _td("labs|group_experimental"),
[LabGroup.Developer]: _td("labs|group_developer"), [LabGroup.Developer]: _td("labs|group_developer"),
[LabGroup.Ui]: _td("labs|group_ui"),
}; };
export type SettingValueType = export type SettingValueType =
@ -1145,6 +1148,24 @@ export const SETTINGS: { [setting: string]: ISetting } = {
default: false, default: false,
isFeature: true, isFeature: true,
}, },
/**
* Enable or disable the release announcement feature
*/
[Features.ReleaseAnnouncement]: {
isFeature: true,
labsGroup: LabGroup.Ui,
supportedLevels: LEVELS_DEVICE_ONLY_SETTINGS_WITH_CONFIG,
default: true,
displayName: _td("labs|release_announcement"),
},
/**
* Managed by the {@link ReleaseAnnouncementStore}
* Store the release announcement data
*/
"releaseAnnouncementData": {
supportedLevels: LEVELS_ACCOUNT_SETTINGS,
default: {},
},
[UIFeature.RoomHistorySettings]: { [UIFeature.RoomHistorySettings]: {
supportedLevels: LEVELS_UI_FEATURE, supportedLevels: LEVELS_UI_FEATURE,
default: true, default: true,

View file

@ -17,6 +17,7 @@ limitations under the License.
import { ClientEvent, MatrixClient, MatrixEvent } from "matrix-js-sdk/src/matrix"; import { ClientEvent, MatrixClient, MatrixEvent } from "matrix-js-sdk/src/matrix";
import { defer } from "matrix-js-sdk/src/utils"; import { defer } from "matrix-js-sdk/src/utils";
import { isEqual } from "lodash";
import MatrixClientBackedSettingsHandler from "./MatrixClientBackedSettingsHandler"; import MatrixClientBackedSettingsHandler from "./MatrixClientBackedSettingsHandler";
import { objectClone, objectKeyChanges } from "../../utils/objects"; import { objectClone, objectKeyChanges } from "../../utils/objects";
@ -168,7 +169,7 @@ export default class AccountSettingsHandler extends MatrixClientBackedSettingsHa
// which race between different lines. // which race between different lines.
const deferred = defer<void>(); const deferred = defer<void>();
const handler = (event: MatrixEvent): void => { const handler = (event: MatrixEvent): void => {
if (event.getType() !== eventType || event.getContent()[field] !== value) return; if (event.getType() !== eventType || !isEqual(event.getContent()[field], value)) return;
this.client.off(ClientEvent.AccountData, handler); this.client.off(ClientEvent.AccountData, handler);
deferred.resolve(); deferred.resolve();
}; };

View file

@ -0,0 +1,176 @@
/*
*
* Copyright 2024 The Matrix.org Foundation C.I.C.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
* /
*/
import { TypedEventEmitter } from "matrix-js-sdk/src/matrix";
import { logger } from "matrix-js-sdk/src/logger";
import SettingsStore from "../settings/SettingsStore";
import { SettingLevel } from "../settings/SettingLevel";
import { Features } from "../settings/Settings";
/**
* The features are shown in the array order.
*/
const FEATURES = ["threadsActivityCentre"] as const;
/**
* All the features that can be shown in the release announcements.
*/
export type Feature = (typeof FEATURES)[number];
/**
* The stored settings for the release announcements.
* The boolean is at true when the user has viewed the feature
*/
type StoredSettings = Record<Feature, boolean>;
/**
* The events emitted by the ReleaseAnnouncementStore.
*/
type ReleaseAnnouncementStoreEvents = "releaseAnnouncementChanged";
/**
* The handlers for the ReleaseAnnouncementStore events.
*/
type HandlerMap = {
releaseAnnouncementChanged: (newFeature: Feature | null) => void;
};
/**
* The ReleaseAnnouncementStore is responsible for managing the release announcements.
* It keeps track of the viewed release announcements and emits events when the release announcement changes.
*/
export class ReleaseAnnouncementStore extends TypedEventEmitter<ReleaseAnnouncementStoreEvents, HandlerMap> {
/**
* The singleton instance of the ReleaseAnnouncementStore.
* @private
*/
private static internalInstance: ReleaseAnnouncementStore;
/**
* The index of the feature to show.
* @private
*/
private index = 0;
/**
* The singleton instance of the ReleaseAnnouncementStore.
*/
public static get instance(): ReleaseAnnouncementStore {
if (!ReleaseAnnouncementStore.internalInstance) {
ReleaseAnnouncementStore.internalInstance = new ReleaseAnnouncementStore();
}
return ReleaseAnnouncementStore.internalInstance;
}
/**
* Should be used only for testing purposes.
* @internal
*/
public constructor() {
super();
SettingsStore.watchSetting("releaseAnnouncementData", null, () => {
this.emit("releaseAnnouncementChanged", this.getReleaseAnnouncement());
});
}
/**
* Get the viewed release announcements from the settings.
* @private
*/
private getViewedReleaseAnnouncements(): StoredSettings {
return SettingsStore.getValue<StoredSettings>("releaseAnnouncementData");
}
/**
* Check if the release announcement is enabled.
* @private
*/
private isReleaseAnnouncementEnabled(): boolean {
return SettingsStore.getValue<boolean>(Features.ReleaseAnnouncement);
}
/**
* Get the release announcement that should be displayed
* @returns The feature to announce or null if there is no feature to announce
*/
public getReleaseAnnouncement(): Feature | null {
// Do nothing if the release announcement is disabled
const isReleaseAnnouncementEnabled = this.isReleaseAnnouncementEnabled();
if (!isReleaseAnnouncementEnabled) return null;
const viewedReleaseAnnouncements = this.getViewedReleaseAnnouncements();
// Find the first feature that has not been viewed
for (let i = this.index; i < FEATURES.length; i++) {
if (!viewedReleaseAnnouncements[FEATURES[i]]) {
this.index = i;
return FEATURES[this.index];
}
}
// All features have been viewed
return null;
}
/**
* Mark the current release announcement as viewed.
* This will update the account settings
* @private
*/
private async markReleaseAnnouncementAsViewed(): Promise<void> {
// Do nothing if the release announcement is disabled
const isReleaseAnnouncementEnabled = this.isReleaseAnnouncementEnabled();
if (!isReleaseAnnouncementEnabled) return;
const viewedReleaseAnnouncements = this.getViewedReleaseAnnouncements();
// If the index is out of bounds, do nothing
// Normally it shouldn't happen, but it's better to be safe
const feature = FEATURES[this.index];
if (!feature) return;
// Mark the feature as viewed
viewedReleaseAnnouncements[FEATURES[this.index]] = true;
this.index++;
// Do sanity check if we can store the new value in the settings
const isSupported = SettingsStore.isLevelSupported(SettingLevel.ACCOUNT);
if (!isSupported) return;
const canSetValue = SettingsStore.canSetValue("releaseAnnouncementData", null, SettingLevel.ACCOUNT);
if (canSetValue) {
try {
await SettingsStore.setValue(
"releaseAnnouncementData",
null,
SettingLevel.ACCOUNT,
viewedReleaseAnnouncements,
);
} catch (e) {
logger.log("Failed to set release announcement settings", e);
}
}
}
/**
* Mark the current release announcement as viewed and move to the next release announcement.
* This will update the account settings and emit the `releaseAnnouncementChanged` event
*/
public async nextReleaseAnnouncement(): Promise<void> {
await this.markReleaseAnnouncementAsViewed();
this.emit("releaseAnnouncementChanged", this.getReleaseAnnouncement());
}
}

View file

@ -0,0 +1,48 @@
/*
*
* Copyright 2024 The Matrix.org Foundation C.I.C.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
* /
*/
import React from "react";
import { render, screen, waitFor } from "@testing-library/react";
import { ReleaseAnnouncement } from "../../../src/components/structures/ReleaseAnnouncement";
describe("ReleaseAnnouncement", () => {
function renderReleaseAnnouncement() {
return render(
<ReleaseAnnouncement
feature="threadsActivityCentre"
header="header"
description="description"
closeLabel="close"
>
<div>content</div>
</ReleaseAnnouncement>,
);
}
test("render the release announcement and close it", async () => {
renderReleaseAnnouncement();
// The release announcement is displayed
expect(screen.queryByRole("dialog", { name: "header" })).toBeDefined();
// Click on the close button in the release announcement
screen.getByRole("button", { name: "close" }).click();
// The release announcement should be hidden after the close button is clicked
await waitFor(() => expect(screen.queryByRole("dialog", { name: "header" })).toBeNull());
});
});

View file

@ -60,7 +60,7 @@ describe("<LabsUserSettingsTab />", () => {
// non-beta labs section // non-beta labs section
expect(screen.getByText("Early previews")).toBeInTheDocument(); expect(screen.getByText("Early previews")).toBeInTheDocument();
const labsSections = container.getElementsByClassName("mx_SettingsSubsection"); const labsSections = container.getElementsByClassName("mx_SettingsSubsection");
expect(labsSections).toHaveLength(10); expect(labsSections).toHaveLength(11);
}); });
describe("Rust crypto setting", () => { describe("Rust crypto setting", () => {

View file

@ -28,6 +28,8 @@ import MatrixClientContext from "../../../../src/contexts/MatrixClientContext";
import { stubClient } from "../../../test-utils"; import { stubClient } from "../../../test-utils";
import { populateThread } from "../../../test-utils/threads"; import { populateThread } from "../../../test-utils/threads";
import DMRoomMap from "../../../../src/utils/DMRoomMap"; import DMRoomMap from "../../../../src/utils/DMRoomMap";
import SettingsStore from "../../../../src/settings/SettingsStore";
import { SettingLevel } from "../../../../src/settings/SettingLevel";
describe("ThreadsActivityCentre", () => { describe("ThreadsActivityCentre", () => {
const getTACButton = () => { const getTACButton = () => {
@ -101,11 +103,23 @@ describe("ThreadsActivityCentre", () => {
); );
}); });
beforeEach(async () => {
await SettingsStore.setValue("feature_release_announcement", null, SettingLevel.DEVICE, false);
});
it("should render the threads activity centre button", async () => { it("should render the threads activity centre button", async () => {
renderTAC(); renderTAC();
expect(getTACButton()).toBeInTheDocument(); expect(getTACButton()).toBeInTheDocument();
}); });
it("should render the release announcement", async () => {
// Enable release announcement
await SettingsStore.setValue("feature_release_announcement", null, SettingLevel.DEVICE, true);
renderTAC();
expect(document.body).toMatchSnapshot();
});
it("should render the threads activity centre button and the display label", async () => { it("should render the threads activity centre button and the display label", async () => {
renderTAC({ displayButtonLabel: true }); renderTAC({ displayButtonLabel: true });
expect(getTACButton()).toBeInTheDocument(); expect(getTACButton()).toBeInTheDocument();

View file

@ -2,7 +2,7 @@
exports[`ThreadsActivityCentre renders notifications matching the snapshot 1`] = ` exports[`ThreadsActivityCentre renders notifications matching the snapshot 1`] = `
<div <div
aria-labelledby="radix-20" aria-labelledby="radix-21"
aria-orientation="vertical" aria-orientation="vertical"
class="_menu_1x5h1_17" class="_menu_1x5h1_17"
data-align="end" data-align="end"
@ -11,7 +11,7 @@ exports[`ThreadsActivityCentre renders notifications matching the snapshot 1`] =
data-side="right" data-side="right"
data-state="open" data-state="open"
dir="ltr" dir="ltr"
id="radix-21" id="radix-22"
role="menu" role="menu"
style="outline: none; --radix-dropdown-menu-content-transform-origin: var(--radix-popper-transform-origin); --radix-dropdown-menu-content-available-width: var(--radix-popper-available-width); --radix-dropdown-menu-content-available-height: var(--radix-popper-available-height); --radix-dropdown-menu-trigger-width: var(--radix-popper-anchor-width); --radix-dropdown-menu-trigger-height: var(--radix-popper-anchor-height); pointer-events: auto;" style="outline: none; --radix-dropdown-menu-content-transform-origin: var(--radix-popper-transform-origin); --radix-dropdown-menu-content-available-width: var(--radix-popper-available-width); --radix-dropdown-menu-content-available-height: var(--radix-popper-available-height); --radix-dropdown-menu-trigger-width: var(--radix-popper-anchor-width); --radix-dropdown-menu-trigger-height: var(--radix-popper-anchor-height); pointer-events: auto;"
tabindex="-1" tabindex="-1"
@ -127,7 +127,7 @@ exports[`ThreadsActivityCentre renders notifications matching the snapshot 1`] =
exports[`ThreadsActivityCentre should match snapshot when empty 1`] = ` exports[`ThreadsActivityCentre should match snapshot when empty 1`] = `
<div <div
aria-labelledby="radix-28" aria-labelledby="radix-29"
aria-orientation="vertical" aria-orientation="vertical"
class="_menu_1x5h1_17" class="_menu_1x5h1_17"
data-align="end" data-align="end"
@ -136,7 +136,7 @@ exports[`ThreadsActivityCentre should match snapshot when empty 1`] = `
data-side="right" data-side="right"
data-state="open" data-state="open"
dir="ltr" dir="ltr"
id="radix-29" id="radix-30"
role="menu" role="menu"
style="outline: none; --radix-dropdown-menu-content-transform-origin: var(--radix-popper-transform-origin); --radix-dropdown-menu-content-available-width: var(--radix-popper-available-width); --radix-dropdown-menu-content-available-height: var(--radix-popper-available-height); --radix-dropdown-menu-trigger-width: var(--radix-popper-anchor-width); --radix-dropdown-menu-trigger-height: var(--radix-popper-anchor-height); pointer-events: auto;" style="outline: none; --radix-dropdown-menu-content-transform-origin: var(--radix-popper-transform-origin); --radix-dropdown-menu-content-available-width: var(--radix-popper-available-width); --radix-dropdown-menu-content-available-height: var(--radix-popper-available-height); --radix-dropdown-menu-trigger-width: var(--radix-popper-anchor-width); --radix-dropdown-menu-trigger-height: var(--radix-popper-anchor-height); pointer-events: auto;"
tabindex="-1" tabindex="-1"
@ -161,7 +161,7 @@ exports[`ThreadsActivityCentre should match snapshot when empty 1`] = `
exports[`ThreadsActivityCentre should order the room with the same notification level by most recent 1`] = ` exports[`ThreadsActivityCentre should order the room with the same notification level by most recent 1`] = `
<div <div
aria-labelledby="radix-31" aria-labelledby="radix-32"
aria-orientation="vertical" aria-orientation="vertical"
class="_menu_1x5h1_17" class="_menu_1x5h1_17"
data-align="end" data-align="end"
@ -170,7 +170,7 @@ exports[`ThreadsActivityCentre should order the room with the same notification
data-side="right" data-side="right"
data-state="open" data-state="open"
dir="ltr" dir="ltr"
id="radix-32" id="radix-33"
role="menu" role="menu"
style="outline: none; --radix-dropdown-menu-content-transform-origin: var(--radix-popper-transform-origin); --radix-dropdown-menu-content-available-width: var(--radix-popper-available-width); --radix-dropdown-menu-content-available-height: var(--radix-popper-available-height); --radix-dropdown-menu-trigger-width: var(--radix-popper-anchor-width); --radix-dropdown-menu-trigger-height: var(--radix-popper-anchor-height); pointer-events: auto;" style="outline: none; --radix-dropdown-menu-content-transform-origin: var(--radix-popper-transform-origin); --radix-dropdown-menu-content-available-width: var(--radix-popper-available-width); --radix-dropdown-menu-content-available-height: var(--radix-popper-available-height); --radix-dropdown-menu-trigger-width: var(--radix-popper-anchor-width); --radix-dropdown-menu-trigger-height: var(--radix-popper-anchor-height); pointer-events: auto;"
tabindex="-1" tabindex="-1"
@ -331,3 +331,129 @@ exports[`ThreadsActivityCentre should order the room with the same notification
</div> </div>
</div> </div>
`; `;
exports[`ThreadsActivityCentre should render the release announcement 1`] = `
<body>
<div
data-floating-ui-inert=""
>
<div
class="mx_ThreadsActivityCentre_container"
>
<button
aria-controls="floating-ui-3"
aria-expanded="true"
aria-haspopup="dialog"
aria-label="Threads"
class="_icon-button_16nk7_17 mx_ThreadsActivityCentreButton"
data-state="open"
role="button"
style="--cpd-icon-button-size: 32px;"
tabindex="0"
>
<div
class="_indicator-icon_133tf_26"
style="--cpd-icon-button-size: 100%;"
>
<div
class="mx_ThreadsActivityCentreButton_Icon"
/>
</div>
</button>
<span
data-floating-ui-focus-guard=""
data-type="outside"
role="button"
style="border: 0px; height: 1px; margin: -1px; overflow: hidden; padding: 0px; position: fixed; white-space: nowrap; width: 1px; top: 0px; left: 0px;"
tabindex="0"
/>
<span
aria-owns="undefined"
style="border: 0px; height: 1px; margin: -1px; overflow: hidden; padding: 0px; position: fixed; white-space: nowrap; width: 1px; top: 0px; left: 0px;"
/>
<span
data-floating-ui-focus-guard=""
data-type="outside"
role="button"
style="border: 0px; height: 1px; margin: -1px; overflow: hidden; padding: 0px; position: fixed; white-space: nowrap; width: 1px; top: 0px; left: 0px;"
tabindex="0"
/>
</div>
</div>
<div
data-floating-ui-portal=""
id="undefined"
>
<span
data-floating-ui-focus-guard=""
data-floating-ui-inert=""
data-type="inside"
role="button"
style="border: 0px; height: 1px; margin: -1px; overflow: hidden; padding: 0px; position: fixed; white-space: nowrap; width: 1px; top: 0px; left: 0px;"
tabindex="0"
/>
<div
aria-describedby="floating-ui-2"
aria-labelledby="floating-ui-1"
class="_content_1oa1y_17"
id="floating-ui-3"
role="dialog"
style="position: absolute; left: 0px; top: 0px; transform: translate(0px, 0px);"
tabindex="-1"
>
<svg
aria-hidden="true"
class="_arrow_1oa1y_62"
height="20"
style="position: absolute; pointer-events: none; right: calc(100% - 0px); transform: rotate(90deg);"
viewBox="0 0 20 20"
width="20"
>
<path
d="M0,0 H20 L10,12 Q10,12 10,12 Z"
stroke="none"
/>
<clippath
id="floating-ui-5"
>
<rect
height="20"
width="20"
x="0"
y="0"
/>
</clippath>
</svg>
<h3
class="_typography_yh5dq_162 _font-body-lg-semibold_yh5dq_83 _header_1oa1y_46"
id="floating-ui-1"
>
Threads Activity Centre
</h3>
<span
class="_typography_yh5dq_162 _font-body-sm-regular_yh5dq_40 _description_1oa1y_52"
id="floating-ui-2"
>
Threads notifications have moved, find them here from now on.
</span>
<button
class="_button_dyfp8_17 _button_1oa1y_57"
data-kind="secondary"
data-size="sm"
role="button"
tabindex="0"
>
OK
</button>
</div>
<span
data-floating-ui-focus-guard=""
data-floating-ui-inert=""
data-type="inside"
role="button"
style="border: 0px; height: 1px; margin: -1px; overflow: hidden; padding: 0px; position: fixed; white-space: nowrap; width: 1px; top: 0px; left: 0px;"
tabindex="0"
/>
</div>
</body>
`;

View file

@ -0,0 +1,125 @@
/*
*
* Copyright 2024 The Matrix.org Foundation C.I.C.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
* /
*/
import { mocked } from "jest-mock";
import SettingsStore, { CallbackFn } from "../../src/settings/SettingsStore";
import { Feature, ReleaseAnnouncementStore } from "../../src/stores/ReleaseAnnouncementStore";
import { SettingLevel } from "../../src/settings/SettingLevel";
jest.mock("../../src/settings/SettingsStore");
describe("ReleaseAnnouncementStore", () => {
let releaseAnnouncementStore: ReleaseAnnouncementStore;
// Local settings
// Instead of using the real SettingsStore, we use a local settings object
// to avoid side effects between tests
let settings: Record<string, any> = {};
beforeEach(() => {
// Default settings
settings = {
feature_release_announcement: true,
releaseAnnouncementData: {},
};
const watchCallbacks: Array<CallbackFn> = [];
mocked(SettingsStore.getValue).mockImplementation((setting: string) => {
return settings[setting];
});
mocked(SettingsStore.setValue).mockImplementation(
(settingName: string, roomId: string | null, level: SettingLevel, value: any): Promise<void> => {
settings[settingName] = value;
// we don't care about the parameters, just call the callbacks
// @ts-ignore
watchCallbacks.forEach((cb) => cb());
return Promise.resolve();
},
);
mocked(SettingsStore.isLevelSupported).mockReturnValue(true);
mocked(SettingsStore.canSetValue).mockReturnValue(true);
mocked(SettingsStore.watchSetting).mockImplementation((settingName: string, roomId: null, callback: any) => {
watchCallbacks.push(callback);
return "watcherId";
});
releaseAnnouncementStore = new ReleaseAnnouncementStore();
});
/**
* Disables the release announcement feature.
*/
function disableReleaseAnnouncement() {
settings["feature_release_announcement"] = false;
}
/**
* Listens to the next release announcement change event.
*/
function listenReleaseAnnouncementChanged() {
return new Promise<Feature | null>((resolve) =>
releaseAnnouncementStore.once("releaseAnnouncementChanged", resolve),
);
}
it("should be a singleton", () => {
expect(ReleaseAnnouncementStore.instance).toBeDefined();
});
it("should return null when the release announcement is disabled", async () => {
disableReleaseAnnouncement();
expect(releaseAnnouncementStore.getReleaseAnnouncement()).toBeNull();
// Wait for the next release announcement change event
const promise = listenReleaseAnnouncementChanged();
// Call the next release announcement
// because the release announcement is disabled, the next release announcement should be null
await releaseAnnouncementStore.nextReleaseAnnouncement();
expect(await promise).toBeNull();
expect(releaseAnnouncementStore.getReleaseAnnouncement()).toBeNull();
});
it("should return the next feature when the next release announcement is called", async () => {
// Sanity check
expect(releaseAnnouncementStore.getReleaseAnnouncement()).toBe("threadsActivityCentre");
const promise = listenReleaseAnnouncementChanged();
await releaseAnnouncementStore.nextReleaseAnnouncement();
// Currently there is only one feature, so the next feature should be null
expect(await promise).toBeNull();
expect(releaseAnnouncementStore.getReleaseAnnouncement()).toBeNull();
const secondStore = new ReleaseAnnouncementStore();
// The TAC release announcement has been viewed, so it should be updated in the store account
// The release announcement viewing states should be share among all instances (devices in the same account)
expect(secondStore.getReleaseAnnouncement()).toBeNull();
});
it("should listen to release announcement data changes in the store", async () => {
const secondStore = new ReleaseAnnouncementStore();
expect(secondStore.getReleaseAnnouncement()).toBe("threadsActivityCentre");
const promise = listenReleaseAnnouncementChanged();
await secondStore.nextReleaseAnnouncement();
// Currently there is only one feature, so the next feature should be null
expect(await promise).toBeNull();
expect(releaseAnnouncementStore.getReleaseAnnouncement()).toBeNull();
});
});

View file

@ -1491,13 +1491,22 @@
"@floating-ui/core" "^1.0.0" "@floating-ui/core" "^1.0.0"
"@floating-ui/utils" "^0.2.0" "@floating-ui/utils" "^0.2.0"
"@floating-ui/react-dom@^2.0.0": "@floating-ui/react-dom@^2.0.0", "@floating-ui/react-dom@^2.0.8":
version "2.0.8" version "2.0.8"
resolved "https://registry.yarnpkg.com/@floating-ui/react-dom/-/react-dom-2.0.8.tgz#afc24f9756d1b433e1fe0d047c24bd4d9cefaa5d" resolved "https://registry.yarnpkg.com/@floating-ui/react-dom/-/react-dom-2.0.8.tgz#afc24f9756d1b433e1fe0d047c24bd4d9cefaa5d"
integrity sha512-HOdqOt3R3OGeTKidaLvJKcgg75S6tibQ3Tif4eyd91QnIJWr0NLvoXFpJA/j8HqkFSL68GDca9AuyWEHlhyClw== integrity sha512-HOdqOt3R3OGeTKidaLvJKcgg75S6tibQ3Tif4eyd91QnIJWr0NLvoXFpJA/j8HqkFSL68GDca9AuyWEHlhyClw==
dependencies: dependencies:
"@floating-ui/dom" "^1.6.1" "@floating-ui/dom" "^1.6.1"
"@floating-ui/react@^0.26.9":
version "0.26.10"
resolved "https://registry.yarnpkg.com/@floating-ui/react/-/react-0.26.10.tgz#d4a4878bcfaed70963ec0eaa67a71bead5924ee5"
integrity sha512-sh6f9gVvWQdEzLObrWbJ97c0clJObiALsFe0LiR/kb3tDRKwEhObASEH2QyfdoO/ZBPzwxa9j+nYFo+sqgbioA==
dependencies:
"@floating-ui/react-dom" "^2.0.0"
"@floating-ui/utils" "^0.2.0"
tabbable "^6.0.0"
"@floating-ui/utils@^0.2.0", "@floating-ui/utils@^0.2.1": "@floating-ui/utils@^0.2.0", "@floating-ui/utils@^0.2.1":
version "0.2.1" version "0.2.1"
resolved "https://registry.yarnpkg.com/@floating-ui/utils/-/utils-0.2.1.tgz#16308cea045f0fc777b6ff20a9f25474dd8293d2" resolved "https://registry.yarnpkg.com/@floating-ui/utils/-/utils-0.2.1.tgz#16308cea045f0fc777b6ff20a9f25474dd8293d2"
@ -3047,11 +3056,13 @@
dependencies: dependencies:
svg2vectordrawable "^2.9.1" svg2vectordrawable "^2.9.1"
"@vector-im/compound-web@^3.1.1": "@vector-im/compound-web@^3.3.1":
version "3.1.3" version "3.3.1"
resolved "https://registry.yarnpkg.com/@vector-im/compound-web/-/compound-web-3.1.3.tgz#bd23b4b2067b5ff0035b7c5f11bf6c57f98eb6be" resolved "https://registry.yarnpkg.com/@vector-im/compound-web/-/compound-web-3.3.1.tgz#f5d69255fa62472626e0ed71b7176b09f21cbcaf"
integrity sha512-h1uEKxMrZXUlEA2b8sd57WbxDy9LV8E0MYbz1vdKbU0n3lJb8neUbCAJE7PdQUoOSCi91jw8H+xH8XRLxTYYYw== integrity sha512-V9CQfaMyKdsWxC1D4Wz08Xh0ge3SnaOBf5SSIp1+uwoJTPyfEFHKgqbZl536SHBvVBc9M9IYg+3+lPB8xkFRFA==
dependencies: dependencies:
"@floating-ui/react" "^0.26.9"
"@floating-ui/react-dom" "^2.0.8"
"@radix-ui/react-context-menu" "^2.1.5" "@radix-ui/react-context-menu" "^2.1.5"
"@radix-ui/react-dropdown-menu" "^2.0.6" "@radix-ui/react-dropdown-menu" "^2.0.6"
"@radix-ui/react-form" "^0.0.3" "@radix-ui/react-form" "^0.0.3"
@ -8940,6 +8951,11 @@ symbol-tree@^3.2.4:
resolved "https://registry.yarnpkg.com/symbol-tree/-/symbol-tree-3.2.4.tgz#430637d248ba77e078883951fb9aa0eed7c63fa2" resolved "https://registry.yarnpkg.com/symbol-tree/-/symbol-tree-3.2.4.tgz#430637d248ba77e078883951fb9aa0eed7c63fa2"
integrity sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw== integrity sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==
tabbable@^6.0.0:
version "6.2.0"
resolved "https://registry.yarnpkg.com/tabbable/-/tabbable-6.2.0.tgz#732fb62bc0175cfcec257330be187dcfba1f3b97"
integrity sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew==
table@^6.8.1: table@^6.8.1:
version "6.8.2" version "6.8.2"
resolved "https://registry.yarnpkg.com/table/-/table-6.8.2.tgz#c5504ccf201213fa227248bdc8c5569716ac6c58" resolved "https://registry.yarnpkg.com/table/-/table-6.8.2.tgz#c5504ccf201213fa227248bdc8c5569716ac6c58"