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 { type IBodyProps } from "../components/views/messages/IBodyProps";
|
||||||
import ModuleApi from "../modules/Api";
|
import ModuleApi from "../modules/Api";
|
||||||
import { TextualEventViewModel } from "../viewmodels/event-tiles/TextualEventViewModel";
|
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
|
// Subset of EventTile's IProps plus some mixins
|
||||||
export interface EventTileTypeProps
|
export interface EventTileTypeProps
|
||||||
@@ -81,7 +81,7 @@ const LegacyCallEventFactory: Factory<FactoryProps & { callEventGrouper: LegacyC
|
|||||||
const CallEventFactory: Factory = (ref, props) => <CallEvent ref={ref} {...props} />;
|
const CallEventFactory: Factory = (ref, props) => <CallEvent ref={ref} {...props} />;
|
||||||
export const TextualEventFactory: Factory = (ref, props) => {
|
export const TextualEventFactory: Factory = (ref, props) => {
|
||||||
const vm = new TextualEventViewModel(props);
|
const vm = new TextualEventViewModel(props);
|
||||||
return <TextualEvent vm={vm} />;
|
return <TextualEventView vm={vm} />;
|
||||||
};
|
};
|
||||||
const VerificationReqFactory: Factory = (_ref, props) => <MKeyVerificationRequest {...props} />;
|
const VerificationReqFactory: Factory = (_ref, props) => <MKeyVerificationRequest {...props} />;
|
||||||
const HiddenEventFactory: Factory = (ref, props) => <HiddenBody ref={ref} {...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 React from "react";
|
||||||
import { type Meta, type StoryFn } from "@storybook/react-vite";
|
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";
|
import { MockViewModel } from "../../MockViewModel";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
@@ -16,7 +16,7 @@ export default {
|
|||||||
component: TextualEventComponent,
|
component: TextualEventComponent,
|
||||||
tags: ["autodocs"],
|
tags: ["autodocs"],
|
||||||
args: {
|
args: {
|
||||||
vm: new MockViewModel("Dummy textual event text"),
|
vm: new MockViewModel({ content: "Dummy textual event text" }),
|
||||||
},
|
},
|
||||||
} as Meta<typeof TextualEventComponent>;
|
} as Meta<typeof TextualEventComponent>;
|
||||||
|
|
||||||
@@ -9,11 +9,11 @@ import { composeStories } from "@storybook/react-vite";
|
|||||||
import { render } from "jest-matrix-react";
|
import { render } from "jest-matrix-react";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
|
||||||
import * as stories from "./TextualEvent.stories.tsx";
|
import * as stories from "./TextualEventView.stories.tsx";
|
||||||
|
|
||||||
const { Default } = composeStories(stories);
|
const { Default } = composeStories(stories);
|
||||||
|
|
||||||
describe("TextualEvent", () => {
|
describe("TextualEventView", () => {
|
||||||
it("renders a textual event", () => {
|
it("renders a textual event", () => {
|
||||||
const { container } = render(<Default />);
|
const { container } = render(<Default />);
|
||||||
expect(container).toMatchSnapshot();
|
expect(container).toMatchSnapshot();
|
||||||
@@ -10,14 +10,15 @@ import React, { type ReactNode, type JSX } from "react";
|
|||||||
import { type ViewModel } from "../../ViewModel";
|
import { type ViewModel } from "../../ViewModel";
|
||||||
import { useViewModel } from "../../useViewModel";
|
import { useViewModel } from "../../useViewModel";
|
||||||
|
|
||||||
export type TextualEventViewSnapshot = string | ReactNode;
|
export type TextualEventViewSnapshot = {
|
||||||
|
content: string | ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
export interface Props {
|
export interface Props {
|
||||||
vm: ViewModel<TextualEventViewSnapshot>;
|
vm: ViewModel<TextualEventViewSnapshot>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TextualEvent({ vm }: Props): JSX.Element {
|
export function TextualEventView({ vm }: Props): JSX.Element {
|
||||||
const contents = useViewModel(vm);
|
const snapshot = useViewModel(vm);
|
||||||
|
return <div className="mx_TextualEvent">{snapshot.content}</div>;
|
||||||
return <div className="mx_TextualEvent">{contents}</div>;
|
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||||
|
|
||||||
exports[`TextualEvent renders a textual event 1`] = `
|
exports[`TextualEventView renders a textual event 1`] = `
|
||||||
<div>
|
<div>
|
||||||
<div
|
<div
|
||||||
class="mx_TextualEvent"
|
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.
|
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.
|
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";
|
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 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.subs = new ViewModelSubscriptions(
|
||||||
this.addDownstreamSubscriptionWrapper,
|
this.addDownstreamSubscriptionWrapper,
|
||||||
this.removeDownstreamSubscriptionWrapper,
|
this.removeDownstreamSubscriptionWrapper,
|
||||||
);
|
);
|
||||||
|
this.snapshot = new Snapshot(initialSnapshot, () => {
|
||||||
|
this.subs.emit();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public subscribe = (listener: () => void): (() => void) => {
|
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.
|
* 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 { type EventTileTypeProps } from "../../events/EventTileFactory";
|
||||||
import { MatrixClientPeg } from "../../MatrixClientPeg";
|
import { MatrixClientPeg } from "../../MatrixClientPeg";
|
||||||
import { textForEvent } from "../../TextForEvent";
|
import { textForEvent } from "../../TextForEvent";
|
||||||
import { type TextualEventViewSnapshot } from "../../shared-components/event-tiles/TextualEvent/TextualEvent";
|
import { type TextualEventViewSnapshot } from "../../shared-components/event-tiles/TextualEventView/TextualEventView";
|
||||||
import { SubscriptionViewModel } from "../SubscriptionViewModel";
|
import { BaseViewModel } from "../base/BaseViewModel";
|
||||||
|
|
||||||
export class TextualEventViewModel extends SubscriptionViewModel<TextualEventViewSnapshot> {
|
export class TextualEventViewModel extends BaseViewModel<TextualEventViewSnapshot, EventTileTypeProps> {
|
||||||
public constructor(private eventTileProps: EventTileTypeProps) {
|
public constructor(props: EventTileTypeProps) {
|
||||||
super();
|
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 => {
|
protected addDownstreamSubscription = (): void => {
|
||||||
this.eventTileProps.mxEvent.on(MatrixEventEvent.SentinelUpdated, this.subs.emit);
|
this.props.mxEvent.on(MatrixEventEvent.SentinelUpdated, this.setTextFromEvent);
|
||||||
};
|
};
|
||||||
|
|
||||||
protected removeDownstreamSubscription = (): void => {
|
protected removeDownstreamSubscription = (): void => {
|
||||||
this.eventTileProps.mxEvent.off(MatrixEventEvent.SentinelUpdated, this.subs.emit);
|
this.props.mxEvent.off(MatrixEventEvent.SentinelUpdated, this.setTextFromEvent);
|
||||||
};
|
|
||||||
|
|
||||||
public getSnapshot = (): TextualEventViewSnapshot => {
|
|
||||||
const text = textForEvent(
|
|
||||||
this.eventTileProps.mxEvent,
|
|
||||||
MatrixClientPeg.safeGet(),
|
|
||||||
true,
|
|
||||||
this.eventTileProps.showHiddenEvents,
|
|
||||||
);
|
|
||||||
return text;
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
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 { MatrixEvent, MatrixEventEvent } from "matrix-js-sdk/src/matrix";
|
||||||
|
|
||||||
import { TextualEventViewModel } from "../../../src/viewmodels/event-tiles/TextualEventViewModel";
|
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", () => {
|
describe("TextualEventViewModel", () => {
|
||||||
it("should update when the sentinel updates", () => {
|
it("should update when the sentinel updates", () => {
|
||||||
const fakeEvent = new MatrixEvent({});
|
const fakeEvent = new MatrixEvent({});
|
||||||
|
stubClient();
|
||||||
|
|
||||||
const vm = new TextualEventViewModel({
|
const vm = new TextualEventViewModel({
|
||||||
showHiddenEvents: false,
|
showHiddenEvents: false,
|
||||||
|
|||||||
Reference in New Issue
Block a user