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`] = `
+
+
+
+
+
+ Messages you send will be shared with new members invited to this room.
+
+ Learn more
+
+
+
+
+
+
+
+
+`;
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);
+ });
+});