Refactor E2eMessageSharedIcon to MVVM (#33544)
* Refactor E2eMessageSharedIcon to MVVM * FIx Prettier and i18n * Fix prettier * Remove legacy E2eMessageSharedIcon component * Cover E2eMessageSharedIcon MVVM updates * Fix prettier * Update apps/web/src/components/views/rooms/EventTile.tsx Co-authored-by: Florian Duros <florian.duros@ormaz.fr> * Skip unknown E2E shared icon visual * Track E2E shared icon listener disposal * Remove E2E shared icon room state guard * Remove E2E shared icon client resync * Use built-in disposables for shared icon VM * Update shared icon VM without React key --------- Co-authored-by: Florian Duros <florian.duros@ormaz.fr>
This commit is contained in:
@@ -41,6 +41,7 @@ import { CircleIcon, CheckCircleIcon, ThreadsIcon } from "@vector-im/compound-de
|
||||
import {
|
||||
useCreateAutoDisposedViewModel,
|
||||
ActionBarView,
|
||||
E2eMessageSharedIconView,
|
||||
MessageTimestampView,
|
||||
PinnedMessageBadge,
|
||||
ReactionsRowButtonView,
|
||||
@@ -88,10 +89,10 @@ import { Icon as LateIcon } from "../../../../res/img/sensor.svg";
|
||||
import PinningUtils from "../../../utils/PinningUtils";
|
||||
import { EventPreview } from "./EventPreview";
|
||||
import { E2eStandardPadlockIcon } from "./EventTile/E2eStandardPadlockIcon";
|
||||
import { E2eMessageSharedIcon } from "./EventTile/E2eMessageSharedIcon";
|
||||
import SettingsStore from "../../../settings/SettingsStore";
|
||||
import { CardContext } from "../right_panel/context";
|
||||
import { EventTileViewModel } from "../../../viewmodels/room/timeline/event-tile/EventTileViewModel";
|
||||
import { E2eMessageSharedIconViewModel } from "../../../viewmodels/room/timeline/event-tile/E2eMessageSharedIconViewModel";
|
||||
import {
|
||||
getEventTileReceiptState,
|
||||
type EventTileReceiptState,
|
||||
@@ -292,6 +293,50 @@ interface IState {
|
||||
thread: Thread | null;
|
||||
}
|
||||
|
||||
interface E2eMessageSharedIconWrapperProps {
|
||||
/**
|
||||
* The ID of the room containing the event whose keys were shared.
|
||||
*/
|
||||
roomId: string;
|
||||
/**
|
||||
* The ID of the user who shared the keys.
|
||||
*/
|
||||
keyForwardingUserId: string;
|
||||
}
|
||||
|
||||
function E2eMessageSharedIconWrapper({
|
||||
roomId,
|
||||
keyForwardingUserId,
|
||||
}: Readonly<E2eMessageSharedIconWrapperProps>): JSX.Element {
|
||||
const client = useMatrixClientContext();
|
||||
const vm = useCreateAutoDisposedViewModel(
|
||||
() =>
|
||||
new E2eMessageSharedIconViewModel({
|
||||
client,
|
||||
roomId,
|
||||
keyForwardingUserId,
|
||||
}),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
vm.setRoomId(roomId);
|
||||
}, [roomId, vm]);
|
||||
|
||||
useEffect(() => {
|
||||
vm.setKeyForwardingUserId(keyForwardingUserId);
|
||||
}, [keyForwardingUserId, vm]);
|
||||
|
||||
return (
|
||||
<E2eMessageSharedIconView
|
||||
vm={vm}
|
||||
className={
|
||||
// Timeline PCSS uses this app class as a layout hook for positioning and layout variants.
|
||||
"mx_EventTile_e2eIcon"
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// MUST be rendered within a RoomContext with a set timelineRenderingType
|
||||
export class UnwrappedEventTile extends React.Component<EventTileProps, IState> {
|
||||
private suppressReadReceiptAnimation: boolean;
|
||||
@@ -685,7 +730,7 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
|
||||
return null;
|
||||
case "messageShared":
|
||||
return (
|
||||
<E2eMessageSharedIcon
|
||||
<E2eMessageSharedIconWrapper
|
||||
keyForwardingUserId={e2ePadlockViewState.keyForwardingUserId}
|
||||
roomId={e2ePadlockViewState.roomId}
|
||||
/>
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
/*
|
||||
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 React, { type JSX } from "react";
|
||||
import { EventTimeline } from "matrix-js-sdk/src/matrix";
|
||||
import { E2ePadlock, E2ePadlockIcon } from "@element-hq/web-shared-components";
|
||||
|
||||
import { useMatrixClientContext } from "../../../../contexts/MatrixClientContext.tsx";
|
||||
import { _t } from "../../../../languageHandler.tsx";
|
||||
|
||||
/** The React properties of an {@link E2eMessageSharedIcon}. */
|
||||
interface E2eMessageSharedIconParams {
|
||||
/** The ID of the user who shared the keys. */
|
||||
keyForwardingUserId: string;
|
||||
|
||||
/** The ID of the room that contains the event whose keys were shared. Used to find the displayname of the user who shared the keys. */
|
||||
roomId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A small icon with tooltip, used as part of an {@link EventTile}, which indicates that the key to this event
|
||||
* was shared with us by another user.
|
||||
*
|
||||
* An alternative to the {@link E2ePadlock} component, which is used for UTD events and other error cases.
|
||||
*/
|
||||
export function E2eMessageSharedIcon(props: E2eMessageSharedIconParams): JSX.Element {
|
||||
const { roomId, keyForwardingUserId } = props;
|
||||
const client = useMatrixClientContext();
|
||||
|
||||
const roomState = client.getRoom(roomId)?.getLiveTimeline()?.getState(EventTimeline.FORWARDS);
|
||||
const forwardingMember = roomState?.getMember(keyForwardingUserId);
|
||||
|
||||
// We always disambiguate the user, since we need to prevent users from forging a disambiguation, and
|
||||
// the ToolTip component doesn't support putting styling inside a label.
|
||||
const tooltip = _t("encryption|message_shared_by", {
|
||||
displayName: forwardingMember?.rawDisplayName ?? keyForwardingUserId,
|
||||
userId: keyForwardingUserId,
|
||||
});
|
||||
|
||||
return (
|
||||
<E2ePadlock
|
||||
className={
|
||||
// Timeline PCSS uses this app class as a layout hook for positioning and layout variants.
|
||||
"mx_EventTile_e2eIcon"
|
||||
}
|
||||
icon={E2ePadlockIcon.Normal}
|
||||
title={tooltip}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -978,7 +978,6 @@
|
||||
"import_invalid_passphrase": "Authentication check failed: incorrect password?",
|
||||
"key_storage_out_of_sync": "Your key storage is out of sync.",
|
||||
"key_storage_out_of_sync_description": "Confirm your recovery key to maintain access to your key storage and message history.",
|
||||
"message_shared_by": "%(displayName)s (%(userId)s) shared this message since you were not in the room when it was sent.",
|
||||
"messages_not_secure": {
|
||||
"cause_1": "Your homeserver",
|
||||
"cause_2": "The homeserver the user you're verifying is connected to",
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
/*
|
||||
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 { EventTimeline, RoomStateEvent, type MatrixClient, type RoomState } from "matrix-js-sdk/src/matrix";
|
||||
import {
|
||||
BaseViewModel,
|
||||
type E2eMessageSharedIconViewModel as E2eMessageSharedIconViewModelInterface,
|
||||
type E2eMessageSharedIconViewSnapshot,
|
||||
} from "@element-hq/web-shared-components";
|
||||
|
||||
export interface E2eMessageSharedIconViewModelProps {
|
||||
/** Matrix client used to look up room membership state. */
|
||||
client: MatrixClient;
|
||||
/** The ID of the room containing the event whose keys were shared. */
|
||||
roomId: string;
|
||||
/** The ID of the user who shared the keys. */
|
||||
keyForwardingUserId: string;
|
||||
}
|
||||
|
||||
export class E2eMessageSharedIconViewModel
|
||||
extends BaseViewModel<E2eMessageSharedIconViewSnapshot, E2eMessageSharedIconViewModelProps>
|
||||
implements E2eMessageSharedIconViewModelInterface
|
||||
{
|
||||
private roomStateListenerCleanup?: () => void;
|
||||
|
||||
public constructor(props: E2eMessageSharedIconViewModelProps) {
|
||||
super(props, E2eMessageSharedIconViewModel.computeSnapshot(props));
|
||||
this.setupRoomStateListener();
|
||||
this.disposables.track(() => this.teardownRoomStateListener());
|
||||
}
|
||||
|
||||
public setRoomId(roomId: string): void {
|
||||
if (this.props.roomId === roomId) return;
|
||||
|
||||
this.props = { ...this.props, roomId };
|
||||
this.setupRoomStateListener();
|
||||
this.updateSnapshotFromProps();
|
||||
}
|
||||
|
||||
public setKeyForwardingUserId(keyForwardingUserId: string): void {
|
||||
if (this.props.keyForwardingUserId === keyForwardingUserId) return;
|
||||
|
||||
this.props = { ...this.props, keyForwardingUserId };
|
||||
this.updateSnapshotFromProps();
|
||||
}
|
||||
|
||||
private updateSnapshotFromProps = (): void => {
|
||||
this.snapshot.merge(E2eMessageSharedIconViewModel.computeSnapshot(this.props));
|
||||
};
|
||||
|
||||
private setupRoomStateListener(): void {
|
||||
this.teardownRoomStateListener();
|
||||
|
||||
const roomState = this.getForwardRoomState();
|
||||
if (!roomState) return;
|
||||
|
||||
// The room can change while the VM lives, so this listener needs per-room cleanup before final VM disposal.
|
||||
roomState.on(RoomStateEvent.Events, this.updateSnapshotFromProps);
|
||||
this.roomStateListenerCleanup = (): void => {
|
||||
roomState.off(RoomStateEvent.Events, this.updateSnapshotFromProps);
|
||||
};
|
||||
}
|
||||
|
||||
private teardownRoomStateListener(): void {
|
||||
this.roomStateListenerCleanup?.();
|
||||
this.roomStateListenerCleanup = undefined;
|
||||
}
|
||||
|
||||
private getForwardRoomState(): RoomState | undefined {
|
||||
return this.props.client.getRoom(this.props.roomId)?.getLiveTimeline()?.getState(EventTimeline.FORWARDS);
|
||||
}
|
||||
|
||||
private static computeSnapshot(props: E2eMessageSharedIconViewModelProps): E2eMessageSharedIconViewSnapshot {
|
||||
const displayName = E2eMessageSharedIconViewModel.getDisplayName(props);
|
||||
|
||||
return {
|
||||
displayName,
|
||||
userId: props.keyForwardingUserId,
|
||||
};
|
||||
}
|
||||
|
||||
private static getDisplayName(props: E2eMessageSharedIconViewModelProps): string {
|
||||
const roomState = props.client.getRoom(props.roomId)?.getLiveTimeline()?.getState(EventTimeline.FORWARDS);
|
||||
const forwardingMember = roomState?.getMember(props.keyForwardingUserId);
|
||||
|
||||
return forwardingMember?.rawDisplayName ?? props.keyForwardingUserId;
|
||||
}
|
||||
}
|
||||
-59
@@ -1,59 +0,0 @@
|
||||
/*
|
||||
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 { render, waitFor } from "jest-matrix-react";
|
||||
import React from "react";
|
||||
import { mocked } from "jest-mock";
|
||||
import { type RoomMember, type RoomState } from "matrix-js-sdk/src/matrix";
|
||||
|
||||
import { E2eMessageSharedIcon } from "../../../../../../src/components/views/rooms/EventTile/E2eMessageSharedIcon.tsx";
|
||||
import { createTestClient, mkStubRoom, withClientContextRenderOptions } from "../../../../../test-utils";
|
||||
|
||||
describe("E2eMessageSharedIcon", () => {
|
||||
it("renders correctly for a known user", async () => {
|
||||
const mockClient = createTestClient();
|
||||
const mockMember = { rawDisplayName: "Bob" } as RoomMember;
|
||||
const mockState = {
|
||||
getMember: (userId) => {
|
||||
expect(userId).toEqual("@bob:example.com");
|
||||
return mockMember;
|
||||
},
|
||||
} as RoomState;
|
||||
const mockRoom = mkStubRoom("!roomId", undefined, mockClient, mockState);
|
||||
mocked(mockClient.getRoom).mockImplementation((roomId) => {
|
||||
expect(roomId).toEqual("!roomId");
|
||||
return mockRoom;
|
||||
});
|
||||
|
||||
const result = render(
|
||||
<E2eMessageSharedIcon keyForwardingUserId="@bob:example.com" roomId="!roomId" />,
|
||||
withClientContextRenderOptions(mockClient),
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(result.container.firstChild).toHaveAccessibleName(
|
||||
"Bob (@bob:example.com) shared this message since you were not in the room when it was sent.",
|
||||
),
|
||||
);
|
||||
expect(result.container).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("renders correctly for an unknown user", async () => {
|
||||
const mockClient = createTestClient();
|
||||
const result = render(
|
||||
<E2eMessageSharedIcon keyForwardingUserId="@bob:example.com" roomId="!roomId" />,
|
||||
withClientContextRenderOptions(mockClient),
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(result.container.firstChild).toHaveAccessibleName(
|
||||
"@bob:example.com (@bob:example.com) shared this message since you were not in the room when it was sent.",
|
||||
),
|
||||
);
|
||||
expect(result.container).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,209 @@
|
||||
/*
|
||||
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 { EventEmitter } from "events";
|
||||
import {
|
||||
EventTimeline,
|
||||
RoomStateEvent,
|
||||
type MatrixClient,
|
||||
type Room,
|
||||
type RoomMember,
|
||||
type RoomState,
|
||||
} from "matrix-js-sdk/src/matrix";
|
||||
|
||||
import { E2eMessageSharedIconViewModel } from "../../../src/viewmodels/room/timeline/event-tile/E2eMessageSharedIconViewModel";
|
||||
|
||||
type TestRoomState = EventEmitter & {
|
||||
getMember: jest.Mock<RoomMember | null, [userId: string]>;
|
||||
};
|
||||
|
||||
const USER_ID = "@bob:example.com";
|
||||
const OTHER_USER_ID = "@alice:example.com";
|
||||
const ROOM_ID = "!roomId";
|
||||
const OTHER_ROOM_ID = "!otherRoomId";
|
||||
|
||||
function createGetMember(
|
||||
displayNames: Record<string, string | undefined>,
|
||||
): jest.Mock<RoomMember | null, [userId: string]> {
|
||||
const getMember = jest.fn((userId: string): RoomMember | null => {
|
||||
const displayName = displayNames[userId];
|
||||
if (displayName === undefined) return null;
|
||||
|
||||
return { rawDisplayName: displayName } as RoomMember;
|
||||
});
|
||||
|
||||
return getMember;
|
||||
}
|
||||
|
||||
function createRoomState(displayNames: Record<string, string | undefined> = {}): TestRoomState {
|
||||
const getMember = createGetMember(displayNames);
|
||||
|
||||
return Object.assign(new EventEmitter(), { getMember }) as TestRoomState;
|
||||
}
|
||||
|
||||
function createRoom(roomState: RoomState): Room {
|
||||
const room = {
|
||||
getLiveTimeline: jest.fn(() => ({
|
||||
getState: jest.fn((timeline: EventTimeline) => {
|
||||
expect(timeline).toBe(EventTimeline.FORWARDS);
|
||||
return roomState;
|
||||
}),
|
||||
})),
|
||||
} as unknown as Room;
|
||||
|
||||
return room;
|
||||
}
|
||||
|
||||
function createClient(roomStates: Record<string, RoomState | undefined>): MatrixClient {
|
||||
return {
|
||||
getRoom: jest.fn((roomId: string) => {
|
||||
const roomState = roomStates[roomId];
|
||||
|
||||
return roomState ? createRoom(roomState) : null;
|
||||
}),
|
||||
} as unknown as MatrixClient;
|
||||
}
|
||||
|
||||
function createViewModel(displayName?: string): {
|
||||
roomState: TestRoomState;
|
||||
vm: E2eMessageSharedIconViewModel;
|
||||
} {
|
||||
const roomState = createRoomState({ [USER_ID]: displayName });
|
||||
const client = createClient({ [ROOM_ID]: roomState as unknown as RoomState });
|
||||
const vm = new E2eMessageSharedIconViewModel({
|
||||
client,
|
||||
roomId: ROOM_ID,
|
||||
keyForwardingUserId: USER_ID,
|
||||
});
|
||||
|
||||
return { roomState, vm };
|
||||
}
|
||||
|
||||
describe("E2eMessageSharedIconViewModel", () => {
|
||||
it("builds a snapshot with the forwarding member display name", () => {
|
||||
const { vm } = createViewModel("Bob");
|
||||
|
||||
expect(vm.getSnapshot()).toMatchObject({
|
||||
displayName: "Bob",
|
||||
userId: USER_ID,
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to the user ID when the forwarding member is unknown", () => {
|
||||
const { vm } = createViewModel();
|
||||
|
||||
expect(vm.getSnapshot()).toMatchObject({
|
||||
displayName: USER_ID,
|
||||
userId: USER_ID,
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to the user ID when the room is unavailable", () => {
|
||||
const client = createClient({});
|
||||
const vm = new E2eMessageSharedIconViewModel({
|
||||
client,
|
||||
roomId: ROOM_ID,
|
||||
keyForwardingUserId: USER_ID,
|
||||
});
|
||||
|
||||
expect(vm.getSnapshot()).toMatchObject({
|
||||
displayName: USER_ID,
|
||||
userId: USER_ID,
|
||||
});
|
||||
expect(() => vm.dispose()).not.toThrow();
|
||||
});
|
||||
|
||||
it("updates when room state events change the forwarding member display name", () => {
|
||||
const { roomState, vm } = createViewModel("Bob");
|
||||
const listener = jest.fn();
|
||||
vm.subscribe(listener);
|
||||
|
||||
roomState.getMember.mockReturnValue({ rawDisplayName: "Alice" } as RoomMember);
|
||||
roomState.emit(RoomStateEvent.Events);
|
||||
|
||||
expect(vm.getSnapshot().displayName).toBe("Alice");
|
||||
expect(listener).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("updates when the key forwarding user changes", () => {
|
||||
const roomState = createRoomState({
|
||||
[USER_ID]: "Bob",
|
||||
[OTHER_USER_ID]: "Alice",
|
||||
});
|
||||
const client = createClient({ [ROOM_ID]: roomState as unknown as RoomState });
|
||||
const vm = new E2eMessageSharedIconViewModel({
|
||||
client,
|
||||
roomId: ROOM_ID,
|
||||
keyForwardingUserId: USER_ID,
|
||||
});
|
||||
const listener = jest.fn();
|
||||
vm.subscribe(listener);
|
||||
|
||||
vm.setKeyForwardingUserId(OTHER_USER_ID);
|
||||
|
||||
expect(vm.getSnapshot()).toMatchObject({
|
||||
displayName: "Alice",
|
||||
userId: OTHER_USER_ID,
|
||||
});
|
||||
expect(listener).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("updates and rebinds its room state listener when the room changes", () => {
|
||||
const originalRoomState = createRoomState({ [USER_ID]: "Bob" });
|
||||
const newRoomState = createRoomState({ [USER_ID]: "Alice" });
|
||||
const client = createClient({
|
||||
[ROOM_ID]: originalRoomState as unknown as RoomState,
|
||||
[OTHER_ROOM_ID]: newRoomState as unknown as RoomState,
|
||||
});
|
||||
const vm = new E2eMessageSharedIconViewModel({
|
||||
client,
|
||||
roomId: ROOM_ID,
|
||||
keyForwardingUserId: USER_ID,
|
||||
});
|
||||
const listener = jest.fn();
|
||||
vm.subscribe(listener);
|
||||
|
||||
vm.setRoomId(OTHER_ROOM_ID);
|
||||
|
||||
expect(originalRoomState.listenerCount(RoomStateEvent.Events)).toBe(0);
|
||||
expect(newRoomState.listenerCount(RoomStateEvent.Events)).toBe(1);
|
||||
expect(vm.getSnapshot().displayName).toBe("Alice");
|
||||
expect(listener).toHaveBeenCalledTimes(1);
|
||||
|
||||
originalRoomState.getMember.mockReturnValue({ rawDisplayName: "Ignored" } as RoomMember);
|
||||
originalRoomState.emit(RoomStateEvent.Events);
|
||||
|
||||
expect(vm.getSnapshot().displayName).toBe("Alice");
|
||||
expect(listener).toHaveBeenCalledTimes(1);
|
||||
|
||||
newRoomState.getMember.mockReturnValue({ rawDisplayName: "Carol" } as RoomMember);
|
||||
newRoomState.emit(RoomStateEvent.Events);
|
||||
|
||||
expect(vm.getSnapshot().displayName).toBe("Carol");
|
||||
expect(listener).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("does not emit updates when setters receive unchanged values", () => {
|
||||
const { vm } = createViewModel("Bob");
|
||||
const listener = jest.fn();
|
||||
vm.subscribe(listener);
|
||||
|
||||
vm.setRoomId(ROOM_ID);
|
||||
vm.setKeyForwardingUserId(USER_ID);
|
||||
|
||||
expect(listener).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("removes its room state listener when disposed", () => {
|
||||
const { roomState, vm } = createViewModel("Bob");
|
||||
expect(roomState.listenerCount(RoomStateEvent.Events)).toBe(1);
|
||||
|
||||
vm.dispose();
|
||||
|
||||
expect(roomState.listenerCount(RoomStateEvent.Events)).toBe(0);
|
||||
});
|
||||
});
|
||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
@@ -251,6 +251,7 @@
|
||||
"label": "Message Actions",
|
||||
"view_in_room": "View in room"
|
||||
},
|
||||
"message_shared_by": "%(displayName)s (%(userId)s) shared this message since you were not in the room when it was sent.",
|
||||
"message_timestamp_received_at": "Received at: %(dateTime)s",
|
||||
"message_timestamp_sent_at": "Sent at: %(dateTime)s",
|
||||
"mjolnir": {
|
||||
|
||||
@@ -40,6 +40,7 @@ export * from "./room/timeline/DateSeparatorView";
|
||||
export * from "./room/timeline/TimelineSeparator";
|
||||
export * from "./room/timeline/event-tile/actions/ActionBarView";
|
||||
export * from "./room/timeline/event-tile/EventTileView/DisambiguatedProfile";
|
||||
export * from "./room/timeline/event-tile/EventTileView/E2eMessageSharedIcon";
|
||||
export * from "./room/timeline/event-tile/EventTileView/E2ePadlock";
|
||||
export * from "./room/timeline/event-tile/EventTileView/EncryptionEventView";
|
||||
export * from "./room/timeline/event-tile/call";
|
||||
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
/*
|
||||
* 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 React, { type JSX } from "react";
|
||||
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { useMockedViewModel } from "../../../../../core/viewmodel";
|
||||
import { withViewDocs } from "../../../../../../.storybook/withViewDocs";
|
||||
import { E2eMessageSharedIconView, type E2eMessageSharedIconViewSnapshot } from "./E2eMessageSharedIconView";
|
||||
|
||||
type E2eMessageSharedIconProps = E2eMessageSharedIconViewSnapshot & { className?: string };
|
||||
|
||||
const E2eMessageSharedIconViewWrapperImpl = ({ className, ...rest }: E2eMessageSharedIconProps): JSX.Element => {
|
||||
const vm = useMockedViewModel(rest, {});
|
||||
|
||||
return <E2eMessageSharedIconView vm={vm} className={className} />;
|
||||
};
|
||||
const E2eMessageSharedIconViewWrapper = withViewDocs(E2eMessageSharedIconViewWrapperImpl, E2eMessageSharedIconView);
|
||||
|
||||
const meta = {
|
||||
title: "Timeline/Timeline Event/E2eMessageSharedIconView",
|
||||
component: E2eMessageSharedIconViewWrapper,
|
||||
tags: ["autodocs"],
|
||||
args: {
|
||||
displayName: "Bob",
|
||||
userId: "@bob:example.com",
|
||||
className: "",
|
||||
},
|
||||
} satisfies Meta<typeof E2eMessageSharedIconViewWrapper>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {};
|
||||
|
||||
export const UnknownUser: Story = {
|
||||
tags: ["skip-test"],
|
||||
args: {
|
||||
displayName: "@bob:example.com",
|
||||
userId: "@bob:example.com",
|
||||
},
|
||||
};
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
/*
|
||||
* 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 React from "react";
|
||||
import { composeStories } from "@storybook/react-vite";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { render, screen } from "@test-utils";
|
||||
|
||||
import { MockViewModel } from "../../../../../core/viewmodel";
|
||||
import {
|
||||
E2eMessageSharedIconView,
|
||||
type E2eMessageSharedIconViewModel,
|
||||
type E2eMessageSharedIconViewSnapshot,
|
||||
} from "./E2eMessageSharedIconView";
|
||||
import * as stories from "./E2eMessageSharedIcon.stories";
|
||||
|
||||
const { Default, UnknownUser } = composeStories(stories);
|
||||
|
||||
describe("E2eMessageSharedIconView", () => {
|
||||
it("renders the default shared-message icon", () => {
|
||||
const { container } = render(<Default />);
|
||||
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("renders an unknown user tooltip state", () => {
|
||||
const { container } = render(<UnknownUser />);
|
||||
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("applies a custom className to the icon container", () => {
|
||||
const vm = new MockViewModel<E2eMessageSharedIconViewSnapshot>({
|
||||
displayName: "Bob",
|
||||
userId: "@bob:example.com",
|
||||
}) as E2eMessageSharedIconViewModel;
|
||||
|
||||
render(<E2eMessageSharedIconView vm={vm} className="custom-shared-icon" />);
|
||||
|
||||
expect(screen.getByTestId("e2e-padlock")).toHaveClass("custom-shared-icon");
|
||||
});
|
||||
|
||||
it("uses the shared translation for the icon accessible name", () => {
|
||||
render(<Default />);
|
||||
|
||||
expect(screen.getByTestId("e2e-padlock")).toHaveAccessibleName(
|
||||
"Bob (@bob:example.com) shared this message since you were not in the room when it was sent.",
|
||||
);
|
||||
});
|
||||
});
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
/*
|
||||
* 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 React, { type JSX } from "react";
|
||||
|
||||
import { _t } from "../../../../../core/i18n/i18n";
|
||||
import { type ViewModel, useViewModel } from "../../../../../core/viewmodel";
|
||||
import { E2ePadlock, E2ePadlockIcon } from "../E2ePadlock";
|
||||
|
||||
export interface E2eMessageSharedIconViewSnapshot {
|
||||
/**
|
||||
* Display name for the user who shared keys for the message.
|
||||
*/
|
||||
displayName: string;
|
||||
/**
|
||||
* User ID for the user who shared keys for the message.
|
||||
*/
|
||||
userId: string;
|
||||
}
|
||||
|
||||
export type E2eMessageSharedIconViewModel = ViewModel<E2eMessageSharedIconViewSnapshot>;
|
||||
|
||||
interface E2eMessageSharedIconViewProps {
|
||||
/**
|
||||
* ViewModel providing the localized tooltip.
|
||||
*/
|
||||
vm: E2eMessageSharedIconViewModel;
|
||||
/**
|
||||
* Optional CSS class name applied to the icon container.
|
||||
*/
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the end-to-end encryption icon used for messages whose keys were
|
||||
* shared by another room member.
|
||||
*/
|
||||
export function E2eMessageSharedIconView({ vm, className }: Readonly<E2eMessageSharedIconViewProps>): JSX.Element {
|
||||
const { displayName, userId } = useViewModel(vm);
|
||||
// We always disambiguate the user, since we need to prevent users from forging a disambiguation, and
|
||||
// the ToolTip component doesn't support putting styling inside a label.
|
||||
const tooltip = _t("timeline|message_shared_by", { displayName, userId });
|
||||
|
||||
return <E2ePadlock className={className} icon={E2ePadlockIcon.Normal} title={tooltip} />;
|
||||
}
|
||||
+5
-5
@@ -1,11 +1,11 @@
|
||||
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`E2eMessageSharedIcon renders correctly for a known user 1`] = `
|
||||
exports[`E2eMessageSharedIconView > renders an unknown user tooltip state 1`] = `
|
||||
<div>
|
||||
<div
|
||||
aria-label="State of the end-to-end encryption"
|
||||
aria-labelledby="react-use-id-1"
|
||||
class="_e2ePadlock_1hynz_8 _normal_1hynz_21 mx_EventTile_e2eIcon"
|
||||
class="E2ePadlock-module_e2ePadlock E2ePadlock-module_normal"
|
||||
data-testid="e2e-padlock"
|
||||
role="img"
|
||||
tabindex="0"
|
||||
@@ -30,12 +30,12 @@ exports[`E2eMessageSharedIcon renders correctly for a known user 1`] = `
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`E2eMessageSharedIcon renders correctly for an unknown user 1`] = `
|
||||
exports[`E2eMessageSharedIconView > renders the default shared-message icon 1`] = `
|
||||
<div>
|
||||
<div
|
||||
aria-label="State of the end-to-end encryption"
|
||||
aria-labelledby="react-use-id-1"
|
||||
class="_e2ePadlock_1hynz_8 _normal_1hynz_21 mx_EventTile_e2eIcon"
|
||||
class="E2ePadlock-module_e2ePadlock E2ePadlock-module_normal"
|
||||
data-testid="e2e-padlock"
|
||||
role="img"
|
||||
tabindex="0"
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
export {
|
||||
E2eMessageSharedIconView,
|
||||
type E2eMessageSharedIconViewModel,
|
||||
type E2eMessageSharedIconViewSnapshot,
|
||||
} from "./E2eMessageSharedIconView";
|
||||
Reference in New Issue
Block a user