MVVM - Introduce some helpers for snapshot management (#30398)

* Introduce snapshot class to track snapshot updates

This avoids the hassle of having to manually call emit.

* Better viewmodel ergonomics

- Rename `SubscriptionViewModel` to `BaseViewModel`. I feel this is
  appropriate since that class does more than just manage subscriptions.
- `getSnapshot` is no longer an abstract method. It's simply a method
  that returns the current snapshot state. This ensures that getSnapshot
result is cached by default which is required by `useSyncExternalStore`.
- `props` are a property of the base vm class so that actual VMs don't
  have to keep creating this property.

* Update `TextualEventViewModel`

* Fix test

* Rename `TextualEvent` to `TextualEventView`

* Fix snapshot object not being merged

* Rename directory to `EventTileView`

* Fix broken snapshot

* Add test for snapshot class
This commit is contained in:
R Midhun Suresh
2025-08-06 17:59:32 +05:30
committed by GitHub
parent 15f1291cbc
commit ee37734cfc
12 changed files with 130 additions and 34 deletions

View File

@@ -44,7 +44,7 @@ import { ElementCall } from "../models/Call";
import { type IBodyProps } from "../components/views/messages/IBodyProps";
import ModuleApi from "../modules/Api";
import { TextualEventViewModel } from "../viewmodels/event-tiles/TextualEventViewModel";
import { TextualEvent } from "../shared-components/event-tiles/TextualEvent";
import { TextualEventView } from "../shared-components/event-tiles/TextualEventView";
// Subset of EventTile's IProps plus some mixins
export interface EventTileTypeProps
@@ -81,7 +81,7 @@ const LegacyCallEventFactory: Factory<FactoryProps & { callEventGrouper: LegacyC
const CallEventFactory: Factory = (ref, props) => <CallEvent ref={ref} {...props} />;
export const TextualEventFactory: Factory = (ref, props) => {
const vm = new TextualEventViewModel(props);
return <TextualEvent vm={vm} />;
return <TextualEventView vm={vm} />;
};
const VerificationReqFactory: Factory = (_ref, props) => <MKeyVerificationRequest {...props} />;
const HiddenEventFactory: Factory = (ref, props) => <HiddenBody ref={ref} {...props} />;

View File

@@ -8,7 +8,7 @@ Please see LICENSE files in the repository root for full details.
import React from "react";
import { type Meta, type StoryFn } from "@storybook/react-vite";
import { TextualEvent as TextualEventComponent } from "./TextualEvent";
import { TextualEventView as TextualEventComponent } from "./TextualEventView";
import { MockViewModel } from "../../MockViewModel";
export default {
@@ -16,7 +16,7 @@ export default {
component: TextualEventComponent,
tags: ["autodocs"],
args: {
vm: new MockViewModel("Dummy textual event text"),
vm: new MockViewModel({ content: "Dummy textual event text" }),
},
} as Meta<typeof TextualEventComponent>;

View File

@@ -9,11 +9,11 @@ import { composeStories } from "@storybook/react-vite";
import { render } from "jest-matrix-react";
import React from "react";
import * as stories from "./TextualEvent.stories.tsx";
import * as stories from "./TextualEventView.stories.tsx";
const { Default } = composeStories(stories);
describe("TextualEvent", () => {
describe("TextualEventView", () => {
it("renders a textual event", () => {
const { container } = render(<Default />);
expect(container).toMatchSnapshot();

View File

@@ -10,14 +10,15 @@ import React, { type ReactNode, type JSX } from "react";
import { type ViewModel } from "../../ViewModel";
import { useViewModel } from "../../useViewModel";
export type TextualEventViewSnapshot = string | ReactNode;
export type TextualEventViewSnapshot = {
content: string | ReactNode;
};
export interface Props {
vm: ViewModel<TextualEventViewSnapshot>;
}
export function TextualEvent({ vm }: Props): JSX.Element {
const contents = useViewModel(vm);
return <div className="mx_TextualEvent">{contents}</div>;
export function TextualEventView({ vm }: Props): JSX.Element {
const snapshot = useViewModel(vm);
return <div className="mx_TextualEvent">{snapshot.content}</div>;
}

View File

@@ -1,6 +1,6 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`TextualEvent renders a textual event 1`] = `
exports[`TextualEventView renders a textual event 1`] = `
<div>
<div
class="mx_TextualEvent"

View File

@@ -5,4 +5,4 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
Please see LICENSE files in the repository root for full details.
*/
export { TextualEvent } from "./TextualEvent";
export { TextualEventView } from "./TextualEventView";

View File

@@ -5,17 +5,24 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
Please see LICENSE files in the repository root for full details.
*/
import { type ViewModel } from "../shared-components/ViewModel";
import { type ViewModel } from "../../shared-components/ViewModel";
import { Snapshot } from "./Snapshot";
import { ViewModelSubscriptions } from "./ViewModelSubscriptions";
export abstract class SubscriptionViewModel<T> implements ViewModel<T> {
export abstract class BaseViewModel<T, P> implements ViewModel<T> {
protected subs: ViewModelSubscriptions;
protected snapshot: Snapshot<T>;
protected props: P;
protected constructor() {
protected constructor(props: P, initialSnapshot: T) {
this.props = props;
this.subs = new ViewModelSubscriptions(
this.addDownstreamSubscriptionWrapper,
this.removeDownstreamSubscriptionWrapper,
);
this.snapshot = new Snapshot(initialSnapshot, () => {
this.subs.emit();
});
}
public subscribe = (listener: () => void): (() => void) => {
@@ -52,5 +59,7 @@ export abstract class SubscriptionViewModel<T> implements ViewModel<T> {
/**
* Returns the current snapshot of the view model.
*/
public abstract getSnapshot: () => T;
public getSnapshot = (): T => {
return this.snapshot.current;
};
}

View File

@@ -0,0 +1,43 @@
/*
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.
*/
/**
* This is the output of the viewmodel that the view consumes.
* Updating snapshot through this object will make react re-render
* components.
*/
export class Snapshot<T> {
public constructor(
private snapshot: T,
private emit: () => void,
) {}
/**
* Replace current snapshot with a new snapshot value.
* @param snapshot New snapshot value
*/
public set(snapshot: T): void {
this.snapshot = snapshot;
this.emit();
}
/**
* Update a part of the current snapshot by merging into the existing snapshot.
* @param snapshot A subset of the snapshot to merge into the current snapshot.
*/
public merge(snapshot: Partial<T>): void {
this.snapshot = { ...this.snapshot, ...snapshot };
this.emit();
}
/**
* The current value of the snapshot.
*/
public get current(): T {
return this.snapshot;
}
}

View File

@@ -10,29 +10,25 @@ import { MatrixEventEvent } from "matrix-js-sdk/src/matrix";
import { type EventTileTypeProps } from "../../events/EventTileFactory";
import { MatrixClientPeg } from "../../MatrixClientPeg";
import { textForEvent } from "../../TextForEvent";
import { type TextualEventViewSnapshot } from "../../shared-components/event-tiles/TextualEvent/TextualEvent";
import { SubscriptionViewModel } from "../SubscriptionViewModel";
import { type TextualEventViewSnapshot } from "../../shared-components/event-tiles/TextualEventView/TextualEventView";
import { BaseViewModel } from "../base/BaseViewModel";
export class TextualEventViewModel extends SubscriptionViewModel<TextualEventViewSnapshot> {
public constructor(private eventTileProps: EventTileTypeProps) {
super();
export class TextualEventViewModel extends BaseViewModel<TextualEventViewSnapshot, EventTileTypeProps> {
public constructor(props: EventTileTypeProps) {
super(props, { content: "" });
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.eventTileProps.mxEvent.on(MatrixEventEvent.SentinelUpdated, this.subs.emit);
this.props.mxEvent.on(MatrixEventEvent.SentinelUpdated, this.setTextFromEvent);
};
protected removeDownstreamSubscription = (): void => {
this.eventTileProps.mxEvent.off(MatrixEventEvent.SentinelUpdated, this.subs.emit);
};
public getSnapshot = (): TextualEventViewSnapshot => {
const text = textForEvent(
this.eventTileProps.mxEvent,
MatrixClientPeg.safeGet(),
true,
this.eventTileProps.showHiddenEvents,
);
return text;
this.props.mxEvent.off(MatrixEventEvent.SentinelUpdated, this.setTextFromEvent);
};
}

View File

@@ -0,0 +1,41 @@
/*
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 { Snapshot } from "../../../src/viewmodels/base/Snapshot";
interface TestSnapshot {
key1: string;
key2: number;
key3: boolean;
}
describe("Snapshot", () => {
it("should accept an initial value", () => {
const snapshot = new Snapshot<TestSnapshot>({ key1: "foo", key2: 5, key3: false }, jest.fn());
expect(snapshot.current).toStrictEqual({ key1: "foo", key2: 5, key3: false });
});
it("should call emit callback when state changes", () => {
const emit = jest.fn();
const snapshot = new Snapshot<TestSnapshot>({ key1: "foo", key2: 5, key3: false }, emit);
snapshot.merge({ key3: true });
expect(emit).toHaveBeenCalledTimes(1);
});
it("should swap out entire snapshot on set call", () => {
const snapshot = new Snapshot<TestSnapshot>({ key1: "foo", key2: 5, key3: false }, jest.fn());
const newValue = { key1: "bar", key2: 8, key3: true };
snapshot.set(newValue);
expect(snapshot.current).toStrictEqual(newValue);
});
it("should merge partial snapshot on merge call", () => {
const snapshot = new Snapshot<TestSnapshot>({ key1: "foo", key2: 5, key3: false }, jest.fn());
snapshot.merge({ key2: 10 });
expect(snapshot.current).toStrictEqual({ key1: "foo", key2: 10, key3: false });
});
});

View File

@@ -8,10 +8,16 @@ Please see LICENSE files in the repository root for full details.
import { MatrixEvent, MatrixEventEvent } from "matrix-js-sdk/src/matrix";
import { TextualEventViewModel } from "../../../src/viewmodels/event-tiles/TextualEventViewModel";
import { stubClient } from "../../test-utils";
jest.mock("../../../src/TextForEvent.tsx", () => ({
textForEvent: jest.fn().mockReturnValue("Test Message"),
}));
describe("TextualEventViewModel", () => {
it("should update when the sentinel updates", () => {
const fakeEvent = new MatrixEvent({});
stubClient();
const vm = new TextualEventViewModel({
showHiddenEvents: false,