Implement UI for history visibility acknowledgement. (#31156)

* feat: Implement UI for history visibility acknowledgement.

Shows a banner above the message composer whenever a user opens a room
with non-join history visibility, which they can dismiss.

- Whenever a user opens an encrypted room with non-join history
  visibility, show them a banner, unless we have already marked it as
  dismissed.
- Whenever a user opens an encrypted room with joined history
  visibility, we unmark it as dismissed.

Issue: https://github.com/element-hq/element-meta/issues/2875

* tests: Add test suite for `RoomStatusBarHistoryVisible`.

* docs: Document `RoomStatusBarHistoryVisible` and props interface.

* feat: Use newer `@vector-im/compound` components.

* test: Update snapshots for `RoomStatusBarHistoryVisible` tests.

* chore: Update playwright screenshots.

* feat: Move `RoomStatusBarHistoryVisible` to `shared-components`.

* fix: Address review comments on `RoomStatusBarHistoryVisible`.

* fix: Address review comments on `RoomStatusBar` and tests.

* chore: Move `RoomStatusBarHistoryVisible` to `room/RoomStatusBarHistoryVisible`

* chore: Fix linting issues.

* feat: Gate behind history visibility labs flag.

* feat: Add link to history sharing docs.

* fix: Resolve build issue with shared-components.

* tests: Enable history sharing lab for unit tests.

* tests: Set labs flag in SettingsStore mock.

* fix: Remove non-existent arg - documentation should be updated!

* chore: Remove old CSS rule filter.

* fix: Use package name for import over relative path.

* fix: Mark styles as important due to improper CSS load order.

* docs: Add doc comments to `!important` directives.

This change should restore my status as a good person.

* docs: Correct license header.

* tests: Update `RoomStatusBarHistoryVisible` snapshot.

* tests: Update shared history invite screenshot.

* tests: Revert spurious screenshot changes.

* feat: Update to use `Banner` component.

* chore: Remove broken import.

* chore: Remove unused translation string.

* tests: Add `getHistoryVisibility` to `currentState` of stub room.

* tests: Update screenshot.

* chore: Remove old snapshots.

* tests: Update playwright screenshot.

* feat: Separate `HistoryVisibleBanner` hooks into MVVM architecture.

* chore: Remove unused imports.

* feat: Use info link over action button for `HistoryVisibleBanner`

* tests: Update snapshot for `HistoryVisibleBanner`.

* chore: Remove unused imports.

* feat: Switch to MVVM architecture per style guide.

* tests: Update snapshot for `HistoryVisibleBanner`.

* tests: Update shared components snapshots.

* tests: Add unit tests for `HistoryVisibleBannerView` stories.

* fix: Linting errors from SonarCloud.

* feat: Finalise conversion to MVVM.

* fix: Silent `this` binding issue.

* tests: Update playwright snapshot.

* feat: Introduce wrapper component for `HistoryVisibleBanner`.

* tests: Update playwright screenshots for `HistoryVisibleBanner`.

* docs: Add doc comments to fields in `HistoryVisibleBannerViewModel`.

* tests: Update playwright snapshot.
This commit is contained in:
Skye Elliot 2025-12-10 10:37:04 +00:00 committed by GitHub
parent a13e9c1285
commit cff9119324
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 490 additions and 1 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

View File

@ -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 <HistoryVisibleBannerView vm={vm} />;
};
export default {
title: "composer/HistoryVisibleBannerView",
component: HistoryVisibleBannerViewWrapper,
tags: ["autodocs"],
argTypes: {},
args: {
visible: true,
onClose: fn(),
},
} as Meta<typeof HistoryVisibleBannerViewWrapper>;
const Template: StoryFn<typeof HistoryVisibleBannerViewWrapper> = (args) => (
<HistoryVisibleBannerViewWrapper {...args} />
);
export const Default = Template.bind({});

View File

@ -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(<Default onClose={dismissFn} />);
expect(container).toMatchSnapshot();
const button = container.querySelector("button");
expect(button).not.toBeNull();
button?.click();
expect(dismissFn).toHaveBeenCalled();
});
});

View File

