mirror of
https://github.com/element-hq/element-web.git
synced 2025-09-17 11:04:05 +02:00
Compare commits
15 Commits
develop
...
midhun/mvv
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b8a4020102 | ||
|
|
66d3471e41 | ||
|
|
a76654ff22 | ||
|
|
a0a03148e4 | ||
|
|
14af817037 | ||
|
|
1e449ae215 | ||
|
|
c08e756854 | ||
|
|
1927a4b235 | ||
|
|
c3f1879189 | ||
|
|
ce2f9ae32a | ||
|
|
4caf52abf3 | ||
|
|
d86dcb1b7b | ||
|
|
2cd2a4a8ef | ||
|
|
3bbb62d346 | ||
|
|
4e42654c4f |
@@ -74,6 +74,7 @@ export default class MAudioBody extends React.PureComponent<IBodyProps, IState>
|
||||
|
||||
public componentWillUnmount(): void {
|
||||
this.state.playback?.destroy();
|
||||
this.state.audioPlayerVm?.dispose();
|
||||
}
|
||||
|
||||
protected get showFileBody(): boolean {
|
||||
|
||||
@@ -28,9 +28,8 @@ import { logger } from "matrix-js-sdk/src/logger";
|
||||
import { CallErrorCode } from "matrix-js-sdk/src/webrtc/call";
|
||||
import {
|
||||
CryptoEvent,
|
||||
DecryptionFailureCode,
|
||||
EventShieldColour,
|
||||
EventShieldReason,
|
||||
type EventShieldReason,
|
||||
type UserVerificationStatus,
|
||||
} from "matrix-js-sdk/src/crypto-api";
|
||||
import { Tooltip } from "@vector-im/compound-web";
|
||||
@@ -75,7 +74,6 @@ import { haveRendererForEvent, isMessageEvent, renderTile } from "../../../event
|
||||
import ThreadSummary, { ThreadMessagePreview } from "./ThreadSummary";
|
||||
import { ReadReceiptGroup } from "./ReadReceiptGroup";
|
||||
import { type ShowThreadPayload } from "../../../dispatcher/payloads/ShowThreadPayload";
|
||||
import { isLocalRoom } from "../../../utils/localRoom/isLocalRoom";
|
||||
import { UnreadNotificationBadge } from "./NotificationBadge/UnreadNotificationBadge";
|
||||
import { EventTileThreadToolbar } from "./EventTile/EventTileThreadToolbar";
|
||||
import { getLateEventInfo } from "../../structures/grouper/LateEventGrouper";
|
||||
@@ -83,6 +81,8 @@ import PinningUtils from "../../../utils/PinningUtils";
|
||||
import { PinnedMessageBadge } from "../messages/PinnedMessageBadge";
|
||||
import { EventPreview } from "./EventPreview";
|
||||
import { ElementCallEventType } from "../../../call-types";
|
||||
import { E2ePadlockViewModel } from "../../../viewmodels/event-tile/E2ePadlockViewModel";
|
||||
import { E2EPadlockView } from "../../../shared-components/event-tile/E2ePadlockView";
|
||||
|
||||
export type GetRelationsForEvent = (
|
||||
eventId: string,
|
||||
@@ -286,6 +286,7 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
|
||||
private isListeningForReceipts: boolean;
|
||||
private tile = createRef<IEventTileType>();
|
||||
private replyChain = createRef<ReplyChain>();
|
||||
private e2ePadlockViewModel: E2ePadlockViewModel;
|
||||
|
||||
public readonly ref = createRef<HTMLElement>();
|
||||
|
||||
@@ -297,7 +298,7 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
|
||||
public static contextType = RoomContext;
|
||||
declare public context: React.ContextType<typeof RoomContext>;
|
||||
|
||||
private unmounted = false;
|
||||
// private unmounted = false;
|
||||
|
||||
public constructor(props: EventTileProps, context: React.ContextType<typeof RoomContext>) {
|
||||
super(props, context);
|
||||
@@ -328,6 +329,12 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
|
||||
// to determine if we've already subscribed and use a combination of other flags to find
|
||||
// out if we should even be subscribed at all.
|
||||
this.isListeningForReceipts = false;
|
||||
|
||||
this.e2ePadlockViewModel = new E2ePadlockViewModel({
|
||||
event: this.props.mxEvent,
|
||||
cli: MatrixClientPeg.get()!,
|
||||
isRoomEncrypted: !!this.context.isRoomEncrypted,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -386,7 +393,6 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
|
||||
}
|
||||
|
||||
public componentDidMount(): void {
|
||||
this.unmounted = false;
|
||||
this.suppressReadReceiptAnimation = false;
|
||||
const client = MatrixClientPeg.safeGet();
|
||||
if (!this.props.forExport) {
|
||||
@@ -441,8 +447,8 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
|
||||
this.props.mxEvent.removeListener(MatrixEventEvent.RelationsCreated, this.onReactionsCreated);
|
||||
}
|
||||
this.props.mxEvent.off(ThreadEvent.Update, this.updateThread);
|
||||
this.unmounted = false;
|
||||
if (this.props.resizeObserver && this.ref.current) this.props.resizeObserver.unobserve(this.ref.current);
|
||||
if (this.e2ePadlockViewModel) this.e2ePadlockViewModel.dispose();
|
||||
}
|
||||
|
||||
public componentDidUpdate(prevProps: Readonly<EventTileProps>, prevState: Readonly<IState>): void {
|
||||
@@ -577,33 +583,33 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
|
||||
};
|
||||
|
||||
private verifyEvent(): void {
|
||||
this.doVerifyEvent().catch((e) => {
|
||||
this.e2ePadlockViewModel.verifyEvent().catch((e) => {
|
||||
const event = this.props.mxEvent;
|
||||
logger.error(`Error getting encryption info on event ${event.getId()} in room ${event.getRoomId()}`, e);
|
||||
});
|
||||
}
|
||||
|
||||
private async doVerifyEvent(): Promise<void> {
|
||||
// if the event was edited, show the verification info for the edit, not
|
||||
// the original
|
||||
const mxEvent = this.props.mxEvent.replacingEvent() ?? this.props.mxEvent;
|
||||
// private async doVerifyEvent(): Promise<void> {
|
||||
// // if the event was edited, show the verification info for the edit, not
|
||||
// // the original
|
||||
// const mxEvent = this.props.mxEvent.replacingEvent() ?? this.props.mxEvent;
|
||||
|
||||
if (!mxEvent.isEncrypted() || mxEvent.isRedacted()) {
|
||||
this.setState({ shieldColour: EventShieldColour.NONE, shieldReason: null });
|
||||
return;
|
||||
}
|
||||
// if (!mxEvent.isEncrypted() || mxEvent.isRedacted()) {
|
||||
// this.setState({ shieldColour: EventShieldColour.NONE, shieldReason: null });
|
||||
// return;
|
||||
// }
|
||||
|
||||
const encryptionInfo =
|
||||
(await MatrixClientPeg.safeGet().getCrypto()?.getEncryptionInfoForEvent(mxEvent)) ?? null;
|
||||
if (this.unmounted) return;
|
||||
if (encryptionInfo === null) {
|
||||
// likely a decryption error
|
||||
this.setState({ shieldColour: EventShieldColour.NONE, shieldReason: null });
|
||||
return;
|
||||
}
|
||||
// const encryptionInfo =
|
||||
// (await MatrixClientPeg.safeGet().getCrypto()?.getEncryptionInfoForEvent(mxEvent)) ?? null;
|
||||
// if (this.unmounted) return;
|
||||
// if (encryptionInfo === null) {
|
||||
// // likely a decryption error
|
||||
// this.setState({ shieldColour: EventShieldColour.NONE, shieldReason: null });
|
||||
// return;
|
||||
// }
|
||||
|
||||
this.setState({ shieldColour: encryptionInfo.shieldColour, shieldReason: encryptionInfo.shieldReason });
|
||||
}
|
||||
// this.setState({ shieldColour: encryptionInfo.shieldColour, shieldReason: encryptionInfo.shieldReason });
|
||||
// }
|
||||
|
||||
private propsEqual(objA: EventTileProps, objB: EventTileProps): boolean {
|
||||
const keysA = Object.keys(objA) as Array<keyof EventTileProps>;
|
||||
@@ -708,98 +714,95 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
|
||||
});
|
||||
};
|
||||
|
||||
private renderE2EPadlock(): ReactNode {
|
||||
// if the event was edited, show the verification info for the edit, not
|
||||
// the original
|
||||
const ev = this.props.mxEvent.replacingEvent() ?? this.props.mxEvent;
|
||||
// private renderE2EPadlock(): ReactNode {
|
||||
// // if the event was edited, show the verification info for the edit, not
|
||||
// // the original
|
||||
// const ev = this.props.mxEvent.replacingEvent() ?? this.props.mxEvent;
|
||||
|
||||
// no icon for local rooms
|
||||
if (isLocalRoom(ev.getRoomId()!)) return null;
|
||||
// // no icon for local rooms
|
||||
// if (isLocalRoom(ev.getRoomId()!)) return null;
|
||||
|
||||
// event could not be decrypted
|
||||
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 />;
|
||||
}
|
||||
}
|
||||
// // event could not be decrypted
|
||||
// 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.shieldColour !== EventShieldColour.NONE) {
|
||||
let shieldReasonMessage: string;
|
||||
switch (this.state.shieldReason) {
|
||||
case EventShieldReason.UNVERIFIED_IDENTITY:
|
||||
shieldReasonMessage = _t("encryption|event_shield_reason_unverified_identity");
|
||||
break;
|
||||
// if (this.state.shieldColour !== EventShieldColour.NONE) {
|
||||
// let shieldReasonMessage: string;
|
||||
// switch (this.state.shieldReason) {
|
||||
// case null:
|
||||
// case EventShieldReason.UNKNOWN:
|
||||
// shieldReasonMessage = _t("error|unknown");
|
||||
// break;
|
||||
|
||||
case EventShieldReason.UNSIGNED_DEVICE:
|
||||
shieldReasonMessage = _t("encryption|event_shield_reason_unsigned_device");
|
||||
break;
|
||||
// case EventShieldReason.UNVERIFIED_IDENTITY:
|
||||
// shieldReasonMessage = _t("encryption|event_shield_reason_unverified_identity");
|
||||
// break;
|
||||
|
||||
case EventShieldReason.UNKNOWN_DEVICE:
|
||||
shieldReasonMessage = _t("encryption|event_shield_reason_unknown_device");
|
||||
break;
|
||||
// case EventShieldReason.UNSIGNED_DEVICE:
|
||||
// shieldReasonMessage = _t("encryption|event_shield_reason_unsigned_device");
|
||||
// break;
|
||||
|
||||
case EventShieldReason.AUTHENTICITY_NOT_GUARANTEED:
|
||||
shieldReasonMessage = _t("encryption|event_shield_reason_authenticity_not_guaranteed");
|
||||
break;
|
||||
// case EventShieldReason.UNKNOWN_DEVICE:
|
||||
// shieldReasonMessage = _t("encryption|event_shield_reason_unknown_device");
|
||||
// break;
|
||||
|
||||
case EventShieldReason.MISMATCHED_SENDER_KEY:
|
||||
shieldReasonMessage = _t("encryption|event_shield_reason_mismatched_sender_key");
|
||||
break;
|
||||
// case EventShieldReason.AUTHENTICITY_NOT_GUARANTEED:
|
||||
// shieldReasonMessage = _t("encryption|event_shield_reason_authenticity_not_guaranteed");
|
||||
// break;
|
||||
|
||||
case EventShieldReason.SENT_IN_CLEAR:
|
||||
shieldReasonMessage = _t("common|unencrypted");
|
||||
break;
|
||||
// case EventShieldReason.MISMATCHED_SENDER_KEY:
|
||||
// shieldReasonMessage = _t("encryption|event_shield_reason_mismatched_sender_key");
|
||||
// break;
|
||||
|
||||
case EventShieldReason.VERIFICATION_VIOLATION:
|
||||
shieldReasonMessage = _t("timeline|decryption_failure|sender_identity_previously_verified");
|
||||
break;
|
||||
// case EventShieldReason.SENT_IN_CLEAR:
|
||||
// shieldReasonMessage = _t("common|unencrypted");
|
||||
// break;
|
||||
|
||||
case EventShieldReason.MISMATCHED_SENDER:
|
||||
shieldReasonMessage = _t("encryption|event_shield_reason_mismatched_sender");
|
||||
break;
|
||||
// case EventShieldReason.VERIFICATION_VIOLATION:
|
||||
// shieldReasonMessage = _t("timeline|decryption_failure|sender_identity_previously_verified");
|
||||
// break;
|
||||
// }
|
||||
|
||||
default:
|
||||
shieldReasonMessage = _t("error|unknown");
|
||||
break;
|
||||
}
|
||||
// if (this.state.shieldColour === EventShieldColour.GREY) {
|
||||
// return <E2ePadlock icon={E2ePadlockIcon.Normal} title={shieldReasonMessage} />;
|
||||
// } else {
|
||||
// // red, by elimination
|
||||
// return <E2ePadlock icon={E2ePadlockIcon.Warning} title={shieldReasonMessage} />;
|
||||
// }
|
||||
// }
|
||||
|
||||
if (this.state.shieldColour === EventShieldColour.GREY) {
|
||||
return <E2ePadlock icon={E2ePadlockIcon.Normal} title={shieldReasonMessage} />;
|
||||
} else {
|
||||
// red, by elimination
|
||||
return <E2ePadlock 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 />;
|
||||
// }
|
||||
// }
|
||||
|
||||
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;
|
||||
}
|
||||
// // no padlock needed
|
||||
// return null;
|
||||
// }
|
||||
|
||||
private onActionBarFocusChange = (actionBarFocused: boolean): void => {
|
||||
this.setState({ actionBarFocused });
|
||||
@@ -1174,8 +1177,9 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
|
||||
const groupTimestamp = !useIRCLayout ? linkedTimestamp : null;
|
||||
const ircTimestamp = useIRCLayout ? linkedTimestamp : null;
|
||||
const bubbleTimestamp = this.props.layout === Layout.Bubble ? messageTimestamp : undefined;
|
||||
const groupPadlock = !useIRCLayout && !isBubbleMessage && this.renderE2EPadlock();
|
||||
const ircPadlock = useIRCLayout && !isBubbleMessage && this.renderE2EPadlock();
|
||||
const padlock = <E2EPadlockView vm={this.e2ePadlockViewModel} />;
|
||||
const groupPadlock = !useIRCLayout && !isBubbleMessage && padlock;
|
||||
const ircPadlock = useIRCLayout && !isBubbleMessage && padlock;
|
||||
|
||||
let msgOption: JSX.Element | undefined;
|
||||
if (this.shouldShowSentReceipt || this.shouldShowSendingReceipt) {
|
||||
@@ -1487,53 +1491,6 @@ const SafeEventTile = (props: EventTileProps): JSX.Element => {
|
||||
};
|
||||
export default SafeEventTile;
|
||||
|
||||
function E2ePadlockUnencrypted(props: Omit<IE2ePadlockProps, "title" | "icon">): JSX.Element {
|
||||
return <E2ePadlock title={_t("common|unencrypted")} icon={E2ePadlockIcon.Warning} {...props} />;
|
||||
}
|
||||
|
||||
function E2ePadlockDecryptionFailure(props: Omit<IE2ePadlockProps, "title" | "icon">): JSX.Element {
|
||||
return (
|
||||
<E2ePadlock title={_t("timeline|undecryptable_tooltip")} icon={E2ePadlockIcon.DecryptionFailure} {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
enum E2ePadlockIcon {
|
||||
/** grey shield */
|
||||
Normal = "normal",
|
||||
|
||||
/** red shield with (!) */
|
||||
Warning = "warning",
|
||||
|
||||
/** key in grey circle */
|
||||
DecryptionFailure = "decryption_failure",
|
||||
}
|
||||
|
||||
interface IE2ePadlockProps {
|
||||
icon: E2ePadlockIcon;
|
||||
title: string;
|
||||
}
|
||||
|
||||
class E2ePadlock extends React.Component<IE2ePadlockProps> {
|
||||
public constructor(props: IE2ePadlockProps) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
hover: false,
|
||||
};
|
||||
}
|
||||
|
||||
public render(): ReactNode {
|
||||
const classes = `mx_EventTile_e2eIcon mx_EventTile_e2eIcon_${this.props.icon}`;
|
||||
// We specify isTriggerInteractive=true and make the div interactive manually as a workaround for
|
||||
// https://github.com/element-hq/compound/issues/294
|
||||
return (
|
||||
<Tooltip label={this.props.title} isTriggerInteractive={true}>
|
||||
<div className={classes} tabIndex={0} aria-label={_t("timeline|e2e_state")} />
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
interface ISentReceiptProps {
|
||||
messageState: EventStatus | null;
|
||||
}
|
||||
|
||||
@@ -45,6 +45,7 @@ import ModuleApi from "../modules/Api";
|
||||
import { TextualEventViewModel } from "../viewmodels/event-tiles/TextualEventViewModel";
|
||||
import { TextualEventView } from "../shared-components/event-tiles/TextualEventView";
|
||||
import { ElementCallEventType } from "../call-types";
|
||||
import { useAutoDisposedViewModel } from "../viewmodels/base/useAutoDisposedViewModel";
|
||||
|
||||
// Subset of EventTile's IProps plus some mixins
|
||||
export interface EventTileTypeProps
|
||||
@@ -79,10 +80,15 @@ const LegacyCallEventFactory: Factory<FactoryProps & { callEventGrouper: LegacyC
|
||||
<LegacyCallEvent ref={ref} {...props} />
|
||||
);
|
||||
const CallEventFactory: Factory = (ref, props) => <CallEvent ref={ref} {...props} />;
|
||||
export const TextualEventFactory: Factory = (ref, props) => {
|
||||
const vm = new TextualEventViewModel(props);
|
||||
|
||||
const TextualEventComponent: React.FC<FactoryProps> = (props) => {
|
||||
const vm = useAutoDisposedViewModel(() => new TextualEventViewModel(props));
|
||||
return <TextualEventView vm={vm} />;
|
||||
};
|
||||
export const TextualEventFactory: Factory = (ref, props) => {
|
||||
return <TextualEventComponent {...props} />;
|
||||
};
|
||||
|
||||
const VerificationReqFactory: Factory = (_ref, props) => <MKeyVerificationRequest {...props} />;
|
||||
const HiddenEventFactory: Factory = (ref, props) => <HiddenBody ref={ref} {...props} />;
|
||||
|
||||
|
||||
56
src/shared-components/event-tile/E2ePadlockView.tsx
Normal file
56
src/shared-components/event-tile/E2ePadlockView.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
/*
|
||||
Copyright 2025 New Vector 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 from "react";
|
||||
import { Tooltip } from "@vector-im/compound-web";
|
||||
|
||||
import { useViewModel } from "../useViewModel";
|
||||
import type { ViewModel } from "../ViewModel";
|
||||
import { _t } from "../../languageHandler";
|
||||
|
||||
interface E2ePadlockViewSnapshotWithShield {
|
||||
noShield?: false;
|
||||
iconType: E2ePadlockIconType;
|
||||
message: string;
|
||||
}
|
||||
interface E2ePadlockViewSnapshotNoShield {
|
||||
noShield: true;
|
||||
}
|
||||
export type E2ePadlockViewSnapshot = E2ePadlockViewSnapshotNoShield | E2ePadlockViewSnapshotWithShield;
|
||||
|
||||
export enum E2ePadlockIconType {
|
||||
/** grey shield */
|
||||
Normal = "normal",
|
||||
|
||||
/** red shield with (!) */
|
||||
Warning = "warning",
|
||||
|
||||
/** key in grey circle */
|
||||
DecryptionFailure = "decryption_failure",
|
||||
}
|
||||
|
||||
interface Props {
|
||||
vm: ViewModel<E2ePadlockViewSnapshot>;
|
||||
}
|
||||
|
||||
/**
|
||||
* This is the padlock icon that is rendered before the encrypted message.
|
||||
*/
|
||||
export const E2EPadlockView: React.FC<Props> = ({ vm }) => {
|
||||
const vs = useViewModel(vm);
|
||||
if (vs.noShield) return null;
|
||||
|
||||
const { iconType: icon, message: title } = vs;
|
||||
const classes = `mx_EventTile_e2eIcon mx_EventTile_e2eIcon_${icon}`;
|
||||
// We specify isTriggerInteractive=true and make the div interactive manually as a workaround for
|
||||
// https://github.com/element-hq/compound/issues/294
|
||||
return (
|
||||
<Tooltip label={title} isTriggerInteractive={true}>
|
||||
<div className={classes} tabIndex={0} aria-label={_t("timeline|e2e_state")} />
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
@@ -77,6 +77,9 @@ export class AudioPlayerViewModel
|
||||
|
||||
public constructor(props: Props) {
|
||||
super(props, AudioPlayerViewModel.computeSnapshot(props.playback, props.mediaName));
|
||||
this.disposables.trackListener(props.playback, UPDATE_EVENT, this.setSnapshot);
|
||||
// There is no unsubscribe method in SimpleObservable
|
||||
this.props.playback.clockInfo.liveData.onUpdate(this.setSnapshot);
|
||||
|
||||
// Don't wait for the promise to complete - it will emit a progress update when it
|
||||
// is done, and it's not meant to take long anyhow.
|
||||
@@ -97,15 +100,6 @@ export class AudioPlayerViewModel
|
||||
}
|
||||
}
|
||||
|
||||
protected addDownstreamSubscription(): void {
|
||||
this.props.playback.on(UPDATE_EVENT, this.setSnapshot);
|
||||
// There is no unsubscribe method in SimpleObservable
|
||||
this.props.playback.clockInfo.liveData.onUpdate(this.setSnapshot);
|
||||
}
|
||||
protected removeDownstreamSubscription(): void {
|
||||
this.props.playback.off(UPDATE_EVENT, this.setSnapshot);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the snapshot and emits an update to subscribers.
|
||||
*/
|
||||
|
||||
@@ -6,6 +6,7 @@ Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { type ViewModel } from "../../shared-components/ViewModel";
|
||||
import { Disposables } from "./Disposables";
|
||||
import { Snapshot } from "./Snapshot";
|
||||
import { ViewModelSubscriptions } from "./ViewModelSubscriptions";
|
||||
|
||||
@@ -13,13 +14,11 @@ export abstract class BaseViewModel<T, P> implements ViewModel<T> {
|
||||
protected subs: ViewModelSubscriptions;
|
||||
protected snapshot: Snapshot<T>;
|
||||
protected props: P;
|
||||
protected disposables = new Disposables();
|
||||
|
||||
protected constructor(props: P, initialSnapshot: T) {
|
||||
this.props = props;
|
||||
this.subs = new ViewModelSubscriptions(
|
||||
this.addDownstreamSubscriptionWrapper,
|
||||
this.removeDownstreamSubscriptionWrapper,
|
||||
);
|
||||
this.subs = new ViewModelSubscriptions();
|
||||
this.snapshot = new Snapshot(initialSnapshot, () => {
|
||||
this.subs.emit();
|
||||
});
|
||||
@@ -29,37 +28,24 @@ export abstract class BaseViewModel<T, P> implements ViewModel<T> {
|
||||
return this.subs.add(listener);
|
||||
};
|
||||
|
||||
/**
|
||||
* Wrapper around the abstract subscribe callback as we can't assume that the subclassed method
|
||||
* has a bound `this` context.
|
||||
*/
|
||||
private addDownstreamSubscriptionWrapper = (): void => {
|
||||
this.addDownstreamSubscription();
|
||||
};
|
||||
|
||||
/**
|
||||
* Wrapper around the abstract unsubscribe callback as we can't call pass an abstract method directly
|
||||
* in the constructor.
|
||||
*/
|
||||
private removeDownstreamSubscriptionWrapper = (): void => {
|
||||
this.removeDownstreamSubscription();
|
||||
};
|
||||
|
||||
/**
|
||||
* Called when the first listener subscribes: the subclass should set up any necessary subscriptions
|
||||
* to call this.subs.emit() when the snapshot changes.
|
||||
*/
|
||||
protected abstract addDownstreamSubscription(): void;
|
||||
|
||||
/**
|
||||
* Called when the last listener unsubscribes: the subclass should clean up any subscriptions.
|
||||
*/
|
||||
protected abstract removeDownstreamSubscription(): void;
|
||||
|
||||
/**
|
||||
* Returns the current snapshot of the view model.
|
||||
*/
|
||||
public getSnapshot = (): T => {
|
||||
return this.snapshot.current;
|
||||
};
|
||||
|
||||
/**
|
||||
* Relinquish any resources held by this view-model.
|
||||
*/
|
||||
public dispose(): void {
|
||||
this.disposables.dispose();
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this view-model has been disposed.
|
||||
*/
|
||||
public get isDisposed(): boolean {
|
||||
return this.disposables.isDisposed;
|
||||
}
|
||||
}
|
||||
|
||||
70
src/viewmodels/base/Disposables.ts
Normal file
70
src/viewmodels/base/Disposables.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
/*
|
||||
Copyright 2025 New Vector 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 { EventEmitter } from "events";
|
||||
|
||||
/**
|
||||
* Something that needs to be eventually disposed. This can be:
|
||||
* - A function that does the disposing
|
||||
* - An object containing a dispose method which does the disposing
|
||||
*/
|
||||
export type DisposableItem = { dispose: () => void } | (() => void);
|
||||
|
||||
/**
|
||||
* This class provides a way for the view-model to track any resource
|
||||
* that it needs to eventually relinquish.
|
||||
*/
|
||||
export class Disposables {
|
||||
private readonly disposables: DisposableItem[] = [];
|
||||
private _isDisposed: boolean = false;
|
||||
|
||||
/**
|
||||
* Relinquish all tracked disposable values
|
||||
*/
|
||||
public dispose(): void {
|
||||
if (this.isDisposed) return;
|
||||
this._isDisposed = true;
|
||||
for (const disposable of this.disposables) {
|
||||
if (typeof disposable === "function") {
|
||||
disposable();
|
||||
} else {
|
||||
disposable.dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Track a value that needs to be eventually relinquished
|
||||
*/
|
||||
public track<T extends DisposableItem>(disposable: T): T {
|
||||
this.throwIfDisposed();
|
||||
this.disposables.push(disposable);
|
||||
return disposable;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add an event listener that will be removed on dispose
|
||||
*/
|
||||
public trackListener(emitter: EventEmitter, event: string, callback: (...args: unknown[]) => void): void {
|
||||
this.throwIfDisposed();
|
||||
emitter.on(event, callback);
|
||||
this.track(() => {
|
||||
emitter.off(event, callback);
|
||||
});
|
||||
}
|
||||
|
||||
private throwIfDisposed(): void {
|
||||
if (this.isDisposed) throw new Error("Disposable is already disposed");
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this disposable has been disposed
|
||||
*/
|
||||
public get isDisposed(): boolean {
|
||||
return this._isDisposed;
|
||||
}
|
||||
}
|
||||
@@ -6,20 +6,11 @@ Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Utility class for view models to manage suscriptions to their updates
|
||||
* Utility class for view models to manage subscriptions to their updates
|
||||
*/
|
||||
export class ViewModelSubscriptions {
|
||||
private listeners = new Set<() => void>();
|
||||
|
||||
/**
|
||||
* @param subscribeCallback Called when the first listener subscribes.
|
||||
* @param unsubscribeCallback Called when the last listener unsubscribes.
|
||||
*/
|
||||
public constructor(
|
||||
private subscribeCallback: () => void,
|
||||
private unsubscribeCallback: () => void,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Subscribe to changes in the view model.
|
||||
* @param listener Will be called whenever the snapshot changes.
|
||||
@@ -27,15 +18,8 @@ export class ViewModelSubscriptions {
|
||||
*/
|
||||
public add = (listener: () => void): (() => void) => {
|
||||
this.listeners.add(listener);
|
||||
if (this.listeners.size === 1) {
|
||||
this.subscribeCallback();
|
||||
}
|
||||
|
||||
return () => {
|
||||
this.listeners.delete(listener);
|
||||
if (this.listeners.size === 0) {
|
||||
this.unsubscribeCallback();
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
62
src/viewmodels/base/useAutoDisposedViewModel.ts
Normal file
62
src/viewmodels/base/useAutoDisposedViewModel.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
/*
|
||||
Copyright 2025 New Vector 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 { useEffect, useState } from "react";
|
||||
|
||||
import type { BaseViewModel } from "./BaseViewModel";
|
||||
|
||||
type VmCreator<B extends BaseViewModel<unknown, unknown>> = () => B;
|
||||
|
||||
/**
|
||||
* Instantiate a view-model that gets disposed when the calling react component unmounts.
|
||||
* In other words, this hook ties the lifecycle of a view-model to the lifecycle of a
|
||||
* react component.
|
||||
*
|
||||
* @param vmCreator A function that returns a view-model instance
|
||||
* @returns view-model instance from vmCreator
|
||||
* @example
|
||||
* const vm = useAutoDisposedViewModel(() => new FooViewModel({prop1, prop2, ...});
|
||||
*/
|
||||
export function useAutoDisposedViewModel<B extends BaseViewModel<unknown, unknown>>(vmCreator: VmCreator<B>): B {
|
||||
/**
|
||||
* The view-model instance may be replaced by a different instance in some scenarios.
|
||||
* We want to be sure that whatever react component called this hook gets re-rendered
|
||||
* when this happens, hence the state.
|
||||
*/
|
||||
const [viewModel, setViewModel] = useState<B>(vmCreator);
|
||||
|
||||
/**
|
||||
* Our intention here is to ensure that the dispose method of the view-model gets called
|
||||
* when the component that uses this hook unmounts.
|
||||
* We can do that by combining a useEffect cleanup with an empty dependency array.
|
||||
*/
|
||||
useEffect(() => {
|
||||
let toDispose = viewModel;
|
||||
|
||||
/**
|
||||
* Because we use react strict mode, react will run our effects twice in dev mode to make
|
||||
* sure that they are pure.
|
||||
* This presents a complication - the vm instance that we created in our state initializer
|
||||
* will get disposed on the first cleanup.
|
||||
* So we'll recreate the view-model if it's already disposed.
|
||||
*/
|
||||
if (viewModel.isDisposed) {
|
||||
const newViewModel = vmCreator();
|
||||
// Change toDispose so that we don't end up disposing the already disposed vm.
|
||||
toDispose = newViewModel;
|
||||
setViewModel(newViewModel);
|
||||
}
|
||||
return () => {
|
||||
// Dispose the view-model when this component unmounts
|
||||
toDispose.dispose();
|
||||
};
|
||||
|
||||
// eslint-disable-next-line react-compiler/react-compiler
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
return viewModel;
|
||||
}
|
||||
154
src/viewmodels/event-tile/E2ePadlockViewModel.ts
Normal file
154
src/viewmodels/event-tile/E2ePadlockViewModel.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
/*
|
||||
Copyright 2025 New Vector 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 MatrixClient, type MatrixEvent } from "matrix-js-sdk/src/matrix";
|
||||
import { DecryptionFailureCode, EventShieldColour, EventShieldReason } from "matrix-js-sdk/src/crypto-api";
|
||||
|
||||
import { E2ePadlockIconType, type E2ePadlockViewSnapshot } from "../../shared-components/event-tile/E2ePadlockView";
|
||||
import { BaseViewModel } from "../base/BaseViewModel";
|
||||
import { isLocalRoom } from "../../utils/localRoom/isLocalRoom";
|
||||
import { _t } from "../../languageHandler";
|
||||
|
||||
interface Props {
|
||||
event: MatrixEvent;
|
||||
cli: MatrixClient;
|
||||
isRoomEncrypted: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* View-model for the padlock icon rendered before encrypted message.
|
||||
*/
|
||||
export class E2ePadlockViewModel extends BaseViewModel<E2ePadlockViewSnapshot, Props> {
|
||||
public constructor(props: Props) {
|
||||
super(props, { noShield: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates the icon and message to show by verifying the encryption
|
||||
* info of the associated event.
|
||||
*/
|
||||
public async verifyEvent(): Promise<void> {
|
||||
const [colour, reason] = await this.getShieldInfo();
|
||||
const newSnapshot = this.getIconAndMessage(colour, reason);
|
||||
this.snapshot.set(newSnapshot);
|
||||
}
|
||||
|
||||
private async getShieldInfo(): Promise<[EventShieldColour, EventShieldReason | null]> {
|
||||
const { event, cli } = this.props;
|
||||
// if the event was edited, show the verification info for the edit, not
|
||||
// the original
|
||||
const mxEvent = event.replacingEvent() ?? event;
|
||||
|
||||
if (!mxEvent.isEncrypted() || mxEvent.isRedacted()) {
|
||||
return [EventShieldColour.NONE, null];
|
||||
}
|
||||
|
||||
const encryptionInfo = (await cli.getCrypto()?.getEncryptionInfoForEvent(mxEvent)) ?? null;
|
||||
if (encryptionInfo === null) {
|
||||
// likely a decryption error
|
||||
return [EventShieldColour.NONE, null];
|
||||
}
|
||||
|
||||
return [encryptionInfo.shieldColour, encryptionInfo.shieldReason];
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert EventShieldReason to a user readable message.
|
||||
*/
|
||||
private getShieldMessage(reason: EventShieldReason | null): string {
|
||||
switch (reason) {
|
||||
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");
|
||||
|
||||
default:
|
||||
return _t("error|unknown");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Some events are expected to be unencrypted even in an encrypted room.
|
||||
* Checks if this is such an event.
|
||||
*/
|
||||
private isEventAllowedToBeUnencrypted(event: MatrixEvent): boolean {
|
||||
// event is being encrypted or is not_sent (Unknown Devices/Network Error)
|
||||
if (event.status === EventStatus.ENCRYPTING) {
|
||||
return true;
|
||||
}
|
||||
if (event.status === EventStatus.NOT_SENT) {
|
||||
return true;
|
||||
}
|
||||
if (event.isState()) {
|
||||
return true; // we expect this to be unencrypted
|
||||
}
|
||||
if (event.isRedacted()) {
|
||||
return true; // we expect this to be unencrypted
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private getIconAndMessage(
|
||||
shieldColour: EventShieldColour,
|
||||
shieldReason: EventShieldReason | null,
|
||||
): E2ePadlockViewSnapshot {
|
||||
const { isRoomEncrypted } = this.props;
|
||||
const event = this.props.event.replacingEvent() ?? this.props.event;
|
||||
|
||||
if (isLocalRoom(event.getRoomId()!)) {
|
||||
// no icon for local rooms
|
||||
return { noShield: true };
|
||||
}
|
||||
|
||||
// event could not be decrypted
|
||||
if (event.isDecryptionFailure()) {
|
||||
switch (event.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 { noShield: true };
|
||||
default:
|
||||
return {
|
||||
message: _t("timeline|undecryptable_tooltip"),
|
||||
iconType: E2ePadlockIconType.DecryptionFailure,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (shieldColour !== EventShieldColour.NONE) {
|
||||
const message = this.getShieldMessage(shieldReason);
|
||||
const iconType =
|
||||
shieldColour === EventShieldColour.GREY ? E2ePadlockIconType.Normal : E2ePadlockIconType.Warning;
|
||||
return { message, iconType };
|
||||
}
|
||||
|
||||
if (isRoomEncrypted && !event.isEncrypted() && !this.isEventAllowedToBeUnencrypted(event)) {
|
||||
// if the event is not encrypted, but it's an e2e room, show a warning
|
||||
return { message: _t("common|unencrypted"), iconType: E2ePadlockIconType.Warning };
|
||||
}
|
||||
|
||||
return {
|
||||
noShield: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -17,18 +17,11 @@ export class TextualEventViewModel extends BaseViewModel<TextualEventViewSnapsho
|
||||
public constructor(props: EventTileTypeProps) {
|
||||
super(props, { content: "" });
|
||||
this.setTextFromEvent();
|
||||
this.disposables.trackListener(this.props.mxEvent, MatrixEventEvent.SentinelUpdated, this.setTextFromEvent);
|
||||
}
|
||||
|
||||
private setTextFromEvent = (): void => {
|
||||
const content = textForEvent(this.props.mxEvent, MatrixClientPeg.safeGet(), true, this.props.showHiddenEvents);
|
||||
this.snapshot.set({ content });
|
||||
};
|
||||
|
||||
protected addDownstreamSubscription = (): void => {
|
||||
this.props.mxEvent.on(MatrixEventEvent.SentinelUpdated, this.setTextFromEvent);
|
||||
};
|
||||
|
||||
protected removeDownstreamSubscription = (): void => {
|
||||
this.props.mxEvent.off(MatrixEventEvent.SentinelUpdated, this.setTextFromEvent);
|
||||
};
|
||||
}
|
||||
|
||||
57
test/viewmodels/base/Disposables-test.ts
Normal file
57
test/viewmodels/base/Disposables-test.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
/*
|
||||
Copyright 2025 New Vector 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 { EventEmitter } from "events";
|
||||
|
||||
import { Disposables } from "../../../src/viewmodels/base/Disposables";
|
||||
|
||||
describe("Disposable", () => {
|
||||
it("isDisposed is true after dispose() is called", () => {
|
||||
const disposables = new Disposables();
|
||||
expect(disposables.isDisposed).toEqual(false);
|
||||
disposables.dispose();
|
||||
expect(disposables.isDisposed).toEqual(true);
|
||||
});
|
||||
|
||||
it("dispose() calls the correct disposing function", () => {
|
||||
const disposables = new Disposables();
|
||||
|
||||
const item1 = {
|
||||
foo: 5,
|
||||
dispose: jest.fn(),
|
||||
};
|
||||
disposables.track(item1);
|
||||
|
||||
const item2 = jest.fn();
|
||||
disposables.track(item2);
|
||||
|
||||
disposables.dispose();
|
||||
|
||||
expect(item1.dispose).toHaveBeenCalledTimes(1);
|
||||
expect(item2).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("Throws error if acting on already disposed disposables", () => {
|
||||
const disposables = new Disposables();
|
||||
disposables.dispose();
|
||||
expect(() => {
|
||||
disposables.track(jest.fn);
|
||||
}).toThrow();
|
||||
});
|
||||
|
||||
it("Removes tracked event listeners on dispose", () => {
|
||||
const disposables = new Disposables();
|
||||
const emitter = new EventEmitter();
|
||||
|
||||
const fn = jest.fn();
|
||||
disposables.trackListener(emitter, "FooEvent", fn);
|
||||
emitter.emit("FooEvent");
|
||||
expect(fn).toHaveBeenCalled();
|
||||
|
||||
disposables.dispose();
|
||||
expect(emitter.listenerCount("FooEvent", fn)).toEqual(0);
|
||||
});
|
||||
});
|
||||
47
test/viewmodels/base/useAutoDisposedViewModel-test.ts
Normal file
47
test/viewmodels/base/useAutoDisposedViewModel-test.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
/*
|
||||
Copyright 2025 New Vector 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 { renderHook } from "jest-matrix-react";
|
||||
|
||||
import { BaseViewModel } from "../../../src/viewmodels/base/BaseViewModel";
|
||||
import { useAutoDisposedViewModel } from "../../../src/viewmodels/base/useAutoDisposedViewModel";
|
||||
|
||||
class TestViewModel extends BaseViewModel<{ count: number }, { initial: number }> {
|
||||
constructor(props: { initial: number }) {
|
||||
super(props, { count: props.initial });
|
||||
}
|
||||
|
||||
public increment() {
|
||||
const newCount = this.getSnapshot().count + 1;
|
||||
this.snapshot.set({ count: newCount });
|
||||
}
|
||||
}
|
||||
|
||||
describe("useAutoDisposedViewModel", () => {
|
||||
it("should return view-model", () => {
|
||||
const vmCreator = () => new TestViewModel({ initial: 0 });
|
||||
const { result } = renderHook(() => useAutoDisposedViewModel(vmCreator));
|
||||
const vm = result.current;
|
||||
expect(vm).toBeInstanceOf(TestViewModel);
|
||||
expect(vm.isDisposed).toStrictEqual(false);
|
||||
});
|
||||
|
||||
it("should dispose view-model on unmount", () => {
|
||||
const vmCreator = () => new TestViewModel({ initial: 0 });
|
||||
const { result, unmount } = renderHook(() => useAutoDisposedViewModel(vmCreator));
|
||||
const vm = result.current;
|
||||
vm.increment();
|
||||
unmount();
|
||||
expect(vm.isDisposed).toStrictEqual(true);
|
||||
});
|
||||
|
||||
it("should recreate view-model on react strict mode", async () => {
|
||||
const vmCreator = () => new TestViewModel({ initial: 0 });
|
||||
const output = renderHook(() => useAutoDisposedViewModel(vmCreator), { reactStrictMode: true });
|
||||
const vm = output.result.current;
|
||||
expect(vm.isDisposed).toStrictEqual(false);
|
||||
});
|
||||
});
|
||||
64
test/viewmodels/event-tiles/E2ePadlockViewModel-test.ts
Normal file
64
test/viewmodels/event-tiles/E2ePadlockViewModel-test.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
/*
|
||||
Copyright 2025 New Vector 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 { mkEvent, stubClient } from "../../test-utils";
|
||||
import { E2ePadlockViewModel } from "../../../src/viewmodels/event-tile/E2ePadlockViewModel";
|
||||
import { isLocalRoom } from "../../../src/utils/localRoom/isLocalRoom";
|
||||
import type { Mock } from "jest-mock";
|
||||
|
||||
jest.mock("../../../src/utils/localRoom/isLocalRoom");
|
||||
|
||||
describe("E2ePadlockViewModel", () => {
|
||||
it("should have initial state with noShield = true", () => {
|
||||
const event = mkEvent({
|
||||
type: "m.room.message",
|
||||
user: "foo@matrix.org",
|
||||
content: {
|
||||
body: "This is a message",
|
||||
msgtype: "m.text",
|
||||
},
|
||||
});
|
||||
const cli = stubClient();
|
||||
const isRoomEncrypted = true;
|
||||
const vm = new E2ePadlockViewModel({ event, cli, isRoomEncrypted });
|
||||
expect(vm.getSnapshot()).toStrictEqual({ noShield: true });
|
||||
});
|
||||
|
||||
it("should have state with noShield = true for local room", async () => {
|
||||
(isLocalRoom as Mock).mockImplementation(() => true);
|
||||
const event = mkEvent({
|
||||
event: true,
|
||||
type: "m.room.message",
|
||||
user: "foo@matrix.org",
|
||||
content: {
|
||||
body: "This is a message",
|
||||
msgtype: "m.text",
|
||||
},
|
||||
});
|
||||
const cli = stubClient();
|
||||
const isRoomEncrypted = true;
|
||||
const vm = new E2ePadlockViewModel({ event, cli, isRoomEncrypted });
|
||||
await vm.verifyEvent();
|
||||
expect(vm.getSnapshot()).toStrictEqual({ noShield: true });
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("should not show padlock for events expected to be unencrypted", async () => {
|
||||
const event = mkEvent({
|
||||
event: true,
|
||||
type: "m.foo",
|
||||
user: "foo@matrix.org",
|
||||
content: {},
|
||||
skey: "foo",
|
||||
});
|
||||
const cli = stubClient();
|
||||
const isRoomEncrypted = true;
|
||||
const vm = new E2ePadlockViewModel({ event, cli, isRoomEncrypted });
|
||||
await vm.verifyEvent();
|
||||
expect(vm.getSnapshot()).toStrictEqual({ noShield: true });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user