From 34c388f760248b921973e3e403f061dbab6c50a1 Mon Sep 17 00:00:00 2001 From: Zack Date: Thu, 28 May 2026 10:01:39 +0200 Subject: [PATCH] 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 * 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 --- .../src/components/views/rooms/EventTile.tsx | 49 +++- .../rooms/EventTile/E2eMessageSharedIcon.tsx | 54 ----- apps/web/src/i18n/strings/en_EN.json | 1 - .../E2eMessageSharedIconViewModel.ts | 92 ++++++++ .../EventTile/E2eMessageSharedIcon-test.tsx | 59 ----- .../E2eMessageSharedIconViewModel-test.ts | 209 ++++++++++++++++++ .../default-auto.png | Bin 0 -> 16888 bytes .../src/i18n/strings/en_EN.json | 1 + packages/shared-components/src/index.ts | 1 + .../E2eMessageSharedIcon.stories.tsx | 46 ++++ .../E2eMessageSharedIcon.test.tsx | 54 +++++ .../E2eMessageSharedIconView.tsx | 49 ++++ .../E2eMessageSharedIcon.test.tsx.snap | 10 +- .../E2eMessageSharedIcon/index.tsx | 12 + 14 files changed, 516 insertions(+), 121 deletions(-) delete mode 100644 apps/web/src/components/views/rooms/EventTile/E2eMessageSharedIcon.tsx create mode 100644 apps/web/src/viewmodels/room/timeline/event-tile/E2eMessageSharedIconViewModel.ts delete mode 100644 apps/web/test/unit-tests/components/views/rooms/EventTile/E2eMessageSharedIcon-test.tsx create mode 100644 apps/web/test/viewmodels/event-tiles/E2eMessageSharedIconViewModel-test.ts create mode 100644 packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/EventTileView/E2eMessageSharedIcon/E2eMessageSharedIcon.stories.tsx/default-auto.png create mode 100644 packages/shared-components/src/room/timeline/event-tile/EventTileView/E2eMessageSharedIcon/E2eMessageSharedIcon.stories.tsx create mode 100644 packages/shared-components/src/room/timeline/event-tile/EventTileView/E2eMessageSharedIcon/E2eMessageSharedIcon.test.tsx create mode 100644 packages/shared-components/src/room/timeline/event-tile/EventTileView/E2eMessageSharedIcon/E2eMessageSharedIconView.tsx rename apps/web/test/unit-tests/components/views/rooms/EventTile/__snapshots__/E2eMessageSharedIcon-test.tsx.snap => packages/shared-components/src/room/timeline/event-tile/EventTileView/E2eMessageSharedIcon/__snapshots__/E2eMessageSharedIcon.test.tsx.snap (83%) create mode 100644 packages/shared-components/src/room/timeline/event-tile/EventTileView/E2eMessageSharedIcon/index.tsx 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 0000000000000000000000000000000000000000..391b8bda9b4e463dc4851e475491775144dca654 GIT binary patch literal 16888 zcmZ8pc|c9+`#<-%)h$amNyu_+g{V6-mXJEMS(2;`D%Th)k&94-bIceqmdqHkUkn;+ z6eZ+*XN+Zp>|5&2*s_OE*?yn1-gAF{xt;r-=Y5{fv%Jsqyw9~bG9s+5QxhkGAnNw( z8$5&{YHeHGxxL5Z92C5v)%Isss2OaUOw`_ znB#lm-22x@_Pnh=b~5f|*@HKq_8f{$98_vNUNQf35JPB4txiwT!Us=%%GlKBfxag` z9(v%q`Qz>fv*vvXI_D7h@xYz3`Bkq1N5T2zh1fG z^Q+f)U#5I`z5c{I_CeIVYWFGgPdr^zdL*rK#pmSei@slObZkETe8MQzzPj)>CJ!dC z`j3k~fi-`&*z@nz15 zxavOxKVSJ~!Iac<>(|^kUQux8eYY>p*`=@l`}y4H(vSb~;_)xh6Ew$NMYX(f0=S#2 zRzB7>g}G@TUFRS8Sah^(#)l&>qYXwJOeksZ;am<(!MJk6fHTw)%P5hvL`&bDlq``fgy=nb)^YRDXCd_vNR@56=4* zCQNMem$Y+hT_($mJ;5CSchf<(UV(4#otyHh#$om54`*A9o_8;*?8L`E=ae2l_G*3M ztA~kS6kGbbcKJsB-PLiLs(pXoDSWhOrxo)_tP5bufF7jmI5yCvEzs4r7n{Hp3)n5Yt{MJ z*E20vR>^zO)KTU$4|ac=2-yDLH&#JX?UExHt}%$$S-L_6(wTPVIPwvQzv)^i?j-I^ z`)KaQHqbM^ud9AhYe0KY7yWc7QRsPpj+HoGY1GG9LHM6sUPq8qq=EPBpj$G1?Ed0L z3U~tBu6kzk;FPb{F{z3f;e#oPCJJ}|+l8H~4ez7K#BjC1wkADKt6NGL?XBCc0NbZM z1Hd+&q`50uvR1bNY!5nEmBF)p(5ZaA#J(v4n0VcrF1(*Hh(0YrYzN}x^8F$oY&bc) ztpNBM5I+}CDE^_<-PaelJDL#Qwq4eo~;Kqcs~{sO%v$ z5$pIsExe0eWw7x$LmVspr=L|6t?p#~;`5#wO^WN9zZCTN5Bd;eVhgfj#8;10T0_j) z&KWg+59)QcIvNjHUEpnh=6wHlwoy9{!sK)=JmYB|quw(b*5muQYB}9pv$4KpS-cuT zpa$}OBB}jaSH4maiJJkW3Rb#;tMpFho-!eJI7wD?@La?LNbO<@MIbd~BKABqr?f_;K}T=2?1MIl<=Nc7iqRm}XeV%#+dJ zS;_{krCT3#Wc|nr3E1LL+Ku19 z4{*2eOxEz0E}$`4^B(Y+e`JX0K9`z_5BYbVIOeY<*ghm_YT5S&zfvL;8N_YW{^O|Q z9(25`HbM3vndIGpBHSM&H6pwm5=rtD;lS36N-dMKE-2O&38- z;iabTfW)JKSHKLrk_bhjV4Cpxv%Ee}AgirKJ{#!v8cqJH-{*)hrE0oKhG7RaEXdp^ z8GC=Hd=c+e2pi{S7c7(5iwxyD@$yDSgLuS~J~DUgHj=kHx-N+Sw5zAATdnSydqo=! z`5Of4pS@3xNxf{D{zg)*PsDp;$Q{f>6SKHweoU`BTvy!W_e&5a=Tb$T{W-c$Hm z*HZ6KEHYfy+AOXEyu)I*mRdo=mgYAwS1={7Y+iW-w#OsfS9t^)0+H3dRe!V`+%1Ut$?MiJ#TLe?y8S;z4CLUA9O zNZPW;UJ;#rL*gtnuVjc4-6&Oz(MC8{Ev0)xHyaQ2mpBt%%J;Rz48`O2dEcoYQx(I^ zZ>MPT59QCcd0ZSqeM`Lf&fHGfk^d0RgFWhCtS#8DzI#v5LhfWGA*Gexngb%+fEGu`-5#7-vz zUCMj?L6pp?^~5%kyRsKF`Dd3du^Jt3HEam+qB_jnRZT(H86LELuygYDYu@|_ zg#_&l`o=06^1CqVb{o=^yR3YXxSu5SE?wt(-0%q|YjP}blaJUlrfr85Zn&BVRXh*^ zh6auq>#E=b^vpQ6ONNelzE)c#ol`udbQ}bHL$2~NEMnR#Hmj>dnEvpRc0e-dV+@c3 z4XcOcr+0~80OsyFWiFL?)Z@fme$a6)PmfC{CrDwL!~w;w-Xa`%lwmcKAfQEe#A$v# zAXzkg-O^UZdOUd@3~|xu#Xrf2R@;U1W|WQ%Pwp*WETci?Fr|)q9prY1S$bGrjKr}) zaThipi%ag7%WU!QPayUnOG^8mA<8xLdg@{?sfUDm5dhuO)!+)dUB9&YsXA60~5@cHG%3^%@Pxt-P zVB*5zuRbZ11&i@v|J0;JIX738raW`kz}znPecGbV=aj{8YQ-P|4rV zw4#W(TdM+i&!Dz4n?%E5gRyw)_yln?)D(3EInT7Eh)1{AB#Caswj*lG{yj$tdAkfE^tlOmc+-#?;febwa-tqXqT@ zYv;(}$3kbQSEFbnO*of-K~c&Z(a$IFL!rhS^g5m)p@&sEytB^Djn^V6(SiSFW)rCS4|!oWM0N}ur7ZM z^;OS`CrRzZ+WCsYjXDUD-lYj2rr&By!X+||5T=`t|Mj83Q4{iolpxq6L^+JwF<8Ke z9;wL_Ysmw^=)k*#CL%`WRg92?8~s2!|FGd5suX6Q+LaeA0>TjgS^tx#uQ0gYEPCnB~lcS^CgF8hM?nL|zZZ0&=czYtS-G!UAb&&YYmhc31k zxk<7ddn7atvHLCw+;LqsI|UQwZo)Qhe(2In+$?(p*PUmlQ7mZ8``cd5;-cM6ygI)@ zZuiZkTh?ya8f_Xfq&A?@=I5@e*nEWyXlqyjL@(|es5bd=;Vb!P6~8&v*?t*yeF{0CQQ)fg5T#DZ?IY_H1IF^Fv1!oVukj=M~3d zawf}FgBTwE2NU?8`y_wzsdj@k!W#-f-Yx5=qW2(ed?qg>fr z!bzgAYcGC{#8Mo+0XcHq@Cr~7qV!O)vpP*ySAYw<(CkTUnP$cB3MrCqGn87Sz)z` z0A)a_WsU6c;ri`6zi$MaNh%l?`wDSVZF$C)@YPD2<%RIcowsJm6h~CIH))w{UH$B&i_~6aQAYuPTFFN<6P(e=l*rru%S9`Mc*`eOL3Fd zPDO4^Sc!S9$esj-@i%mdmLx0?~GMJEx z%szp?PPrReDi7HxSX7kDN4^qiT{}}Q<7mrbhWL2$e?l$SXec*KHp4jV_l=XUz#11% zK9nNJ3#0+Fx*U^~cgn>e=B6pAMgoafVpR?UT);Hu-?SV6@tY^TB%&Jfh+Q&FkNp+I z`!!S@QD-`sr;yqUaD_+Tr^&SwP@)4g&0OO_2p3kY~L;2o>Bqjc&2v(Biil zL6(VY$co{u9Mn%~qn15Tt3v1TbuBd!T?5o?KO0UR3szTsd?-T8KIz|U-;z7jM=sY3 zebMHNx@kjQ4C=7&3}hFzDs`nY#&B9IHxUR9UXf`?VAvvZw&IHY=0T=2UI67)tkncodT+yW1Jc9LAesz7L@4HjR(d zBB8^~3jmRg1S0nD`+7xqHjAujMH~0R)JFUxM0?5Z|8p!)=j;(?YOUU86$RyH8}pjU zV5O_CcxEUY{KP%<(OS-b-`wgruyS@OInK_q)T3A`cqPN{47}GH^4f28QaIs1Y(S}Z z@lKWJ8b(T7#cxeG(hJYr8$im%g2)g8v!-fY20A+FzUCmS1A60tbnCku#y}lpN)`2A zhfzJ+UzQ9kPTK!br0os1hs$> z3UA|40C=~E@05$;&$Sx{MRPs?(0#qoDHnrGW&OxC1bZ9+#&*9b>5_k`IF=3}9l&_4 zl;)~#A43n&99j;>Pk;26RV;2WSz9m4g>iN*kb;+l`q#Sq^|;1Az7@rhxoHW)`ox*iaCGJ9lnac@{aZ^u ze59n9PxnN*#r`eZ3Yor3=o)Jn$P)Uu{lUuiHg|)pP|Yb^q}q2ln?5}E zzcqsHA`0~Hh_rojFF<6jDNa!5SE-q_w`PVc9QOCa;nTvc(jQ5*-7rbjJXbWj$xOKu zs9kMf5^omqEsa~h{H5DQ^90PxnbERN{)VQ$Tje7rIo59ODpW!PCKgEdlG2?D3KHEO&FSLZHO61w!9T6iDdiM#7Kwr9Yi0 z5f`is-Q2_bfc=dd&uy^Jjxg7>*W~v&pk9m?MQ>^*v7Gwd!RB$JJ~UD9Sm<7rZvl|* z;CaLL_5V2KE9V`LcL)OZ)(t+bBl~l`R9misjSl#^U8U_Xf|*lm6JRsY`{@ zPoQyuo4DO7s-lxn`scCH+S=Qz62)bLk*(*z)iYm56$@b7zdk!47cI@!w4UFZ&xRdF<&02SDlBdjba?Uld0a@SQ#*P% zcI&YJoZ}uh({fc*xecG4JQkNurWj6@u8a4Y{b9kDUl?l|bsNXsyM?Of5f{N1J50;QcRvXSpnA++0+cCNU*$j8WEnONJoBG`ug%vndh zx}7UX&n+bKnWT zq{@+>&x&PUi+02{C3o-zB>NfLeQ8!MzKIO6fZ^+DJ@2*$><22Ld#Se(Mfp~!dvMn+ z&Q^nN?UcEgrqF>X{yMjhOavz_QnbZw(M`L(C`oONh9z`7yY z)*z$3<{Rr_p+oNK@a=+zD5pnr$sz%~^c7%kG)L%Gg}NEsMH8c?4sH{{%=s!q6jbiu z3U3?RXmSL!ln$=QL!%(B0l&c7!4-YvVC{0gE2HY*3OLcRnvJfMUe&=B$p~X2HXU5i z1deEvJ#0I;0yospbX9O7wmOx|lTR6;Se(;g8Sf3WzowaVHm}9v@H>~qXo0%I zU4%Oviw_%~ORn>kHd%;;)mWU?^U7Buue3Rjsll#&+oL@ky?%$1jMO-0g){UBn9TWw_t%fyUI38%7CgPX>M%VNpO*L*!thULcd0p4E zHka7J@Be{J0%V8Pmccf$vASS@IT4hJke>rlR3{Z zSYK?dOxYi?_|NSL7`!MXcPXB7AK`ZTi-9*Fe|cN8kAw)d7dA9=MiaiiWZ#h;B>dJw zeMNV!^b}Ubw>LKwKJ-O-=rzXtPQ;DvO77xA35~HCzc2L$N~WgI>SWKyox0Cr6k--2u~7u@gIgmkMRC^Da~$Lw_v{iMOvN%n*nl%YKEv+LLh7~3-Ner` z;0wbqkneKW^Wu@DOo7yaUY!p|-jkpkuPqT4+=FA^dwx}#03@U#5H16xGZMQfvx^vG z8dRTeKN0>nNPvxnZEI}iFtw*pv7f#*{&7Fs8IUc|$(HL0nbXK5Ywwgcc1GQs zyKLPe+YyjPk!U2F{s?VFTQV7I``{Gp`E<$dKd`%c0%R;C85@;o;PJibW07)0jj>cF6#3yt?AG^AE8`wBE(&6?Cl@=U3fYjgsQC0#s}K?<>zn;C+JVV5T5R7Gtl;$rUP z93hIZN6Ay<95F!>=3;)W`%G0L!>NqvNhh#$mN;hq_0tm$Vb4&ute2)h~W+3_ySqnkOZ zsR6j;aqg-nrRI4p)k;Qc$anMLiH*BKiKwj`JD}BZkQ&8`_xz!a$#&E0D<=wd%p=< z$rOZL!#kmSfBf^>pSp?XzsO<^j_8~(cA4|*X@*J%7*9D{$9>lE7f~D|BFK|Sf-QR3 z@xjZmk2x!F%s-ArDLCeb3L*ChZD4T4BmO%~^bj9Q{^avNydY125bPXyMX!3&MGiYy zTO?Lq&B3&D$T8U>@4Fj>ifIEbaR-^J!X_evf|kfuLq%T~1RR-RrX@Z0PhaacPKJFlo;mW&0;w@aH#&64s)r#2sJL1G(LGQ+G zRTN-r3m~KBbktgHr4Uvn%-M60aeRxi`e$02)%QjFU~5r}n_`m!=ChU19+<<8cHM)3 zd_LJjF_v!;XZHU%O_(fd<)n-wW#f`IcFF_HjAFO+wq_5}7LC6`#>MBxihof{m;hk| zj*r8;GvCOZeI|Pd_R|a;*6X}N35=BuBKc7L0R}raX_NJYFAk%JliHc^)}xR8RY?V% zsCkR#80KorQ{_7#0mSFpVLjTRhpDZUTj!!ebM=0)K}0}OxoXNEO!cKQdJtX}*IKDn zrqhD(4;{Y?^WdA!qCE&)*9F)19m877qvaC&cfj@J*a+8jX|!B&m9F4={aJtboH$x8 zk$MK=@3T_*fqr4MT-x9!;C}hz8)2Zo5~JnPw%KEF4>09XKLOWbrJ-xdfacRLN(22$ z6PFu|K#*mABpZ*^#AQk3qnh9-1#YQ{`wxtMeABzqQOw(!(8O)$iGKCAWET}Cd=uCF zJNj)yuEa>X6uxceW!dp3h>wlDp~?>-vLpL?7G|{v-c>0LB5CY~*jbo)D4Nx{;wsrJ+d`P3nWV zV+cPWjE3QQ85yk;j@*S*46I&5cF~sfllW=ph3Vm(WHFkz^R{#q4ceRq*;ic|FUpP) z?e_wf>}3#782nzAeR=JCp@Z51z`8}o$V4eqFqeZ@vrwwtm^#CH5V3Pf7`u5Lh+`C2 z1?(g@(hg$5yKE&hc z)9e&Uc)gn!!M=iG24bGNp+W3#@KAUG3fE%?0aoTv?y977b{+@>&iRceVC<@SBjuJc z;BDW>C1P!pstp%U>)BB2@owe-Q3}HkOk@5vih;ox9Y@IyyR?&$FS1WU+2fw|c9%Ai ze(}emOJK?z9&erB<5Eb^R{DOJea)?VoY$EI5*i017a->Sui;kpwT*6ROZ^lljbCX* zvef*Q-f`2ev$r^XI%-=s>h_>Wjrz*1`+ebOD+>8?oVl}3Zr+`npoR{=E+Sq$-Cv`W z6$0~S++ml@wQE9Q^vTh>JQxDDp48hJq|`T;I}IFeAfqrk~)qG`a+Sw?iw1Sa1EQpAI}bL@!w_tO0pxR3sA{79B5F zx)PYwEX4S=H(J9n%TCFQF@Eh@{1tl!i|;=#S6%k;zw@h$CV|74jCa<>=oAG}^VX22 zkIf#dvPKBwq#IV3v#XhIog)<_Y=?Vl_p$z|i=C|q@y3C3ziE8qafiNZqIQ*&(8{>y zLEX0LGSwI9CUBSkTYNhDZLU^RNQc~EB};IXe5Rv$mQ020*`$>)5GwAj`y7{Y* zBA?L6WjkZ>y5R}d=9|#SJ%gAQcYAfLvRE7t!N8a4;KBser7wTe@p?u};?bS8%T@@UJf)CIG)?J#WYiUF!Hcp0Kr-J zthut)@V1)ka%M~jap6Re%EoxNbYg=vDSv$mJ6`#AL*v6ImkZJL1x81|< zs#jBMAXDO)f@Q7Ds+&3OWh{O?V#iqWlbz~lptfQ0)Wq4I{I5Q4D~jj`0Dk}Z3O~(R zqsx-Zq%#2E(cO(gaf$0n=~q(x% i0Z;KSVb3d~7BS<=xCON`a!c`q=ob { + 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`] = `