diff --git a/apps/web/src/components/views/rooms/EventTile.tsx b/apps/web/src/components/views/rooms/EventTile.tsx index 6bda81145e..239509d2ab 100644 --- a/apps/web/src/components/views/rooms/EventTile.tsx +++ b/apps/web/src/components/views/rooms/EventTile.tsx @@ -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): 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 ( + + ); +} + // MUST be rendered within a RoomContext with a set timelineRenderingType export class UnwrappedEventTile extends React.Component { private suppressReadReceiptAnimation: boolean; @@ -685,7 +730,7 @@ export class UnwrappedEventTile extends React.Component return null; case "messageShared": return ( - diff --git a/apps/web/src/components/views/rooms/EventTile/E2eMessageSharedIcon.tsx b/apps/web/src/components/views/rooms/EventTile/E2eMessageSharedIcon.tsx deleted file mode 100644 index 3ea0474850..0000000000 --- a/apps/web/src/components/views/rooms/EventTile/E2eMessageSharedIcon.tsx +++ /dev/null @@ -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 ( - - ); -} diff --git a/apps/web/src/i18n/strings/en_EN.json b/apps/web/src/i18n/strings/en_EN.json index 66af19497e..af72bc3c83 100644 --- a/apps/web/src/i18n/strings/en_EN.json +++ b/apps/web/src/i18n/strings/en_EN.json @@ -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", diff --git a/apps/web/src/viewmodels/room/timeline/event-tile/E2eMessageSharedIconViewModel.ts b/apps/web/src/viewmodels/room/timeline/event-tile/E2eMessageSharedIconViewModel.ts new file mode 100644 index 0000000000..c992b4f54b --- /dev/null +++ b/apps/web/src/viewmodels/room/timeline/event-tile/E2eMessageSharedIconViewModel.ts @@ -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 + 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; + } +} diff --git a/apps/web/test/unit-tests/components/views/rooms/EventTile/E2eMessageSharedIcon-test.tsx b/apps/web/test/unit-tests/components/views/rooms/EventTile/E2eMessageSharedIcon-test.tsx deleted file mode 100644 index 3ea0924d58..0000000000 --- a/apps/web/test/unit-tests/components/views/rooms/EventTile/E2eMessageSharedIcon-test.tsx +++ /dev/null @@ -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( - , - 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( - , - 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(); - }); -}); diff --git a/apps/web/test/viewmodels/event-tiles/E2eMessageSharedIconViewModel-test.ts b/apps/web/test/viewmodels/event-tiles/E2eMessageSharedIconViewModel-test.ts new file mode 100644 index 0000000000..7aa334541f --- /dev/null +++ b/apps/web/test/viewmodels/event-tiles/E2eMessageSharedIconViewModel-test.ts @@ -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; +}; + +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, +): jest.Mock { + 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 = {}): 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): 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); + }); +}); diff --git a/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/EventTileView/E2eMessageSharedIcon/E2eMessageSharedIcon.stories.tsx/default-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/EventTileView/E2eMessageSharedIcon/E2eMessageSharedIcon.stories.tsx/default-auto.png new file mode 100644 index 0000000000..391b8bda9b Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/EventTileView/E2eMessageSharedIcon/E2eMessageSharedIcon.stories.tsx/default-auto.png differ diff --git a/packages/shared-components/src/i18n/strings/en_EN.json b/packages/shared-components/src/i18n/strings/en_EN.json index 42e73991c4..823efba081 100644 --- a/packages/shared-components/src/i18n/strings/en_EN.json +++ b/packages/shared-components/src/i18n/strings/en_EN.json @@ -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": { diff --git a/packages/shared-components/src/index.ts b/packages/shared-components/src/index.ts index ed9db2e38d..36c124e522 100644 --- a/packages/shared-components/src/index.ts +++ b/packages/shared-components/src/index.ts @@ -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"; diff --git a/packages/shared-components/src/room/timeline/event-tile/EventTileView/E2eMessageSharedIcon/E2eMessageSharedIcon.stories.tsx b/packages/shared-components/src/room/timeline/event-tile/EventTileView/E2eMessageSharedIcon/E2eMessageSharedIcon.stories.tsx new file mode 100644 index 0000000000..a4dc6a6550 --- /dev/null +++ b/packages/shared-components/src/room/timeline/event-tile/EventTileView/E2eMessageSharedIcon/E2eMessageSharedIcon.stories.tsx @@ -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 ; +}; +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; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +export const UnknownUser: Story = { + tags: ["skip-test"], + args: { + displayName: "@bob:example.com", + userId: "@bob:example.com", + }, +}; diff --git a/packages/shared-components/src/room/timeline/event-tile/EventTileView/E2eMessageSharedIcon/E2eMessageSharedIcon.test.tsx b/packages/shared-components/src/room/timeline/event-tile/EventTileView/E2eMessageSharedIcon/E2eMessageSharedIcon.test.tsx new file mode 100644 index 0000000000..9f743bd020 --- /dev/null +++ b/packages/shared-components/src/room/timeline/event-tile/EventTileView/E2eMessageSharedIcon/E2eMessageSharedIcon.test.tsx @@ -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(); + + expect(container).toMatchSnapshot(); + }); + + it("renders an unknown user tooltip state", () => { + const { container } = render(); + + expect(container).toMatchSnapshot(); + }); + + it("applies a custom className to the icon container", () => { + const vm = new MockViewModel({ + displayName: "Bob", + userId: "@bob:example.com", + }) as E2eMessageSharedIconViewModel; + + render(); + + expect(screen.getByTestId("e2e-padlock")).toHaveClass("custom-shared-icon"); + }); + + it("uses the shared translation for the icon accessible name", () => { + render(); + + expect(screen.getByTestId("e2e-padlock")).toHaveAccessibleName( + "Bob (@bob:example.com) shared this message since you were not in the room when it was sent.", + ); + }); +}); diff --git a/packages/shared-components/src/room/timeline/event-tile/EventTileView/E2eMessageSharedIcon/E2eMessageSharedIconView.tsx b/packages/shared-components/src/room/timeline/event-tile/EventTileView/E2eMessageSharedIcon/E2eMessageSharedIconView.tsx new file mode 100644 index 0000000000..7b21f61e41 --- /dev/null +++ b/packages/shared-components/src/room/timeline/event-tile/EventTileView/E2eMessageSharedIcon/E2eMessageSharedIconView.tsx @@ -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; + +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): 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 ; +} diff --git a/apps/web/test/unit-tests/components/views/rooms/EventTile/__snapshots__/E2eMessageSharedIcon-test.tsx.snap b/packages/shared-components/src/room/timeline/event-tile/EventTileView/E2eMessageSharedIcon/__snapshots__/E2eMessageSharedIcon.test.tsx.snap similarity index 83% rename from apps/web/test/unit-tests/components/views/rooms/EventTile/__snapshots__/E2eMessageSharedIcon-test.tsx.snap rename to packages/shared-components/src/room/timeline/event-tile/EventTileView/E2eMessageSharedIcon/__snapshots__/E2eMessageSharedIcon.test.tsx.snap index 06a36a821b..2bfb63d23a 100644 --- a/apps/web/test/unit-tests/components/views/rooms/EventTile/__snapshots__/E2eMessageSharedIcon-test.tsx.snap +++ b/packages/shared-components/src/room/timeline/event-tile/EventTileView/E2eMessageSharedIcon/__snapshots__/E2eMessageSharedIcon.test.tsx.snap @@ -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`] = `