Refactor EventTile using the MVVM pattern - #5b (#33600)
* Extract EventTile E2E padlock state * Refactor EventTile E2E padlock rendering after review comments
This commit is contained in:
@@ -22,7 +22,7 @@ import React, {
|
|||||||
} from "react";
|
} from "react";
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
import {
|
import {
|
||||||
EventStatus,
|
type EventStatus,
|
||||||
EventType,
|
EventType,
|
||||||
type MatrixEvent,
|
type MatrixEvent,
|
||||||
MatrixEventEvent,
|
MatrixEventEvent,
|
||||||
@@ -38,9 +38,8 @@ import { logger } from "matrix-js-sdk/src/logger";
|
|||||||
import { CallErrorCode } from "matrix-js-sdk/src/webrtc/call";
|
import { CallErrorCode } from "matrix-js-sdk/src/webrtc/call";
|
||||||
import {
|
import {
|
||||||
CryptoEvent,
|
CryptoEvent,
|
||||||
DecryptionFailureCode,
|
|
||||||
EventShieldColour,
|
EventShieldColour,
|
||||||
EventShieldReason,
|
type EventShieldReason,
|
||||||
type UserVerificationStatus,
|
type UserVerificationStatus,
|
||||||
} from "matrix-js-sdk/src/crypto-api";
|
} from "matrix-js-sdk/src/crypto-api";
|
||||||
import { Tooltip } from "@vector-im/compound-web";
|
import { Tooltip } from "@vector-im/compound-web";
|
||||||
@@ -49,8 +48,6 @@ import { CircleIcon, CheckCircleIcon, ThreadsIcon } from "@vector-im/compound-de
|
|||||||
import {
|
import {
|
||||||
useCreateAutoDisposedViewModel,
|
useCreateAutoDisposedViewModel,
|
||||||
ActionBarView,
|
ActionBarView,
|
||||||
E2ePadlock,
|
|
||||||
E2ePadlockIcon,
|
|
||||||
MessageTimestampView,
|
MessageTimestampView,
|
||||||
PinnedMessageBadge,
|
PinnedMessageBadge,
|
||||||
ReactionsRowButtonView,
|
ReactionsRowButtonView,
|
||||||
@@ -98,7 +95,8 @@ import { getLateEventInfo } from "../../structures/grouper/LateEventGrouper";
|
|||||||
import { Icon as LateIcon } from "../../../../res/img/sensor.svg";
|
import { Icon as LateIcon } from "../../../../res/img/sensor.svg";
|
||||||
import PinningUtils from "../../../utils/PinningUtils";
|
import PinningUtils from "../../../utils/PinningUtils";
|
||||||
import { EventPreview } from "./EventPreview";
|
import { EventPreview } from "./EventPreview";
|
||||||
import { E2eMessageSharedIcon } from "./EventTile/E2eMessageSharedIcon.tsx";
|
import { E2eStandardPadlockIcon } from "./EventTile/E2eStandardPadlockIcon";
|
||||||
|
import { E2eMessageSharedIcon } from "./EventTile/E2eMessageSharedIcon";
|
||||||
import SettingsStore from "../../../settings/SettingsStore";
|
import SettingsStore from "../../../settings/SettingsStore";
|
||||||
import { CardContext } from "../right_panel/context";
|
import { CardContext } from "../right_panel/context";
|
||||||
import { EventTileViewModel } from "../../../viewmodels/room/timeline/event-tile/EventTileViewModel";
|
import { EventTileViewModel } from "../../../viewmodels/room/timeline/event-tile/EventTileViewModel";
|
||||||
@@ -144,6 +142,7 @@ import { ThreadListActionBarViewModel } from "../../../viewmodels/room/ThreadLis
|
|||||||
import { useMatrixClientContext } from "../../../contexts/MatrixClientContext";
|
import { useMatrixClientContext } from "../../../contexts/MatrixClientContext";
|
||||||
import { useSettingValue } from "../../../hooks/useSettings";
|
import { useSettingValue } from "../../../hooks/useSettings";
|
||||||
import { DecryptionFailureBodyFactory, RedactedBodyFactory } from "../messages/MBodyFactory";
|
import { DecryptionFailureBodyFactory, RedactedBodyFactory } from "../messages/MBodyFactory";
|
||||||
|
import { getEventTileE2ePadlockViewState } from "../../../viewmodels/room/timeline/event-tile/EventTileE2eState";
|
||||||
|
|
||||||
/** Relation lookup type retained for EventTile consumers. */
|
/** Relation lookup type retained for EventTile consumers. */
|
||||||
export type { GetRelationsForEvent } from "../../../viewmodels/room/timeline/event-tile/reactions/EventTileReactionState";
|
export type { GetRelationsForEvent } from "../../../viewmodels/room/timeline/event-tile/reactions/EventTileReactionState";
|
||||||
@@ -728,120 +727,29 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
|
|||||||
private renderE2EPadlock(): ReactNode {
|
private renderE2EPadlock(): ReactNode {
|
||||||
// if the event was edited, show the verification info for the edit, not
|
// if the event was edited, show the verification info for the edit, not
|
||||||
// the original
|
// the original
|
||||||
const ev = this.props.mxEvent.replacingEvent() ?? this.props.mxEvent;
|
const verificationEvent = this.props.mxEvent.replacingEvent() ?? this.props.mxEvent;
|
||||||
|
const e2ePadlockViewState = getEventTileE2ePadlockViewState({
|
||||||
|
mxEvent: this.props.mxEvent,
|
||||||
|
verificationEvent,
|
||||||
|
shieldColour: this.state.shieldColour,
|
||||||
|
shieldReason: this.state.shieldReason,
|
||||||
|
isRoomEncrypted: this.context.isRoomEncrypted,
|
||||||
|
isLocalRoom: isLocalRoom(verificationEvent.getRoomId()!),
|
||||||
|
});
|
||||||
|
|
||||||
// no icon for local rooms
|
switch (e2ePadlockViewState.kind) {
|
||||||
if (isLocalRoom(ev.getRoomId()!)) return null;
|
case "none":
|
||||||
|
return null;
|
||||||
// event could not be decrypted
|
case "messageShared":
|
||||||
if (ev.isDecryptionFailure()) {
|
|
||||||
switch (ev.decryptionFailureReason) {
|
|
||||||
// These two errors get icons from DecryptionFailureBody, so we hide the padlock icon
|
|
||||||
case DecryptionFailureCode.SENDER_IDENTITY_PREVIOUSLY_VERIFIED:
|
|
||||||
case DecryptionFailureCode.UNSIGNED_SENDER_DEVICE:
|
|
||||||
return null;
|
|
||||||
default:
|
|
||||||
return <E2ePadlockDecryptionFailure />;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.state.shieldReason === EventShieldReason.AUTHENTICITY_NOT_GUARANTEED) {
|
|
||||||
// This may happen if the message was forwarded to us by another user, in which case we can show a better message
|
|
||||||
const forwarder = this.props.mxEvent.getKeyForwardingUser();
|
|
||||||
if (forwarder) {
|
|
||||||
return <E2eMessageSharedIcon keyForwardingUserId={forwarder} roomId={ev.getRoomId()!} />;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.state.shieldColour !== EventShieldColour.NONE) {
|
|
||||||
let shieldReasonMessage: string;
|
|
||||||
switch (this.state.shieldReason) {
|
|
||||||
case EventShieldReason.UNVERIFIED_IDENTITY:
|
|
||||||
shieldReasonMessage = _t("encryption|event_shield_reason_unverified_identity");
|
|
||||||
break;
|
|
||||||
|
|
||||||
case EventShieldReason.UNSIGNED_DEVICE:
|
|
||||||
shieldReasonMessage = _t("encryption|event_shield_reason_unsigned_device");
|
|
||||||
break;
|
|
||||||
|
|
||||||
case EventShieldReason.UNKNOWN_DEVICE:
|
|
||||||
shieldReasonMessage = _t("encryption|event_shield_reason_unknown_device");
|
|
||||||
break;
|
|
||||||
|
|
||||||
case EventShieldReason.AUTHENTICITY_NOT_GUARANTEED:
|
|
||||||
shieldReasonMessage = _t("encryption|event_shield_reason_authenticity_not_guaranteed");
|
|
||||||
break;
|
|
||||||
|
|
||||||
case EventShieldReason.MISMATCHED_SENDER_KEY:
|
|
||||||
shieldReasonMessage = _t("encryption|event_shield_reason_mismatched_sender_key");
|
|
||||||
break;
|
|
||||||
|
|
||||||
case EventShieldReason.SENT_IN_CLEAR:
|
|
||||||
shieldReasonMessage = _t("common|unencrypted");
|
|
||||||
break;
|
|
||||||
|
|
||||||
case EventShieldReason.VERIFICATION_VIOLATION:
|
|
||||||
shieldReasonMessage = _t("timeline|decryption_failure|sender_identity_previously_verified");
|
|
||||||
break;
|
|
||||||
|
|
||||||
case EventShieldReason.MISMATCHED_SENDER:
|
|
||||||
shieldReasonMessage = _t("encryption|event_shield_reason_mismatched_sender");
|
|
||||||
break;
|
|
||||||
|
|
||||||
default:
|
|
||||||
shieldReasonMessage = _t("error|unknown");
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.state.shieldColour === EventShieldColour.GREY) {
|
|
||||||
return (
|
return (
|
||||||
<E2ePadlock
|
<E2eMessageSharedIcon
|
||||||
className={
|
keyForwardingUserId={e2ePadlockViewState.keyForwardingUserId}
|
||||||
// Timeline PCSS uses this app class as a layout hook for positioning and layout variants.
|
roomId={e2ePadlockViewState.roomId}
|
||||||
"mx_EventTile_e2eIcon"
|
|
||||||
}
|
|
||||||
icon={E2ePadlockIcon.Normal}
|
|
||||||
title={shieldReasonMessage}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
} else {
|
case "icon":
|
||||||
// red, by elimination
|
return <E2eStandardPadlockIcon icon={e2ePadlockViewState.icon} title={e2ePadlockViewState.title} />;
|
||||||
return (
|
|
||||||
<E2ePadlock
|
|
||||||
className={
|
|
||||||
// Timeline PCSS uses this app class as a layout hook for positioning and layout variants.
|
|
||||||
"mx_EventTile_e2eIcon"
|
|
||||||
}
|
|
||||||
icon={E2ePadlockIcon.Warning}
|
|
||||||
title={shieldReasonMessage}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.context.isRoomEncrypted) {
|
|
||||||
// else if room is encrypted
|
|
||||||
// and event is being encrypted or is not_sent (Unknown Devices/Network Error)
|
|
||||||
if (ev.status === EventStatus.ENCRYPTING) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
if (ev.status === EventStatus.NOT_SENT) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
if (ev.isState()) {
|
|
||||||
return null; // we expect this to be unencrypted
|
|
||||||
}
|
|
||||||
if (ev.isRedacted()) {
|
|
||||||
return null; // we expect this to be unencrypted
|
|
||||||
}
|
|
||||||
if (!ev.isEncrypted()) {
|
|
||||||
// if the event is not encrypted, but it's an e2e room, show a warning
|
|
||||||
return <E2ePadlockUnencrypted />;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// no padlock needed
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private readonly onActionBarFocusChange = (actionBarFocused: boolean): void => {
|
private readonly onActionBarFocusChange = (actionBarFocused: boolean): void => {
|
||||||
@@ -1585,32 +1493,6 @@ const SafeEventTile = (props: EventTileProps): JSX.Element => {
|
|||||||
};
|
};
|
||||||
export default SafeEventTile;
|
export default SafeEventTile;
|
||||||
|
|
||||||
function E2ePadlockUnencrypted(): JSX.Element {
|
|
||||||
return (
|
|
||||||
<E2ePadlock
|
|
||||||
className={
|
|
||||||
// Timeline PCSS uses this app class as a layout hook for positioning and layout variants.
|
|
||||||
"mx_EventTile_e2eIcon"
|
|
||||||
}
|
|
||||||
title={_t("common|unencrypted")}
|
|
||||||
icon={E2ePadlockIcon.Warning}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function E2ePadlockDecryptionFailure(): JSX.Element {
|
|
||||||
return (
|
|
||||||
<E2ePadlock
|
|
||||||
className={
|
|
||||||
// Timeline PCSS uses this app class as a layout hook for positioning and layout variants.
|
|
||||||
"mx_EventTile_e2eIcon"
|
|
||||||
}
|
|
||||||
title={_t("timeline|undecryptable_tooltip")}
|
|
||||||
icon={E2ePadlockIcon.DecryptionFailure}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ISentReceiptProps {
|
interface ISentReceiptProps {
|
||||||
messageState: EventStatus | undefined;
|
messageState: EventStatus | undefined;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
/*
|
||||||
|
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 { E2ePadlock, type E2ePadlockIcon } from "@element-hq/web-shared-components";
|
||||||
|
|
||||||
|
interface E2eStandardPadlockIconProps {
|
||||||
|
icon: E2ePadlockIcon;
|
||||||
|
title: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function E2eStandardPadlockIcon({ icon, title }: Readonly<E2eStandardPadlockIconProps>): JSX.Element {
|
||||||
|
return (
|
||||||
|
<E2ePadlock
|
||||||
|
className={
|
||||||
|
// Timeline PCSS uses this app class as a layout hook for positioning and layout variants.
|
||||||
|
"mx_EventTile_e2eIcon"
|
||||||
|
}
|
||||||
|
title={title}
|
||||||
|
icon={icon}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
/*
|
||||||
|
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 { EventStatus, type MatrixEvent } from "matrix-js-sdk/src/matrix";
|
||||||
|
import { DecryptionFailureCode, EventShieldColour, EventShieldReason } from "matrix-js-sdk/src/crypto-api";
|
||||||
|
import { E2ePadlockIcon } from "@element-hq/web-shared-components";
|
||||||
|
|
||||||
|
import { _t } from "../../../../languageHandler";
|
||||||
|
|
||||||
|
/** Display state for the EventTile E2E padlock area. */
|
||||||
|
export type EventTileE2ePadlockState =
|
||||||
|
| {
|
||||||
|
/** No E2E padlock should render. */
|
||||||
|
kind: "none";
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
/** Render the undecryptable padlock. */
|
||||||
|
kind: "decryptionFailure";
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
/** Render the forwarded/shared message indicator. */
|
||||||
|
kind: "messageShared";
|
||||||
|
/** User ID that forwarded the message key. */
|
||||||
|
keyForwardingUserId: string;
|
||||||
|
/** Room ID used by the shared-message indicator. */
|
||||||
|
roomId: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
/** Render a trust shield padlock. */
|
||||||
|
kind: "shield";
|
||||||
|
/** Shield colour used to choose the rendered icon. */
|
||||||
|
shieldColour: EventShieldColour;
|
||||||
|
/** Shield reason used to choose the visible tooltip. */
|
||||||
|
shieldReason: EventShieldReason | null;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
/** Render the unencrypted warning in an encrypted room. */
|
||||||
|
kind: "unencrypted";
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Renderable display state for the EventTile E2E padlock area. */
|
||||||
|
export type EventTileE2ePadlockViewState =
|
||||||
|
| {
|
||||||
|
/** No E2E padlock should render. */
|
||||||
|
kind: "none";
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
/** Render the shared-message indicator. */
|
||||||
|
kind: "messageShared";
|
||||||
|
/** User ID that forwarded the message key. */
|
||||||
|
keyForwardingUserId: string;
|
||||||
|
/** Room ID used by the shared-message indicator. */
|
||||||
|
roomId: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
/** Render a standard E2E padlock icon. */
|
||||||
|
kind: "icon";
|
||||||
|
/** Icon variant to render. */
|
||||||
|
icon: E2ePadlockIcon;
|
||||||
|
/** Tooltip shown for the icon. */
|
||||||
|
title: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Inputs for deriving EventTile E2E padlock display state. */
|
||||||
|
export interface EventTileE2ePadlockStateInput {
|
||||||
|
/** Matrix event rendered by the tile. */
|
||||||
|
mxEvent: MatrixEvent;
|
||||||
|
/** Event used for verification decisions, usually the replacing event for edits. */
|
||||||
|
verificationEvent: MatrixEvent;
|
||||||
|
/** Current event shield colour. */
|
||||||
|
shieldColour: EventShieldColour;
|
||||||
|
/** Current event shield reason. */
|
||||||
|
shieldReason: EventShieldReason | null;
|
||||||
|
/** Whether the room is encrypted. */
|
||||||
|
isRoomEncrypted?: boolean | null;
|
||||||
|
/** Whether the event belongs to a local room. */
|
||||||
|
isLocalRoom: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Derives the E2E padlock display state for EventTile. */
|
||||||
|
export function getEventTileE2ePadlockState({
|
||||||
|
mxEvent,
|
||||||
|
verificationEvent,
|
||||||
|
shieldColour,
|
||||||
|
shieldReason,
|
||||||
|
isRoomEncrypted,
|
||||||
|
isLocalRoom,
|
||||||
|
}: EventTileE2ePadlockStateInput): EventTileE2ePadlockState {
|
||||||
|
if (isLocalRoom) {
|
||||||
|
return { kind: "none" };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (verificationEvent.isDecryptionFailure()) {
|
||||||
|
switch (verificationEvent.decryptionFailureReason) {
|
||||||
|
case DecryptionFailureCode.SENDER_IDENTITY_PREVIOUSLY_VERIFIED:
|
||||||
|
case DecryptionFailureCode.UNSIGNED_SENDER_DEVICE:
|
||||||
|
return { kind: "none" };
|
||||||
|
default:
|
||||||
|
return { kind: "decryptionFailure" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shieldReason === EventShieldReason.AUTHENTICITY_NOT_GUARANTEED) {
|
||||||
|
const keyForwardingUserId = mxEvent.getKeyForwardingUser();
|
||||||
|
if (keyForwardingUserId) {
|
||||||
|
return {
|
||||||
|
kind: "messageShared",
|
||||||
|
keyForwardingUserId,
|
||||||
|
roomId: verificationEvent.getRoomId()!,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shieldColour !== EventShieldColour.NONE) {
|
||||||
|
return {
|
||||||
|
kind: "shield",
|
||||||
|
shieldColour,
|
||||||
|
shieldReason,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isRoomEncrypted) {
|
||||||
|
if (
|
||||||
|
verificationEvent.status === EventStatus.ENCRYPTING ||
|
||||||
|
verificationEvent.status === EventStatus.NOT_SENT ||
|
||||||
|
verificationEvent.isState() ||
|
||||||
|
verificationEvent.isRedacted()
|
||||||
|
) {
|
||||||
|
return { kind: "none" };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!verificationEvent.isEncrypted()) {
|
||||||
|
return { kind: "unencrypted" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { kind: "none" };
|
||||||
|
}
|
||||||
|
|
||||||
|
function getEventTileShieldReasonMessage(shieldReason: EventShieldReason | null): string {
|
||||||
|
switch (shieldReason) {
|
||||||
|
case EventShieldReason.UNVERIFIED_IDENTITY:
|
||||||
|
return _t("encryption|event_shield_reason_unverified_identity");
|
||||||
|
|
||||||
|
case EventShieldReason.UNSIGNED_DEVICE:
|
||||||
|
return _t("encryption|event_shield_reason_unsigned_device");
|
||||||
|
|
||||||
|
case EventShieldReason.UNKNOWN_DEVICE:
|
||||||
|
return _t("encryption|event_shield_reason_unknown_device");
|
||||||
|
|
||||||
|
case EventShieldReason.AUTHENTICITY_NOT_GUARANTEED:
|
||||||
|
return _t("encryption|event_shield_reason_authenticity_not_guaranteed");
|
||||||
|
|
||||||
|
case EventShieldReason.MISMATCHED_SENDER_KEY:
|
||||||
|
return _t("encryption|event_shield_reason_mismatched_sender_key");
|
||||||
|
|
||||||
|
case EventShieldReason.SENT_IN_CLEAR:
|
||||||
|
return _t("common|unencrypted");
|
||||||
|
|
||||||
|
case EventShieldReason.VERIFICATION_VIOLATION:
|
||||||
|
return _t("timeline|decryption_failure|sender_identity_previously_verified");
|
||||||
|
|
||||||
|
case EventShieldReason.MISMATCHED_SENDER:
|
||||||
|
return _t("encryption|event_shield_reason_mismatched_sender");
|
||||||
|
|
||||||
|
default:
|
||||||
|
return _t("error|unknown");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Derives renderable E2E padlock display state for EventTile. */
|
||||||
|
export function getEventTileE2ePadlockViewState(input: EventTileE2ePadlockStateInput): EventTileE2ePadlockViewState {
|
||||||
|
const state = getEventTileE2ePadlockState(input);
|
||||||
|
|
||||||
|
switch (state.kind) {
|
||||||
|
case "none":
|
||||||
|
return { kind: "none" };
|
||||||
|
|
||||||
|
case "decryptionFailure":
|
||||||
|
return {
|
||||||
|
kind: "icon",
|
||||||
|
icon: E2ePadlockIcon.DecryptionFailure,
|
||||||
|
title: _t("timeline|undecryptable_tooltip"),
|
||||||
|
};
|
||||||
|
|
||||||
|
case "messageShared":
|
||||||
|
return {
|
||||||
|
kind: "messageShared",
|
||||||
|
keyForwardingUserId: state.keyForwardingUserId,
|
||||||
|
roomId: state.roomId,
|
||||||
|
};
|
||||||
|
|
||||||
|
case "unencrypted":
|
||||||
|
return {
|
||||||
|
kind: "icon",
|
||||||
|
icon: E2ePadlockIcon.Warning,
|
||||||
|
title: _t("common|unencrypted"),
|
||||||
|
};
|
||||||
|
|
||||||
|
case "shield":
|
||||||
|
return {
|
||||||
|
kind: "icon",
|
||||||
|
icon: state.shieldColour === EventShieldColour.GREY ? E2ePadlockIcon.Normal : E2ePadlockIcon.Warning,
|
||||||
|
title: getEventTileShieldReasonMessage(state.shieldReason),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,267 @@
|
|||||||
|
/*
|
||||||
|
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 { EventStatus, EventType, type MatrixEvent, MsgType } from "matrix-js-sdk/src/matrix";
|
||||||
|
import { DecryptionFailureCode, EventShieldColour, EventShieldReason } from "matrix-js-sdk/src/crypto-api";
|
||||||
|
import { E2ePadlockIcon } from "@element-hq/web-shared-components";
|
||||||
|
|
||||||
|
import { mkEvent } from "../../test-utils";
|
||||||
|
import {
|
||||||
|
getEventTileE2ePadlockState,
|
||||||
|
getEventTileE2ePadlockViewState,
|
||||||
|
type EventTileE2ePadlockStateInput,
|
||||||
|
} from "../../../src/viewmodels/room/timeline/event-tile/EventTileE2eState";
|
||||||
|
|
||||||
|
const roomId = "!room:example.org";
|
||||||
|
const userId = "@alice:example.org";
|
||||||
|
|
||||||
|
function makeEvent(): MatrixEvent {
|
||||||
|
return mkEvent({
|
||||||
|
event: true,
|
||||||
|
type: EventType.RoomMessage,
|
||||||
|
room: roomId,
|
||||||
|
user: userId,
|
||||||
|
content: {
|
||||||
|
msgtype: MsgType.Text,
|
||||||
|
body: "Hello",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeInput(overrides: Partial<EventTileE2ePadlockStateInput> = {}): EventTileE2ePadlockStateInput {
|
||||||
|
const mxEvent = overrides.mxEvent ?? makeEvent();
|
||||||
|
|
||||||
|
return {
|
||||||
|
mxEvent,
|
||||||
|
verificationEvent: overrides.verificationEvent ?? mxEvent,
|
||||||
|
shieldColour: EventShieldColour.NONE,
|
||||||
|
shieldReason: null,
|
||||||
|
isRoomEncrypted: false,
|
||||||
|
isLocalRoom: false,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mockDecryptionFailure(mxEvent: MatrixEvent, reason: DecryptionFailureCode): void {
|
||||||
|
jest.spyOn(mxEvent, "isDecryptionFailure").mockReturnValue(true);
|
||||||
|
Object.defineProperty(mxEvent, "decryptionFailureReason", {
|
||||||
|
configurable: true,
|
||||||
|
value: reason,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("EventTileE2eState", () => {
|
||||||
|
it("does not show a padlock for local rooms", () => {
|
||||||
|
const state = getEventTileE2ePadlockState(makeInput({ isLocalRoom: true }));
|
||||||
|
|
||||||
|
expect(state.kind).toBe("none");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows a decryption failure padlock for generic decryption failures", () => {
|
||||||
|
const mxEvent = makeEvent();
|
||||||
|
mockDecryptionFailure(mxEvent, DecryptionFailureCode.UNKNOWN_ERROR);
|
||||||
|
|
||||||
|
const state = getEventTileE2ePadlockState(makeInput({ mxEvent }));
|
||||||
|
|
||||||
|
expect(state.kind).toBe("decryptionFailure");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the verification event for edited event padlock decisions", () => {
|
||||||
|
const mxEvent = makeEvent();
|
||||||
|
const verificationEvent = makeEvent();
|
||||||
|
mockDecryptionFailure(verificationEvent, DecryptionFailureCode.UNKNOWN_ERROR);
|
||||||
|
|
||||||
|
const state = getEventTileE2ePadlockState(makeInput({ mxEvent, verificationEvent }));
|
||||||
|
|
||||||
|
expect(state.kind).toBe("decryptionFailure");
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([DecryptionFailureCode.SENDER_IDENTITY_PREVIOUSLY_VERIFIED, DecryptionFailureCode.UNSIGNED_SENDER_DEVICE])(
|
||||||
|
"does not show a padlock for %s decryption failures",
|
||||||
|
(reason) => {
|
||||||
|
const mxEvent = makeEvent();
|
||||||
|
mockDecryptionFailure(mxEvent, reason);
|
||||||
|
|
||||||
|
const state = getEventTileE2ePadlockState(makeInput({ mxEvent }));
|
||||||
|
|
||||||
|
expect(state.kind).toBe("none");
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it("shows a message-shared state when the authenticity warning has a key forwarding user", () => {
|
||||||
|
const mxEvent = makeEvent();
|
||||||
|
jest.spyOn(mxEvent, "getKeyForwardingUser").mockReturnValue("@bob:example.org");
|
||||||
|
|
||||||
|
const state = getEventTileE2ePadlockState(
|
||||||
|
makeInput({
|
||||||
|
mxEvent,
|
||||||
|
shieldColour: EventShieldColour.GREY,
|
||||||
|
shieldReason: EventShieldReason.AUTHENTICITY_NOT_GUARANTEED,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(state).toEqual({
|
||||||
|
kind: "messageShared",
|
||||||
|
keyForwardingUserId: "@bob:example.org",
|
||||||
|
roomId,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows a normal shield for grey shield state", () => {
|
||||||
|
const state = getEventTileE2ePadlockState(
|
||||||
|
makeInput({
|
||||||
|
shieldColour: EventShieldColour.GREY,
|
||||||
|
shieldReason: EventShieldReason.UNSIGNED_DEVICE,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(state).toEqual({
|
||||||
|
kind: "shield",
|
||||||
|
shieldColour: EventShieldColour.GREY,
|
||||||
|
shieldReason: EventShieldReason.UNSIGNED_DEVICE,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows a warning shield for red shield state", () => {
|
||||||
|
const state = getEventTileE2ePadlockState(
|
||||||
|
makeInput({
|
||||||
|
shieldColour: EventShieldColour.RED,
|
||||||
|
shieldReason: EventShieldReason.UNKNOWN_DEVICE,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(state).toEqual({
|
||||||
|
kind: "shield",
|
||||||
|
shieldColour: EventShieldColour.RED,
|
||||||
|
shieldReason: EventShieldReason.UNKNOWN_DEVICE,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows an unencrypted warning for unencrypted events in encrypted rooms", () => {
|
||||||
|
const state = getEventTileE2ePadlockState(makeInput({ isRoomEncrypted: true }));
|
||||||
|
|
||||||
|
expect(state.kind).toBe("unencrypted");
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([EventStatus.ENCRYPTING, EventStatus.NOT_SENT])(
|
||||||
|
"does not show an unencrypted warning for %s local echo state",
|
||||||
|
(status) => {
|
||||||
|
const mxEvent = makeEvent();
|
||||||
|
mxEvent.status = status;
|
||||||
|
|
||||||
|
const state = getEventTileE2ePadlockState(makeInput({ mxEvent, isRoomEncrypted: true }));
|
||||||
|
|
||||||
|
expect(state.kind).toBe("none");
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it("does not show an unencrypted warning for state events in encrypted rooms", () => {
|
||||||
|
const mxEvent = makeEvent();
|
||||||
|
jest.spyOn(mxEvent, "isState").mockReturnValue(true);
|
||||||
|
|
||||||
|
const state = getEventTileE2ePadlockState(makeInput({ mxEvent, isRoomEncrypted: true }));
|
||||||
|
|
||||||
|
expect(state.kind).toBe("none");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not show an unencrypted warning for redacted events in encrypted rooms", () => {
|
||||||
|
const mxEvent = makeEvent();
|
||||||
|
jest.spyOn(mxEvent, "isRedacted").mockReturnValue(true);
|
||||||
|
|
||||||
|
const state = getEventTileE2ePadlockState(makeInput({ mxEvent, isRoomEncrypted: true }));
|
||||||
|
|
||||||
|
expect(state.kind).toBe("none");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not show an unencrypted warning for encrypted events", () => {
|
||||||
|
const mxEvent = makeEvent();
|
||||||
|
jest.spyOn(mxEvent, "isEncrypted").mockReturnValue(true);
|
||||||
|
|
||||||
|
const state = getEventTileE2ePadlockState(makeInput({ mxEvent, isRoomEncrypted: true }));
|
||||||
|
|
||||||
|
expect(state.kind).toBe("none");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps decryption failure state to the decryption failure icon view state", () => {
|
||||||
|
const mxEvent = makeEvent();
|
||||||
|
mockDecryptionFailure(mxEvent, DecryptionFailureCode.UNKNOWN_ERROR);
|
||||||
|
|
||||||
|
const state = getEventTileE2ePadlockViewState(makeInput({ mxEvent }));
|
||||||
|
|
||||||
|
expect(state).toEqual({
|
||||||
|
kind: "icon",
|
||||||
|
icon: E2ePadlockIcon.DecryptionFailure,
|
||||||
|
title: "This message could not be decrypted",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps unencrypted state to the warning icon view state", () => {
|
||||||
|
const state = getEventTileE2ePadlockViewState(makeInput({ isRoomEncrypted: true }));
|
||||||
|
|
||||||
|
expect(state).toEqual({
|
||||||
|
kind: "icon",
|
||||||
|
icon: E2ePadlockIcon.Warning,
|
||||||
|
title: "Not encrypted",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
[EventShieldReason.UNKNOWN, E2ePadlockIcon.Normal, "Unknown error"],
|
||||||
|
[EventShieldReason.UNVERIFIED_IDENTITY, E2ePadlockIcon.Normal, "Encrypted by an unverified user."],
|
||||||
|
[EventShieldReason.UNSIGNED_DEVICE, E2ePadlockIcon.Normal, "Encrypted by a device not verified by its owner."],
|
||||||
|
[EventShieldReason.UNKNOWN_DEVICE, E2ePadlockIcon.Normal, "Encrypted by an unknown or deleted device."],
|
||||||
|
[
|
||||||
|
EventShieldReason.AUTHENTICITY_NOT_GUARANTEED,
|
||||||
|
E2ePadlockIcon.Warning,
|
||||||
|
"The authenticity of this encrypted message can't be guaranteed on this device.",
|
||||||
|
],
|
||||||
|
[EventShieldReason.MISMATCHED_SENDER_KEY, E2ePadlockIcon.Warning, "Encrypted by an unverified session"],
|
||||||
|
[EventShieldReason.SENT_IN_CLEAR, E2ePadlockIcon.Warning, "Not encrypted"],
|
||||||
|
[
|
||||||
|
EventShieldReason.VERIFICATION_VIOLATION,
|
||||||
|
E2ePadlockIcon.Warning,
|
||||||
|
"Sender's verified digital identity was reset",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
EventShieldReason.MISMATCHED_SENDER,
|
||||||
|
E2ePadlockIcon.Warning,
|
||||||
|
"The sender of the event does not match the owner of the device that sent it.",
|
||||||
|
],
|
||||||
|
])("maps shield reason %s to an icon view state", (shieldReason, expectedIcon, expectedTitle) => {
|
||||||
|
const state = getEventTileE2ePadlockViewState(
|
||||||
|
makeInput({
|
||||||
|
shieldColour: expectedIcon === E2ePadlockIcon.Normal ? EventShieldColour.GREY : EventShieldColour.RED,
|
||||||
|
shieldReason,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(state).toEqual({
|
||||||
|
kind: "icon",
|
||||||
|
icon: expectedIcon,
|
||||||
|
title: expectedTitle,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps forwarded-message state renderable by the shared-message indicator", () => {
|
||||||
|
const mxEvent = makeEvent();
|
||||||
|
jest.spyOn(mxEvent, "getKeyForwardingUser").mockReturnValue("@bob:example.org");
|
||||||
|
|
||||||
|
const state = getEventTileE2ePadlockViewState(
|
||||||
|
makeInput({
|
||||||
|
mxEvent,
|
||||||
|
shieldColour: EventShieldColour.GREY,
|
||||||
|
shieldReason: EventShieldReason.AUTHENTICITY_NOT_GUARANTEED,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(state).toEqual({
|
||||||
|
kind: "messageShared",
|
||||||
|
keyForwardingUserId: "@bob:example.org",
|
||||||
|
roomId,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user