Refactor view source event to MVVM (#33428)

* Refactor view source event to MVVM

* remove unused variable since movement

* Update view source event screenshots

* Update packages/shared-components/src/room/timeline/event-tile/body/ViewSourceEventView/ViewSourceEventView.tsx

Co-authored-by: Florian Duros <florian.duros@ormaz.fr>

* Use view model disposables for source event decryption

* Consolidate source event view model updates

* Fix prettier

* Fix view source expanded class name

* Remove void from source event decryption

---------

Co-authored-by: Florian Duros <florian.duros@ormaz.fr>
This commit is contained in:
Zack
2026-05-12 13:17:47 +02:00
committed by GitHub
parent b19025e578
commit 39607799de
23 changed files with 708 additions and 138 deletions
@@ -718,7 +718,7 @@ test.describe("Timeline", () => {
await viewSourceEventExpanded.hover();
const toggleEventButton = viewSourceEventExpanded.getByRole("button", { name: "toggle event" });
// Check size and position of toggle on expanded view source event
// See: _ViewSourceEvent.pcss
// See: ViewSourceEventView.module.css
await expect(toggleEventButton).toHaveCSS("height", "16px"); // --ViewSourceEvent_toggle-size
await expect(toggleEventButton).toHaveCSS("align-self", "flex-end");
// Click again to collapse the source
Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

-1
View File
@@ -234,7 +234,6 @@
@import "./views/messages/_ReactionsRow.pcss";
@import "./views/messages/_TextualEvent.pcss";
@import "./views/messages/_ThreadActionBar.pcss";
@import "./views/messages/_ViewSourceEvent.pcss";
@import "./views/messages/_common_CryptoEvent.pcss";
@import "./views/polls/pollHistory/_PollHistory.pcss";
@import "./views/polls/pollHistory/_PollHistoryList.pcss";
@@ -1,50 +0,0 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2019 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.
*/
.mx_EventTile_content.mx_ViewSourceEvent {
display: flex;
opacity: 0.6;
font-size: $font-12px;
width: 100%;
overflow-x: auto; /* Cancel overflow setting of .mx_EventTile_content */
line-height: normal; /* Align with avatar and E2E icon */
pre,
code {
flex: 1;
}
pre {
line-height: 1.2;
margin: 3.5px 0;
}
.mx_ViewSourceEvent_toggle {
--ViewSourceEvent_toggle-size: 16px;
visibility: hidden;
/* icon */
width: var(--ViewSourceEvent_toggle-size);
min-width: var(--ViewSourceEvent_toggle-size);
svg {
color: $accent;
width: var(--ViewSourceEvent_toggle-size);
height: var(--ViewSourceEvent_toggle-size);
}
.mx_EventTile:hover & {
visibility: visible;
}
}
&.mx_ViewSourceEvent_expanded .mx_ViewSourceEvent_toggle {
align-self: flex-end;
height: var(--ViewSourceEvent_toggle-size);
}
}
@@ -1,83 +0,0 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2019 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import { type MatrixEvent, MatrixEventEvent } from "matrix-js-sdk/src/matrix";
import classNames from "classnames";
import { CollapseIcon, ExpandIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
import { MatrixClientPeg } from "../../../MatrixClientPeg";
import { _t } from "../../../languageHandler";
import AccessibleButton, { type ButtonEvent } from "../elements/AccessibleButton";
interface IProps {
mxEvent: MatrixEvent;
}
interface IState {
expanded: boolean;
}
export default class ViewSourceEvent extends React.PureComponent<IProps, IState> {
public constructor(props: IProps) {
super(props);
this.state = {
expanded: false,
};
}
public componentDidMount(): void {
const { mxEvent } = this.props;
const client = MatrixClientPeg.safeGet();
client.decryptEventIfNeeded(mxEvent);
if (mxEvent.isBeingDecrypted()) {
mxEvent.once(MatrixEventEvent.Decrypted, () => this.forceUpdate());
}
}
private onToggle = (ev: ButtonEvent): void => {
ev.preventDefault();
const { expanded } = this.state;
this.setState({
expanded: !expanded,
});
};
public render(): React.ReactNode {
const { mxEvent } = this.props;
const { expanded } = this.state;
let content;
if (expanded) {
content = <pre>{JSON.stringify(mxEvent, null, 4)}</pre>;
} else {
content = <code>{`{ "type": ${mxEvent.getType()} }`}</code>;
}
const classes = classNames("mx_ViewSourceEvent mx_EventTile_content", {
mx_ViewSourceEvent_expanded: expanded,
});
return (
<span className={classes}>
{content}
<AccessibleButton
kind="link"
title={_t("devtools|toggle_event")}
className="mx_ViewSourceEvent_toggle"
onClick={this.onToggle}
>
{expanded ? <CollapseIcon /> : <ExpandIcon />}
</AccessibleButton>
</span>
);
}
}
+21 -2
View File
@@ -25,6 +25,7 @@ import {
MKeyVerificationRequestView,
RoomAvatarEventView,
TextualEventView,
ViewSourceEventView,
useCreateAutoDisposedViewModel,
} from "@element-hq/web-shared-components";
@@ -44,7 +45,6 @@ import { useMatrixClientContext } from "../contexts/MatrixClientContext";
import { WidgetType } from "../widgets/WidgetType";
import { hasText } from "../TextForEvent";
import { getMessageModerationState, MessageModerationState } from "../utils/EventUtils";
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";
@@ -54,6 +54,7 @@ import { MKeyVerificationRequestViewModel } from "../viewmodels/room/timeline/ev
import { RoomAvatarEventViewModel } from "../viewmodels/room/timeline/event-tile/RoomAvatarEventViewModel";
import { TextualEventViewModel } from "../viewmodels/room/timeline/event-tile/TextualEventViewModel";
import { HiddenBodyViewModel } from "../viewmodels/room/timeline/event-tile/body/HiddenBodyViewModel";
import { ViewSourceEventViewModel } from "../viewmodels/room/timeline/event-tile/body/ViewSourceEventViewModel";
import { ElementCallEventType } from "../call-types";
import { CallStartedTileViewModel } from "../viewmodels/room/timeline/event-tile/call/CallStartedTileViewModel";
@@ -127,6 +128,24 @@ function HiddenBodyWrappedView({ mxEvent, ref }: IBodyProps): JSX.Element {
}
const HiddenEventFactory: Factory = (ref, props) => <HiddenBodyWrappedView ref={ref} {...props} />;
function ViewSourceEventWrappedView({ mxEvent, ref }: IBodyProps): JSX.Element {
const cli = useMatrixClientContext();
const vm = useCreateAutoDisposedViewModel(() => new ViewSourceEventViewModel({ mxEvent, cli }));
useEffect(() => {
vm.setProps({ cli, mxEvent });
}, [cli, mxEvent, vm]);
return (
<ViewSourceEventView
vm={vm}
ref={ref}
className="mx_ViewSourceEvent mx_EventTile_content"
expandedClassName="mx_ViewSourceEvent_expanded"
/>
);
}
function MJitsiWidgetEventWrappedView({ mxEvent, ref }: IBodyProps): JSX.Element {
const cli = useMatrixClientContext();
const vm = useCreateAutoDisposedViewModel(() => new MJitsiWidgetEventViewModel({ mxEvent, cli }));
@@ -178,8 +197,8 @@ export const CallStartedEventFactory: Factory = (ref, props) => {
};
// These factories are exported for reference comparison against pickFactory()
export const JSONEventFactory: Factory = (ref, props) => <ViewSourceEventWrappedView ref={ref} {...props} />;
export const JitsiEventFactory: Factory = (ref, props) => <MJitsiWidgetEventWrappedView ref={ref} {...props} />;
export const JSONEventFactory: Factory = (ref, props) => <ViewSourceEvent ref={ref} {...props} />;
export const RoomCreateEventFactory: Factory = (_ref, props) => <RoomPredecessorTile {...props} />;
const EVENT_TILE_TYPES = new Map<string, Factory>([
-1
View File
@@ -892,7 +892,6 @@
"thread_root_id": "Thread Root ID: %(threadRootId)s",
"threads_timeline": "Threads timeline",
"title": "Developer tools",
"toggle_event": "toggle event",
"toolbox": "Toolbox",
"use_at_own_risk": "This UI does NOT check the types of the values. Use at your own risk.",
"user_avatar": "Avatar: %(avatar)s",
@@ -0,0 +1,113 @@
/*
Copyright 2026 Element Creations Ltd.
Copyright 2024 New Vector Ltd.
Copyright 2019 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 { type MouseEvent } from "react";
import { type MatrixClient, type MatrixEvent, MatrixEventEvent } from "matrix-js-sdk/src/matrix";
import {
BaseViewModel,
Disposables,
type ViewSourceEventViewModel as ViewSourceEventViewModelInterface,
type ViewSourceEventViewSnapshot,
} from "@element-hq/web-shared-components";
export interface ViewSourceEventViewModelProps {
/**
* The hidden event whose source is being rendered.
*/
mxEvent: MatrixEvent;
/**
* Matrix client used to request decryption before rendering event source.
*/
cli: MatrixClient;
}
/**
* ViewModel for hidden event source rendering.
*/
export class ViewSourceEventViewModel
extends BaseViewModel<ViewSourceEventViewSnapshot, ViewSourceEventViewModelProps>
implements ViewSourceEventViewModelInterface
{
private decryptionListenerDisposables?: Disposables;
private static computeSnapshot(
{ mxEvent }: ViewSourceEventViewModelProps,
expanded: boolean,
): ViewSourceEventViewSnapshot {
return {
expanded,
preview: `{ "type": ${mxEvent.getType()} }`,
source: expanded ? ViewSourceEventViewModel.computeSource(mxEvent) : "",
};
}
private static computeSource(mxEvent: MatrixEvent): string {
return JSON.stringify(mxEvent, null, 4) ?? "";
}
public constructor(props: ViewSourceEventViewModelProps) {
super(props, ViewSourceEventViewModel.computeSnapshot(props, false));
this.disposables.track(() => this.removeDecryptionListener());
this.setupDecryptionListener();
}
public setProps(newProps: Partial<ViewSourceEventViewModelProps>): void {
const nextProps = { ...this.props, ...newProps };
const eventChanged = this.props.mxEvent !== nextProps.mxEvent;
const clientChanged = this.props.cli !== nextProps.cli;
if (!eventChanged && !clientChanged) return;
this.props = nextProps;
this.setupDecryptionListener();
if (eventChanged) {
this.updateSnapshotFromProps();
}
}
public onToggle = (event: MouseEvent<HTMLButtonElement>): void => {
event.preventDefault();
const expanded = !this.snapshot.current.expanded;
this.snapshot.merge({
expanded,
source: expanded ? ViewSourceEventViewModel.computeSource(this.props.mxEvent) : "",
});
};
private updateSnapshotFromProps(): void {
this.snapshot.merge(ViewSourceEventViewModel.computeSnapshot(this.props, this.snapshot.current.expanded));
}
private setupDecryptionListener(): void {
this.removeDecryptionListener();
const { cli, mxEvent } = this.props;
cli.decryptEventIfNeeded(mxEvent);
if (!mxEvent.isBeingDecrypted()) return;
const onDecrypted = (): void => {
this.removeDecryptionListener();
if (this.props.mxEvent !== mxEvent) return;
this.updateSnapshotFromProps();
};
this.decryptionListenerDisposables = new Disposables();
this.decryptionListenerDisposables.trackListener(mxEvent, MatrixEventEvent.Decrypted, onDecrypted);
}
private removeDecryptionListener(): void {
this.decryptionListenerDisposables?.dispose();
this.decryptionListenerDisposables = undefined;
}
}
@@ -0,0 +1,143 @@
/*
Copyright 2026 Element Creations Ltd.
Copyright 2024 New Vector Ltd.
Copyright 2019 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 { type MouseEvent } from "react";
import { type MatrixClient, MatrixEvent, MatrixEventEvent } from "matrix-js-sdk/src/matrix";
import { ViewSourceEventViewModel } from "../../../src/viewmodels/room/timeline/event-tile/body/ViewSourceEventViewModel";
describe("ViewSourceEventViewModel", () => {
const createClient = (): MatrixClient =>
({
decryptEventIfNeeded: jest.fn().mockResolvedValue(undefined),
}) as unknown as MatrixClient;
const createEvent = (type = "m.room.message", content: Record<string, unknown> = {}): MatrixEvent =>
new MatrixEvent({
type,
event_id: "$event:example.org",
sender: "@alice:example.org",
content,
});
const createClickEvent = (): MouseEvent<HTMLButtonElement> =>
({
preventDefault: jest.fn(),
}) as unknown as MouseEvent<HTMLButtonElement>;
it("creates a collapsed event source snapshot and requests decryption", () => {
const cli = createClient();
const mxEvent = createEvent("m.room.member");
const vm = new ViewSourceEventViewModel({ cli, mxEvent });
expect(cli.decryptEventIfNeeded).toHaveBeenCalledWith(mxEvent);
expect(vm.getSnapshot()).toEqual({
expanded: false,
preview: '{ "type": m.room.member }',
source: "",
});
});
it("toggles expanded state", () => {
const mxEvent = createEvent();
const vm = new ViewSourceEventViewModel({ cli: createClient(), mxEvent });
const event = createClickEvent();
vm.onToggle(event);
expect(event.preventDefault).toHaveBeenCalled();
expect(vm.getSnapshot().expanded).toBe(true);
expect(vm.getSnapshot().source).toBe(JSON.stringify(mxEvent, null, 4));
vm.onToggle(createClickEvent());
expect(vm.getSnapshot().expanded).toBe(false);
expect(vm.getSnapshot().source).toBe("");
});
it("updates the event source when the event changes", () => {
const cli = createClient();
const oldEvent = createEvent("m.room.message");
const newEvent = createEvent("m.room.topic", { topic: "New topic" });
const vm = new ViewSourceEventViewModel({ cli, mxEvent: oldEvent });
vm.onToggle(createClickEvent());
vm.setProps({ mxEvent: newEvent });
expect(cli.decryptEventIfNeeded).toHaveBeenCalledWith(newEvent);
expect(vm.getSnapshot()).toEqual({
expanded: true,
preview: '{ "type": m.room.topic }',
source: JSON.stringify(newEvent, null, 4),
});
});
it("removes the previous decryption listener when the event changes", () => {
const oldEvent = createEvent("m.room.encrypted");
jest.spyOn(oldEvent, "isBeingDecrypted").mockReturnValue(true);
const offSpy = jest.spyOn(oldEvent, "off");
const vm = new ViewSourceEventViewModel({ cli: createClient(), mxEvent: oldEvent });
vm.setProps({ mxEvent: createEvent("m.room.message") });
expect(offSpy).toHaveBeenCalledWith(MatrixEventEvent.Decrypted, expect.any(Function));
});
it("updates the decryption request when the client changes", () => {
const oldClient = createClient();
const newClient = createClient();
const mxEvent = createEvent();
const vm = new ViewSourceEventViewModel({ cli: oldClient, mxEvent });
const listener = jest.fn();
vm.subscribe(listener);
vm.setProps({ cli: newClient });
expect(newClient.decryptEventIfNeeded).toHaveBeenCalledWith(mxEvent);
expect(listener).not.toHaveBeenCalled();
});
it("does not emit when setProps receives unchanged props", () => {
const cli = createClient();
const mxEvent = createEvent();
const vm = new ViewSourceEventViewModel({ cli, mxEvent });
const listener = jest.fn();
vm.subscribe(listener);
vm.setProps({ cli, mxEvent });
expect(listener).not.toHaveBeenCalled();
});
it("updates the source after decryption completes", () => {
const mxEvent = createEvent("m.room.encrypted", { ciphertext: "encrypted" });
jest.spyOn(mxEvent, "isBeingDecrypted").mockReturnValue(true);
const vm = new ViewSourceEventViewModel({ cli: createClient(), mxEvent });
vm.onToggle(createClickEvent());
const listener = jest.fn();
vm.subscribe(listener);
mxEvent.getContent().body = "decrypted";
mxEvent.emit(MatrixEventEvent.Decrypted, mxEvent);
expect(listener).toHaveBeenCalledTimes(1);
expect(vm.getSnapshot().source).toContain("decrypted");
});
it("removes decryption listeners on dispose", () => {
const mxEvent = createEvent("m.room.encrypted");
jest.spyOn(mxEvent, "isBeingDecrypted").mockReturnValue(true);
const offSpy = jest.spyOn(mxEvent, "off");
const vm = new ViewSourceEventViewModel({ cli: createClient(), mxEvent });
vm.dispose();
expect(offSpy).toHaveBeenCalledWith(MatrixEventEvent.Decrypted, expect.any(Function));
});
});