diff --git a/src/components/views/rooms/EventTile.tsx b/src/components/views/rooms/EventTile.tsx index 4355eacad9..91b76ee631 100644 --- a/src/components/views/rooms/EventTile.tsx +++ b/src/components/views/rooms/EventTile.tsx @@ -35,12 +35,7 @@ import { } from "matrix-js-sdk/src/crypto-api"; import { Tooltip } from "@vector-im/compound-web"; import { uniqueId } from "lodash"; -import { - CircleIcon, - ErrorSolidIcon, - InfoIcon, - CheckCircleIcon, -} from "@vector-im/compound-design-tokens/assets/web/icons"; +import { CircleIcon, CheckCircleIcon } from "@vector-im/compound-design-tokens/assets/web/icons"; import ReplyChain from "../elements/ReplyChain"; import { _t } from "../../../languageHandler"; @@ -89,6 +84,8 @@ import PinningUtils from "../../../utils/PinningUtils"; import { PinnedMessageBadge } from "../messages/PinnedMessageBadge"; import { EventPreview } from "./EventPreview"; import { ElementCallEventType } from "../../../call-types"; +import { E2eMessageSharedIcon } from "./EventTile/E2eMessageSharedIcon.tsx"; +import { E2ePadlock, E2ePadlockIcon } from "./EventTile/E2ePadlock.tsx"; export type GetRelationsForEvent = ( eventId: string, @@ -740,6 +737,14 @@ export class UnwrappedEventTile extends React.Component } } + if (this.state.shieldReason === EventShieldReason.AUTHENTICITY_NOT_GUARANTEED) { + // This may happen if the message was forwarded to us by another user, in which case we can show a better message + const forwarder = this.props.mxEvent.getKeyForwardingUser(); + if (forwarder) { + return ; + } + } + if (this.state.shieldColour !== EventShieldColour.NONE) { let shieldReasonMessage: string; switch (this.state.shieldReason) { @@ -1513,58 +1518,12 @@ const SafeEventTile = (props: EventTileProps): JSX.Element => { }; export default SafeEventTile; -function E2ePadlockUnencrypted(props: Omit): JSX.Element { - return ; +function E2ePadlockUnencrypted(): JSX.Element { + return ; } -function E2ePadlockDecryptionFailure(props: Omit): JSX.Element { - return ( - - ); -} - -enum E2ePadlockIcon { - /** Compound Info icon in grey */ - Normal = "normal", - - /** Compound ErrorSolid icon in red */ - Warning = "warning", - - /** Compound ErrorSolid icon in grey */ - DecryptionFailure = "decryption_failure", -} - -interface IE2ePadlockProps { - icon: E2ePadlockIcon; - title: string; -} - -class E2ePadlock extends React.Component { - private static icons: Record = { - [E2ePadlockIcon.Normal]: , - [E2ePadlockIcon.Warning]: , - [E2ePadlockIcon.DecryptionFailure]: , - }; - - public constructor(props: IE2ePadlockProps) { - super(props); - - this.state = { - hover: false, - }; - } - - public render(): ReactNode { - // We specify isTriggerInteractive=true and make the div interactive manually as a workaround for - // https://github.com/element-hq/compound/issues/294 - return ( - -
- {E2ePadlock.icons[this.props.icon]} -
-
- ); - } +function E2ePadlockDecryptionFailure(): JSX.Element { + return ; } interface ISentReceiptProps { diff --git a/src/components/views/rooms/EventTile/E2eMessageSharedIcon.tsx b/src/components/views/rooms/EventTile/E2eMessageSharedIcon.tsx new file mode 100644 index 0000000000..d6dd6868eb --- /dev/null +++ b/src/components/views/rooms/EventTile/E2eMessageSharedIcon.tsx @@ -0,0 +1,45 @@ +/* +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 { useMatrixClientContext } from "../../../../contexts/MatrixClientContext.tsx"; +import { _t } from "../../../../languageHandler.tsx"; +import { E2ePadlock, E2ePadlockIcon } from "./E2ePadlock.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/src/components/views/rooms/EventTile/E2ePadlock.tsx b/src/components/views/rooms/EventTile/E2ePadlock.tsx new file mode 100644 index 0000000000..a7fc925331 --- /dev/null +++ b/src/components/views/rooms/EventTile/E2ePadlock.tsx @@ -0,0 +1,66 @@ +/* +Copyright 2025 Vector Creations Ltd. +Copyright 2024 New Vector Ltd. +Copyright 2015-2023 The Matrix.org Foundation C.I.C. +Copyright 2019 Michael Telatynski <7t3chguy@gmail.com> + +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 ReactNode } from "react"; +import { ErrorSolidIcon, InfoIcon } from "@vector-im/compound-design-tokens/assets/web/icons"; +import { Tooltip } from "@vector-im/compound-web"; + +import { _t } from "../../../../languageHandler.tsx"; + +/** + * The icon to display in an {@link E2ePadlock}. + */ +export enum E2ePadlockIcon { + /** Compound Info icon in grey */ + Normal = "normal", + + /** Compound ErrorSolid icon in red */ + Warning = "warning", + + /** Compound ErrorSolid icon in grey */ + DecryptionFailure = "decryption_failure", +} + +interface IE2ePadlockProps { + /** The icon to display. */ + icon: E2ePadlockIcon; + + /** The tooltip for the icon, displayed on hover. */ + title: string; +} + +const icons = { + [E2ePadlockIcon.Normal]: , + [E2ePadlockIcon.Warning]: , + [E2ePadlockIcon.DecryptionFailure]: , +}; + +/** + * A small icon with tooltip, used in the left margin of an {@link EventTile}, which indicates a problem + * with an encrypted event. + * + * The icon is rendered with `data-testid="e2e-padlock"`. + */ +export function E2ePadlock(props: IE2ePadlockProps): ReactNode { + // We specify isTriggerInteractive=true and make the div interactive manually as a workaround for + // https://github.com/element-hq/compound/issues/294 + return ( + +
+ {icons[props.icon]} +
+
+ ); +} diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 99758d21ab..c3b2e8371d 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -978,6 +978,7 @@ "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/test/unit-tests/components/views/rooms/EventTile-test.tsx b/test/unit-tests/components/views/rooms/EventTile-test.tsx index 47d9031435..626bd28ba2 100644 --- a/test/unit-tests/components/views/rooms/EventTile-test.tsx +++ b/test/unit-tests/components/views/rooms/EventTile-test.tsx @@ -28,6 +28,7 @@ import { EventShieldReason, } from "matrix-js-sdk/src/crypto-api"; import { mkEncryptedMatrixEvent } from "matrix-js-sdk/src/testing"; +import { getByTestId } from "@testing-library/dom"; import EventTile, { type EventTileProps } from "../../../../../src/components/views/rooms/EventTile"; import MatrixClientContext from "../../../../../src/contexts/MatrixClientContext"; @@ -333,6 +334,28 @@ describe("EventTile", () => { expect(e2eIcons[0]).toHaveAccessibleName(expectedText); }); + it("shows the correct reason code for a forwarded message", async () => { + mxEvent = await mkEncryptedMatrixEvent({ + plainContent: { msgtype: "m.text", body: "msg1" }, + plainType: "m.room.message", + sender: "@alice:example.org", + roomId: room.roomId, + }); + // @ts-ignore assignment to private member + mxEvent.keyForwardedBy = "@bob:example.org"; + eventToEncryptionInfoMap.set(mxEvent.getId()!, { + shieldColour: EventShieldColour.GREY, + shieldReason: EventShieldReason.AUTHENTICITY_NOT_GUARANTEED, + } as EventEncryptionInfo); + + const { container } = getComponent(); + + const e2eIcon = await waitFor(() => getByTestId(container, "e2e-padlock")); + expect(e2eIcon).toHaveAccessibleName( + "@bob:example.org (@bob:example.org) shared this message since you were not in the room when it was sent.", + ); + }); + describe("undecryptable event", () => { filterConsole("Error decrypting event"); diff --git a/test/unit-tests/components/views/rooms/EventTile/E2eMessageSharedIcon-test.tsx b/test/unit-tests/components/views/rooms/EventTile/E2eMessageSharedIcon-test.tsx new file mode 100644 index 0000000000..83e5afa697 --- /dev/null +++ b/test/unit-tests/components/views/rooms/EventTile/E2eMessageSharedIcon-test.tsx @@ -0,0 +1,55 @@ +/* +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 } 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", () => { + 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), + ); + + expect(result.container).toMatchSnapshot(); + expect(result.container.firstChild).toHaveAccessibleName( + "Bob (@bob:example.com) shared this message since you were not in the room when it was sent.", + ); + }); + + it("renders correctly for an unknown user", () => { + const mockClient = createTestClient(); + const result = render( + , + withClientContextRenderOptions(mockClient), + ); + + expect(result.container).toMatchSnapshot(); + 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.", + ); + }); +}); diff --git a/test/unit-tests/components/views/rooms/EventTile/E2ePadlock-test.tsx b/test/unit-tests/components/views/rooms/EventTile/E2ePadlock-test.tsx new file mode 100644 index 0000000000..8205b47a62 --- /dev/null +++ b/test/unit-tests/components/views/rooms/EventTile/E2ePadlock-test.tsx @@ -0,0 +1,28 @@ +/* +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 } from "jest-matrix-react"; +import React from "react"; + +import { E2ePadlock, E2ePadlockIcon } from "../../../../../../src/components/views/rooms/EventTile/E2ePadlock.tsx"; + +describe("E2ePadlock", () => { + it("renders a 'Normal' icon", () => { + const result = render(); + expect(result.asFragment()).toMatchSnapshot(); + }); + + it("renders a 'Warning' icon", () => { + const result = render(); + expect(result.asFragment()).toMatchSnapshot(); + }); + + it("renders a 'DecryptionFailure' icon", () => { + const result = render(); + expect(result.asFragment()).toMatchSnapshot(); + }); +}); diff --git a/test/unit-tests/components/views/rooms/EventTile/__snapshots__/E2eMessageSharedIcon-test.tsx.snap b/test/unit-tests/components/views/rooms/EventTile/__snapshots__/E2eMessageSharedIcon-test.tsx.snap new file mode 100644 index 0000000000..f5108aad36 --- /dev/null +++ b/test/unit-tests/components/views/rooms/EventTile/__snapshots__/E2eMessageSharedIcon-test.tsx.snap @@ -0,0 +1,61 @@ +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing + +exports[`E2eMessageSharedIcon renders correctly for a known user 1`] = ` +
+
+ + + + +
+
+`; + +exports[`E2eMessageSharedIcon renders correctly for an unknown user 1`] = ` +
+
+ + + + +
+
+`; diff --git a/test/unit-tests/components/views/rooms/EventTile/__snapshots__/E2ePadlock-test.tsx.snap b/test/unit-tests/components/views/rooms/EventTile/__snapshots__/E2ePadlock-test.tsx.snap new file mode 100644 index 0000000000..b723bb868f --- /dev/null +++ b/test/unit-tests/components/views/rooms/EventTile/__snapshots__/E2ePadlock-test.tsx.snap @@ -0,0 +1,81 @@ +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing + +exports[`E2ePadlock renders a 'DecryptionFailure' icon 1`] = ` + +
+ + + +
+
+`; + +exports[`E2ePadlock renders a 'Normal' icon 1`] = ` + +
+ + + + +
+
+`; + +exports[`E2ePadlock renders a 'Warning' icon 1`] = ` + +
+ + + +
+
+`;