Refactor EncryptionEvent using MVVM and move to shared-components (#32531)
* Refactor EncryptionEvent using MVVM and move to shared-components * Added viewmodel and unit tests for bothe viewmodel and component. * Added test for custom-class * Update EventTileFactory and RoomView to use the new component * Clean up unused language strings from element-web * Changed how the view model is created * Make sure the initial snapshot mimics the previous component * Optimizing viewmodel initial snapshot and update * Updated playwright screenshots
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 8.4 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 8.8 KiB |
|
After Width: | Height: | Size: 8.9 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 9.0 KiB |
|
After Width: | Height: | Size: 13 KiB |
@@ -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);
|
||||||
|
}
|
||||||
@@ -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 <EncryptionEventView vm={vm} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
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<typeof EncryptionEventViewWrapper>;
|
||||||
|
|
||||||
|
const Template: StoryFn<typeof EncryptionEventViewWrapper> = (args) => <EncryptionEventViewWrapper {...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: <span>14:56</span>,
|
||||||
|
};
|
||||||
@@ -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(<EncryptionEventView vm={vm} />);
|
||||||
|
};
|
||||||
|
|
||||||
|
it("renders Default story", () => {
|
||||||
|
const { container } = render(<Default />);
|
||||||
|
expect(container).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders StateEncryptionEnabled story", () => {
|
||||||
|
const { container } = render(<StateEncryptionEnabled />);
|
||||||
|
expect(container).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders ParametersChanged story", () => {
|
||||||
|
const { container } = render(<ParametersChanged />);
|
||||||
|
expect(container).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders DisableAttempt story", () => {
|
||||||
|
const { container } = render(<DisableAttempt />);
|
||||||
|
expect(container).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders EnabledDirectMessage story", () => {
|
||||||
|
const { container } = render(<EnabledDirectMessage />);
|
||||||
|
expect(container).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders EnabledLocalRoom story", () => {
|
||||||
|
const { container } = render(<EnabledLocalRoom />);
|
||||||
|
expect(container).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders Unsupported story", () => {
|
||||||
|
const { container } = render(<Unsupported />);
|
||||||
|
expect(container).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders WithTimestamp story", () => {
|
||||||
|
const { container } = render(<WithTimestamp />);
|
||||||
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<EncryptionEventViewSnapshot>;
|
||||||
|
|
||||||
|
export interface EncryptionEventViewProps {
|
||||||
|
/**
|
||||||
|
* ViewModel providing the current encryption event snapshot.
|
||||||
|
*/
|
||||||
|
vm: ViewModel<EncryptionEventViewSnapshot>;
|
||||||
|
/**
|
||||||
|
* Ref forwarded to the root DOM element.
|
||||||
|
*/
|
||||||
|
ref?: React.RefObject<HTMLDivElement>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EncryptionEventView({ vm, ref }: Readonly<EncryptionEventViewProps>): JSX.Element {
|
||||||
|
const { translate: _t } = useI18n();
|
||||||
|
const { state, encryptedStateEvents, userName, className, timestamp } = useViewModel(vm);
|
||||||
|
|
||||||
|
let icon = <LockSolidIcon />;
|
||||||
|
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 = <ErrorSolidIcon className={styles.error} />;
|
||||||
|
title = _t("timeline|m.room.encryption|disabled");
|
||||||
|
subtitle = _t("timeline|m.room.encryption|unsupported");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<EventTileBubble icon={icon} className={className} title={title} subtitle={subtitle} ref={ref}>
|
||||||
|
{timestamp}
|
||||||
|
</EventTileBubble>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,245 @@
|
|||||||
|
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||||
|
|
||||||
|
exports[`EncryptionEventView > renders Default story 1`] = `
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
class="container"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
fill="currentColor"
|
||||||
|
height="1em"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
width="1em"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M6 22q-.824 0-1.412-.587A1.93 1.93 0 0 1 4 20V10q0-.825.588-1.412A1.93 1.93 0 0 1 6 8h1V6q0-2.075 1.463-3.537Q9.926 1 12 1q2.075 0 3.537 1.463Q17 3.925 17 6v2h1q.824 0 1.413.588Q20 9.175 20 10v10q0 .824-.587 1.413A1.93 1.93 0 0 1 18 22zM9 8h6V6q0-1.25-.875-2.125A2.9 2.9 0 0 0 12 3q-1.25 0-2.125.875A2.9 2.9 0 0 0 9 6z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<div
|
||||||
|
class="title"
|
||||||
|
>
|
||||||
|
Encryption enabled
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="subtitle"
|
||||||
|
>
|
||||||
|
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.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`EncryptionEventView > renders DisableAttempt story 1`] = `
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
class="container"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
fill="currentColor"
|
||||||
|
height="1em"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
width="1em"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M6 22q-.824 0-1.412-.587A1.93 1.93 0 0 1 4 20V10q0-.825.588-1.412A1.93 1.93 0 0 1 6 8h1V6q0-2.075 1.463-3.537Q9.926 1 12 1q2.075 0 3.537 1.463Q17 3.925 17 6v2h1q.824 0 1.413.588Q20 9.175 20 10v10q0 .824-.587 1.413A1.93 1.93 0 0 1 18 22zM9 8h6V6q0-1.25-.875-2.125A2.9 2.9 0 0 0 12 3q-1.25 0-2.125.875A2.9 2.9 0 0 0 9 6z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<div
|
||||||
|
class="title"
|
||||||
|
>
|
||||||
|
Encryption enabled
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="subtitle"
|
||||||
|
>
|
||||||
|
Ignored attempt to disable encryption
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`EncryptionEventView > renders EnabledDirectMessage story 1`] = `
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
class="container"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
fill="currentColor"
|
||||||
|
height="1em"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
width="1em"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M6 22q-.824 0-1.412-.587A1.93 1.93 0 0 1 4 20V10q0-.825.588-1.412A1.93 1.93 0 0 1 6 8h1V6q0-2.075 1.463-3.537Q9.926 1 12 1q2.075 0 3.537 1.463Q17 3.925 17 6v2h1q.824 0 1.413.588Q20 9.175 20 10v10q0 .824-.587 1.413A1.93 1.93 0 0 1 18 22zM9 8h6V6q0-1.25-.875-2.125A2.9 2.9 0 0 0 12 3q-1.25 0-2.125.875A2.9 2.9 0 0 0 9 6z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<div
|
||||||
|
class="title"
|
||||||
|
>
|
||||||
|
Encryption enabled
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="subtitle"
|
||||||
|
>
|
||||||
|
Messages here are end-to-end encrypted. Verify Alice in their profile - tap on their profile picture.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`EncryptionEventView > renders EnabledLocalRoom story 1`] = `
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
class="container"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
fill="currentColor"
|
||||||
|
height="1em"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
width="1em"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M6 22q-.824 0-1.412-.587A1.93 1.93 0 0 1 4 20V10q0-.825.588-1.412A1.93 1.93 0 0 1 6 8h1V6q0-2.075 1.463-3.537Q9.926 1 12 1q2.075 0 3.537 1.463Q17 3.925 17 6v2h1q.824 0 1.413.588Q20 9.175 20 10v10q0 .824-.587 1.413A1.93 1.93 0 0 1 18 22zM9 8h6V6q0-1.25-.875-2.125A2.9 2.9 0 0 0 12 3q-1.25 0-2.125.875A2.9 2.9 0 0 0 9 6z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<div
|
||||||
|
class="title"
|
||||||
|
>
|
||||||
|
Encryption enabled
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="subtitle"
|
||||||
|
>
|
||||||
|
Messages in this chat will be end-to-end encrypted.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`EncryptionEventView > renders ParametersChanged story 1`] = `
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
class="container"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
fill="currentColor"
|
||||||
|
height="1em"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
width="1em"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M6 22q-.824 0-1.412-.587A1.93 1.93 0 0 1 4 20V10q0-.825.588-1.412A1.93 1.93 0 0 1 6 8h1V6q0-2.075 1.463-3.537Q9.926 1 12 1q2.075 0 3.537 1.463Q17 3.925 17 6v2h1q.824 0 1.413.588Q20 9.175 20 10v10q0 .824-.587 1.413A1.93 1.93 0 0 1 18 22zM9 8h6V6q0-1.25-.875-2.125A2.9 2.9 0 0 0 12 3q-1.25 0-2.125.875A2.9 2.9 0 0 0 9 6z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<div
|
||||||
|
class="title"
|
||||||
|
>
|
||||||
|
Encryption enabled
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="subtitle"
|
||||||
|
>
|
||||||
|
Some encryption parameters have been changed.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`EncryptionEventView > renders StateEncryptionEnabled story 1`] = `
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
class="container"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
fill="currentColor"
|
||||||
|
height="1em"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
width="1em"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M6 22q-.824 0-1.412-.587A1.93 1.93 0 0 1 4 20V10q0-.825.588-1.412A1.93 1.93 0 0 1 6 8h1V6q0-2.075 1.463-3.537Q9.926 1 12 1q2.075 0 3.537 1.463Q17 3.925 17 6v2h1q.824 0 1.413.588Q20 9.175 20 10v10q0 .824-.587 1.413A1.93 1.93 0 0 1 18 22zM9 8h6V6q0-1.25-.875-2.125A2.9 2.9 0 0 0 12 3q-1.25 0-2.125.875A2.9 2.9 0 0 0 9 6z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<div
|
||||||
|
class="title"
|
||||||
|
>
|
||||||
|
Experimental state encryption enabled
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="subtitle"
|
||||||
|
>
|
||||||
|
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.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`EncryptionEventView > renders Unsupported story 1`] = `
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
class="container"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="error"
|
||||||
|
fill="currentColor"
|
||||||
|
height="1em"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
width="1em"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M12 17q.424 0 .713-.288A.97.97 0 0 0 13 16a.97.97 0 0 0-.287-.713A.97.97 0 0 0 12 15a.97.97 0 0 0-.713.287A.97.97 0 0 0 11 16q0 .424.287.712.288.288.713.288m0-4q.424 0 .713-.287A.97.97 0 0 0 13 12V8a.97.97 0 0 0-.287-.713A.97.97 0 0 0 12 7a.97.97 0 0 0-.713.287A.97.97 0 0 0 11 8v4q0 .424.287.713.288.287.713.287m0 9a9.7 9.7 0 0 1-3.9-.788 10.1 10.1 0 0 1-3.175-2.137q-1.35-1.35-2.137-3.175A9.7 9.7 0 0 1 2 12q0-2.075.788-3.9a10.1 10.1 0 0 1 2.137-3.175q1.35-1.35 3.175-2.137A9.7 9.7 0 0 1 12 2q2.075 0 3.9.788a10.1 10.1 0 0 1 3.175 2.137q1.35 1.35 2.137 3.175A9.7 9.7 0 0 1 22 12a9.7 9.7 0 0 1-.788 3.9 10.1 10.1 0 0 1-2.137 3.175q-1.35 1.35-3.175 2.137A9.7 9.7 0 0 1 12 22"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<div
|
||||||
|
class="title"
|
||||||
|
>
|
||||||
|
Encryption not enabled
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="subtitle"
|
||||||
|
>
|
||||||
|
The encryption used by this room isn't supported.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`EncryptionEventView > renders WithTimestamp story 1`] = `
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
class="container"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
fill="currentColor"
|
||||||
|
height="1em"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
width="1em"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M6 22q-.824 0-1.412-.587A1.93 1.93 0 0 1 4 20V10q0-.825.588-1.412A1.93 1.93 0 0 1 6 8h1V6q0-2.075 1.463-3.537Q9.926 1 12 1q2.075 0 3.537 1.463Q17 3.925 17 6v2h1q.824 0 1.413.588Q20 9.175 20 10v10q0 .824-.587 1.413A1.93 1.93 0 0 1 18 22zM9 8h6V6q0-1.25-.875-2.125A2.9 2.9 0 0 0 12 3q-1.25 0-2.125.875A2.9 2.9 0 0 0 9 6z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<div
|
||||||
|
class="title"
|
||||||
|
>
|
||||||
|
Encryption enabled
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="subtitle"
|
||||||
|
>
|
||||||
|
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.
|
||||||
|
</div>
|
||||||
|
<span>
|
||||||
|
14:56
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
@@ -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";
|
||||||
@@ -20,7 +20,9 @@
|
|||||||
"start_chat": "Start chat"
|
"start_chat": "Start chat"
|
||||||
},
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"preferences": "Preferences"
|
"encryption_enabled": "Encryption enabled",
|
||||||
|
"preferences": "Preferences",
|
||||||
|
"state_encryption_enabled": "Experimental state encryption enabled"
|
||||||
},
|
},
|
||||||
"left_panel": {
|
"left_panel": {
|
||||||
"open_dial_pad": "Open dial pad"
|
"open_dial_pad": "Open dial pad"
|
||||||
@@ -159,6 +161,16 @@
|
|||||||
"audio_player": "Audio player",
|
"audio_player": "Audio player",
|
||||||
"error_downloading_audio": "Error downloading audio",
|
"error_downloading_audio": "Error downloading audio",
|
||||||
"unnamed_audio": "Unnamed 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": {
|
"widget": {
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export * from "./audio/SeekBar";
|
|||||||
export * from "./avatar/AvatarWithDetails";
|
export * from "./avatar/AvatarWithDetails";
|
||||||
export * from "./composer/Banner";
|
export * from "./composer/Banner";
|
||||||
export * from "./crypto/SasEmoji";
|
export * from "./crypto/SasEmoji";
|
||||||
|
export * from "./event-tiles/EncryptionEventView";
|
||||||
export * from "./event-tiles/EventTileBubble";
|
export * from "./event-tiles/EventTileBubble";
|
||||||
export * from "./event-tiles/TextualEventView";
|
export * from "./event-tiles/TextualEventView";
|
||||||
export * from "./message-body/MediaBody";
|
export * from "./message-body/MediaBody";
|
||||||
|
|||||||
@@ -46,7 +46,11 @@ import { debounce, throttle } from "lodash";
|
|||||||
import { CryptoEvent } from "matrix-js-sdk/src/crypto-api";
|
import { CryptoEvent } from "matrix-js-sdk/src/crypto-api";
|
||||||
import { type ViewRoomOpts } from "@matrix-org/react-sdk-module-api/lib/lifecycles/RoomViewLifecycle";
|
import { type ViewRoomOpts } from "@matrix-org/react-sdk-module-api/lib/lifecycles/RoomViewLifecycle";
|
||||||
import { type RoomViewProps } from "@element-hq/element-web-module-api";
|
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 shouldHideEvent from "../../shouldHideEvent";
|
||||||
import { _t } from "../../languageHandler";
|
import { _t } from "../../languageHandler";
|
||||||
@@ -69,6 +73,7 @@ import { TimelineRenderingType, MainSplitContentType } from "../../contexts/Room
|
|||||||
import { E2EStatus, shieldStatusForRoom } from "../../utils/ShieldUtils";
|
import { E2EStatus, shieldStatusForRoom } from "../../utils/ShieldUtils";
|
||||||
import { Action } from "../../dispatcher/actions";
|
import { Action } from "../../dispatcher/actions";
|
||||||
import { type IMatrixClientCreds } from "../../MatrixClientPeg";
|
import { type IMatrixClientCreds } from "../../MatrixClientPeg";
|
||||||
|
import { useMatrixClientContext } from "../../contexts/MatrixClientContext";
|
||||||
import ScrollPanel from "./ScrollPanel";
|
import ScrollPanel from "./ScrollPanel";
|
||||||
import TimelinePanel from "./TimelinePanel";
|
import TimelinePanel from "./TimelinePanel";
|
||||||
import ErrorBoundary from "../views/elements/ErrorBoundary";
|
import ErrorBoundary from "../views/elements/ErrorBoundary";
|
||||||
@@ -111,7 +116,6 @@ import { type FocusComposerPayload } from "../../dispatcher/payloads/FocusCompos
|
|||||||
import { LocalRoom, LocalRoomState } from "../../models/LocalRoom";
|
import { LocalRoom, LocalRoomState } from "../../models/LocalRoom";
|
||||||
import { createRoomFromLocalRoom } from "../../utils/direct-messages";
|
import { createRoomFromLocalRoom } from "../../utils/direct-messages";
|
||||||
import NewRoomIntro from "../views/rooms/NewRoomIntro";
|
import NewRoomIntro from "../views/rooms/NewRoomIntro";
|
||||||
import EncryptionEvent from "../views/messages/EncryptionEvent";
|
|
||||||
import { isLocalRoom } from "../../utils/localRoom/isLocalRoom";
|
import { isLocalRoom } from "../../utils/localRoom/isLocalRoom";
|
||||||
import { type ShowThreadPayload } from "../../dispatcher/payloads/ShowThreadPayload";
|
import { type ShowThreadPayload } from "../../dispatcher/payloads/ShowThreadPayload";
|
||||||
import { LargeLoader } from "./LargeLoader";
|
import { LargeLoader } from "./LargeLoader";
|
||||||
@@ -136,6 +140,7 @@ import { type FocusMessageSearchPayload } from "../../dispatcher/payloads/FocusM
|
|||||||
import { isRoomEncrypted } from "../../hooks/useIsEncrypted";
|
import { isRoomEncrypted } from "../../hooks/useIsEncrypted";
|
||||||
import { type RoomViewStore } from "../../stores/RoomViewStore.tsx";
|
import { type RoomViewStore } from "../../stores/RoomViewStore.tsx";
|
||||||
import { RoomStatusBarViewModel } from "../../viewmodels/room/RoomStatusBar.ts";
|
import { RoomStatusBarViewModel } from "../../viewmodels/room/RoomStatusBar.ts";
|
||||||
|
import { EncryptionEventViewModel } from "../../viewmodels/event-tiles/EncryptionEventViewModel.ts";
|
||||||
|
|
||||||
const DEBUG = false;
|
const DEBUG = false;
|
||||||
const PREVENT_MULTIPLE_JITSI_WITHIN = 30_000;
|
const PREVENT_MULTIPLE_JITSI_WITHIN = 30_000;
|
||||||
@@ -313,7 +318,7 @@ function LocalRoomView(props: LocalRoomViewProps): ReactElement {
|
|||||||
let encryptionTile: ReactNode;
|
let encryptionTile: ReactNode;
|
||||||
|
|
||||||
if (encryptionEvent) {
|
if (encryptionEvent) {
|
||||||
encryptionTile = <EncryptionEvent mxEvent={encryptionEvent} />;
|
encryptionTile = <EncryptionEventWrappedView mxEvent={encryptionEvent} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
let statusBar: ReactElement | null = null;
|
let statusBar: ReactElement | null = null;
|
||||||
@@ -405,6 +410,15 @@ function RoomStatusBarWrappedView(props: ConstructorParameters<typeof RoomStatus
|
|||||||
return <RoomStatusBarView vm={vm} />;
|
return <RoomStatusBarView vm={vm} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 <EncryptionEventView vm={vm} />;
|
||||||
|
}
|
||||||
|
|
||||||
export class RoomView extends React.Component<IRoomProps, IRoomState> {
|
export class RoomView extends React.Component<IRoomProps, IRoomState> {
|
||||||
// We cache the latest computed e2eStatus per room to show as soon as we switch rooms otherwise defaulting to
|
// 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.
|
// unencrypted causes a flicker which can yield confusion/concern in a larger room.
|
||||||
|
|||||||
@@ -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<HTMLDivElement>;
|
|
||||||
}
|
|
||||||
|
|
||||||
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<RoomEncryptionEventContent>();
|
|
||||||
|
|
||||||
// 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 (
|
|
||||||
<EventTileBubble
|
|
||||||
icon={<LockSolidIcon />}
|
|
||||||
className="mx_EventTileBubble mx_cryptoEvent mx_cryptoEvent_icon"
|
|
||||||
title={stateEncrypted ? _t("common|state_encryption_enabled") : _t("common|encryption_enabled")}
|
|
||||||
subtitle={subtitle}
|
|
||||||
>
|
|
||||||
{timestamp}
|
|
||||||
</EventTileBubble>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isRoomEncrypted) {
|
|
||||||
return (
|
|
||||||
<EventTileBubble
|
|
||||||
icon={<LockSolidIcon />}
|
|
||||||
className="mx_EventTileBubble mx_cryptoEvent mx_cryptoEvent_icon"
|
|
||||||
title={_t("common|encryption_enabled")}
|
|
||||||
subtitle={_t("timeline|m.room.encryption|disable_attempt")}
|
|
||||||
>
|
|
||||||
{timestamp}
|
|
||||||
</EventTileBubble>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<EventTileBubble
|
|
||||||
icon={<ErrorSolidIcon color="var(--cpd-color-icon-critical-primary)" />}
|
|
||||||
className="mx_EventTileBubble mx_cryptoEvent"
|
|
||||||
title={_t("timeline|m.room.encryption|disabled")}
|
|
||||||
subtitle={_t("timeline|m.room.encryption|unsupported")}
|
|
||||||
ref={ref}
|
|
||||||
>
|
|
||||||
{timestamp}
|
|
||||||
</EventTileBubble>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default EncryptionEvent;
|
|
||||||
@@ -17,7 +17,11 @@ import {
|
|||||||
M_POLL_END,
|
M_POLL_END,
|
||||||
M_POLL_START,
|
M_POLL_START,
|
||||||
} from "matrix-js-sdk/src/matrix";
|
} 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 SettingsStore from "../settings/SettingsStore";
|
||||||
import type LegacyCallEventGrouper from "../components/structures/LegacyCallEventGrouper";
|
import type LegacyCallEventGrouper from "../components/structures/LegacyCallEventGrouper";
|
||||||
@@ -26,12 +30,12 @@ import { TimelineRenderingType } from "../contexts/RoomContext";
|
|||||||
import MessageEvent from "../components/views/messages/MessageEvent";
|
import MessageEvent from "../components/views/messages/MessageEvent";
|
||||||
import LegacyCallEvent from "../components/views/messages/LegacyCallEvent";
|
import LegacyCallEvent from "../components/views/messages/LegacyCallEvent";
|
||||||
import { CallEvent } from "../components/views/messages/CallEvent";
|
import { CallEvent } from "../components/views/messages/CallEvent";
|
||||||
import EncryptionEvent from "../components/views/messages/EncryptionEvent";
|
|
||||||
import { RoomPredecessorTile } from "../components/views/messages/RoomPredecessorTile";
|
import { RoomPredecessorTile } from "../components/views/messages/RoomPredecessorTile";
|
||||||
import RoomAvatarEvent from "../components/views/messages/RoomAvatarEvent";
|
import RoomAvatarEvent from "../components/views/messages/RoomAvatarEvent";
|
||||||
import { WIDGET_LAYOUT_EVENT_TYPE } from "../stores/widgets/WidgetLayoutStore";
|
import { WIDGET_LAYOUT_EVENT_TYPE } from "../stores/widgets/WidgetLayoutStore";
|
||||||
import { ALL_RULE_TYPES } from "../mjolnir/BanList";
|
import { ALL_RULE_TYPES } from "../mjolnir/BanList";
|
||||||
import { MatrixClientPeg } from "../MatrixClientPeg";
|
import { MatrixClientPeg } from "../MatrixClientPeg";
|
||||||
|
import { useMatrixClientContext } from "../contexts/MatrixClientContext";
|
||||||
import MKeyVerificationRequest from "../components/views/messages/MKeyVerificationRequest";
|
import MKeyVerificationRequest from "../components/views/messages/MKeyVerificationRequest";
|
||||||
import { WidgetType } from "../widgets/WidgetType";
|
import { WidgetType } from "../widgets/WidgetType";
|
||||||
import MJitsiWidgetEvent from "../components/views/messages/MJitsiWidgetEvent";
|
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 { shouldDisplayAsBeaconTile } from "../utils/beacon/timeline";
|
||||||
import { type IBodyProps } from "../components/views/messages/IBodyProps";
|
import { type IBodyProps } from "../components/views/messages/IBodyProps";
|
||||||
import { ModuleApi } from "../modules/Api";
|
import { ModuleApi } from "../modules/Api";
|
||||||
|
import { EncryptionEventViewModel } from "../viewmodels/event-tiles/EncryptionEventViewModel";
|
||||||
import { TextualEventViewModel } from "../viewmodels/event-tiles/TextualEventViewModel";
|
import { TextualEventViewModel } from "../viewmodels/event-tiles/TextualEventViewModel";
|
||||||
import { ElementCallEventType } from "../call-types";
|
import { ElementCallEventType } from "../call-types";
|
||||||
|
|
||||||
@@ -80,6 +85,14 @@ export const TextualEventFactory: Factory = (ref, props) => {
|
|||||||
const vm = new TextualEventViewModel(props);
|
const vm = new TextualEventViewModel(props);
|
||||||
return <TextualEventView vm={vm} />;
|
return <TextualEventView vm={vm} />;
|
||||||
};
|
};
|
||||||
|
function EncryptionEventWrappedView({ mxEvent, ref }: IBodyProps): JSX.Element {
|
||||||
|
const cli = useMatrixClientContext();
|
||||||
|
const vm = useCreateAutoDisposedViewModel(() => new EncryptionEventViewModel({ mxEvent, cli }));
|
||||||
|
return <EncryptionEventView vm={vm} ref={ref} />;
|
||||||
|
}
|
||||||
|
const EncryptionEventFactory: Factory = (ref, props) => {
|
||||||
|
return <EncryptionEventWrappedView ref={ref} {...props} />;
|
||||||
|
};
|
||||||
const VerificationReqFactory: Factory = (_ref, props) => <MKeyVerificationRequest {...props} />;
|
const VerificationReqFactory: Factory = (_ref, props) => <MKeyVerificationRequest {...props} />;
|
||||||
const HiddenEventFactory: Factory = (ref, props) => <HiddenBody ref={ref} {...props} />;
|
const HiddenEventFactory: Factory = (ref, props) => <HiddenBody ref={ref} {...props} />;
|
||||||
|
|
||||||
@@ -99,7 +112,7 @@ const EVENT_TILE_TYPES = new Map<string, Factory>([
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
const STATE_EVENT_TILE_TYPES = new Map<string, Factory>([
|
const STATE_EVENT_TILE_TYPES = new Map<string, Factory>([
|
||||||
[EventType.RoomEncryption, (ref, props) => <EncryptionEvent ref={ref} {...props} />],
|
[EventType.RoomEncryption, EncryptionEventFactory],
|
||||||
[EventType.RoomCanonicalAlias, TextualEventFactory],
|
[EventType.RoomCanonicalAlias, TextualEventFactory],
|
||||||
[EventType.RoomCreate, RoomCreateEventFactory],
|
[EventType.RoomCreate, RoomCreateEventFactory],
|
||||||
[EventType.RoomMember, TextualEventFactory],
|
[EventType.RoomMember, TextualEventFactory],
|
||||||
|
|||||||
@@ -482,7 +482,6 @@
|
|||||||
"email_address": "Email address",
|
"email_address": "Email address",
|
||||||
"emoji": "Emoji",
|
"emoji": "Emoji",
|
||||||
"encrypted": "Encrypted",
|
"encrypted": "Encrypted",
|
||||||
"encryption_enabled": "Encryption enabled",
|
|
||||||
"error": "Error",
|
"error": "Error",
|
||||||
"faq": "FAQ",
|
"faq": "FAQ",
|
||||||
"favourites": "Favourites",
|
"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": "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:"
|
"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": {
|
"m.room.guest_access": {
|
||||||
"can_join": "%(senderDisplayName)s has allowed guests to join the room.",
|
"can_join": "%(senderDisplayName)s has allowed guests to join the room.",
|
||||||
"forbidden": "%(senderDisplayName)s has prevented guests from joining the room.",
|
"forbidden": "%(senderDisplayName)s has prevented guests from joining the room.",
|
||||||
|
|||||||
@@ -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<EncryptionEventViewSnapshotInterface, EncryptionEventViewModelProps>
|
||||||
|
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<void> => {
|
||||||
|
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<RoomEncryptionEventContent>();
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(
|
|
||||||
<MatrixClientContext.Provider value={client}>
|
|
||||||
<EncryptionEvent mxEvent={event} />
|
|
||||||
</MatrixClientContext.Provider>,
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
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.");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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<ConstructorParameters<typeof EncryptionEventViewModel>[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<typeof room.getMember>);
|
||||||
|
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||