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
This commit is contained in:
@@ -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 = <EncryptionEvent mxEvent={encryptionEvent} />;
|
||||
encryptionTile = <EncryptionEventWrappedView mxEvent={encryptionEvent} />;
|
||||
}
|
||||
|
||||
let statusBar: ReactElement | null = null;
|
||||
@@ -405,6 +410,15 @@ function RoomStatusBarWrappedView(props: ConstructorParameters<typeof RoomStatus
|
||||
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> {
|
||||
// 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.
|
||||
|
||||
@@ -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_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 <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 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>([
|
||||
[EventType.RoomEncryption, (ref, props) => <EncryptionEvent ref={ref} {...props} />],
|
||||
[EventType.RoomEncryption, EncryptionEventFactory],
|
||||
[EventType.RoomCanonicalAlias, TextualEventFactory],
|
||||
[EventType.RoomCreate, RoomCreateEventFactory],
|
||||
[EventType.RoomMember, TextualEventFactory],
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user