diff --git a/apps/web/playwright/e2e/retention/retention.spec.ts b/apps/web/playwright/e2e/retention/retention.spec.ts new file mode 100644 index 0000000000..f1440e4c4e --- /dev/null +++ b/apps/web/playwright/e2e/retention/retention.spec.ts @@ -0,0 +1,152 @@ +/* + * 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 { rejectToastIfExists } from "@element-hq/element-web-playwright-common"; + +import { test, expect, type TestFixtures } from "../../element-web-test"; +import type { Page } from "@playwright/test"; + +const ONE_MINUTE = 60 * 1000; + +async function checkRetentionInRoom( + { bot, app, page }: Pick & { page: Page }, + roomId: string, +) { + await bot.joinRoom(roomId); + await app.viewRoomById(roomId); + const tiles = ( + await Promise.all(Array.from({ length: 5 }).map((_o, index) => bot.sendMessage(roomId, `Message ${index}`))) + ).map(({ event_id: evtId }) => page.locator(`.mx_RoomView_MessageList .mx_EventTile[data-event-id='${evtId}']`)); + for (const tile of tiles) { + await expect(tile).toBeVisible(); + } + await page.clock.fastForward(ONE_MINUTE + 1); + for (const tile of tiles) { + await expect(tile).toBeHidden(); + } +} + +test.describe("Retention", () => { + test.use({ + displayName: "Tom", + botCreateOpts: { + displayName: "Bob", + }, + labsFlags: ["feature_retention"], + }); + + test.beforeEach(async ({ app, homeserver, page, user }) => { + await rejectToastIfExists(page, "Verify this device"); + await rejectToastIfExists(page, "Notifications"); + await page.clock.install(); + }); + + test("should apply retention to a bunch of messages", async ({ app, homeserver, page, user, bot }) => { + const roomId = await app.client.createRoom({ + name: "Test", + invite: [bot.credentials.userId], + initial_state: [ + { + state_key: "", + type: "org.matrix.msc1763.retention", + content: { + max_lifetime: ONE_MINUTE, + }, + }, + ], + }); + // When the bot joins the room= + await bot.joinRoom(roomId); + await app.viewRoomByName("Test"); + await checkRetentionInRoom({ app, bot, page }, roomId); + }); + + test.describe("global retention rules", () => { + test.use({ + page: async ({ page }, runFixture) => { + await page.route("**/_matrix/client/unstable/org.matrix.msc1763/retention/configuration", (route) => { + return route.fulfill({ + json: { + policies: { + "*": { + max_lifetime: ONE_MINUTE, + }, + }, + }, + }); + }); + await runFixture(page); + }, + }); + test("should apply", async ({ app, bot, page }) => { + const roomId = await app.client.createRoom({ + name: "Test", + invite: [bot.credentials.userId], + }); + await checkRetentionInRoom({ app, bot, page }, roomId); + }); + }); + + test("retention rules should apply after restart", async ({ app, bot, page }) => { + const roomId = await app.client.createRoom({ + name: "Test", + invite: [bot.credentials.userId], + }); + await bot.joinRoom(roomId); + await app.viewRoomByName("Test"); + const tiles = ( + await Promise.all(Array.from({ length: 5 }).map((_o, index) => bot.sendMessage(roomId, `Message ${index}`))) + ).map(({ event_id: evtId }) => + page.locator(`.mx_RoomView_MessageList .mx_EventTile[data-event-id='${evtId}']`), + ); + for (const tile of tiles) { + await expect(tile).toBeVisible(); + } + // Reload and apply new policy + await page.reload(); + await page.clock.fastForward(ONE_MINUTE + 1); + await page.route("**/_matrix/client/unstable/org.matrix.msc1763/retention/configuration", (route) => { + return route.fulfill({ + json: { + policies: { + "*": { + max_lifetime: ONE_MINUTE, + }, + }, + }, + }); + }); + await app.viewRoomByName("Test"); + for (const tile of tiles) { + await expect(tile).toBeHidden(); + } + }); + + test("should stop applying retention when the policy is removed", async ({ app, homeserver, page, user, bot }) => { + const currentTime = new Date(); + const roomId = await app.client.createRoom({ + name: "Test", + invite: [bot.credentials.userId], + initial_state: [ + { + state_key: "", + type: "org.matrix.msc1763.retention", + content: { + max_lifetime: ONE_MINUTE, + }, + }, + ], + }); + await checkRetentionInRoom({ app, bot, page }, roomId); + // Check that retention rules no longer apply. + await page.clock.setFixedTime(currentTime); + const { event_id: eventId } = await bot.sendMessage(roomId, `Message afterwards`); + await app.client.sendStateEvent(roomId, "org.matrix.msc1763.retention", {}); + await page.clock.fastForward(ONE_MINUTE + 1); + await expect(page.locator(`.mx_RoomView_MessageList .mx_EventTile[data-event-id='${eventId}']`)).toBeVisible(); + }); +}); diff --git a/apps/web/src/MatrixClientPeg.ts b/apps/web/src/MatrixClientPeg.ts index 5f8125be67..a25c733dc3 100644 --- a/apps/web/src/MatrixClientPeg.ts +++ b/apps/web/src/MatrixClientPeg.ts @@ -441,6 +441,7 @@ class MatrixClientPegClass implements IMatrixClientPeg { // can toggle on without reloading and also be accessed immediately after login. cryptoCallbacks: { ...crossSigningCallbacks }, enableEncryptedStateEvents: SettingsStore.getValue("feature_msc4362_encrypted_state_events"), + unstableMSC1763Retention: SettingsStore.getValue("feature_retention"), roomNameGenerator: (_: string, state: RoomNameState) => { switch (state.type) { case RoomNameType.Generated: diff --git a/apps/web/src/i18n/strings/en_EN.json b/apps/web/src/i18n/strings/en_EN.json index 17faf2caeb..cf48aca1ce 100644 --- a/apps/web/src/i18n/strings/en_EN.json +++ b/apps/web/src/i18n/strings/en_EN.json @@ -1542,6 +1542,11 @@ "experimental_section": "Early previews", "extended_profiles_msc_support": "Requires your server to support MSC4133", "feature_disable_call_per_sender_encryption": "Disable per-sender encryption for Element Call", + "feature_retention": { + "description": "Automatically remove messages from a room when they reach a configured retention period. Currently only supports reading settings.", + "disabled_sliding_sync": "Retention cannot be enabled when Sliding Sync is enabled.", + "display_name": "Message retention" + }, "feature_user_status": { "description": "Enables being able to see and set a current status.", "display_name": "User status", diff --git a/apps/web/src/settings/Settings.tsx b/apps/web/src/settings/Settings.tsx index 8c47861bf8..e51382ae53 100644 --- a/apps/web/src/settings/Settings.tsx +++ b/apps/web/src/settings/Settings.tsx @@ -225,6 +225,7 @@ export interface Settings { "feature_dynamic_room_predecessors": IFeature; "feature_render_reaction_images": IFeature; "feature_new_room_list": IFeature; + "feature_retention": IFeature; "feature_room_list_sections": IFeature; "feature_ask_to_join": IFeature; "feature_notifications": IFeature; @@ -807,6 +808,22 @@ export const SETTINGS: Settings = { ), default: false, }, + "feature_retention": { + isFeature: true, + labsGroup: LabGroup.Messaging, + displayName: _td("labs|feature_retention|display_name"), + description: _td("labs|feature_retention|description"), + supportedLevels: LEVELS_DEVICE_ONLY_SETTINGS_WITH_CONFIG_PRIORITISED, + supportedLevelsAreOrdered: true, + controller: new IncompatibleController( + "feature_sliding_sync", + false, + true, + _td("labs|feature_retention|disabled_sliding_sync"), + true, + ), + default: false, + }, "useCompactLayout": { supportedLevels: LEVELS_DEVICE_ONLY_SETTINGS, displayName: _td("settings|preferences|compact_modern"), diff --git a/apps/web/src/settings/controllers/IncompatibleController.ts b/apps/web/src/settings/controllers/IncompatibleController.ts index 81c8ca74ac..7351139d97 100644 --- a/apps/web/src/settings/controllers/IncompatibleController.ts +++ b/apps/web/src/settings/controllers/IncompatibleController.ts @@ -10,6 +10,7 @@ import SettingController from "./SettingController"; import { type SettingLevel } from "../SettingLevel"; import SettingsStore from "../SettingsStore"; import { type BooleanSettingKey } from "../Settings.tsx"; +import PlatformPeg from "../../PlatformPeg.ts"; /** * Enforces that a boolean setting cannot be enabled if the incompatible setting @@ -21,6 +22,8 @@ export default class IncompatibleController extends SettingController { private settingName: BooleanSettingKey, private forcedValue: any = false, private incompatibleValue: any | ((v: any) => boolean) = true, + private readonly disabledMessage?: string, + private readonly forceReload = false, ) { super(); } @@ -37,8 +40,11 @@ export default class IncompatibleController extends SettingController { return null; // no override } - public get settingDisabled(): boolean { - return this.incompatibleSetting; + public get settingDisabled(): boolean | string { + if (this.incompatibleSetting) { + return this.disabledMessage ?? true; + } + return false; } public get incompatibleSetting(): boolean { @@ -47,4 +53,10 @@ export default class IncompatibleController extends SettingController { } return SettingsStore.getValue(this.settingName) === this.incompatibleValue; } + + public onChange(): void { + if (this.forceReload) { + PlatformPeg.get()?.reload(); + } + } }