diff --git a/packages/shared-components/__vis__/linux/__baselines__/message-body/DecryptionFailureBodyView/DecryptionFailureBodyView.stories.tsx/default-auto.png b/packages/shared-components/__vis__/linux/__baselines__/message-body/DecryptionFailureBodyView/DecryptionFailureBodyView.stories.tsx/default-auto.png new file mode 100644 index 0000000000..27809a1621 Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/message-body/DecryptionFailureBodyView/DecryptionFailureBodyView.stories.tsx/default-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/message-body/DecryptionFailureBodyView/DecryptionFailureBodyView.stories.tsx/has-backup-configured-verified-false-auto.png b/packages/shared-components/__vis__/linux/__baselines__/message-body/DecryptionFailureBodyView/DecryptionFailureBodyView.stories.tsx/has-backup-configured-verified-false-auto.png new file mode 100644 index 0000000000..4b752bc958 Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/message-body/DecryptionFailureBodyView/DecryptionFailureBodyView.stories.tsx/has-backup-configured-verified-false-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/message-body/DecryptionFailureBodyView/DecryptionFailureBodyView.stories.tsx/has-backup-configured-verified-true-auto.png b/packages/shared-components/__vis__/linux/__baselines__/message-body/DecryptionFailureBodyView/DecryptionFailureBodyView.stories.tsx/has-backup-configured-verified-true-auto.png new file mode 100644 index 0000000000..27809a1621 Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/message-body/DecryptionFailureBodyView/DecryptionFailureBodyView.stories.tsx/has-backup-configured-verified-true-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/message-body/DecryptionFailureBodyView/DecryptionFailureBodyView.stories.tsx/has-error-block-icon-auto.png b/packages/shared-components/__vis__/linux/__baselines__/message-body/DecryptionFailureBodyView/DecryptionFailureBodyView.stories.tsx/has-error-block-icon-auto.png new file mode 100644 index 0000000000..51d6af59b8 Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/message-body/DecryptionFailureBodyView/DecryptionFailureBodyView.stories.tsx/has-error-block-icon-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/message-body/DecryptionFailureBodyView/DecryptionFailureBodyView.stories.tsx/has-error-class-name-auto.png b/packages/shared-components/__vis__/linux/__baselines__/message-body/DecryptionFailureBodyView/DecryptionFailureBodyView.stories.tsx/has-error-class-name-auto.png new file mode 100644 index 0000000000..b83cd9932d Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/message-body/DecryptionFailureBodyView/DecryptionFailureBodyView.stories.tsx/has-error-class-name-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/message-body/DecryptionFailureBodyView/DecryptionFailureBodyView.stories.tsx/has-extra-class-names-auto.png b/packages/shared-components/__vis__/linux/__baselines__/message-body/DecryptionFailureBodyView/DecryptionFailureBodyView.stories.tsx/has-extra-class-names-auto.png new file mode 100644 index 0000000000..27809a1621 Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/message-body/DecryptionFailureBodyView/DecryptionFailureBodyView.stories.tsx/has-extra-class-names-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 bf5e266af9..a76f3a54a0 100644 --- a/packages/shared-components/src/i18n/strings/en_EN.json +++ b/packages/shared-components/src/i18n/strings/en_EN.json @@ -80,6 +80,15 @@ "n_minutes_ago": "%(num)s minutes ago" }, "timeline": { + "decryption_failure": { + "blocked": "The sender has blocked you from receiving this message because your device is unverified", + "historical_event_no_key_backup": "Historical messages are not available on this device", + "historical_event_unverified_device": "You need to verify this device for access to historical messages", + "historical_event_user_not_joined": "You don't have access to this message", + "sender_identity_previously_verified": "Sender's verified identity was reset", + "sender_unsigned_device": "Sent from an insecure device.", + "unable_to_decrypt": "Unable to decrypt message" + }, "m.audio": { "audio_player": "Audio player", "error_downloading_audio": "Error downloading audio", diff --git a/packages/shared-components/src/index.ts b/packages/shared-components/src/index.ts index 6ae20f6069..840342eea7 100644 --- a/packages/shared-components/src/index.ts +++ b/packages/shared-components/src/index.ts @@ -15,6 +15,7 @@ export * from "./composer/Banner"; export * from "./crypto/SasEmoji"; export * from "./event-tiles/TextualEventView"; export * from "./message-body/MediaBody"; +export * from "./message-body/DecryptionFailureBodyView"; export * from "./message-body/ReactionsRowButtonTooltip"; export * from "./pill-input/Pill"; export * from "./pill-input/PillInput"; @@ -36,6 +37,5 @@ export * from "./utils/DateUtils"; export * from "./utils/numbers"; export * from "./utils/FormattingUtils"; export * from "./utils/I18nApi"; - // MVVM export * from "./viewmodel"; diff --git a/packages/shared-components/src/message-body/DecryptionFailureBodyView/DecryptionFailureBodyView.module.css b/packages/shared-components/src/message-body/DecryptionFailureBodyView/DecryptionFailureBodyView.module.css new file mode 100644 index 0000000000..45e21088e4 --- /dev/null +++ b/packages/shared-components/src/message-body/DecryptionFailureBodyView/DecryptionFailureBodyView.module.css @@ -0,0 +1,26 @@ +/* + * 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. + */ + +.content { + color: var(--cpd-color-text-secondary); + font-style: italic; +} + +/* Formatting for errors due to sender trust requirement failures */ +.error > span { + /* some space between the (/) icon and text */ + display: inline-flex; + gap: var(--cpd-space-1x); + + /* Center vertically */ + align-items: center; +} + +.icon { + box-sizing: border-box; + flex: 0 0 16px; +} diff --git a/packages/shared-components/src/message-body/DecryptionFailureBodyView/DecryptionFailureBodyView.stories.tsx b/packages/shared-components/src/message-body/DecryptionFailureBodyView/DecryptionFailureBodyView.stories.tsx new file mode 100644 index 0000000000..741f7420de --- /dev/null +++ b/packages/shared-components/src/message-body/DecryptionFailureBodyView/DecryptionFailureBodyView.stories.tsx @@ -0,0 +1,81 @@ +/* + * 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, StoryFn } from "@storybook/react-vite"; +import { + DecryptionFailureBodyView, + DecryptionFailureReason, + type DecryptionFailureBodyViewSnapshot, +} from "./DecryptionFailureBodyView"; +import { useMockedViewModel } from "../../viewmodel/useMockedViewModel"; + +type DecryptionFailureBodyProps = DecryptionFailureBodyViewSnapshot; + +const DecryptionFailureBodyViewWrapper = ({ ...rest }: DecryptionFailureBodyProps): JSX.Element => { + const vm = useMockedViewModel(rest, {}); + + return ; +}; + +export default { + title: "MessageBody/DecryptionFailureBodyView", + component: DecryptionFailureBodyViewWrapper, + tags: ["autodocs"], + argTypes: { + decryptionFailureReason: { + options: Object.entries(DecryptionFailureReason) + .filter(([key, value]) => key === value) + .map(([key]) => key), + control: { type: "select" }, + }, + }, + args: { + decryptionFailureReason: DecryptionFailureReason.UNABLE_TO_DECRYPT, + isLocalDeviceVerified: true, + extraClassNames: ["extra_class"], + }, +} as Meta; + +const Template: StoryFn = (args) => ( + +); + +export const Default = Template.bind({}); + +export const HasExtraClassNames = Template.bind({}); +HasExtraClassNames.args = { + decryptionFailureReason: DecryptionFailureReason.UNABLE_TO_DECRYPT, + extraClassNames: ["extra_class_1", "extra_class_2"], +}; + +export const HasErrorClassName = Template.bind({}); +HasErrorClassName.args = { + decryptionFailureReason: DecryptionFailureReason.UNSIGNED_SENDER_DEVICE, + extraClassNames: undefined, +}; + +export const HasErrorBlockIcon = Template.bind({}); +HasErrorBlockIcon.args = { + decryptionFailureReason: DecryptionFailureReason.SENDER_IDENTITY_PREVIOUSLY_VERIFIED, + extraClassNames: undefined, +}; + +export const HasBackupConfiguredVerifiedFalse = Template.bind({}); +HasBackupConfiguredVerifiedFalse.args = { + decryptionFailureReason: DecryptionFailureReason.HISTORICAL_MESSAGE_BACKUP_UNCONFIGURED, + isLocalDeviceVerified: false, + extraClassNames: undefined, +}; + +export const HasBackupConfiguredVerifiedTrue = Template.bind({}); +HasBackupConfiguredVerifiedTrue.args = { + decryptionFailureReason: DecryptionFailureReason.HISTORICAL_MESSAGE_BACKUP_UNCONFIGURED, + isLocalDeviceVerified: true, + extraClassNames: undefined, +}; diff --git a/packages/shared-components/src/message-body/DecryptionFailureBodyView/DecryptionFailureBodyView.test.tsx b/packages/shared-components/src/message-body/DecryptionFailureBodyView/DecryptionFailureBodyView.test.tsx new file mode 100644 index 0000000000..b886584c35 --- /dev/null +++ b/packages/shared-components/src/message-body/DecryptionFailureBodyView/DecryptionFailureBodyView.test.tsx @@ -0,0 +1,149 @@ +/* + * 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 { composeStories } from "@storybook/react-vite"; +import { render } from "@test-utils"; +import React from "react"; +import { describe, it, expect } from "vitest"; + +import { DecryptionFailureBodyView, DecryptionFailureReason } from "./DecryptionFailureBodyView"; +import { MockViewModel } from "../../viewmodel"; +import * as stories from "./DecryptionFailureBodyView.stories"; + +const { HasExtraClassNames } = composeStories(stories); + +describe("DecryptionFailureBodyView", () => { + function customRender( + decryptionFailureReason: DecryptionFailureReason, + isLocalDeviceVerified: boolean = false, + extraClassNames: string[] | undefined = undefined, + ): ReturnType { + return render( + , + ); + } + + function customRenderWithRef(ref: React.RefObject): ReturnType { + return render( + , + ); + } + + it("Should display with extra class names", () => { + // When + const { container } = render(); + + // Then + expect(container).toMatchSnapshot(); + }); + + it.each([true, false])(`Should display "Unable to decrypt message and device verification is %s"`, (verified) => { + // When + const { container } = customRender(DecryptionFailureReason.UNABLE_TO_DECRYPT, verified); + + // Then + expect(container).toHaveTextContent("Unable to decrypt message"); + expect(container).toMatchSnapshot(); + }); + + it.each([true, false])( + `Should display "The sender has blocked you from receiving this message and device verification is %s"`, + (verified) => { + // When + const { container } = customRender( + DecryptionFailureReason.MEGOLM_KEY_WITHHELD_FOR_UNVERIFIED_DEVICE, + verified, + ); + + // Then + expect(container).toHaveTextContent( + "The sender has blocked you from receiving this message because your device is unverified", + ); + expect(container).toMatchSnapshot(); + }, + ); + + it.each([true, false])( + "should handle historical messages with no key backup and device verification is %s", + (verified) => { + // When + const { container } = customRender(DecryptionFailureReason.HISTORICAL_MESSAGE_NO_KEY_BACKUP, verified); + + // Then + expect(container).toHaveTextContent("Historical messages are not available on this device"); + expect(container).toMatchSnapshot(); + }, + ); + + it.each([true, false])( + "should handle historical messages when there is a backup and device verification is %s", + (verified) => { + // When + const { container } = customRender( + DecryptionFailureReason.HISTORICAL_MESSAGE_BACKUP_UNCONFIGURED, + verified, + ); + + // Then + expect(container).toHaveTextContent( + verified ? "Unable to decrypt" : "You need to verify this device for access to historical messages", + ); + }, + ); + + it.each([true, false])( + "should handle undecryptable pre-join messages and device verification is %s", + (verified) => { + // When + const { container } = customRender(DecryptionFailureReason.HISTORICAL_MESSAGE_USER_NOT_JOINED, verified); + + // Then + expect(container).toHaveTextContent("You don't have access to this message"); + expect(container).toMatchSnapshot(); + }, + ); + + it.each([true, false])( + "should handle messages from users who change identities after verification and device verification is %s", + (verified) => { + // When + const { container } = customRender(DecryptionFailureReason.SENDER_IDENTITY_PREVIOUSLY_VERIFIED, verified); + + // Then + expect(container).toHaveTextContent("Sender's verified identity was reset"); + expect(container).toMatchSnapshot(); + }, + ); + + it.each([true, false])( + "should handle messages from unverified devices and device verification is %s", + (verified) => { + // When + const { container } = customRender(DecryptionFailureReason.UNSIGNED_SENDER_DEVICE, verified); + + // Then + expect(container).toHaveTextContent("Sent from an insecure device"); + expect(container).toMatchSnapshot(); + }, + ); + + it("should handle ref input", async () => { + const ref = React.createRef(); + // When + const { container } = customRenderWithRef(ref); + + // Then + expect(container).toBeInstanceOf(HTMLDivElement); + expect(container.firstChild).toHaveTextContent("Unable to decrypt message"); + expect(ref.current).toBeInstanceOf(HTMLDivElement); + }); +}); diff --git a/packages/shared-components/src/message-body/DecryptionFailureBodyView/DecryptionFailureBodyView.tsx b/packages/shared-components/src/message-body/DecryptionFailureBodyView/DecryptionFailureBodyView.tsx new file mode 100644 index 0000000000..23b0d639d5 --- /dev/null +++ b/packages/shared-components/src/message-body/DecryptionFailureBodyView/DecryptionFailureBodyView.tsx @@ -0,0 +1,176 @@ +/* + * 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 classNames from "classnames"; +import React, { type JSX } from "react"; +import { BlockIcon } from "@vector-im/compound-design-tokens/assets/web/icons"; +import { type I18nApi } from "@element-hq/element-web-module-api"; + +import { type ViewModel } from "../../viewmodel/ViewModel"; +import { useViewModel } from "../../viewmodel/useViewModel"; +import styles from "./DecryptionFailureBodyView.module.css"; +import { useI18n } from "../../utils/i18nContext"; + +/** + * A reason code for a failure to decrypt an event. + */ +export enum DecryptionFailureReason { + /** A special case of {@link MEGOLM_KEY_WITHHELD}: the sender has told us it is withholding the key, because the current device is unverified. */ + MEGOLM_KEY_WITHHELD_FOR_UNVERIFIED_DEVICE = "MEGOLM_KEY_WITHHELD_FOR_UNVERIFIED_DEVICE", + + /** + * Message was sent before the current device was created; there is no key backup on the server, so this + * decryption failure is expected. + */ + HISTORICAL_MESSAGE_NO_KEY_BACKUP = "HISTORICAL_MESSAGE_NO_KEY_BACKUP", + + /** + * Message was sent before the current device was created; there was a key backup on the server, but we don't + * seem to have access to the backup. (Probably we don't have the right key.) + */ + HISTORICAL_MESSAGE_BACKUP_UNCONFIGURED = "HISTORICAL_MESSAGE_BACKUP_UNCONFIGURED", + + /** + * Message was sent when the user was not a member of the room. + */ + HISTORICAL_MESSAGE_USER_NOT_JOINED = "HISTORICAL_MESSAGE_USER_NOT_JOINED", + + /** + * The sender's identity is not verified, but was previously verified. + */ + SENDER_IDENTITY_PREVIOUSLY_VERIFIED = "SENDER_IDENTITY_PREVIOUSLY_VERIFIED", + + /** + * The sender device is not cross-signed. This will only be used if the + * device isolation mode is set to `OnlySignedDevicesIsolationMode`. + */ + UNSIGNED_SENDER_DEVICE = "UNSIGNED_SENDER_DEVICE", + + /** + * Default message for decryption failures. + */ + UNABLE_TO_DECRYPT = "UNABLE_TO_DECRYPT", +} + +export interface DecryptionFailureBodyViewSnapshot { + /** + * The decryption failure reason of the event. + */ + decryptionFailureReason: DecryptionFailureReason; + /** + * The local device verification state. + */ + isLocalDeviceVerified?: boolean; + /** + * Extra CSS classes to apply to the component + */ + extraClassNames?: string[]; +} + +/** + * The view model for the component. + */ +export type DecryptionFailureBodyViewModel = ViewModel; + +interface DecryptionFailureBodyViewProps { + /** + * The view model for the component. + */ + vm: DecryptionFailureBodyViewModel; + /** + * React ref to attach to any React components returned + */ + ref?: React.RefObject; +} + +/** + * Resolve the localized error message for a decryption failure reason. + * + * @param i18nApi - I18n API used to translate message keys. + * @param decryptionFailureReason - Reason code for the decryption failure. + * @param isLocalDeviceVerified - Whether the local device is verified, used for certain historical cases. + */ +function getErrorMessage( + i18nApi: I18nApi, + decryptionFailureReason: DecryptionFailureReason, + isLocalDeviceVerified?: boolean, +): string | JSX.Element { + const _t = i18nApi.translate; + + switch (decryptionFailureReason) { + case DecryptionFailureReason.MEGOLM_KEY_WITHHELD_FOR_UNVERIFIED_DEVICE: + return _t("timeline|decryption_failure|blocked"); + + case DecryptionFailureReason.HISTORICAL_MESSAGE_NO_KEY_BACKUP: + return _t("timeline|decryption_failure|historical_event_no_key_backup"); + + case DecryptionFailureReason.HISTORICAL_MESSAGE_BACKUP_UNCONFIGURED: + if (isLocalDeviceVerified === false) { + // The user seems to have a key backup, so prompt them to verify in the hope that doing so will + // mean we can restore from backup and we'll get the key for this message. + return _t("timeline|decryption_failure|historical_event_unverified_device"); + } + // otherwise, use the default. + break; + + case DecryptionFailureReason.HISTORICAL_MESSAGE_USER_NOT_JOINED: + // TODO: event should be hidden instead of showing this error. + // To be revisited as part of https://github.com/element-hq/element-meta/issues/2449 + return _t("timeline|decryption_failure|historical_event_user_not_joined"); + + case DecryptionFailureReason.SENDER_IDENTITY_PREVIOUSLY_VERIFIED: + return ( + + + {_t("timeline|decryption_failure|sender_identity_previously_verified")} + + ); + + case DecryptionFailureReason.UNSIGNED_SENDER_DEVICE: + // TODO: event should be hidden instead of showing this error. + // To be revisited as part of https://github.com/element-hq/element-meta/issues/2449 + return ( + + + {_t("timeline|decryption_failure|sender_unsigned_device")} + + ); + } + return _t("timeline|decryption_failure|unable_to_decrypt"); +} + +/** + * Get the extra CSS class for the given decryption failure reason, when one applies. + */ +function errorClassName(decryptionFailureReason: DecryptionFailureReason): string | null { + switch (decryptionFailureReason) { + case DecryptionFailureReason.SENDER_IDENTITY_PREVIOUSLY_VERIFIED: + case DecryptionFailureReason.UNSIGNED_SENDER_DEVICE: + return styles.error; + } + return null; +} + +/** + * A placeholder element for messages that could not be decrypted + * + * @example + * ```tsx + * + * ``` + */ +export function DecryptionFailureBodyView({ vm, ref }: Readonly): JSX.Element { + const i18nApi = useI18n(); + const { decryptionFailureReason, isLocalDeviceVerified, extraClassNames } = useViewModel(vm); + const classes = classNames(styles.content, errorClassName(decryptionFailureReason), extraClassNames); + + return ( +
+ {getErrorMessage(i18nApi, decryptionFailureReason, isLocalDeviceVerified)} +
+ ); +} diff --git a/packages/shared-components/src/message-body/DecryptionFailureBodyView/__snapshots__/DecryptionFailureBodyView.test.tsx.snap b/packages/shared-components/src/message-body/DecryptionFailureBodyView/__snapshots__/DecryptionFailureBodyView.test.tsx.snap new file mode 100644 index 0000000000..7bd5899caf --- /dev/null +++ b/packages/shared-components/src/message-body/DecryptionFailureBodyView/__snapshots__/DecryptionFailureBodyView.test.tsx.snap @@ -0,0 +1,187 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`DecryptionFailureBodyView > Should display "The sender has blocked you from receiving this message and device verification is false" 1`] = ` +
+
+ The sender has blocked you from receiving this message because your device is unverified +
+
+`; + +exports[`DecryptionFailureBodyView > Should display "The sender has blocked you from receiving this message and device verification is true" 1`] = ` +
+
+ The sender has blocked you from receiving this message because your device is unverified +
+
+`; + +exports[`DecryptionFailureBodyView > Should display "Unable to decrypt message and device verification is false" 1`] = ` +
+
+ Unable to decrypt message +
+
+`; + +exports[`DecryptionFailureBodyView > Should display "Unable to decrypt message and device verification is true" 1`] = ` +
+
+ Unable to decrypt message +
+
+`; + +exports[`DecryptionFailureBodyView > Should display with extra class names 1`] = ` +
+
+ Unable to decrypt message +
+
+`; + +exports[`DecryptionFailureBodyView > should handle historical messages with no key backup and device verification is false 1`] = ` +
+
+ Historical messages are not available on this device +
+
+`; + +exports[`DecryptionFailureBodyView > should handle historical messages with no key backup and device verification is true 1`] = ` +
+
+ Historical messages are not available on this device +
+
+`; + +exports[`DecryptionFailureBodyView > should handle messages from unverified devices and device verification is false 1`] = ` +
+
+ + + + + Sent from an insecure device. + +
+
+`; + +exports[`DecryptionFailureBodyView > should handle messages from unverified devices and device verification is true 1`] = ` +
+
+ + + + + Sent from an insecure device. + +
+
+`; + +exports[`DecryptionFailureBodyView > should handle messages from users who change identities after verification and device verification is false 1`] = ` +
+
+ + + + + Sender's verified identity was reset + +
+
+`; + +exports[`DecryptionFailureBodyView > should handle messages from users who change identities after verification and device verification is true 1`] = ` +
+
+ + + + + Sender's verified identity was reset + +
+
+`; + +exports[`DecryptionFailureBodyView > should handle undecryptable pre-join messages and device verification is false 1`] = ` +
+
+ You don't have access to this message +
+
+`; + +exports[`DecryptionFailureBodyView > should handle undecryptable pre-join messages and device verification is true 1`] = ` +
+
+ You don't have access to this message +
+
+`; diff --git a/packages/shared-components/src/message-body/DecryptionFailureBodyView/index.tsx b/packages/shared-components/src/message-body/DecryptionFailureBodyView/index.tsx new file mode 100644 index 0000000000..bc533a89fd --- /dev/null +++ b/packages/shared-components/src/message-body/DecryptionFailureBodyView/index.tsx @@ -0,0 +1,13 @@ +/* + * 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 { + DecryptionFailureBodyView, + DecryptionFailureReason, + type DecryptionFailureBodyViewModel, + type DecryptionFailureBodyViewSnapshot, +} from "./DecryptionFailureBodyView"; diff --git a/res/css/_components.pcss b/res/css/_components.pcss index a93f040b6c..60bca97841 100644 --- a/res/css/_components.pcss +++ b/res/css/_components.pcss @@ -220,7 +220,6 @@ @import "./views/messages/_CallEvent.pcss"; @import "./views/messages/_CreateEvent.pcss"; @import "./views/messages/_DateSeparator.pcss"; -@import "./views/messages/_DecryptionFailureBody.pcss"; @import "./views/messages/_DisambiguatedProfile.pcss"; @import "./views/messages/_EventTileBubble.pcss"; @import "./views/messages/_HiddenBody.pcss"; diff --git a/res/css/views/messages/_DecryptionFailureBody.pcss b/res/css/views/messages/_DecryptionFailureBody.pcss deleted file mode 100644 index 4a4940abe3..0000000000 --- a/res/css/views/messages/_DecryptionFailureBody.pcss +++ /dev/null @@ -1,22 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2022 The Matrix.org Foundation C.I.C. - -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. -*/ - -.mx_DecryptionFailureBody { - color: $secondary-content; - font-style: italic; -} - -/* Formatting for errors due to sender trust requirement failures */ -.mx_DecryptionFailureSenderTrustRequirement > span { - /* some space between the (/) icon and text */ - display: inline-flex; - gap: var(--cpd-space-1x); - - /* Center vertically */ - align-items: center; -} diff --git a/src/components/structures/MatrixClientContextProvider.tsx b/src/components/structures/MatrixClientContextProvider.tsx index 7d555f5809..f99ad901e6 100644 --- a/src/components/structures/MatrixClientContextProvider.tsx +++ b/src/components/structures/MatrixClientContextProvider.tsx @@ -11,9 +11,9 @@ import { type MatrixClient } from "matrix-js-sdk/src/matrix"; import { CryptoEvent } from "matrix-js-sdk/src/crypto-api"; import { logger } from "matrix-js-sdk/src/logger"; +import { LocalDeviceVerificationStateContext } from "../../contexts/LocalDeviceVerificationStateContext"; import MatrixClientContext from "../../contexts/MatrixClientContext"; import { useEventEmitter } from "../../hooks/useEventEmitter"; -import { LocalDeviceVerificationStateContext } from "../../contexts/LocalDeviceVerificationStateContext"; /** * A React hook whose value is whether the local device has been "verified". diff --git a/src/components/views/messages/DecryptionFailureBody.tsx b/src/components/views/messages/DecryptionFailureBody.tsx deleted file mode 100644 index f75a7c48f8..0000000000 --- a/src/components/views/messages/DecryptionFailureBody.tsx +++ /dev/null @@ -1,84 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2022-2024 The Matrix.org Foundation C.I.C. - -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 classNames from "classnames"; -import React, { type JSX, useContext } from "react"; -import { type MatrixEvent } from "matrix-js-sdk/src/matrix"; -import { DecryptionFailureCode } from "matrix-js-sdk/src/crypto-api"; -import { BlockIcon } from "@vector-im/compound-design-tokens/assets/web/icons"; - -import { _t } from "../../../languageHandler"; -import { type IBodyProps } from "./IBodyProps"; -import { LocalDeviceVerificationStateContext } from "../../../contexts/LocalDeviceVerificationStateContext"; - -function getErrorMessage(mxEvent: MatrixEvent, isVerified: boolean | undefined): string | JSX.Element { - switch (mxEvent.decryptionFailureReason) { - case DecryptionFailureCode.MEGOLM_KEY_WITHHELD_FOR_UNVERIFIED_DEVICE: - return _t("timeline|decryption_failure|blocked"); - - case DecryptionFailureCode.HISTORICAL_MESSAGE_NO_KEY_BACKUP: - return _t("timeline|decryption_failure|historical_event_no_key_backup"); - - case DecryptionFailureCode.HISTORICAL_MESSAGE_BACKUP_UNCONFIGURED: - if (isVerified === false) { - // The user seems to have a key backup, so prompt them to verify in the hope that doing so will - // mean we can restore from backup and we'll get the key for this message. - return _t("timeline|decryption_failure|historical_event_unverified_device"); - } - // otherwise, use the default. - break; - - case DecryptionFailureCode.HISTORICAL_MESSAGE_USER_NOT_JOINED: - // TODO: event should be hidden instead of showing this error. - // To be revisited as part of https://github.com/element-hq/element-meta/issues/2449 - return _t("timeline|decryption_failure|historical_event_user_not_joined"); - - case DecryptionFailureCode.SENDER_IDENTITY_PREVIOUSLY_VERIFIED: - return ( - - - {_t("timeline|decryption_failure|sender_identity_previously_verified")} - - ); - - case DecryptionFailureCode.UNSIGNED_SENDER_DEVICE: - // TODO: event should be hidden instead of showing this error. - // To be revisited as part of https://github.com/element-hq/element-meta/issues/2449 - return ( - - - {_t("timeline|decryption_failure|sender_unsigned_device")} - - ); - } - return _t("timeline|decryption_failure|unable_to_decrypt"); -} - -/** Get an extra CSS class, specific to the decryption failure reason */ -function errorClassName(mxEvent: MatrixEvent): string | null { - switch (mxEvent.decryptionFailureReason) { - case DecryptionFailureCode.SENDER_IDENTITY_PREVIOUSLY_VERIFIED: - case DecryptionFailureCode.UNSIGNED_SENDER_DEVICE: - return "mx_DecryptionFailureSenderTrustRequirement"; - - default: - return null; - } -} - -// A placeholder element for messages that could not be decrypted -export const DecryptionFailureBody = ({ mxEvent, ref }: IBodyProps): JSX.Element => { - const verificationState = useContext(LocalDeviceVerificationStateContext); - const classes = classNames("mx_DecryptionFailureBody", "mx_EventTile_content", errorClassName(mxEvent)); - - return ( -
- {getErrorMessage(mxEvent, verificationState)} -
- ); -}; diff --git a/src/components/views/messages/MessageEvent.tsx b/src/components/views/messages/MessageEvent.tsx index 6d124c88a8..6bb28fd188 100644 --- a/src/components/views/messages/MessageEvent.tsx +++ b/src/components/views/messages/MessageEvent.tsx @@ -7,7 +7,7 @@ Please see LICENSE files in the repository root for full details. */ import mime from "mime"; -import React, { createRef } from "react"; +import React, { type JSX, createRef, useContext, useEffect } from "react"; import { logger } from "matrix-js-sdk/src/logger"; import { EventType, @@ -18,7 +18,9 @@ import { M_POLL_START, type IContent, } from "matrix-js-sdk/src/matrix"; +import { useCreateAutoDisposedViewModel, DecryptionFailureBodyView } from "@element-hq/web-shared-components"; +import { LocalDeviceVerificationStateContext } from "../../../contexts/LocalDeviceVerificationStateContext"; import SettingsStore from "../../../settings/SettingsStore"; import { Mjolnir } from "../../../mjolnir/Mjolnir"; import RedactedBody from "./RedactedBody"; @@ -36,8 +38,8 @@ import MPollBody from "./MPollBody"; import MLocationBody from "./MLocationBody"; import MjolnirBody from "./MjolnirBody"; import MBeaconBody from "./MBeaconBody"; -import { DecryptionFailureBody } from "./DecryptionFailureBody"; import { type GetRelationsForEvent, type IEventTileOps } from "../rooms/EventTile"; +import { DecryptionFailureBodyViewModel } from "../../../viewmodels/message-body/DecryptionFailureBodyViewModel"; // onMessageAllowed is handled internally interface IProps extends Omit { @@ -248,7 +250,7 @@ export default class MessageEvent extends React.Component implements IMe if (!this.props.mxEvent.isRedacted()) { // only resolve BodyType if event is not redacted if (this.props.mxEvent.isDecryptionFailure()) { - BodyType = DecryptionFailureBody; + BodyType = DecryptionFailureBodyWrapper; } else if (type && this.evTypes.has(type)) { BodyType = this.evTypes.get(type)!; } else if (msgtype && this.bodyTypes.has(msgtype)) { @@ -328,3 +330,22 @@ const CaptionBody: React.FunctionComponent ); + +/** + * Bridge decryption-failure events into the view model using current local verification state. + * This wrapper can be removed after MessageEvent has been changed to a function component. + */ +function DecryptionFailureBodyWrapper({ mxEvent, ref }: IBodyProps): JSX.Element { + const verificationState = useContext(LocalDeviceVerificationStateContext); + const vm = useCreateAutoDisposedViewModel( + () => + new DecryptionFailureBodyViewModel({ + decryptionFailureCode: mxEvent.decryptionFailureReason, + verificationState, + }), + ); + useEffect(() => { + vm.setVerificationState(verificationState); + }, [verificationState, vm]); + return ; +} diff --git a/src/components/views/rooms/EventTile.tsx b/src/components/views/rooms/EventTile.tsx index 2546936ab5..93225a43dc 100644 --- a/src/components/views/rooms/EventTile.tsx +++ b/src/components/views/rooms/EventTile.tsx @@ -7,7 +7,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import React, { createRef, type JSX, type Ref, type MouseEvent, type ReactNode } from "react"; +import React, { createRef, useContext, useEffect, type JSX, type Ref, type MouseEvent, type ReactNode } from "react"; import classNames from "classnames"; import { EventStatus, @@ -36,13 +36,14 @@ import { import { Tooltip } from "@vector-im/compound-web"; import { uniqueId } from "lodash"; import { CircleIcon, CheckCircleIcon, ThreadsIcon } from "@vector-im/compound-design-tokens/assets/web/icons"; +import { useCreateAutoDisposedViewModel, DecryptionFailureBodyView } from "@element-hq/web-shared-components"; +import { LocalDeviceVerificationStateContext } from "../../../contexts/LocalDeviceVerificationStateContext"; import ReplyChain from "../elements/ReplyChain"; import { _t } from "../../../languageHandler"; import dis from "../../../dispatcher/dispatcher"; import { Layout } from "../../../settings/enums/Layout"; import { MatrixClientPeg } from "../../../MatrixClientPeg"; -import { DecryptionFailureBody } from "../messages/DecryptionFailureBody"; import RoomAvatar from "../avatars/RoomAvatar"; import MessageContextMenu from "../context_menus/MessageContextMenu"; import { aboveRightOf } from "../../structures/ContextMenu"; @@ -84,6 +85,7 @@ import PinningUtils from "../../../utils/PinningUtils"; import { PinnedMessageBadge } from "../messages/PinnedMessageBadge"; import { EventPreview } from "./EventPreview"; import { ElementCallEventType } from "../../../call-types"; +import { DecryptionFailureBodyViewModel } from "../../../viewmodels/message-body/DecryptionFailureBodyViewModel"; import { E2eMessageSharedIcon } from "./EventTile/E2eMessageSharedIcon.tsx"; import { E2ePadlock, E2ePadlockIcon } from "./EventTile/E2ePadlock.tsx"; @@ -1373,7 +1375,7 @@ export class UnwrappedEventTile extends React.Component {this.props.mxEvent.isRedacted() ? ( ) : this.props.mxEvent.isDecryptionFailure() ? ( - + ) : ( )} @@ -1569,3 +1571,23 @@ function SentReceipt({ messageState }: ISentReceiptProps): JSX.Element { ); } + +/** + * Bridge decryption-failure events into the view model using current local verification state. + * This wrapper can be removed after EventTile has been changed to a function component. + */ +function DecryptionFailureBodyWrapper({ mxEvent }: { mxEvent: MatrixEvent }): JSX.Element { + const verificationState = useContext(LocalDeviceVerificationStateContext); + const vm = useCreateAutoDisposedViewModel( + () => + new DecryptionFailureBodyViewModel({ + decryptionFailureCode: mxEvent.decryptionFailureReason, + verificationState, + }), + ); + useEffect(() => { + vm.setVerificationState(verificationState); + }, [verificationState, vm]); + + return ; +} diff --git a/src/contexts/LocalDeviceVerificationStateContext.ts b/src/contexts/LocalDeviceVerificationStateContext.ts index df5af67252..9d0a24ede0 100644 --- a/src/contexts/LocalDeviceVerificationStateContext.ts +++ b/src/contexts/LocalDeviceVerificationStateContext.ts @@ -1,10 +1,9 @@ /* -Copyright 2024 New Vector Ltd. -Copyright 2024 The Matrix.org Foundation C.I.C. - -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. -*/ + * 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 { createContext } from "react"; @@ -13,7 +12,5 @@ import { createContext } from "react"; * * (Specifically, this is true if we have done enough verification to confirm that the published public cross-signing * keys are genuine -- which normally means that we or another device will have published a signature of this device.) - * - * This context is available to all components under {@link LoggedInView}, via {@link MatrixClientContextProvider}. */ export const LocalDeviceVerificationStateContext = createContext(false); diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index c9e5e1e028..a5b5ec50ca 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -3394,12 +3394,7 @@ "creation_summary_dm": "%(creator)s created this DM.", "creation_summary_room": "%(creator)s created and configured the room.", "decryption_failure": { - "blocked": "The sender has blocked you from receiving this message because your device is unverified", - "historical_event_no_key_backup": "Historical messages are not available on this device", - "historical_event_unverified_device": "You need to verify this device for access to historical messages", - "historical_event_user_not_joined": "You don't have access to this message", "sender_identity_previously_verified": "Sender's verified identity was reset", - "sender_unsigned_device": "Sent from an insecure device.", "unable_to_decrypt": "Unable to decrypt message" }, "disambiguated_profile": "%(displayName)s (%(matrixId)s)", diff --git a/src/viewmodels/message-body/DecryptionFailureBodyViewModel.ts b/src/viewmodels/message-body/DecryptionFailureBodyViewModel.ts new file mode 100644 index 0000000000..965c4a0942 --- /dev/null +++ b/src/viewmodels/message-body/DecryptionFailureBodyViewModel.ts @@ -0,0 +1,100 @@ +/* + * 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 { DecryptionFailureCode } from "matrix-js-sdk/src/crypto-api"; +import { + BaseViewModel, + DecryptionFailureReason, + type DecryptionFailureBodyViewSnapshot as DecryptionFailureBodyViewSnapshotInterface, + type DecryptionFailureBodyViewModel as DecryptionFailureBodyViewModelInterface, +} from "@element-hq/web-shared-components"; + +export interface DecryptionFailureBodyViewModelProps { + /** + * The message event being rendered. + */ + decryptionFailureCode: DecryptionFailureCode | null; + /** + * The local device verification state. + */ + verificationState?: boolean; + /** + * Extra CSS classes to apply to the component + */ + extraClassNames?: string[]; +} + +/** + * ViewModel for the decryption failure body, providing the current state of the component. + */ +export class DecryptionFailureBodyViewModel + extends BaseViewModel + implements DecryptionFailureBodyViewModelInterface +{ + /** + * Convert enum DecryptionFailureCode to enum DecryptionFailureReason. + */ + private static getDecryptionReasonFromCode( + decryptionFailureCode: DecryptionFailureCode | null, + ): DecryptionFailureReason { + switch (decryptionFailureCode) { + case DecryptionFailureCode.HISTORICAL_MESSAGE_BACKUP_UNCONFIGURED: + return DecryptionFailureReason.HISTORICAL_MESSAGE_BACKUP_UNCONFIGURED; + case DecryptionFailureCode.HISTORICAL_MESSAGE_NO_KEY_BACKUP: + return DecryptionFailureReason.HISTORICAL_MESSAGE_NO_KEY_BACKUP; + case DecryptionFailureCode.HISTORICAL_MESSAGE_USER_NOT_JOINED: + return DecryptionFailureReason.HISTORICAL_MESSAGE_USER_NOT_JOINED; + case DecryptionFailureCode.MEGOLM_KEY_WITHHELD_FOR_UNVERIFIED_DEVICE: + return DecryptionFailureReason.MEGOLM_KEY_WITHHELD_FOR_UNVERIFIED_DEVICE; + case DecryptionFailureCode.SENDER_IDENTITY_PREVIOUSLY_VERIFIED: + return DecryptionFailureReason.SENDER_IDENTITY_PREVIOUSLY_VERIFIED; + case DecryptionFailureCode.UNSIGNED_SENDER_DEVICE: + return DecryptionFailureReason.UNSIGNED_SENDER_DEVICE; + default: + return DecryptionFailureReason.UNABLE_TO_DECRYPT; + } + } + + /** + * @param decryptionFailureCode - The decryption failure code for the event. + * @param verificationState - The local device verification state. + * @param extraClassNames - Extra CSS classes to apply to the component. + */ + private static readonly computeSnapshot = ( + decryptionFailureCode: DecryptionFailureCode | null, + verificationState?: boolean, + extraClassNames?: string[], + ): DecryptionFailureBodyViewSnapshotInterface => { + // Keep mx_DecryptionFailureBody and mx_EventTile_content to support the compatibility with existing timeline and the all the layout + const defaultClassNames = ["mx_DecryptionFailureBody", "mx_EventTile_content"]; + return { + decryptionFailureReason: DecryptionFailureBodyViewModel.getDecryptionReasonFromCode(decryptionFailureCode), + isLocalDeviceVerified: verificationState, + extraClassNames: extraClassNames ? defaultClassNames.concat(extraClassNames) : defaultClassNames, + }; + }; + + public constructor(props: DecryptionFailureBodyViewModelProps) { + super( + props, + DecryptionFailureBodyViewModel.computeSnapshot( + props.decryptionFailureCode, + props.verificationState, + props.extraClassNames, + ), + ); + } + + /** + * Updates the properties of the view model and recomputes the snapshot. + * @param verificationState - The updated local device verification state. + */ + public setVerificationState(verificationState?: boolean): void { + this.props.verificationState = verificationState; + this.snapshot.merge({ isLocalDeviceVerified: verificationState }); + } +} diff --git a/test/unit-tests/components/structures/MatrixClientContextProvider-test.tsx b/test/unit-tests/components/structures/MatrixClientContextProvider-test.tsx index 2710dcd57a..b70017ee4f 100644 --- a/test/unit-tests/components/structures/MatrixClientContextProvider-test.tsx +++ b/test/unit-tests/components/structures/MatrixClientContextProvider-test.tsx @@ -11,9 +11,9 @@ import React, { useContext } from "react"; import { type MatrixClient } from "matrix-js-sdk/src/matrix"; import { CryptoEvent, UserVerificationStatus } from "matrix-js-sdk/src/crypto-api"; +import { LocalDeviceVerificationStateContext } from "../../../../src/contexts/LocalDeviceVerificationStateContext"; import MatrixClientContext from "../../../../src/contexts/MatrixClientContext"; import { MatrixClientContextProvider } from "../../../../src/components/structures/MatrixClientContextProvider"; -import { LocalDeviceVerificationStateContext } from "../../../../src/contexts/LocalDeviceVerificationStateContext"; import { flushPromises, getMockClientWithEventEmitter, diff --git a/test/unit-tests/components/views/messages/DecryptionFailureBody-test.tsx b/test/unit-tests/components/views/messages/DecryptionFailureBody-test.tsx deleted file mode 100644 index ce628da309..0000000000 --- a/test/unit-tests/components/views/messages/DecryptionFailureBody-test.tsx +++ /dev/null @@ -1,134 +0,0 @@ -/* - * Copyright 2024 New Vector Ltd. - * Copyright 2023 The Matrix.org Foundation C.I.C. - * - * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial - * Please see LICENSE files in the repository root for full details. - */ - -import React from "react"; -import { render } from "jest-matrix-react"; -import { type MatrixEvent } from "matrix-js-sdk/src/matrix"; -import { mkDecryptionFailureMatrixEvent } from "matrix-js-sdk/src/testing"; -import { DecryptionFailureCode } from "matrix-js-sdk/src/crypto-api"; - -import { mkEvent } from "../../../../test-utils"; -import { DecryptionFailureBody } from "../../../../../src/components/views/messages/DecryptionFailureBody"; -import { LocalDeviceVerificationStateContext } from "../../../../../src/contexts/LocalDeviceVerificationStateContext"; - -describe("DecryptionFailureBody", () => { - function customRender(event: MatrixEvent, localDeviceVerified: boolean = false) { - return render( - - - , - ); - } - - it(`Should display "Unable to decrypt message"`, () => { - // When - const event = mkEvent({ - type: "m.room.message", - room: "myfakeroom", - user: "myfakeuser", - content: { - msgtype: "m.bad.encrypted", - }, - event: true, - }); - const { container } = customRender(event); - - // Then - expect(container).toMatchSnapshot(); - }); - - it(`Should display "The sender has blocked you from receiving this message"`, async () => { - // When - const event = await mkDecryptionFailureMatrixEvent({ - code: DecryptionFailureCode.MEGOLM_KEY_WITHHELD_FOR_UNVERIFIED_DEVICE, - msg: "withheld", - roomId: "myfakeroom", - sender: "myfakeuser", - }); - - const { container } = customRender(event); - - // Then - expect(container).toMatchSnapshot(); - }); - - it("should handle historical messages with no key backup", async () => { - // When - const event = await mkDecryptionFailureMatrixEvent({ - code: DecryptionFailureCode.HISTORICAL_MESSAGE_NO_KEY_BACKUP, - msg: "No backup", - roomId: "fakeroom", - sender: "fakesender", - }); - const { container } = customRender(event); - - // Then - expect(container).toHaveTextContent("Historical messages are not available on this device"); - }); - - it.each([true, false])( - "should handle historical messages when there is a backup and device verification is %s", - async (verified) => { - // When - const event = await mkDecryptionFailureMatrixEvent({ - code: DecryptionFailureCode.HISTORICAL_MESSAGE_BACKUP_UNCONFIGURED, - msg: "Failure", - roomId: "fakeroom", - sender: "fakesender", - }); - const { container } = customRender(event, verified); - - // Then - expect(container).toHaveTextContent( - verified ? "Unable to decrypt" : "You need to verify this device for access to historical messages", - ); - }, - ); - - it("should handle undecryptable pre-join messages", async () => { - // When - const event = await mkDecryptionFailureMatrixEvent({ - code: DecryptionFailureCode.HISTORICAL_MESSAGE_USER_NOT_JOINED, - msg: "Not joined", - roomId: "fakeroom", - sender: "fakesender", - }); - const { container } = customRender(event); - - // Then - expect(container).toHaveTextContent("You don't have access to this message"); - }); - - it("should handle messages from users who change identities after verification", async () => { - // When - const event = await mkDecryptionFailureMatrixEvent({ - code: DecryptionFailureCode.SENDER_IDENTITY_PREVIOUSLY_VERIFIED, - msg: "User previously verified", - roomId: "fakeroom", - sender: "fakesender", - }); - const { container } = customRender(event); - - // Then - expect(container).toMatchSnapshot(); - }); - - it("should handle messages from unverified devices", async () => { - // When - const event = await mkDecryptionFailureMatrixEvent({ - code: DecryptionFailureCode.UNSIGNED_SENDER_DEVICE, - msg: "Unsigned device", - roomId: "fakeroom", - sender: "fakesender", - }); - const { container } = customRender(event); - - // Then - expect(container).toHaveTextContent("Sent from an insecure device"); - }); -}); diff --git a/test/unit-tests/components/views/messages/__snapshots__/DecryptionFailureBody-test.tsx.snap b/test/unit-tests/components/views/messages/__snapshots__/DecryptionFailureBody-test.tsx.snap deleted file mode 100644 index 823b1f5e6c..0000000000 --- a/test/unit-tests/components/views/messages/__snapshots__/DecryptionFailureBody-test.tsx.snap +++ /dev/null @@ -1,45 +0,0 @@ -// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing - -exports[`DecryptionFailureBody Should display "The sender has blocked you from receiving this message" 1`] = ` -
-
- The sender has blocked you from receiving this message because your device is unverified -
-
-`; - -exports[`DecryptionFailureBody Should display "Unable to decrypt message" 1`] = ` -
-
- Unable to decrypt message -
-
-`; - -exports[`DecryptionFailureBody should handle messages from users who change identities after verification 1`] = ` -
-
- - - - - Sender's verified identity was reset - -
-
-`; diff --git a/test/viewmodels/message-body/DecryptionFailureBodyViewModel-test.tsx b/test/viewmodels/message-body/DecryptionFailureBodyViewModel-test.tsx new file mode 100644 index 0000000000..85580842de --- /dev/null +++ b/test/viewmodels/message-body/DecryptionFailureBodyViewModel-test.tsx @@ -0,0 +1,101 @@ +/* + * 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 { DecryptionFailureCode } from "matrix-js-sdk/src/crypto-api"; +import { DecryptionFailureReason } from "@element-hq/web-shared-components"; + +import { DecryptionFailureBodyViewModel } from "../../../src/viewmodels/message-body/DecryptionFailureBodyViewModel"; + +describe("DecryptionFailureBodyViewModel", () => { + it("should return the snapshot", () => { + const vm = new DecryptionFailureBodyViewModel({ + decryptionFailureCode: null, + verificationState: true, + }); + expect(vm.getSnapshot()).toMatchObject({ + decryptionFailureReason: DecryptionFailureReason.UNABLE_TO_DECRYPT, + isLocalDeviceVerified: true, + }); + }); + + it("should return the snapshot with extra class names", () => { + const vm = new DecryptionFailureBodyViewModel({ + decryptionFailureCode: null, + verificationState: true, + extraClassNames: ["custom-class"], + }); + expect(vm.getSnapshot()).toMatchObject({ + decryptionFailureReason: DecryptionFailureReason.UNABLE_TO_DECRYPT, + isLocalDeviceVerified: true, + extraClassNames: ["mx_DecryptionFailureBody", "mx_EventTile_content", "custom-class"], + }); + }); + + it.each([ + { + code: DecryptionFailureCode.HISTORICAL_MESSAGE_BACKUP_UNCONFIGURED, + reason: DecryptionFailureReason.HISTORICAL_MESSAGE_BACKUP_UNCONFIGURED, + }, + { + code: DecryptionFailureCode.HISTORICAL_MESSAGE_NO_KEY_BACKUP, + reason: DecryptionFailureReason.HISTORICAL_MESSAGE_NO_KEY_BACKUP, + }, + { + code: DecryptionFailureCode.HISTORICAL_MESSAGE_USER_NOT_JOINED, + reason: DecryptionFailureReason.HISTORICAL_MESSAGE_USER_NOT_JOINED, + }, + { + code: DecryptionFailureCode.MEGOLM_KEY_WITHHELD, + reason: DecryptionFailureReason.UNABLE_TO_DECRYPT, + }, + { + code: DecryptionFailureCode.MEGOLM_KEY_WITHHELD_FOR_UNVERIFIED_DEVICE, + reason: DecryptionFailureReason.MEGOLM_KEY_WITHHELD_FOR_UNVERIFIED_DEVICE, + }, + { + code: DecryptionFailureCode.MEGOLM_UNKNOWN_INBOUND_SESSION_ID, + reason: DecryptionFailureReason.UNABLE_TO_DECRYPT, + }, + { + code: DecryptionFailureCode.OLM_UNKNOWN_MESSAGE_INDEX, + reason: DecryptionFailureReason.UNABLE_TO_DECRYPT, + }, + { + code: DecryptionFailureCode.SENDER_IDENTITY_PREVIOUSLY_VERIFIED, + reason: DecryptionFailureReason.SENDER_IDENTITY_PREVIOUSLY_VERIFIED, + }, + { + code: DecryptionFailureCode.UNKNOWN_ERROR, + reason: DecryptionFailureReason.UNABLE_TO_DECRYPT, + }, + { + code: DecryptionFailureCode.UNKNOWN_SENDER_DEVICE, + reason: DecryptionFailureReason.UNABLE_TO_DECRYPT, + }, + { + code: DecryptionFailureCode.UNSIGNED_SENDER_DEVICE, + reason: DecryptionFailureReason.UNSIGNED_SENDER_DEVICE, + }, + ])("should return the snapshot with code converted to reason (%s)", ({ code, reason }) => { + const vm = new DecryptionFailureBodyViewModel({ + decryptionFailureCode: code, + }); + + expect(vm.getSnapshot().decryptionFailureReason).toBe(reason); + }); + + it("should update snapshot when setProps is called with new verificationState", () => { + const vm = new DecryptionFailureBodyViewModel({ + decryptionFailureCode: DecryptionFailureCode.UNKNOWN_ERROR, + verificationState: false, + }); + expect(vm.getSnapshot().isLocalDeviceVerified).toBe(false); + + vm.setVerificationState(true); + expect(vm.getSnapshot().isLocalDeviceVerified).toBe(true); + }); +});