diff --git a/packages/shared-components/__vis__/linux/__baselines__/event-tiles/EncryptionEventView/EncryptionEventView.stories.tsx/default-auto.png b/packages/shared-components/__vis__/linux/__baselines__/event-tiles/EncryptionEventView/EncryptionEventView.stories.tsx/default-auto.png new file mode 100644 index 0000000000..14c32d0ae4 Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/event-tiles/EncryptionEventView/EncryptionEventView.stories.tsx/default-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/event-tiles/EncryptionEventView/EncryptionEventView.stories.tsx/disable-attempt-auto.png b/packages/shared-components/__vis__/linux/__baselines__/event-tiles/EncryptionEventView/EncryptionEventView.stories.tsx/disable-attempt-auto.png new file mode 100644 index 0000000000..bd68a47de2 Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/event-tiles/EncryptionEventView/EncryptionEventView.stories.tsx/disable-attempt-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/event-tiles/EncryptionEventView/EncryptionEventView.stories.tsx/enabled-direct-message-auto.png b/packages/shared-components/__vis__/linux/__baselines__/event-tiles/EncryptionEventView/EncryptionEventView.stories.tsx/enabled-direct-message-auto.png new file mode 100644 index 0000000000..edb1d6c214 Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/event-tiles/EncryptionEventView/EncryptionEventView.stories.tsx/enabled-direct-message-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/event-tiles/EncryptionEventView/EncryptionEventView.stories.tsx/enabled-local-room-auto.png b/packages/shared-components/__vis__/linux/__baselines__/event-tiles/EncryptionEventView/EncryptionEventView.stories.tsx/enabled-local-room-auto.png new file mode 100644 index 0000000000..67f0f4d7f3 Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/event-tiles/EncryptionEventView/EncryptionEventView.stories.tsx/enabled-local-room-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/event-tiles/EncryptionEventView/EncryptionEventView.stories.tsx/parameters-changed-auto.png b/packages/shared-components/__vis__/linux/__baselines__/event-tiles/EncryptionEventView/EncryptionEventView.stories.tsx/parameters-changed-auto.png new file mode 100644 index 0000000000..e0c09520c0 Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/event-tiles/EncryptionEventView/EncryptionEventView.stories.tsx/parameters-changed-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/event-tiles/EncryptionEventView/EncryptionEventView.stories.tsx/state-encryption-enabled-auto.png b/packages/shared-components/__vis__/linux/__baselines__/event-tiles/EncryptionEventView/EncryptionEventView.stories.tsx/state-encryption-enabled-auto.png new file mode 100644 index 0000000000..5c529852cc Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/event-tiles/EncryptionEventView/EncryptionEventView.stories.tsx/state-encryption-enabled-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/event-tiles/EncryptionEventView/EncryptionEventView.stories.tsx/unsupported-auto.png b/packages/shared-components/__vis__/linux/__baselines__/event-tiles/EncryptionEventView/EncryptionEventView.stories.tsx/unsupported-auto.png new file mode 100644 index 0000000000..ae08d9e220 Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/event-tiles/EncryptionEventView/EncryptionEventView.stories.tsx/unsupported-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/event-tiles/EncryptionEventView/EncryptionEventView.stories.tsx/with-timestamp-auto.png b/packages/shared-components/__vis__/linux/__baselines__/event-tiles/EncryptionEventView/EncryptionEventView.stories.tsx/with-timestamp-auto.png new file mode 100644 index 0000000000..acaaaa09d2 Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/event-tiles/EncryptionEventView/EncryptionEventView.stories.tsx/with-timestamp-auto.png differ diff --git a/packages/shared-components/src/event-tiles/EncryptionEventView/EncryptionEventView.module.css b/packages/shared-components/src/event-tiles/EncryptionEventView/EncryptionEventView.module.css new file mode 100644 index 0000000000..a49c7f1b05 --- /dev/null +++ b/packages/shared-components/src/event-tiles/EncryptionEventView/EncryptionEventView.module.css @@ -0,0 +1,10 @@ +/* + * 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. + */ + +.error { + color: var(--cpd-color-icon-critical-primary); +} diff --git a/packages/shared-components/src/event-tiles/EncryptionEventView/EncryptionEventView.stories.tsx b/packages/shared-components/src/event-tiles/EncryptionEventView/EncryptionEventView.stories.tsx new file mode 100644 index 0000000000..04066d1dbe --- /dev/null +++ b/packages/shared-components/src/event-tiles/EncryptionEventView/EncryptionEventView.stories.tsx @@ -0,0 +1,82 @@ +/* + * 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 { EncryptionEventView, EncryptionEventState, type EncryptionEventViewSnapshot } from "./EncryptionEventView"; +import { useMockedViewModel } from "../../viewmodel/useMockedViewModel"; + +type EncryptionEventProps = EncryptionEventViewSnapshot; + +const EncryptionEventViewWrapper = ({ ...rest }: EncryptionEventProps): JSX.Element => { + const vm = useMockedViewModel(rest, {}); + + return ; +}; + +export default { + title: "Event/EncryptionEvent", + component: EncryptionEventViewWrapper, + tags: ["autodocs"], + argTypes: { + state: { + options: Object.entries(EncryptionEventState) + .filter(([key, value]) => key === value) + .map(([key]) => key), + control: { type: "select" }, + }, + }, + args: { + state: EncryptionEventState.ENABLED, + encryptedStateEvents: false, + userName: "Alice", + className: "", + }, +} as Meta; + +const Template: StoryFn = (args) => ; + +export const Default = Template.bind({}); + +export const StateEncryptionEnabled = Template.bind({}); +StateEncryptionEnabled.args = { + state: EncryptionEventState.ENABLED, + encryptedStateEvents: true, +}; + +export const ParametersChanged = Template.bind({}); +ParametersChanged.args = { + state: EncryptionEventState.CHANGED, +}; + +export const DisableAttempt = Template.bind({}); +DisableAttempt.args = { + state: EncryptionEventState.DISABLE_ATTEMPT, +}; + +export const EnabledDirectMessage = Template.bind({}); +EnabledDirectMessage.args = { + state: EncryptionEventState.ENABLED_DM, + userName: "Alice", +}; + +export const EnabledLocalRoom = Template.bind({}); +EnabledLocalRoom.args = { + state: EncryptionEventState.ENABLED_LOCAL, +}; + +export const Unsupported = Template.bind({}); +Unsupported.args = { + state: EncryptionEventState.UNSUPPORTED, +}; + +export const WithTimestamp = Template.bind({}); +WithTimestamp.args = { + state: EncryptionEventState.ENABLED, + timestamp: 14:56, +}; diff --git a/packages/shared-components/src/event-tiles/EncryptionEventView/EncryptionEventView.test.tsx b/packages/shared-components/src/event-tiles/EncryptionEventView/EncryptionEventView.test.tsx new file mode 100644 index 0000000000..214890b8ee --- /dev/null +++ b/packages/shared-components/src/event-tiles/EncryptionEventView/EncryptionEventView.test.tsx @@ -0,0 +1,150 @@ +/* + * 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, screen } from "@test-utils"; +import { composeStories } from "@storybook/react-vite"; +import { describe, it, expect } from "vitest"; +import React from "react"; + +import { EncryptionEventState, EncryptionEventView } from "./EncryptionEventView"; +import * as stories from "./EncryptionEventView.stories"; +import { MockViewModel } from "../../viewmodel"; + +const { + Default, + StateEncryptionEnabled, + ParametersChanged, + DisableAttempt, + EnabledDirectMessage, + EnabledLocalRoom, + Unsupported, + WithTimestamp, +} = composeStories(stories); + +describe("EncryptionEventView", () => { + const renderView = ( + state: EncryptionEventState, + encryptedStateEvents?: boolean, + userName?: string, + className?: string, + ): void => { + const vm = new MockViewModel({ + state, + encryptedStateEvents, + userName, + className, + }); + render(); + }; + + it("renders Default story", () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); + + it("renders StateEncryptionEnabled story", () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); + + it("renders ParametersChanged story", () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); + + it("renders DisableAttempt story", () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); + + it("renders EnabledDirectMessage story", () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); + + it("renders EnabledLocalRoom story", () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); + + it("renders Unsupported story", () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); + + it("renders WithTimestamp story", () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); + + it("shows enabled room encryption copy", () => { + renderView(EncryptionEventState.ENABLED); + + expect(screen.getByText("Encryption enabled")).toBeInTheDocument(); + expect( + screen.getByText( + "Messages in this room are end-to-end encrypted. When people join, you can verify them in their profile, just tap on their profile picture.", + ), + ).toBeInTheDocument(); + }); + + it("shows enabled state encryption copy", () => { + renderView(EncryptionEventState.ENABLED, true); + + expect(screen.getByText("Experimental state encryption enabled")).toBeInTheDocument(); + expect( + screen.getByText( + "Messages and state events in this room are end-to-end encrypted. When people join, you can verify them in their profile, just tap on their profile picture.", + ), + ).toBeInTheDocument(); + }); + + it("shows changed encryption parameters copy", () => { + renderView(EncryptionEventState.CHANGED); + + expect(screen.getByText("Encryption enabled")).toBeInTheDocument(); + expect(screen.getByText("Some encryption parameters have been changed.")).toBeInTheDocument(); + }); + + it("shows disable attempt copy", () => { + renderView(EncryptionEventState.DISABLE_ATTEMPT); + + expect(screen.getByText("Encryption enabled")).toBeInTheDocument(); + expect(screen.getByText("Ignored attempt to disable encryption")).toBeInTheDocument(); + }); + + it("shows unsupported encryption copy", () => { + renderView(EncryptionEventState.UNSUPPORTED); + + expect(screen.getByText("Encryption not enabled")).toBeInTheDocument(); + expect(screen.getByText("The encryption used by this room isn't supported.")).toBeInTheDocument(); + }); + + it("shows local room encryption copy", () => { + renderView(EncryptionEventState.ENABLED_LOCAL); + + expect(screen.getByText("Encryption enabled")).toBeInTheDocument(); + expect(screen.getByText("Messages in this chat will be end-to-end encrypted.")).toBeInTheDocument(); + }); + + it("shows dm room encryption copy with display name", () => { + renderView(EncryptionEventState.ENABLED_DM, false, "Alice"); + + expect(screen.getByText("Encryption enabled")).toBeInTheDocument(); + expect( + screen.getByText( + "Messages here are end-to-end encrypted. Verify Alice in their profile - tap on their profile picture.", + ), + ).toBeInTheDocument(); + }); + + it("renders additional class name on the event tile bubble", () => { + renderView(EncryptionEventState.ENABLED, false, undefined, "custom-class"); + + expect(screen.getByText("Encryption enabled").parentElement).toHaveClass("custom-class"); + }); +}); diff --git a/packages/shared-components/src/event-tiles/EncryptionEventView/EncryptionEventView.tsx b/packages/shared-components/src/event-tiles/EncryptionEventView/EncryptionEventView.tsx new file mode 100644 index 0000000000..218ae74da6 --- /dev/null +++ b/packages/shared-components/src/event-tiles/EncryptionEventView/EncryptionEventView.tsx @@ -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 React, { type JSX } from "react"; +import { LockSolidIcon, ErrorSolidIcon } from "@vector-im/compound-design-tokens/assets/web/icons"; + +import { type ViewModel, useViewModel } from "../../viewmodel"; +import styles from "./EncryptionEventView.module.css"; +import { useI18n } from "../../utils/i18nContext"; +import { EventTileBubble } from "../EventTileBubble"; + +export enum EncryptionEventState { + /** Encryption settings changed while encryption stayed enabled. */ + CHANGED = "CHANGED", + /** Someone attempted to disable encryption in an encrypted room. */ + DISABLE_ATTEMPT = "DISABLE_ATTEMPT", + /** Encryption was enabled in a regular room. */ + ENABLED = "ENABLED", + /** Encryption was enabled in a DM room. */ + ENABLED_DM = "ENABLED_DM", + /** Encryption was enabled in a local room. */ + ENABLED_LOCAL = "ENABLED_LOCAL", + /** Encryption is unavailable/unsupported for this event context. */ + UNSUPPORTED = "UNSUPPORTED", +} + +export type EncryptionEventViewSnapshot = { + /** Which encryption event variant to render. */ + state: EncryptionEventState; + /** Whether state-event encryption messaging should be shown. */ + encryptedStateEvents?: boolean; + /** Display name for DM partner, used by ENABLED_DM subtitle text. */ + userName?: string; + /** Optional CSS classes passed through to EventTileBubble. */ + className?: string; + /** Optional timestamp element rendered in the EventTileBubble footer slot. */ + timestamp?: JSX.Element; +}; + +/** + * ViewModel contract consumed by {@link EncryptionEventView}. + */ +export type EncryptionEventViewModel = ViewModel; + +export interface EncryptionEventViewProps { + /** + * ViewModel providing the current encryption event snapshot. + */ + vm: ViewModel; + /** + * Ref forwarded to the root DOM element. + */ + ref?: React.RefObject; +} + +export function EncryptionEventView({ vm, ref }: Readonly): JSX.Element { + const { translate: _t } = useI18n(); + const { state, encryptedStateEvents, userName, className, timestamp } = useViewModel(vm); + + let icon = ; + let title = encryptedStateEvents ? _t("common|state_encryption_enabled") : _t("common|encryption_enabled"); + let subtitle = ""; + + switch (state) { + case EncryptionEventState.CHANGED: + subtitle = _t("timeline|m.room.encryption|parameters_changed"); + break; + case EncryptionEventState.DISABLE_ATTEMPT: + title = _t("common|encryption_enabled"); + subtitle = _t("timeline|m.room.encryption|disable_attempt"); + break; + case EncryptionEventState.ENABLED: + subtitle = encryptedStateEvents + ? _t("timeline|m.room.encryption|state_enabled") + : _t("timeline|m.room.encryption|enabled"); + break; + case EncryptionEventState.ENABLED_DM: + subtitle = _t("timeline|m.room.encryption|enabled_dm", { displayName: userName }); + break; + case EncryptionEventState.ENABLED_LOCAL: + subtitle = _t("timeline|m.room.encryption|enabled_local"); + break; + case EncryptionEventState.UNSUPPORTED: + default: + icon = ; + title = _t("timeline|m.room.encryption|disabled"); + subtitle = _t("timeline|m.room.encryption|unsupported"); + break; + } + + return ( + + {timestamp} + + ); +} diff --git a/packages/shared-components/src/event-tiles/EncryptionEventView/__snapshots__/EncryptionEventView.test.tsx.snap b/packages/shared-components/src/event-tiles/EncryptionEventView/__snapshots__/EncryptionEventView.test.tsx.snap new file mode 100644 index 0000000000..09e39927bb --- /dev/null +++ b/packages/shared-components/src/event-tiles/EncryptionEventView/__snapshots__/EncryptionEventView.test.tsx.snap @@ -0,0 +1,245 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`EncryptionEventView > renders Default story 1`] = ` + + + + + + + Encryption enabled + + + Messages in this room are end-to-end encrypted. When people join, you can verify them in their profile, just tap on their profile picture. + + + +`; + +exports[`EncryptionEventView > renders DisableAttempt story 1`] = ` + + + + + + + Encryption enabled + + + Ignored attempt to disable encryption + + + +`; + +exports[`EncryptionEventView > renders EnabledDirectMessage story 1`] = ` + + + + + + + Encryption enabled + + + Messages here are end-to-end encrypted. Verify Alice in their profile - tap on their profile picture. + + + +`; + +exports[`EncryptionEventView > renders EnabledLocalRoom story 1`] = ` + + + + + + + Encryption enabled + + + Messages in this chat will be end-to-end encrypted. + + + +`; + +exports[`EncryptionEventView > renders ParametersChanged story 1`] = ` + + + + + + + Encryption enabled + + + Some encryption parameters have been changed. + + + +`; + +exports[`EncryptionEventView > renders StateEncryptionEnabled story 1`] = ` + + + + + + + Experimental state encryption enabled + + + Messages and state events in this room are end-to-end encrypted. When people join, you can verify them in their profile, just tap on their profile picture. + + + +`; + +exports[`EncryptionEventView > renders Unsupported story 1`] = ` + + + + + + + Encryption not enabled + + + The encryption used by this room isn't supported. + + + +`; + +exports[`EncryptionEventView > renders WithTimestamp story 1`] = ` + + + + + + + Encryption enabled + + + Messages in this room are end-to-end encrypted. When people join, you can verify them in their profile, just tap on their profile picture. + + + 14:56 + + + +`; diff --git a/packages/shared-components/src/event-tiles/EncryptionEventView/index.ts b/packages/shared-components/src/event-tiles/EncryptionEventView/index.ts new file mode 100644 index 0000000000..746d62db4e --- /dev/null +++ b/packages/shared-components/src/event-tiles/EncryptionEventView/index.ts @@ -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 { + EncryptionEventView, + EncryptionEventState, + type EncryptionEventViewSnapshot, + type EncryptionEventViewModel, +} from "./EncryptionEventView"; diff --git a/packages/shared-components/src/i18n/strings/en_EN.json b/packages/shared-components/src/i18n/strings/en_EN.json index 2ba72dd1c5..cab1f9b45b 100644 --- a/packages/shared-components/src/i18n/strings/en_EN.json +++ b/packages/shared-components/src/i18n/strings/en_EN.json @@ -20,7 +20,9 @@ "start_chat": "Start chat" }, "common": { - "preferences": "Preferences" + "encryption_enabled": "Encryption enabled", + "preferences": "Preferences", + "state_encryption_enabled": "Experimental state encryption enabled" }, "left_panel": { "open_dial_pad": "Open dial pad" @@ -159,6 +161,16 @@ "audio_player": "Audio player", "error_downloading_audio": "Error downloading audio", "unnamed_audio": "Unnamed audio" + }, + "m.room.encryption": { + "disable_attempt": "Ignored attempt to disable encryption", + "disabled": "Encryption not enabled", + "enabled": "Messages in this room are end-to-end encrypted. When people join, you can verify them in their profile, just tap on their profile picture.", + "enabled_dm": "Messages here are end-to-end encrypted. Verify %(displayName)s in their profile - tap on their profile picture.", + "enabled_local": "Messages in this chat will be end-to-end encrypted.", + "parameters_changed": "Some encryption parameters have been changed.", + "state_enabled": "Messages and state events in this room are end-to-end encrypted. When people join, you can verify them in their profile, just tap on their profile picture.", + "unsupported": "The encryption used by this room isn't supported." } }, "widget": { diff --git a/packages/shared-components/src/index.ts b/packages/shared-components/src/index.ts index 4a0a971dfd..64b92ade5b 100644 --- a/packages/shared-components/src/index.ts +++ b/packages/shared-components/src/index.ts @@ -13,6 +13,7 @@ export * from "./audio/SeekBar"; export * from "./avatar/AvatarWithDetails"; export * from "./composer/Banner"; export * from "./crypto/SasEmoji"; +export * from "./event-tiles/EncryptionEventView"; export * from "./event-tiles/EventTileBubble"; export * from "./event-tiles/TextualEventView"; export * from "./message-body/MediaBody"; diff --git a/src/components/structures/RoomView.tsx b/src/components/structures/RoomView.tsx index 76177bc5c8..a8050571b2 100644 --- a/src/components/structures/RoomView.tsx +++ b/src/components/structures/RoomView.tsx @@ -46,7 +46,11 @@ import { debounce, throttle } from "lodash"; import { CryptoEvent } from "matrix-js-sdk/src/crypto-api"; import { type ViewRoomOpts } from "@matrix-org/react-sdk-module-api/lib/lifecycles/RoomViewLifecycle"; import { type RoomViewProps } from "@element-hq/element-web-module-api"; -import { RoomStatusBarView, useCreateAutoDisposedViewModel } from "@element-hq/web-shared-components"; +import { + EncryptionEventView, + RoomStatusBarView, + useCreateAutoDisposedViewModel, +} from "@element-hq/web-shared-components"; import shouldHideEvent from "../../shouldHideEvent"; import { _t } from "../../languageHandler"; @@ -69,6 +73,7 @@ import { TimelineRenderingType, MainSplitContentType } from "../../contexts/Room import { E2EStatus, shieldStatusForRoom } from "../../utils/ShieldUtils"; import { Action } from "../../dispatcher/actions"; import { type IMatrixClientCreds } from "../../MatrixClientPeg"; +import { useMatrixClientContext } from "../../contexts/MatrixClientContext"; import ScrollPanel from "./ScrollPanel"; import TimelinePanel from "./TimelinePanel"; import ErrorBoundary from "../views/elements/ErrorBoundary"; @@ -111,7 +116,6 @@ import { type FocusComposerPayload } from "../../dispatcher/payloads/FocusCompos import { LocalRoom, LocalRoomState } from "../../models/LocalRoom"; import { createRoomFromLocalRoom } from "../../utils/direct-messages"; import NewRoomIntro from "../views/rooms/NewRoomIntro"; -import EncryptionEvent from "../views/messages/EncryptionEvent"; import { isLocalRoom } from "../../utils/localRoom/isLocalRoom"; import { type ShowThreadPayload } from "../../dispatcher/payloads/ShowThreadPayload"; import { LargeLoader } from "./LargeLoader"; @@ -136,6 +140,7 @@ import { type FocusMessageSearchPayload } from "../../dispatcher/payloads/FocusM import { isRoomEncrypted } from "../../hooks/useIsEncrypted"; import { type RoomViewStore } from "../../stores/RoomViewStore.tsx"; import { RoomStatusBarViewModel } from "../../viewmodels/room/RoomStatusBar.ts"; +import { EncryptionEventViewModel } from "../../viewmodels/event-tiles/EncryptionEventViewModel.ts"; const DEBUG = false; const PREVENT_MULTIPLE_JITSI_WITHIN = 30_000; @@ -313,7 +318,7 @@ function LocalRoomView(props: LocalRoomViewProps): ReactElement { let encryptionTile: ReactNode; if (encryptionEvent) { - encryptionTile = ; + encryptionTile = ; } let statusBar: ReactElement | null = null; @@ -405,6 +410,15 @@ function RoomStatusBarWrappedView(props: ConstructorParameters; } +/** + * Wrap an EncryptionEventView and ViewModel into one component, for usage with legacy React components. + */ +function EncryptionEventWrappedView({ mxEvent }: { mxEvent: MatrixEvent }): ReactElement | null { + const cli = useMatrixClientContext(); + const vm = useCreateAutoDisposedViewModel(() => new EncryptionEventViewModel({ mxEvent, cli })); + return ; +} + export class RoomView extends React.Component { // We cache the latest computed e2eStatus per room to show as soon as we switch rooms otherwise defaulting to // unencrypted causes a flicker which can yield confusion/concern in a larger room. diff --git a/src/components/views/messages/EncryptionEvent.tsx b/src/components/views/messages/EncryptionEvent.tsx deleted file mode 100644 index a8467e0519..0000000000 --- a/src/components/views/messages/EncryptionEvent.tsx +++ /dev/null @@ -1,98 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2020 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, { type JSX, type ReactNode } from "react"; -import { type MatrixEvent } from "matrix-js-sdk/src/matrix"; -import { ErrorSolidIcon, LockSolidIcon } from "@vector-im/compound-design-tokens/assets/web/icons"; -import { EventTileBubble } from "@element-hq/web-shared-components"; - -import type { RoomEncryptionEventContent } from "matrix-js-sdk/src/types"; -import { _t } from "../../../languageHandler"; -import { useMatrixClientContext } from "../../../contexts/MatrixClientContext"; -import DMRoomMap from "../../../utils/DMRoomMap"; -import { objectHasDiff } from "../../../utils/objects"; -import { isLocalRoom } from "../../../utils/localRoom/isLocalRoom"; -import { MEGOLM_ENCRYPTION_ALGORITHM } from "../../../utils/crypto"; -import { useIsEncrypted } from "../../../hooks/useIsEncrypted.ts"; - -interface IProps { - mxEvent: MatrixEvent; - timestamp?: JSX.Element; - ref?: React.RefObject; -} - -const EncryptionEvent = ({ mxEvent, timestamp, ref }: IProps): ReactNode => { - const cli = useMatrixClientContext(); - const roomId = mxEvent.getRoomId()!; - const isRoomEncrypted = useIsEncrypted(cli, cli.getRoom(roomId) || undefined); - - const prevContent = mxEvent.getPrevContent() as RoomEncryptionEventContent; - const content = mxEvent.getContent(); - - // if no change happened then skip rendering this, a shallow check is enough as all known fields are top-level. - if (!objectHasDiff(prevContent, content)) return null; // nop - - if (content.algorithm === MEGOLM_ENCRYPTION_ALGORITHM && isRoomEncrypted) { - let subtitle: string; - const dmPartner = DMRoomMap.shared().getUserIdForRoomId(roomId); - const room = cli?.getRoom(roomId); - - const stateEncrypted = content["io.element.msc4362.encrypt_state_events"] && cli.enableEncryptedStateEvents; - - if (prevContent.algorithm === MEGOLM_ENCRYPTION_ALGORITHM) { - subtitle = _t("timeline|m.room.encryption|parameters_changed"); - } else if (dmPartner) { - const displayName = room?.getMember(dmPartner)?.rawDisplayName || dmPartner; - subtitle = _t("timeline|m.room.encryption|enabled_dm", { displayName }); - } else if (room && isLocalRoom(room)) { - subtitle = _t("timeline|m.room.encryption|enabled_local"); - } else if (stateEncrypted) { - subtitle = _t("timeline|m.room.encryption|state_enabled"); - } else { - subtitle = _t("timeline|m.room.encryption|enabled"); - } - - return ( - } - className="mx_EventTileBubble mx_cryptoEvent mx_cryptoEvent_icon" - title={stateEncrypted ? _t("common|state_encryption_enabled") : _t("common|encryption_enabled")} - subtitle={subtitle} - > - {timestamp} - - ); - } - - if (isRoomEncrypted) { - return ( - } - className="mx_EventTileBubble mx_cryptoEvent mx_cryptoEvent_icon" - title={_t("common|encryption_enabled")} - subtitle={_t("timeline|m.room.encryption|disable_attempt")} - > - {timestamp} - - ); - } - - return ( - } - className="mx_EventTileBubble mx_cryptoEvent" - title={_t("timeline|m.room.encryption|disabled")} - subtitle={_t("timeline|m.room.encryption|unsupported")} - ref={ref} - > - {timestamp} - - ); -}; - -export default EncryptionEvent; diff --git a/src/events/EventTileFactory.tsx b/src/events/EventTileFactory.tsx index 3c3735ab81..41286c5075 100644 --- a/src/events/EventTileFactory.tsx +++ b/src/events/EventTileFactory.tsx @@ -17,7 +17,11 @@ import { M_POLL_END, M_POLL_START, } from "matrix-js-sdk/src/matrix"; -import { TextualEventView } from "@element-hq/web-shared-components"; +import { + EncryptionEventView, + TextualEventView, + useCreateAutoDisposedViewModel, +} from "@element-hq/web-shared-components"; import SettingsStore from "../settings/SettingsStore"; import type LegacyCallEventGrouper from "../components/structures/LegacyCallEventGrouper"; @@ -26,12 +30,12 @@ import { TimelineRenderingType } from "../contexts/RoomContext"; import MessageEvent from "../components/views/messages/MessageEvent"; import LegacyCallEvent from "../components/views/messages/LegacyCallEvent"; import { CallEvent } from "../components/views/messages/CallEvent"; -import EncryptionEvent from "../components/views/messages/EncryptionEvent"; import { RoomPredecessorTile } from "../components/views/messages/RoomPredecessorTile"; import RoomAvatarEvent from "../components/views/messages/RoomAvatarEvent"; import { WIDGET_LAYOUT_EVENT_TYPE } from "../stores/widgets/WidgetLayoutStore"; import { ALL_RULE_TYPES } from "../mjolnir/BanList"; import { MatrixClientPeg } from "../MatrixClientPeg"; +import { useMatrixClientContext } from "../contexts/MatrixClientContext"; import MKeyVerificationRequest from "../components/views/messages/MKeyVerificationRequest"; import { WidgetType } from "../widgets/WidgetType"; import MJitsiWidgetEvent from "../components/views/messages/MJitsiWidgetEvent"; @@ -42,6 +46,7 @@ import ViewSourceEvent from "../components/views/messages/ViewSourceEvent"; import { shouldDisplayAsBeaconTile } from "../utils/beacon/timeline"; import { type IBodyProps } from "../components/views/messages/IBodyProps"; import { ModuleApi } from "../modules/Api"; +import { EncryptionEventViewModel } from "../viewmodels/event-tiles/EncryptionEventViewModel"; import { TextualEventViewModel } from "../viewmodels/event-tiles/TextualEventViewModel"; import { ElementCallEventType } from "../call-types"; @@ -80,6 +85,14 @@ export const TextualEventFactory: Factory = (ref, props) => { const vm = new TextualEventViewModel(props); return ; }; +function EncryptionEventWrappedView({ mxEvent, ref }: IBodyProps): JSX.Element { + const cli = useMatrixClientContext(); + const vm = useCreateAutoDisposedViewModel(() => new EncryptionEventViewModel({ mxEvent, cli })); + return ; +} +const EncryptionEventFactory: Factory = (ref, props) => { + return ; +}; const VerificationReqFactory: Factory = (_ref, props) => ; const HiddenEventFactory: Factory = (ref, props) => ; @@ -99,7 +112,7 @@ const EVENT_TILE_TYPES = new Map([ ]); const STATE_EVENT_TILE_TYPES = new Map([ - [EventType.RoomEncryption, (ref, props) => ], + [EventType.RoomEncryption, EncryptionEventFactory], [EventType.RoomCanonicalAlias, TextualEventFactory], [EventType.RoomCreate, RoomCreateEventFactory], [EventType.RoomMember, TextualEventFactory], diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index d28e456bab..cb57da8eeb 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -482,7 +482,6 @@ "email_address": "Email address", "emoji": "Emoji", "encrypted": "Encrypted", - "encryption_enabled": "Encryption enabled", "error": "Error", "faq": "FAQ", "favourites": "Favourites", @@ -3461,16 +3460,6 @@ "unknown_predecessor": "Can't find the old version of this room (room ID: %(roomId)s), and we have not been provided with 'via_servers' to look for it.", "unknown_predecessor_guess_server": "Can't find the old version of this room (room ID: %(roomId)s), and we have not been provided with 'via_servers' to look for it. It's possible that guessing the server from the room ID will work. If you want to try, click this link:" }, - "m.room.encryption": { - "disable_attempt": "Ignored attempt to disable encryption", - "disabled": "Encryption not enabled", - "enabled": "Messages in this room are end-to-end encrypted. When people join, you can verify them in their profile, just tap on their profile picture.", - "enabled_dm": "Messages here are end-to-end encrypted. Verify %(displayName)s in their profile - tap on their profile picture.", - "enabled_local": "Messages in this chat will be end-to-end encrypted.", - "parameters_changed": "Some encryption parameters have been changed.", - "state_enabled": "Messages and state events in this room are end-to-end encrypted. When people join, you can verify them in their profile, just tap on their profile picture.", - "unsupported": "The encryption used by this room isn't supported." - }, "m.room.guest_access": { "can_join": "%(senderDisplayName)s has allowed guests to join the room.", "forbidden": "%(senderDisplayName)s has prevented guests from joining the room.", diff --git a/src/viewmodels/event-tiles/EncryptionEventViewModel.ts b/src/viewmodels/event-tiles/EncryptionEventViewModel.ts new file mode 100644 index 0000000000..03a840b621 --- /dev/null +++ b/src/viewmodels/event-tiles/EncryptionEventViewModel.ts @@ -0,0 +1,122 @@ +/* + * 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 { type JSX } from "react"; +import { RoomStateEvent, type MatrixClient, type MatrixEvent } from "matrix-js-sdk/src/matrix"; +import { + BaseViewModel, + EncryptionEventState, + type EncryptionEventViewSnapshot as EncryptionEventViewSnapshotInterface, + type EncryptionEventViewModel as EncryptionEventViewModelInterface, +} from "@element-hq/web-shared-components"; + +import type { RoomEncryptionEventContent } from "matrix-js-sdk/src/types"; +import DMRoomMap from "../../utils/DMRoomMap"; +import { MEGOLM_ENCRYPTION_ALGORITHM } from "../../utils/crypto"; +import { isLocalRoom } from "../../utils/localRoom/isLocalRoom"; +import { isRoomEncrypted } from "../../hooks/useIsEncrypted"; +import { objectHasDiff } from "../../utils/objects"; + +export interface EncryptionEventViewModelProps { + /** Caller-provided client. */ + cli: MatrixClient; + /** Encryption state event to derive tile state from. */ + mxEvent: MatrixEvent; + /** Optional timestamp element rendered in the tile footer slot. */ + timestamp?: JSX.Element; +} + +export class EncryptionEventViewModel + extends BaseViewModel + implements EncryptionEventViewModelInterface +{ + public constructor(props: EncryptionEventViewModelProps) { + super( + props, + EncryptionEventViewModel.calculateSnapshot(props, EncryptionEventViewModel.getInitialIsEncrypted(props)), + ); + void this.refreshSnapshotFromEvent(); + + const roomId = this.props.mxEvent.getRoomId()!; + const room = this.props.cli.getRoom(roomId); + if (room) { + // Recompute when room state changes (including encryption state updates). + this.disposables.trackListener(room, RoomStateEvent.Update, () => void this.refreshSnapshotFromEvent()); + } + } + + private refreshSnapshotFromEvent = async (): Promise => { + const roomId = this.props.mxEvent.getRoomId()!; + const room = this.props.cli.getRoom(roomId); + const crypto = this.props.cli.getCrypto(); + const isEncrypted = Boolean(room && crypto && (await isRoomEncrypted(room, crypto))); + const nextSnapshot = EncryptionEventViewModel.calculateSnapshot(this.props, isEncrypted); + + if (objectHasDiff(this.snapshot.current, nextSnapshot)) { + this.snapshot.set(nextSnapshot); + } + }; + + private static getInitialIsEncrypted(props: EncryptionEventViewModelProps): boolean { + const roomId = props.mxEvent.getRoomId()!; + const room = props.cli.getRoom(roomId); + if (!room) return false; + + if (isLocalRoom(room)) { + const localRoom = room as { isEncryptionEnabled?: () => boolean }; + return Boolean(localRoom.isEncryptionEnabled?.()); + } + + return room.hasEncryptionStateEvent(); + } + + private static calculateSnapshot( + props: EncryptionEventViewModelProps, + isEncrypted: boolean, + ): EncryptionEventViewSnapshotInterface { + // Keep legacy class names for compatibility with existing timeline layout and styling. + const newSnapshot: EncryptionEventViewSnapshotInterface = { + state: EncryptionEventState.CHANGED, + encryptedStateEvents: undefined, + userName: undefined, + timestamp: props.timestamp, + className: "mx_EventTileBubble mx_cryptoEvent mx_cryptoEvent_icon", + }; + + const content = props.mxEvent.getContent(); + if (isEncrypted && content.algorithm === MEGOLM_ENCRYPTION_ALGORITHM) { + const roomId = props.mxEvent.getRoomId()!; + const room = props.cli.getRoom(roomId); + const isRoomLocal = isLocalRoom(room); + const prevContent = props.mxEvent.getPrevContent() as RoomEncryptionEventContent; + const dmPartner = roomId ? DMRoomMap.shared().getUserIdForRoomId(roomId) : undefined; + const stateEncrypted = Boolean( + content["io.element.msc4362.encrypt_state_events"] && props.cli.enableEncryptedStateEvents, + ); + + newSnapshot.state = EncryptionEventState.ENABLED; + newSnapshot.encryptedStateEvents = stateEncrypted; + + if (prevContent.algorithm === MEGOLM_ENCRYPTION_ALGORITHM) { + newSnapshot.state = EncryptionEventState.CHANGED; + } else if (dmPartner) { + newSnapshot.state = EncryptionEventState.ENABLED_DM; + newSnapshot.userName = room?.getMember(dmPartner)?.rawDisplayName ?? dmPartner; + } else if (isRoomLocal) { + newSnapshot.state = EncryptionEventState.ENABLED_LOCAL; + } + } else if (isEncrypted) { + newSnapshot.state = EncryptionEventState.DISABLE_ATTEMPT; + } else { + newSnapshot.state = EncryptionEventState.UNSUPPORTED; + // Unsupported branch matches legacy EncryptionEvent class usage (no icon class). + newSnapshot.className = "mx_EventTileBubble mx_cryptoEvent"; + } + + return newSnapshot; + } +} diff --git a/test/unit-tests/components/views/messages/EncryptionEvent-test.tsx b/test/unit-tests/components/views/messages/EncryptionEvent-test.tsx deleted file mode 100644 index 3375b6b0b5..0000000000 --- a/test/unit-tests/components/views/messages/EncryptionEvent-test.tsx +++ /dev/null @@ -1,145 +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. -*/ - -import React from "react"; -import { mocked } from "jest-mock"; -import { type MatrixClient, type MatrixEvent, Room } from "matrix-js-sdk/src/matrix"; -import { render, screen } from "jest-matrix-react"; -import { waitFor } from "@testing-library/dom"; - -import EncryptionEvent from "../../../../../src/components/views/messages/EncryptionEvent"; -import { createTestClient, mkMessage } from "../../../../test-utils"; -import { MatrixClientPeg } from "../../../../../src/MatrixClientPeg"; -import { LocalRoom } from "../../../../../src/models/LocalRoom"; -import DMRoomMap from "../../../../../src/utils/DMRoomMap"; -import MatrixClientContext from "../../../../../src/contexts/MatrixClientContext"; - -const renderEncryptionEvent = (client: MatrixClient, event: MatrixEvent) => { - render( - - - , - ); -}; - -const checkTexts = async (title: string, subTitle: string) => { - await screen.findByText(title); - await screen.findByText(subTitle); -}; - -describe("EncryptionEvent", () => { - const roomId = "!room:example.com"; - const algorithm = "m.megolm.v1.aes-sha2"; - let client: MatrixClient; - let event: MatrixEvent; - - beforeEach(() => { - jest.clearAllMocks(); - client = createTestClient(); - jest.spyOn(MatrixClientPeg, "get").mockReturnValue(client); - jest.spyOn(MatrixClientPeg, "safeGet").mockReturnValue(client); - event = mkMessage({ - event: true, - room: roomId, - user: client.getUserId()!, - }); - jest.spyOn(DMRoomMap, "shared").mockReturnValue({ - getUserIdForRoomId: jest.fn(), - } as unknown as DMRoomMap); - }); - - describe("for an encrypted room", () => { - beforeEach(() => { - event.event.content!.algorithm = algorithm; - jest.spyOn(client.getCrypto()!, "isEncryptionEnabledInRoom").mockResolvedValue(true); - const room = new Room(roomId, client, client.getUserId()!); - mocked(client.getRoom).mockReturnValue(room); - }); - - it("should show the expected texts", async () => { - renderEncryptionEvent(client, event); - await waitFor(() => - checkTexts( - "Encryption enabled", - "Messages in this room are end-to-end encrypted. " + - "When people join, you can verify them in their profile, just tap on their profile picture.", - ), - ); - }); - - it("should show the expected texts for experimental state event encryption", async () => { - client.enableEncryptedStateEvents = true; - event.event.content!["io.element.msc4362.encrypt_state_events"] = true; - renderEncryptionEvent(client, event); - await waitFor(() => - checkTexts( - "Experimental state encryption enabled", - "Messages and state events in this room are end-to-end encrypted. " + - "When people join, you can verify them in their profile, " + - "just tap on their profile picture.", - ), - ); - }); - - describe("with same previous algorithm", () => { - beforeEach(() => { - jest.spyOn(event, "getPrevContent").mockReturnValue({ - algorithm: algorithm, - }); - }); - - it("should show the expected texts", async () => { - renderEncryptionEvent(client, event); - await waitFor(() => checkTexts("Encryption enabled", "Some encryption parameters have been changed.")); - }); - }); - - describe("with unknown algorithm", () => { - beforeEach(() => { - event.event.content!.algorithm = "unknown"; - }); - - it("should show the expected texts", async () => { - renderEncryptionEvent(client, event); - await waitFor(() => checkTexts("Encryption enabled", "Ignored attempt to disable encryption")); - }); - }); - }); - - describe("for an unencrypted room", () => { - beforeEach(() => { - jest.spyOn(client.getCrypto()!, "isEncryptionEnabledInRoom").mockResolvedValue(false); - renderEncryptionEvent(client, event); - }); - - it("should show the expected texts", async () => { - expect(client.getCrypto()!.isEncryptionEnabledInRoom).toHaveBeenCalledWith(roomId); - await waitFor(() => - checkTexts("Encryption not enabled", "The encryption used by this room isn't supported."), - ); - }); - }); - - describe("for an encrypted local room", () => { - let localRoom: LocalRoom; - - beforeEach(() => { - event.event.content!.algorithm = algorithm; - // jest.spyOn(client.getCrypto()!, "isEncryptionEnabledInRoom").mockResolvedValue(true); - localRoom = new LocalRoom(roomId, client, client.getUserId()!); - jest.spyOn(localRoom, "isEncryptionEnabled").mockReturnValue(true); - mocked(client.getRoom).mockReturnValue(localRoom); - renderEncryptionEvent(client, event); - }); - - it("should show the expected texts", async () => { - expect(localRoom.isEncryptionEnabled).toHaveBeenCalled(); - await checkTexts("Encryption enabled", "Messages in this chat will be end-to-end encrypted."); - }); - }); -}); diff --git a/test/viewmodels/event-tiles/EncryptionEventViewModel-test.ts b/test/viewmodels/event-tiles/EncryptionEventViewModel-test.ts new file mode 100644 index 0000000000..9da5bdd09c --- /dev/null +++ b/test/viewmodels/event-tiles/EncryptionEventViewModel-test.ts @@ -0,0 +1,192 @@ +/* + * 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 { waitFor } from "@testing-library/dom"; +import { mocked } from "jest-mock"; +import { RoomStateEvent, type MatrixClient, type MatrixEvent, type Room } from "matrix-js-sdk/src/matrix"; +import { EncryptionEventState } from "@element-hq/web-shared-components"; + +import type { RoomEncryptionEventContent } from "matrix-js-sdk/src/types"; +import { EncryptionEventViewModel } from "../../../src/viewmodels/event-tiles/EncryptionEventViewModel"; +import { LocalRoom } from "../../../src/models/LocalRoom"; +import DMRoomMap from "../../../src/utils/DMRoomMap"; +import { mkEvent, stubClient } from "../../test-utils"; + +describe("EncryptionEventViewModel", () => { + const roomId = "!room:example.com"; + const algorithm = "m.megolm.v1.aes-sha2"; + let client: MatrixClient; + let event: MatrixEvent; + let room: Room; + + beforeEach(() => { + jest.clearAllMocks(); + client = stubClient(); + room = client.getRoom(roomId)!; + mocked(client.getRoom).mockReturnValue(room); + event = mkEvent({ + event: true, + room: roomId, + user: client.getUserId()!, + type: "m.room.encryption", + content: { + algorithm, + }, + prev_content: {}, + }); + jest.spyOn(DMRoomMap, "shared").mockReturnValue({ + getUserIdForRoomId: jest.fn(), + } as unknown as DMRoomMap); + }); + + const setRoomEncrypted = (encrypted: boolean): void => { + const crypto = client.getCrypto()!; + mocked(crypto.isEncryptionEnabledInRoom).mockResolvedValue(encrypted); + }; + + const createVm = ( + props: Partial[0]> = {}, + ): EncryptionEventViewModel => + new EncryptionEventViewModel({ + mxEvent: event, + cli: client, + ...props, + }); + + it("sets ENABLED for encrypted room", async () => { + setRoomEncrypted(true); + + const vm = createVm(); + await waitFor(() => expect(vm.getSnapshot().state).toBe(EncryptionEventState.ENABLED)); + expect(vm.getSnapshot()).toMatchObject({ + state: EncryptionEventState.ENABLED, + className: "mx_EventTileBubble mx_cryptoEvent mx_cryptoEvent_icon", + encryptedStateEvents: false, + }); + }); + + it("uses synchronous room encryption state for the initial snapshot", () => { + jest.spyOn(room, "hasEncryptionStateEvent").mockReturnValue(true); + setRoomEncrypted(false); + + const vm = createVm(); + expect(vm.getSnapshot().state).toBe(EncryptionEventState.ENABLED); + }); + + it("sets ENABLED with encryptedStateEvents=true for encrypted state events", async () => { + setRoomEncrypted(true); + client.enableEncryptedStateEvents = true; + (event.getContent() as RoomEncryptionEventContent)["io.element.msc4362.encrypt_state_events"] = true; + + const vm = createVm(); + await waitFor(() => expect(vm.getSnapshot().state).toBe(EncryptionEventState.ENABLED)); + expect(vm.getSnapshot().encryptedStateEvents).toBe(true); + }); + + it("sets CHANGED when previous algorithm is already megolm", async () => { + setRoomEncrypted(true); + event = mkEvent({ + event: true, + room: roomId, + user: client.getUserId()!, + type: "m.room.encryption", + content: { + algorithm, + rotation_period_ms: 1, + }, + prev_content: { algorithm }, + }); + + const vm = createVm(); + await waitFor(() => expect(vm.getSnapshot().state).toBe(EncryptionEventState.CHANGED)); + }); + + it("sets DISABLE_ATTEMPT for unknown algorithm in encrypted room", async () => { + setRoomEncrypted(true); + event = mkEvent({ + event: true, + room: roomId, + user: client.getUserId()!, + type: "m.room.encryption", + content: { algorithm: "unknown" }, + prev_content: {}, + }); + + const vm = createVm(); + await waitFor(() => expect(vm.getSnapshot().state).toBe(EncryptionEventState.DISABLE_ATTEMPT)); + }); + + it("sets UNSUPPORTED for unencrypted room", async () => { + setRoomEncrypted(false); + + const vm = createVm(); + await waitFor(() => expect(vm.getSnapshot().state).toBe(EncryptionEventState.UNSUPPORTED)); + expect(vm.getSnapshot().className).toBe("mx_EventTileBubble mx_cryptoEvent"); + }); + + it("sets ENABLED_DM with partner display name", async () => { + setRoomEncrypted(true); + jest.spyOn(DMRoomMap, "shared").mockReturnValue({ + getUserIdForRoomId: jest.fn().mockReturnValue("@alice:example.com"), + } as unknown as DMRoomMap); + mocked(room.getMember).mockReturnValue({ + rawDisplayName: "Alice", + } as unknown as ReturnType); + + const vm = createVm(); + await waitFor(() => expect(vm.getSnapshot().state).toBe(EncryptionEventState.ENABLED_DM)); + expect(vm.getSnapshot().userName).toBe("Alice"); + }); + + it("sets ENABLED_LOCAL for encrypted local room", async () => { + const localRoomId = "local+123"; + const localRoom = new LocalRoom(localRoomId, client, client.getUserId()!); + jest.spyOn(localRoom, "isEncryptionEnabled").mockReturnValue(true); + mocked(client.getRoom).mockReturnValue(localRoom); + event = mkEvent({ + event: true, + room: localRoomId, + user: client.getUserId()!, + type: "m.room.encryption", + content: { algorithm }, + prev_content: {}, + }); + jest.spyOn(DMRoomMap, "shared").mockReturnValue({ + getUserIdForRoomId: jest.fn(), + } as unknown as DMRoomMap); + + const vm = createVm(); + await waitFor(() => expect(vm.getSnapshot().state).toBe(EncryptionEventState.ENABLED_LOCAL)); + expect(localRoom.isEncryptionEnabled).toHaveBeenCalled(); + }); + + it("recomputes snapshot on RoomStateEvent.Update", async () => { + setRoomEncrypted(false); + const vm = createVm(); + await waitFor(() => expect(vm.getSnapshot().state).toBe(EncryptionEventState.UNSUPPORTED)); + + setRoomEncrypted(true); + room.emit(RoomStateEvent.Update, room.currentState); + + await waitFor(() => expect(vm.getSnapshot().state).toBe(EncryptionEventState.ENABLED)); + }); + + it("does not emit updates when snapshot is unchanged", async () => { + setRoomEncrypted(true); + const vm = createVm(); + await waitFor(() => expect(vm.getSnapshot().state).toBe(EncryptionEventState.ENABLED)); + + const listener = jest.fn(); + const unsubscribe = vm.subscribe(listener); + + room.emit(RoomStateEvent.Update, room.currentState); + + await waitFor(() => expect(mocked(client.getCrypto()!.isEncryptionEnabledInRoom)).toHaveBeenCalledTimes(2)); + expect(listener).not.toHaveBeenCalled(); + unsubscribe(); + }); +});