diff --git a/src/events/EventTileFactory.tsx b/src/events/EventTileFactory.tsx index 9d0daadb80..9187e05546 100644 --- a/src/events/EventTileFactory.tsx +++ b/src/events/EventTileFactory.tsx @@ -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 ; export const TextualEventFactory: Factory = (ref, props) => { const vm = new TextualEventViewModel(props); - return ; + return ; }; const VerificationReqFactory: Factory = (_ref, props) => ; const HiddenEventFactory: Factory = (ref, props) => ; diff --git a/src/shared-components/event-tiles/TextualEvent/TextualEvent.stories.tsx b/src/shared-components/event-tiles/TextualEventView/TextualEventView.stories.tsx similarity index 80% rename from src/shared-components/event-tiles/TextualEvent/TextualEvent.stories.tsx rename to src/shared-components/event-tiles/TextualEventView/TextualEventView.stories.tsx index 1746bc14b2..836641a84c 100644 --- a/src/shared-components/event-tiles/TextualEvent/TextualEvent.stories.tsx +++ b/src/shared-components/event-tiles/TextualEventView/TextualEventView.stories.tsx @@ -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; diff --git a/src/shared-components/event-tiles/TextualEvent/TextualEvent.test.tsx b/src/shared-components/event-tiles/TextualEventView/TextualEventView.test.tsx similarity index 84% rename from src/shared-components/event-tiles/TextualEvent/TextualEvent.test.tsx rename to src/shared-components/event-tiles/TextualEventView/TextualEventView.test.tsx index b1ef5e8f52..5d2dd912ef 100644 --- a/src/shared-components/event-tiles/TextualEvent/TextualEvent.test.tsx +++ b/src/shared-components/event-tiles/TextualEventView/TextualEventView.test.tsx @@ -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(); expect(container).toMatchSnapshot(); diff --git a/src/shared-components/event-tiles/TextualEvent/TextualEvent.tsx b/src/shared-components/event-tiles/TextualEventView/TextualEventView.tsx similarity index 63% rename from src/shared-components/event-tiles/TextualEvent/TextualEvent.tsx rename to src/shared-components/event-tiles/TextualEventView/TextualEventView.tsx index 1dec80905e..fa18a4c599 100644 --- a/src/shared-components/event-tiles/TextualEvent/TextualEvent.tsx +++ b/src/shared-components/event-tiles/TextualEventView/TextualEventView.tsx @@ -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; } -export function TextualEvent({ vm }: Props): JSX.Element { - const contents = useViewModel(vm); - - return
{contents}
; +export function TextualEventView({ vm }: Props): JSX.Element { + const snapshot = useViewModel(vm); + return
{snapshot.content}
; } diff --git a/src/shared-components/event-tiles/TextualEvent/__snapshots__/TextualEvent.test.tsx.snap b/src/shared-components/event-tiles/TextualEventView/__snapshots__/TextualEventView.test.tsx.snap similarity index 70% rename from src/shared-components/event-tiles/TextualEvent/__snapshots__/TextualEvent.test.tsx.snap rename to src/shared-components/event-tiles/TextualEventView/__snapshots__/TextualEventView.test.tsx.snap index 186e5f0afd..a0be215af7 100644 --- a/src/shared-components/event-tiles/TextualEvent/__snapshots__/TextualEvent.test.tsx.snap +++ b/src/shared-components/event-tiles/TextualEventView/__snapshots__/TextualEventView.test.tsx.snap @@ -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`] = `
implements ViewModel { +export abstract class BaseViewModel implements ViewModel { protected subs: ViewModelSubscriptions; + protected snapshot: Snapshot; + 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 implements ViewModel { /** * Returns the current snapshot of the view model. */ - public abstract getSnapshot: () => T; + public getSnapshot = (): T => { + return this.snapshot.current; + }; } diff --git a/src/viewmodels/base/Snapshot.ts b/src/viewmodels/base/Snapshot.ts new file mode 100644 index 0000000000..e8d0b7412c --- /dev/null +++ b/src/viewmodels/base/Snapshot.ts @@ -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 { + 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): void { + this.snapshot = { ...this.snapshot, ...snapshot }; + this.emit(); + } + + /** + * The current value of the snapshot. + */ + public get current(): T { + return this.snapshot; + } +} diff --git a/src/viewmodels/ViewModelSubscriptions.ts b/src/viewmodels/base/ViewModelSubscriptions.ts similarity index 100% rename from src/viewmodels/ViewModelSubscriptions.ts rename to src/viewmodels/base/ViewModelSubscriptions.ts diff --git a/src/viewmodels/event-tiles/TextualEventViewModel.ts b/src/viewmodels/event-tiles/TextualEventViewModel.ts index d2f56482d7..40121ecf2b 100644 --- a/src/viewmodels/event-tiles/TextualEventViewModel.ts +++ b/src/viewmodels/event-tiles/TextualEventViewModel.ts @@ -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 { - public constructor(private eventTileProps: EventTileTypeProps) { - super(); +export class TextualEventViewModel extends BaseViewModel { + 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); }; } diff --git a/test/viewmodels/base/Snapshot-test.ts b/test/viewmodels/base/Snapshot-test.ts new file mode 100644 index 0000000000..796caa65ab --- /dev/null +++ b/test/viewmodels/base/Snapshot-test.ts @@ -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({ 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({ 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({ 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({ key1: "foo", key2: 5, key3: false }, jest.fn()); + snapshot.merge({ key2: 10 }); + expect(snapshot.current).toStrictEqual({ key1: "foo", key2: 10, key3: false }); + }); +}); diff --git a/test/viewmodels/event-tiles/TextualEventViewModel-test.ts b/test/viewmodels/event-tiles/TextualEventViewModel-test.ts index 92037df9fc..735ecd2542 100644 --- a/test/viewmodels/event-tiles/TextualEventViewModel-test.ts +++ b/test/viewmodels/event-tiles/TextualEventViewModel-test.ts @@ -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,