diff --git a/package.json b/package.json index 6f2f894ac5b..42e635d2bad 100644 --- a/package.json +++ b/package.json @@ -76,7 +76,7 @@ "@sentry/browser": "^7.0.0", "@testing-library/react-hooks": "^8.0.1", "@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/language-common": "^3.0.4", "@zxcvbn-ts/language-en": "^3.0.2", diff --git a/playwright/e2e/release-announcement/index.ts b/playwright/e2e/release-announcement/index.ts new file mode 100644 index 00000000000..d5ea4f29175 --- /dev/null +++ b/playwright/e2e/release-announcement/index.ts @@ -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 }; diff --git a/playwright/e2e/release-announcement/releaseAnnouncement.spec.ts b/playwright/e2e/release-announcement/releaseAnnouncement.spec.ts new file mode 100644 index 00000000000..24854560c85 --- /dev/null +++ b/playwright/e2e/release-announcement/releaseAnnouncement.spec.ts @@ -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"); + }); +}); diff --git a/playwright/element-web-test.ts b/playwright/element-web-test.ts index a524c139f6d..e67cca6ab82 100644 --- a/playwright/element-web-test.ts +++ b/playwright/element-web-test.ts @@ -52,6 +52,11 @@ const CONFIG_JSON: Partial = { // the location tests want a map style url. 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 = { diff --git a/playwright/snapshots/release-announcement/releaseAnnouncement.spec.ts/release-announcement-Threads-Activity-Centre-linux.png b/playwright/snapshots/release-announcement/releaseAnnouncement.spec.ts/release-announcement-Threads-Activity-Centre-linux.png new file mode 100644 index 00000000000..6439fe305bb Binary files /dev/null and b/playwright/snapshots/release-announcement/releaseAnnouncement.spec.ts/release-announcement-Threads-Activity-Centre-linux.png differ diff --git a/src/components/structures/ReleaseAnnouncement.tsx b/src/components/structures/ReleaseAnnouncement.tsx new file mode 100644 index 00000000000..cc74c3b05be --- /dev/null +++ b/src/components/structures/ReleaseAnnouncement.tsx @@ -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, "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): JSX.Element { + const enabled = useIsReleaseAnnouncementOpen(feature); + + return ( + ReleaseAnnouncementStore.instance.nextReleaseAnnouncement()} + {...props} + > + {children} + + ); +} diff --git a/src/components/views/spaces/threads-activity-centre/ThreadsActivityCentre.tsx b/src/components/views/spaces/threads-activity-centre/ThreadsActivityCentre.tsx index ddb1dd98d3e..2f8607fb133 100644 --- a/src/components/views/spaces/threads-activity-centre/ThreadsActivityCentre.tsx +++ b/src/components/views/spaces/threads-activity-centre/ThreadsActivityCentre.tsx @@ -34,6 +34,8 @@ import { NotificationLevel } from "../../../../stores/notifications/Notification import PosthogTrackers from "../../../../PosthogTrackers"; import { getKeyBindingsManager } from "../../../../KeyBindingsManager"; import { KeyBindingAction } from "../../../../accessibility/KeyboardShortcuts"; +import { ReleaseAnnouncement } from "../../../structures/ReleaseAnnouncement"; +import { useIsReleaseAnnouncementOpen } from "../../../../hooks/useIsReleaseAnnouncementOpen"; interface ThreadsActivityCentreProps { /** @@ -49,6 +51,7 @@ interface ThreadsActivityCentreProps { export function ThreadsActivityCentre({ displayButtonLabel }: ThreadsActivityCentreProps): JSX.Element { const [open, setOpen] = useState(false); const roomsAndNotifications = useUnreadThreadRooms(open); + const isReleaseAnnouncementOpen = useIsReleaseAnnouncementOpen("threadsActivityCentre"); return (
- { - // Track only when the Threads Activity Centre is opened - if (newOpen) PosthogTrackers.trackInteraction("WebThreadsActivityCentreButton"); - - setOpen(newOpen); - }} - side="right" - title={_t("threads_activity_centre|header")} - trigger={ + {isReleaseAnnouncementOpen ? ( + - } - > - {/* Make the content of the pop-up scrollable */} -
- {roomsAndNotifications.rooms.map(({ room, notificationLevel }) => ( - setOpen(false)} + + ) : ( + { + // Track only when the Threads Activity Centre is opened + if (newOpen) PosthogTrackers.trackInteraction("WebThreadsActivityCentreButton"); + + setOpen(newOpen); + }} + side="right" + title={_t("threads_activity_centre|header")} + trigger={ + - ))} - {roomsAndNotifications.rooms.length === 0 && ( -
- {_t("threads_activity_centre|no_rooms_with_unreads_threads")} -
- )} -
-
+ } + > + {/* Make the content of the pop-up scrollable */} +
+ {roomsAndNotifications.rooms.map(({ room, notificationLevel }) => ( + setOpen(false)} + /> + ))} + {roomsAndNotifications.rooms.length === 0 && ( +
+ {_t("threads_activity_centre|no_rooms_with_unreads_threads")} +
+ )} +
+ + )}
); } diff --git a/src/hooks/useIsReleaseAnnouncementOpen.ts b/src/hooks/useIsReleaseAnnouncementOpen.ts new file mode 100644 index 00000000000..ab8bf07c5e9 --- /dev/null +++ b/src/hooks/useIsReleaseAnnouncementOpen.ts @@ -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, + ); +} diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 07a85767e17..8067412064b 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -1417,6 +1417,7 @@ "group_spaces": "Spaces", "group_themes": "Themes", "group_threads": "Threads", + "group_ui": "User interface", "group_voip": "Voice & Video", "group_widgets": "Widgets", "hidebold": "Hide notification dot (only display counters badges)", @@ -1440,6 +1441,7 @@ "oidc_native_flow": "OIDC native authentication", "oidc_native_flow_description": "⚠ WARNING: Experimental. Use OIDC native authentication when supported by the server.", "pinning": "Message Pinning", + "release_announcement": "Release announcement", "render_reaction_images": "Render custom images in reactions", "render_reaction_images_description": "Sometimes referred to as \"custom emojis\".", "report_to_moderators": "Report to moderators", @@ -3161,7 +3163,9 @@ }, "threads_activity_centre": { "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": { "about_day_ago": "about a day ago", diff --git a/src/settings/Settings.tsx b/src/settings/Settings.tsx index 3dc842945ef..22974374793 100644 --- a/src/settings/Settings.tsx +++ b/src/settings/Settings.tsx @@ -89,6 +89,7 @@ export enum LabGroup { Encryption, Experimental, Developer, + Ui, } export enum Features { @@ -98,6 +99,7 @@ export enum Features { OidcNativeFlow = "feature_oidc_native_flow", // If true, every new login will use the new rust crypto implementation RustCrypto = "feature_rust_crypto", + ReleaseAnnouncement = "feature_release_announcement", } export const labGroupNames: Record = { @@ -114,6 +116,7 @@ export const labGroupNames: Record = { [LabGroup.Encryption]: _td("labs|group_encryption"), [LabGroup.Experimental]: _td("labs|group_experimental"), [LabGroup.Developer]: _td("labs|group_developer"), + [LabGroup.Ui]: _td("labs|group_ui"), }; export type SettingValueType = @@ -1145,6 +1148,24 @@ export const SETTINGS: { [setting: string]: ISetting } = { default: false, 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]: { supportedLevels: LEVELS_UI_FEATURE, default: true, diff --git a/src/settings/handlers/AccountSettingsHandler.ts b/src/settings/handlers/AccountSettingsHandler.ts index e931a926005..639bef628c6 100644 --- a/src/settings/handlers/AccountSettingsHandler.ts +++ b/src/settings/handlers/AccountSettingsHandler.ts @@ -17,6 +17,7 @@ limitations under the License. import { ClientEvent, MatrixClient, MatrixEvent } from "matrix-js-sdk/src/matrix"; import { defer } from "matrix-js-sdk/src/utils"; +import { isEqual } from "lodash"; import MatrixClientBackedSettingsHandler from "./MatrixClientBackedSettingsHandler"; import { objectClone, objectKeyChanges } from "../../utils/objects"; @@ -168,7 +169,7 @@ export default class AccountSettingsHandler extends MatrixClientBackedSettingsHa // which race between different lines. const deferred = defer(); 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); deferred.resolve(); }; diff --git a/src/stores/ReleaseAnnouncementStore.ts b/src/stores/ReleaseAnnouncementStore.ts new file mode 100644 index 00000000000..9beeed4f700 --- /dev/null +++ b/src/stores/ReleaseAnnouncementStore.ts @@ -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; + +/** + * 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 { + /** + * 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("releaseAnnouncementData"); + } + + /** + * Check if the release announcement is enabled. + * @private + */ + private isReleaseAnnouncementEnabled(): boolean { + return SettingsStore.getValue(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 { + // 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 { + await this.markReleaseAnnouncementAsViewed(); + + this.emit("releaseAnnouncementChanged", this.getReleaseAnnouncement()); + } +} diff --git a/test/components/structures/ReleaseAnnouncement-test.tsx b/test/components/structures/ReleaseAnnouncement-test.tsx new file mode 100644 index 00000000000..3477e54d4b5 --- /dev/null +++ b/test/components/structures/ReleaseAnnouncement-test.tsx @@ -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( + +
content
+
, + ); + } + + 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()); + }); +}); diff --git a/test/components/views/settings/tabs/user/LabsUserSettingsTab-test.tsx b/test/components/views/settings/tabs/user/LabsUserSettingsTab-test.tsx index 3239c0f875d..fd9a92a2253 100644 --- a/test/components/views/settings/tabs/user/LabsUserSettingsTab-test.tsx +++ b/test/components/views/settings/tabs/user/LabsUserSettingsTab-test.tsx @@ -60,7 +60,7 @@ describe("", () => { // non-beta labs section expect(screen.getByText("Early previews")).toBeInTheDocument(); const labsSections = container.getElementsByClassName("mx_SettingsSubsection"); - expect(labsSections).toHaveLength(10); + expect(labsSections).toHaveLength(11); }); describe("Rust crypto setting", () => { diff --git a/test/components/views/spaces/ThreadsActivityCentre-test.tsx b/test/components/views/spaces/ThreadsActivityCentre-test.tsx index 8deb27ec7e4..9cc47c93f1c 100644 --- a/test/components/views/spaces/ThreadsActivityCentre-test.tsx +++ b/test/components/views/spaces/ThreadsActivityCentre-test.tsx @@ -28,6 +28,8 @@ import MatrixClientContext from "../../../../src/contexts/MatrixClientContext"; import { stubClient } from "../../../test-utils"; import { populateThread } from "../../../test-utils/threads"; import DMRoomMap from "../../../../src/utils/DMRoomMap"; +import SettingsStore from "../../../../src/settings/SettingsStore"; +import { SettingLevel } from "../../../../src/settings/SettingLevel"; describe("ThreadsActivityCentre", () => { 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 () => { renderTAC(); 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 () => { renderTAC({ displayButtonLabel: true }); expect(getTACButton()).toBeInTheDocument(); diff --git a/test/components/views/spaces/__snapshots__/ThreadsActivityCentre-test.tsx.snap b/test/components/views/spaces/__snapshots__/ThreadsActivityCentre-test.tsx.snap index 0d2841c6148..3146a3c80a9 100644 --- a/test/components/views/spaces/__snapshots__/ThreadsActivityCentre-test.tsx.snap +++ b/test/components/views/spaces/__snapshots__/ThreadsActivityCentre-test.tsx.snap @@ -2,7 +2,7 @@ exports[`ThreadsActivityCentre renders notifications matching the snapshot 1`] = ` `; + +exports[`ThreadsActivityCentre should render the release announcement 1`] = ` + +
+
+ + + + +
+
+
+ + + +
+ +`; diff --git a/test/stores/ReleaseAnnouncementStore-test.tsx b/test/stores/ReleaseAnnouncementStore-test.tsx new file mode 100644 index 00000000000..77e79cd5453 --- /dev/null +++ b/test/stores/ReleaseAnnouncementStore-test.tsx @@ -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 = {}; + + beforeEach(() => { + // Default settings + settings = { + feature_release_announcement: true, + releaseAnnouncementData: {}, + }; + const watchCallbacks: Array = []; + + mocked(SettingsStore.getValue).mockImplementation((setting: string) => { + return settings[setting]; + }); + mocked(SettingsStore.setValue).mockImplementation( + (settingName: string, roomId: string | null, level: SettingLevel, value: any): Promise => { + 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((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(); + }); +}); diff --git a/yarn.lock b/yarn.lock index 15f8a7ae099..3fbabea662c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1491,13 +1491,22 @@ "@floating-ui/core" "^1.0.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" resolved "https://registry.yarnpkg.com/@floating-ui/react-dom/-/react-dom-2.0.8.tgz#afc24f9756d1b433e1fe0d047c24bd4d9cefaa5d" integrity sha512-HOdqOt3R3OGeTKidaLvJKcgg75S6tibQ3Tif4eyd91QnIJWr0NLvoXFpJA/j8HqkFSL68GDca9AuyWEHlhyClw== dependencies: "@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": version "0.2.1" resolved "https://registry.yarnpkg.com/@floating-ui/utils/-/utils-0.2.1.tgz#16308cea045f0fc777b6ff20a9f25474dd8293d2" @@ -3047,11 +3056,13 @@ dependencies: svg2vectordrawable "^2.9.1" -"@vector-im/compound-web@^3.1.1": - version "3.1.3" - resolved "https://registry.yarnpkg.com/@vector-im/compound-web/-/compound-web-3.1.3.tgz#bd23b4b2067b5ff0035b7c5f11bf6c57f98eb6be" - integrity sha512-h1uEKxMrZXUlEA2b8sd57WbxDy9LV8E0MYbz1vdKbU0n3lJb8neUbCAJE7PdQUoOSCi91jw8H+xH8XRLxTYYYw== +"@vector-im/compound-web@^3.3.1": + version "3.3.1" + resolved "https://registry.yarnpkg.com/@vector-im/compound-web/-/compound-web-3.3.1.tgz#f5d69255fa62472626e0ed71b7176b09f21cbcaf" + integrity sha512-V9CQfaMyKdsWxC1D4Wz08Xh0ge3SnaOBf5SSIp1+uwoJTPyfEFHKgqbZl536SHBvVBc9M9IYg+3+lPB8xkFRFA== 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-dropdown-menu" "^2.0.6" "@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" 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: version "6.8.2" resolved "https://registry.yarnpkg.com/table/-/table-6.8.2.tgz#c5504ccf201213fa227248bdc8c5569716ac6c58"