@ -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<HistoryVisibleBannerViewSnapshot> &
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
* <HistoryVisibleBannerView vm={historyVisibleBannerViewModel} />
* ```
*/
export function HistoryVisibleBannerView({ vm }: Readonly<HistoryVisibleBannerViewProps>): JSX.Element {
const { visible } = useViewModel(vm);
const contents = _t(
"room|status_bar|history_visible",
{},
{
a: substituteATag,
},
);
return (
<>
{visible && (
<Banner type="info" onClose={() => vm.onClose()}>
{contents}
</Banner>
)}
</>
);
}
function substituteATag(sub: string): JSX.Element {
return (
<Link href="https://element.io/en/help#e2ee-history-sharing" target="_blank">
{sub}
</Link>
);
}

View File

@ -0,0 +1,62 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`HistoryVisibleBannerView renders a history visible banner 1`] = `
<div>
<div
class="banner"
data-type="info"
>
<div
class="icon"
>
<svg
fill="currentColor"
font-size="24"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M11.288 7.288A.97.97 0 0 1 12 7q.424 0 .713.287Q13 7.576 13 8t-.287.713A.97.97 0 0 1 12 9a.97.97 0 0 1-.713-.287A.97.97 0 0 1 11 8q0-.424.287-.713m.001 4.001A.97.97 0 0 1 12 11q.424 0 .713.287.287.288.287.713v4q0 .424-.287.712A.97.97 0 0 1 12 17a.97.97 0 0 1-.713-.288A.97.97 0 0 1 11 16v-4q0-.424.287-.713"
/>
<path
clip-rule="evenodd"
d="M22 12c0 5.523-4.477 10-10 10S2 17.523 2 12 6.477 2 12 2s10 4.477 10 10m-2 0a8 8 0 1 1-16 0 8 8 0 0 1 16 0"
fill-rule="evenodd"
/>
</svg>
</div>
<span
class="content"
>
<span>
Messages you send will be shared with new members invited to this room.
<a
class="_link_1v5rz_8"
data-kind="primary"
data-size="medium"
href="https://element.io/en/help#e2ee-history-sharing"
rel="noreferrer noopener"
target="_blank"
>
Learn more
</a>
</span>
</span>
<div
class="actions"
>
<button
class="_button_187yx_8"
data-kind="secondary"
data-size="sm"
role="button"
tabindex="0"
>
Dismiss
</button>
</div>
</div>
</div>
`;

View File

@ -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";

View File

@ -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";

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

After

Width:  |  Height:  |  Size: 53 KiB

View File

@ -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 <HistoryVisibleBannerView vm={vm} />;
};

View File

@ -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<IProps, IState> {
return (
<div className={classes} ref={this.ref} role="region" aria-label={_t("a11y|message_composer")}>
<HistoryVisibleBanner room={this.props.room} />
<div className="mx_MessageComposer_wrapper">
<UserIdentityWarning room={this.props.room} key={this.props.room.roomId} />
<ReplyPreview

View File

@ -2123,6 +2123,7 @@
"status_bar": {
"delete_all": "Delete all",
"exceeded_resource_limit": "Your message wasn't sent because this homeserver has exceeded a resource limit. Please <a>contact your service administrator</a> to continue using the service.",
"history_visible": "Messages you send will be shared with new members invited to this room. <a>Learn more</a>",
"homeserver_blocked": "Your message wasn't sent because this homeserver has been blocked by its administrator. Please <a>contact your service administrator</a> 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 <a>contact your service administrator</a> to continue using the service.",
"requires_consent_agreement": "You can't send any messages until you review and agree to <consentLink>our terms and conditions</consentLink>.",

View File

@ -371,6 +371,7 @@ export interface Settings {
"inviteRules": IBaseSetting<ComputedInviteConfig>;
"blockInvites": IBaseSetting<boolean>;
"Developer.elementCallUrl": IBaseSetting<string>;
"acknowledgedHistoryVisibility": IBaseSetting<boolean>;
}
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,
},
};

View File

@ -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<HistoryVisibleBannerViewSnapshot, Props>
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<void> {
await SettingsStore.setValue(
"acknowledgedHistoryVisibility",
this.props.room.roomId,
SettingLevel.ROOM_ACCOUNT,
false,
);
}
/**
* Called when the user dismisses the banner.
*/
public async onClose(): Promise<void> {
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);
}
}

View File

@ -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),

View File

@ -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);
});
});