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:
@@ -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} />;
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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();
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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";
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
43
src/viewmodels/base/Snapshot.ts
Normal file
43
src/viewmodels/base/Snapshot.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
|
||||
41
test/viewmodels/base/Snapshot-test.ts
Normal file
41
test/viewmodels/base/Snapshot-test.ts
Normal 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 });
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user