diff --git a/packages/shared-components/playwright/snapshots/composer-historyvisiblebannerview--default-linux.png b/packages/shared-components/playwright/snapshots/composer-historyvisiblebannerview--default-linux.png new file mode 100644 index 00000000000..902ac00f7b9 Binary files /dev/null and b/packages/shared-components/playwright/snapshots/composer-historyvisiblebannerview--default-linux.png differ diff --git a/packages/shared-components/src/composer/HistoryVisibleBannerView/HistoryVisibleBannerView.stories.tsx b/packages/shared-components/src/composer/HistoryVisibleBannerView/HistoryVisibleBannerView.stories.tsx new file mode 100644 index 00000000000..5bd95e0113e --- /dev/null +++ b/packages/shared-components/src/composer/HistoryVisibleBannerView/HistoryVisibleBannerView.stories.tsx @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2025 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 { type Meta, type StoryFn } from "@storybook/react-vite"; +import React, { type JSX } from "react"; +import { fn } from "storybook/test"; + +import { useMockedViewModel } from "../../useMockedViewModel"; +import { + HistoryVisibleBannerView, + type HistoryVisibleBannerViewActions, + type HistoryVisibleBannerViewSnapshot, +} from "./HistoryVisibleBannerView"; + +type HistoryVisibleBannerProps = HistoryVisibleBannerViewSnapshot & HistoryVisibleBannerViewActions; + +const HistoryVisibleBannerViewWrapper = ({ onClose, ...rest }: HistoryVisibleBannerProps): JSX.Element => { + const vm = useMockedViewModel(rest, { + onClose, + }); + return ; +}; + +export default { + title: "composer/HistoryVisibleBannerView", + component: HistoryVisibleBannerViewWrapper, + tags: ["autodocs"], + argTypes: {}, + args: { + visible: true, + onClose: fn(), + }, +} as Meta; + +const Template: StoryFn = (args) => ( + +); + +export const Default = Template.bind({}); diff --git a/packages/shared-components/src/composer/HistoryVisibleBannerView/HistoryVisibleBannerView.test.tsx b/packages/shared-components/src/composer/HistoryVisibleBannerView/HistoryVisibleBannerView.test.tsx new file mode 100644 index 00000000000..04d1ca40e61 --- /dev/null +++ b/packages/shared-components/src/composer/HistoryVisibleBannerView/HistoryVisibleBannerView.test.tsx @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2025 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 React from "react"; +import { render } from "jest-matrix-react"; +import { composeStories } from "@storybook/react-vite"; + +import * as stories from "./HistoryVisibleBannerView.stories.tsx"; + +const { Default } = composeStories(stories); + +describe("HistoryVisibleBannerView", () => { + it("renders a history visible banner", () => { + const dismissFn = jest.fn(); + + const { container } = render(); + expect(container).toMatchSnapshot(); + + const button = container.querySelector("button"); + expect(button).not.toBeNull(); + button?.click(); + expect(dismissFn).toHaveBeenCalled(); + }); +}); diff --git a/packages/shared-components/src/composer/HistoryVisibleBannerView/HistoryVisibleBannerView.tsx b/packages/shared-components/src/composer/HistoryVisibleBannerView/HistoryVisibleBannerView.tsx new file mode 100644 index 00000000000..fcd00327d65 --- /dev/null +++ b/packages/shared-components/src/composer/HistoryVisibleBannerView/HistoryVisibleBannerView.tsx @@ -0,0 +1,79 @@ +/* + * Copyright (c) 2025 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 { Link } from "@vector-im/compound-web"; +import React, { type JSX } from "react"; + +import { useViewModel } from "../../useViewModel"; +import { _t } from "../../utils/i18n"; +import { type ViewModel } from "../../viewmodel"; +import { Banner } from "../Banner"; + +export interface HistoryVisibleBannerViewActions { + /** + * Called when the user dismisses the banner. + */ + onClose: () => void; +} + +export interface HistoryVisibleBannerViewSnapshot { + /** + * Whether the banner is currently visible. + */ + visible: boolean; +} + +/** + * The view model for the banner. + */ +export type HistoryVisibleBannerViewModel = ViewModel & + HistoryVisibleBannerViewActions; + +interface HistoryVisibleBannerViewProps { + /** + * The view model for the banner. + */ + vm: HistoryVisibleBannerViewModel; +} + +/** + * A component to alert that history is shared to new members of the room. + * + * @example + * ```tsx + * + * ``` + */ +export function HistoryVisibleBannerView({ vm }: Readonly): JSX.Element { + const { visible } = useViewModel(vm); + + const contents = _t( + "room|status_bar|history_visible", + {}, + { + a: substituteATag, + }, + ); + + return ( + <> + {visible && ( + vm.onClose()}> + {contents} + + )} + + ); +} + +function substituteATag(sub: string): JSX.Element { + return ( + + {sub} + + ); +} diff --git a/packages/shared-components/src/composer/HistoryVisibleBannerView/__snapshots__/HistoryVisibleBannerView.test.tsx.snap b/packages/shared-components/src/composer/HistoryVisibleBannerView/__snapshots__/HistoryVisibleBannerView.test.tsx.snap new file mode 100644 index 00000000000..b046bf35723 --- /dev/null +++ b/packages/shared-components/src/composer/HistoryVisibleBannerView/__snapshots__/HistoryVisibleBannerView.test.tsx.snap @@ -0,0 +1,62 @@ +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing + +exports[`HistoryVisibleBannerView renders a history visible banner 1`] = ` +
+ +
+`; diff --git a/packages/shared-components/src/composer/HistoryVisibleBannerView/index.ts b/packages/shared-components/src/composer/HistoryVisibleBannerView/index.ts new file mode 100644 index 00000000000..96bf208bea4 --- /dev/null +++ b/packages/shared-components/src/composer/HistoryVisibleBannerView/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright (c) 2025 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. + */ + +export * from "./HistoryVisibleBannerView"; diff --git a/packages/shared-components/src/index.ts b/packages/shared-components/src/index.ts index 8440a4fb0a9..a806971a3f9 100644 --- a/packages/shared-components/src/index.ts +++ b/packages/shared-components/src/index.ts @@ -12,6 +12,7 @@ export * from "./audio/PlayPauseButton"; export * from "./audio/SeekBar"; export * from "./avatar/AvatarWithDetails"; export * from "./composer/Banner"; +export * from "./composer/HistoryVisibleBannerView"; export * from "./event-tiles/TextualEventView"; export * from "./message-body/MediaBody"; export * from "./pill-input/Pill"; diff --git a/packages/shared-components/src/viewmodel/index.ts b/packages/shared-components/src/viewmodel/index.ts index 3699f8dc3f1..0267f7934d3 100644 --- a/packages/shared-components/src/viewmodel/index.ts +++ b/packages/shared-components/src/viewmodel/index.ts @@ -11,3 +11,4 @@ export * from "./Snapshot"; export * from "./ViewModelSubscriptions"; export type * from "./ViewModel"; export * from "./MockViewModel"; +export * from "./useCreateAutoDisposedViewModel"; diff --git a/playwright/snapshots/crypto/history-sharing.spec.ts/shared-history-invite-accepted-linux.png b/playwright/snapshots/crypto/history-sharing.spec.ts/shared-history-invite-accepted-linux.png index b5467fe9dc4..ff22d723584 100644 Binary files a/playwright/snapshots/crypto/history-sharing.spec.ts/shared-history-invite-accepted-linux.png and b/playwright/snapshots/crypto/history-sharing.spec.ts/shared-history-invite-accepted-linux.png differ diff --git a/src/components/views/composer/HistoryVisibleBanner.tsx b/src/components/views/composer/HistoryVisibleBanner.tsx new file mode 100644 index 00000000000..cfc50ade6db --- /dev/null +++ b/src/components/views/composer/HistoryVisibleBanner.tsx @@ -0,0 +1,17 @@ +/* + * Copyright (c) 2025 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 { HistoryVisibleBannerView, useCreateAutoDisposedViewModel } from "@element-hq/web-shared-components"; +import React from "react"; +import { type Room } from "matrix-js-sdk/src/matrix"; + +import { HistoryVisibleBannerViewModel } from "../../../viewmodels/composer/HistoryVisibleBannerViewModel"; + +export const HistoryVisibleBanner: React.FC<{ room: Room }> = ({ room }) => { + const vm = useCreateAutoDisposedViewModel(() => new HistoryVisibleBannerViewModel({ room })); + return ; +}; diff --git a/src/components/views/rooms/MessageComposer.tsx b/src/components/views/rooms/MessageComposer.tsx index 06c843f1907..3c5dbf1891c 100644 --- a/src/components/views/rooms/MessageComposer.tsx +++ b/src/components/views/rooms/MessageComposer.tsx @@ -54,6 +54,7 @@ import { type MatrixClientProps, withMatrixClientHOC } from "../../../contexts/M import { UIFeature } from "../../../settings/UIFeature"; import { formatTimeLeft } from "../../../DateUtils"; import RoomReplacedSvg from "../../../../res/img/room_replaced.svg"; +import { HistoryVisibleBanner } from "../composer/HistoryVisibleBanner"; // The prefix used when persisting editor drafts to localstorage. export const WYSIWYG_EDITOR_STATE_STORAGE_PREFIX = "mx_wysiwyg_state_"; @@ -674,6 +675,7 @@ export class MessageComposer extends React.Component { return (
+
contact your service administrator to continue using the service.", + "history_visible": "Messages you send will be shared with new members invited to this room. Learn more", "homeserver_blocked": "Your message wasn't sent because this homeserver has been blocked by its administrator. Please contact your service administrator to continue using the service.", "monthly_user_limit_reached": "Your message wasn't sent because this homeserver has hit its Monthly Active User Limit. Please contact your service administrator to continue using the service.", "requires_consent_agreement": "You can't send any messages until you review and agree to our terms and conditions.", diff --git a/src/settings/Settings.tsx b/src/settings/Settings.tsx index 2ceb1b807f4..fe57708dfa1 100644 --- a/src/settings/Settings.tsx +++ b/src/settings/Settings.tsx @@ -371,6 +371,7 @@ export interface Settings { "inviteRules": IBaseSetting; "blockInvites": IBaseSetting; "Developer.elementCallUrl": IBaseSetting; + "acknowledgedHistoryVisibility": IBaseSetting; } export type SettingKey = keyof Settings; @@ -1488,4 +1489,8 @@ export const SETTINGS: Settings = { displayName: _td("devtools|settings|elementCallUrl"), default: "", }, + "acknowledgedHistoryVisibility": { + supportedLevels: [SettingLevel.ROOM_ACCOUNT], + default: false, + }, }; diff --git a/src/viewmodels/composer/HistoryVisibleBannerViewModel.tsx b/src/viewmodels/composer/HistoryVisibleBannerViewModel.tsx new file mode 100644 index 00000000000..12aa589506c --- /dev/null +++ b/src/viewmodels/composer/HistoryVisibleBannerViewModel.tsx @@ -0,0 +1,112 @@ +/* + * Copyright (c) 2025 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 { + BaseViewModel, + type HistoryVisibleBannerViewModel as HistoryVisibleBannerViewModelInterface, + type HistoryVisibleBannerViewSnapshot, +} from "@element-hq/web-shared-components"; +import { HistoryVisibility, RoomStateEvent, type Room } from "matrix-js-sdk/src/matrix"; + +import SettingsStore from "../../settings/SettingsStore"; +import { SettingLevel } from "../../settings/SettingLevel"; + +interface Props { + room: Room; +} + +export class HistoryVisibleBannerViewModel + extends BaseViewModel + implements HistoryVisibleBannerViewModelInterface +{ + /** + * Watcher ID for the "feature_share_history_on_invite" setting. + */ + private readonly featureWatcher: string; + + /** + * Watcher ID for the "acknowledgedHistoryVisibility" setting specific to the room. + */ + private readonly acknowledgedWatcher: string; + + private static readonly computeSnapshot = (room: Room): HistoryVisibleBannerViewSnapshot => { + const featureEnabled = SettingsStore.getValue("feature_share_history_on_invite"); + const acknowledged = SettingsStore.getValue("acknowledgedHistoryVisibility", room.roomId); + + return { + visible: + featureEnabled && + room.hasEncryptionStateEvent() && + room.getHistoryVisibility() !== HistoryVisibility.Joined && + !acknowledged, + }; + }; + + public constructor(props: Props) { + super(props, HistoryVisibleBannerViewModel.computeSnapshot(props.room)); + + this.disposables.trackListener(props.room, RoomStateEvent.Update, () => this.setSnapshot()); + + // `SettingsStore` is not an `EventListener`, so we must manage these manually. + this.featureWatcher = SettingsStore.watchSetting( + "feature_share_history_on_invite", + null, + (_key, _roomId, _level, value: boolean) => this.setSnapshot(), + ); + this.acknowledgedWatcher = SettingsStore.watchSetting( + "acknowledgedHistoryVisibility", + props.room.roomId, + (_key, _roomId, _level, value: boolean) => this.setSnapshot(), + ); + } + + private setSnapshot(): void { + const acknowledged = SettingsStore.getValue("acknowledgedHistoryVisibility", this.props.room.roomId); + + // Reset the acknowleded flag when the history visibility is set back to joined. + if (this.props.room.getHistoryVisibility() === HistoryVisibility.Joined && acknowledged) { + SettingsStore.setValue( + "acknowledgedHistoryVisibility", + this.props.room.roomId, + SettingLevel.ROOM_ACCOUNT, + false, + ); + } + + this.snapshot.set(HistoryVisibleBannerViewModel.computeSnapshot(this.props.room)); + } + + /** + * Revoke the banner's acknoledgement status. + */ + public async revoke(): Promise { + await SettingsStore.setValue( + "acknowledgedHistoryVisibility", + this.props.room.roomId, + SettingLevel.ROOM_ACCOUNT, + false, + ); + } + + /** + * Called when the user dismisses the banner. + */ + public async onClose(): Promise { + await SettingsStore.setValue( + "acknowledgedHistoryVisibility", + this.props.room.roomId, + SettingLevel.ROOM_ACCOUNT, + true, + ); + } + + public dispose(): void { + super.dispose(); + SettingsStore.unwatchSetting(this.featureWatcher); + SettingsStore.unwatchSetting(this.acknowledgedWatcher); + } +} diff --git a/test/test-utils/test-utils.ts b/test/test-utils/test-utils.ts index 9fe885429bc..b0746fc422f 100644 --- a/test/test-utils/test-utils.ts +++ b/test/test-utils/test-utils.ts @@ -9,6 +9,7 @@ Please see LICENSE files in the repository root for full details. import EventEmitter from "events"; import { mocked, type MockedObject } from "jest-mock"; import { + type EventTimeline, MatrixEvent, type Room, type User, @@ -16,7 +17,6 @@ import { type IEvent, type RoomMember, type MatrixClient, - type EventTimeline, type RoomState, EventType, type IEventRelation, @@ -30,6 +30,7 @@ import { JoinRule, type OidcClientConfig, type GroupCall, + HistoryVisibility, } from "matrix-js-sdk/src/matrix"; import { KnownMembership } from "matrix-js-sdk/src/types"; import { normalize } from "matrix-js-sdk/src/utils"; @@ -627,6 +628,7 @@ export function mkStubRoom( createThreadsTimelineSets: jest.fn().mockReturnValue(new Promise(() => {})), currentState: { getStateEvents: jest.fn((_type, key) => (key === undefined ? [] : null)), + getHistoryVisibility: jest.fn().mockReturnValue(HistoryVisibility.Joined), getMember: jest.fn(), mayClientSendStateEvent: jest.fn().mockReturnValue(true), maySendStateEvent: jest.fn().mockReturnValue(true), diff --git a/test/unit-tests/components/viewmodels/composer/HistoryVisibleBannerViewModel-test.tsx b/test/unit-tests/components/viewmodels/composer/HistoryVisibleBannerViewModel-test.tsx new file mode 100644 index 00000000000..12b7edc6f47 --- /dev/null +++ b/test/unit-tests/components/viewmodels/composer/HistoryVisibleBannerViewModel-test.tsx @@ -0,0 +1,130 @@ +/* + * Copyright 2025 New Vector 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 { Room } from "matrix-js-sdk/src/matrix"; + +import { SettingLevel } from "../../../../../src/settings/SettingLevel"; +import SettingsStore, { type CallbackFn } from "../../../../../src/settings/SettingsStore"; +import { mkEvent, stubClient, upsertRoomStateEvents } from "../../../../test-utils"; +import { HistoryVisibleBannerViewModel } from "../../../../../src/viewmodels/composer/HistoryVisibleBannerViewModel"; + +describe("HistoryVisibleBannerViewModel", () => { + const ROOM_ID = "!roomId:example.org"; + + let room: Room; + let watcherCallbacks: CallbackFn[]; + let acknowledgedHistoryVisibility: boolean; + + beforeEach(() => { + watcherCallbacks = []; + acknowledgedHistoryVisibility = false; + + jest.spyOn(SettingsStore, "setValue").mockImplementation(async (settingName, roomId, level, value) => { + if (settingName === "acknowledgedHistoryVisibility") { + acknowledgedHistoryVisibility = value; + } + watcherCallbacks.forEach((callbackFn) => callbackFn(settingName, roomId, level, value, value)); + }); + + jest.spyOn(SettingsStore, "getValue").mockImplementation((settingName, roomId) => { + if (settingName === "acknowledgedHistoryVisibility") { + return acknowledgedHistoryVisibility; + } + if (settingName === "feature_share_history_on_invite") { + return true; + } + return SettingsStore.getDefaultValue(settingName); + }); + + jest.spyOn(SettingsStore, "watchSetting").mockImplementation((settingName, roomId, callbackFn) => { + watcherCallbacks.push(callbackFn); + return `mockWatcherId-${settingName}-${roomId}`; + }); + + stubClient(); + room = new Room(ROOM_ID, {} as any, "@user:example.org"); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it("should not show the banner in unencrypted rooms", () => { + const vm = new HistoryVisibleBannerViewModel({ room }); + expect(vm.getSnapshot().visible).toBe(false); + }); + + it("should not show the banner in encrypted rooms with joined history visibility", () => { + upsertRoomStateEvents(room, [ + mkEvent({ + event: true, + type: "m.room.encryption", + user: "@user1:server", + content: {}, + }), + mkEvent({ + event: true, + type: "m.room.history_visibility", + content: { + history_visibility: "joined", + }, + user: "@user1:server", + }), + ]); + + const vm = new HistoryVisibleBannerViewModel({ room }); + expect(vm.getSnapshot().visible).toBe(false); + }); + + it("should not show the banner if it has been dismissed", async () => { + await SettingsStore.setValue("acknowledgedHistoryVisibility", ROOM_ID, SettingLevel.ROOM_ACCOUNT, true); + upsertRoomStateEvents(room, [ + mkEvent({ + event: true, + type: "m.room.encryption", + user: "@user1:server", + content: {}, + }), + mkEvent({ + event: true, + type: "m.room.history_visibility", + user: "@user1:server", + content: { + history_visibility: "shared", + }, + }), + ]); + + const vm = new HistoryVisibleBannerViewModel({ room }); + expect(vm.getSnapshot().visible).toBe(false); + vm.dispose(); + }); + + it("should show the banner in encrypted rooms with non-joined history visibility", async () => { + upsertRoomStateEvents(room, [ + mkEvent({ + event: true, + type: "m.room.encryption", + user: "@user1:server", + content: {}, + }), + mkEvent({ + event: true, + type: "m.room.history_visibility", + user: "@user1:server", + content: { + history_visibility: "shared", + }, + }), + ]); + + const vm = new HistoryVisibleBannerViewModel({ room }); + expect(vm.getSnapshot().visible).toBe(true); + await vm.onClose(); + expect(vm.getSnapshot().visible).toBe(false); + }); +});