Compare commits

...

10 Commits

Author SHA1 Message Date
R Midhun Suresh
6b05bd54a8 Render component through createElement
So that we can use hooks in the event factory components
2025-08-11 11:45:29 +05:30
R Midhun Suresh
5ba300ce7e Pass ref through props 2025-08-11 11:43:19 +05:30
R Midhun Suresh
1927a4b235 Expose isDisposed through base vm 2025-08-11 11:03:40 +05:30
R Midhun Suresh
c3f1879189 Fix audio player vm 2025-08-11 11:01:32 +05:30
R Midhun Suresh
ce2f9ae32a Throw error in trackListener as well 2025-08-11 10:43:36 +05:30
R Midhun Suresh
4caf52abf3 No-op on dispose call instead of throwing error 2025-08-11 10:42:44 +05:30
R Midhun Suresh
d86dcb1b7b Update vm so that the listener is tracked through disposable 2025-08-11 10:34:13 +05:30
R Midhun Suresh
2cd2a4a8ef Use disposable in BaseViewModel 2025-08-11 10:34:12 +05:30
R Midhun Suresh
3bbb62d346 Remove old code 2025-08-11 10:34:12 +05:30
R Midhun Suresh
4e42654c4f Introduce disposables to track sub vms and event listeners 2025-08-11 10:34:09 +05:30
8 changed files with 174 additions and 84 deletions

View File

@@ -71,25 +71,24 @@ export interface EventTileTypeProps
showHiddenEvents: boolean;
}
type FactoryProps = Omit<EventTileTypeProps, "ref">;
type Factory<X = FactoryProps> = (ref: React.RefObject<any> | undefined, props: X) => JSX.Element;
type Factory<X = EventTileTypeProps> = (props: X) => JSX.Element;
export const MessageEventFactory: Factory = (ref, props) => <MessageEvent ref={ref} {...props} />;
const LegacyCallEventFactory: Factory<FactoryProps & { callEventGrouper: LegacyCallEventGrouper }> = (ref, props) => (
<LegacyCallEvent ref={ref} {...props} />
export const MessageEventFactory: Factory = (props) => <MessageEvent {...props} />;
const LegacyCallEventFactory: Factory<EventTileTypeProps & { callEventGrouper: LegacyCallEventGrouper }> = (props) => (
<LegacyCallEvent {...props} />
);
const CallEventFactory: Factory = (ref, props) => <CallEvent ref={ref} {...props} />;
export const TextualEventFactory: Factory = (ref, props) => {
const CallEventFactory: Factory = (props) => <CallEvent {...props} />;
export const TextualEventFactory: Factory = (props) => {
const vm = new TextualEventViewModel(props);
return <TextualEventView vm={vm} />;
};
const VerificationReqFactory: Factory = (_ref, props) => <MKeyVerificationRequest {...props} />;
const HiddenEventFactory: Factory = (ref, props) => <HiddenBody ref={ref} {...props} />;
const VerificationReqFactory: Factory = (props) => <MKeyVerificationRequest {...props} />;
const HiddenEventFactory: Factory = (props) => <HiddenBody {...props} />;
// These factories are exported for reference comparison against pickFactory()
export const JitsiEventFactory: Factory = (ref, props) => <MJitsiWidgetEvent ref={ref} {...props} />;
export const JSONEventFactory: Factory = (ref, props) => <ViewSourceEvent ref={ref} {...props} />;
export const RoomCreateEventFactory: Factory = (_ref, props) => <RoomPredecessorTile {...props} />;
export const JitsiEventFactory: Factory = (props) => <MJitsiWidgetEvent {...props} />;
export const JSONEventFactory: Factory = (props) => <ViewSourceEvent {...props} />;
export const RoomCreateEventFactory: Factory = (props) => <RoomPredecessorTile {...props} />;
const EVENT_TILE_TYPES = new Map<string, Factory>([
[EventType.RoomMessage, MessageEventFactory], // note that verification requests are handled in pickFactory()
@@ -102,12 +101,12 @@ const EVENT_TILE_TYPES = new Map<string, Factory>([
]);
const STATE_EVENT_TILE_TYPES = new Map<string, Factory>([
[EventType.RoomEncryption, (ref, props) => <EncryptionEvent ref={ref} {...props} />],
[EventType.RoomEncryption, (props) => <EncryptionEvent {...props} />],
[EventType.RoomCanonicalAlias, TextualEventFactory],
[EventType.RoomCreate, RoomCreateEventFactory],
[EventType.RoomMember, TextualEventFactory],
[EventType.RoomName, TextualEventFactory],
[EventType.RoomAvatar, (ref, props) => <RoomAvatarEvent ref={ref} {...props} />],
[EventType.RoomAvatar, (props) => <RoomAvatarEvent {...props} />],
[EventType.RoomThirdPartyInvite, TextualEventFactory],
[EventType.RoomHistoryVisibility, TextualEventFactory],
[EventType.RoomTopic, TextualEventFactory],
@@ -302,8 +301,9 @@ export function renderTile(
mxEvent: props.mxEvent,
},
(origProps) =>
factory(props.ref, {
factory({
// We only want a subset of props, so we don't end up causing issues for downstream components.
ref,
mxEvent,
highlights,
highlightLink,
@@ -323,7 +323,8 @@ export function renderTile(
mxEvent: props.mxEvent,
},
(origProps) =>
factory(ref, {
factory({
ref,
// NEARLY ALL THE OPTIONS!
mxEvent,
forExport,
@@ -389,7 +390,8 @@ export function renderReplyTile(
mxEvent: props.mxEvent,
},
(origProps) =>
factory(ref, {
factory({
ref,
mxEvent,
highlights,
highlightLink,

View File

@@ -7,6 +7,7 @@ Please see LICENSE files in the repository root for full details.
import { type MatrixEvent } from "matrix-js-sdk/src/matrix";
import { logger } from "matrix-js-sdk/src/logger";
import { createElement } from "react";
import type {
CustomComponentsApi as ICustomComponentsApi,
@@ -16,7 +17,6 @@ import type {
CustomMessageRenderHints as ModuleCustomCustomMessageRenderHints,
MatrixEvent as ModuleMatrixEvent,
} from "@element-hq/element-web-module-api";
import type React from "react";
type EventTypeOrFilter = Parameters<ICustomComponentsApi["registerMessageRenderer"]>[0];
@@ -112,7 +112,11 @@ export class CustomComponentsApi implements ICustomComponentsApi {
// Fall through to original component. If the module encounters an error we still want to display messages to the user!
}
}
return originalComponent?.() ?? null;
if (originalComponent) {
return createElement(originalComponent);
}
return null;
}
/**

View File

@@ -77,6 +77,9 @@ export class AudioPlayerViewModel
public constructor(props: Props) {
super(props, AudioPlayerViewModel.computeSnapshot(props.playback, props.mediaName));
this.disposables.trackListener(props.playback, UPDATE_EVENT, this.setSnapshot);
// There is no unsubscribe method in SimpleObservable
this.props.playback.clockInfo.liveData.onUpdate(this.setSnapshot);
// Don't wait for the promise to complete - it will emit a progress update when it
// is done, and it's not meant to take long anyhow.
@@ -97,15 +100,6 @@ export class AudioPlayerViewModel
}
}
protected addDownstreamSubscription(): void {
this.props.playback.on(UPDATE_EVENT, this.setSnapshot);
// There is no unsubscribe method in SimpleObservable
this.props.playback.clockInfo.liveData.onUpdate(this.setSnapshot);
}
protected removeDownstreamSubscription(): void {
this.props.playback.off(UPDATE_EVENT, this.setSnapshot);
}
/**
* Sets the snapshot and emits an update to subscribers.
*/

View File

@@ -6,6 +6,7 @@ Please see LICENSE files in the repository root for full details.
*/
import { type ViewModel } from "../../shared-components/ViewModel";
import { Disposables } from "./Disposables";
import { Snapshot } from "./Snapshot";
import { ViewModelSubscriptions } from "./ViewModelSubscriptions";
@@ -13,13 +14,11 @@ export abstract class BaseViewModel<T, P> implements ViewModel<T> {
protected subs: ViewModelSubscriptions;
protected snapshot: Snapshot<T>;
protected props: P;
protected disposables = new Disposables();
protected constructor(props: P, initialSnapshot: T) {
this.props = props;
this.subs = new ViewModelSubscriptions(
this.addDownstreamSubscriptionWrapper,
this.removeDownstreamSubscriptionWrapper,
);
this.subs = new ViewModelSubscriptions();
this.snapshot = new Snapshot(initialSnapshot, () => {
this.subs.emit();
});
@@ -29,37 +28,24 @@ export abstract class BaseViewModel<T, P> implements ViewModel<T> {
return this.subs.add(listener);
};
/**
* Wrapper around the abstract subscribe callback as we can't assume that the subclassed method
* has a bound `this` context.
*/
private addDownstreamSubscriptionWrapper = (): void => {
this.addDownstreamSubscription();
};
/**
* Wrapper around the abstract unsubscribe callback as we can't call pass an abstract method directly
* in the constructor.
*/
private removeDownstreamSubscriptionWrapper = (): void => {
this.removeDownstreamSubscription();
};
/**
* Called when the first listener subscribes: the subclass should set up any necessary subscriptions
* to call this.subs.emit() when the snapshot changes.
*/
protected abstract addDownstreamSubscription(): void;
/**
* Called when the last listener unsubscribes: the subclass should clean up any subscriptions.
*/
protected abstract removeDownstreamSubscription(): void;
/**
* Returns the current snapshot of the view model.
*/
public getSnapshot = (): T => {
return this.snapshot.current;
};
/**
* Relinquish any resources held by this view-model.
*/
public dispose(): void {
this.disposables.dispose();
}
/**
* Whether this view-model has been disposed.
*/
public get isDisposed(): boolean {
return this.disposables.isDisposed;
}
}

View File

@@ -0,0 +1,70 @@
/*
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 type { EventEmitter } from "events";
/**
* Something that needs to be eventually disposed. This can be:
* - A function that does the disposing
* - An object containing a dispose method which does the disposing
*/
export type DisposableItem = { dispose: () => void } | (() => void);
/**
* This class provides a way for the view-model to track any resource
* that it needs to eventually relinquish.
*/
export class Disposables {
private readonly disposables: DisposableItem[] = [];
private _isDisposed: boolean = false;
/**
* Relinquish all tracked disposable values
*/
public dispose(): void {
if (this.isDisposed) return;
this._isDisposed = true;
for (const disposable of this.disposables) {
if (typeof disposable === "function") {
disposable();
} else {
disposable.dispose();
}
}
}
/**
* Track a value that needs to be eventually relinquished
*/
public track<T extends DisposableItem>(disposable: T): T {
this.throwIfDisposed();
this.disposables.push(disposable);
return disposable;
}
/**
* Add an event listener that will be removed on dispose
*/
public trackListener(emitter: EventEmitter, event: string, callback: (...args: unknown[]) => void): void {
this.throwIfDisposed();
emitter.on(event, callback);
this.track(() => {
emitter.off(event, callback);
});
}
private throwIfDisposed(): void {
if (this.isDisposed) throw new Error("Disposable is already disposed");
}
/**
* Whether this disposable has been disposed
*/
public get isDisposed(): boolean {
return this._isDisposed;
}
}

View File

@@ -6,20 +6,11 @@ Please see LICENSE files in the repository root for full details.
*/
/**
* Utility class for view models to manage suscriptions to their updates
* Utility class for view models to manage subscriptions to their updates
*/
export class ViewModelSubscriptions {
private listeners = new Set<() => void>();
/**
* @param subscribeCallback Called when the first listener subscribes.
* @param unsubscribeCallback Called when the last listener unsubscribes.
*/
public constructor(
private subscribeCallback: () => void,
private unsubscribeCallback: () => void,
) {}
/**
* Subscribe to changes in the view model.
* @param listener Will be called whenever the snapshot changes.
@@ -27,15 +18,8 @@ export class ViewModelSubscriptions {
*/
public add = (listener: () => void): (() => void) => {
this.listeners.add(listener);
if (this.listeners.size === 1) {
this.subscribeCallback();
}
return () => {
this.listeners.delete(listener);
if (this.listeners.size === 0) {
this.unsubscribeCallback();
}
};
};

View File

@@ -17,18 +17,11 @@ export class TextualEventViewModel extends BaseViewModel<TextualEventViewSnapsho
public constructor(props: EventTileTypeProps) {
super(props, { content: "" });
this.setTextFromEvent();
this.disposables.trackListener(this.props.mxEvent, MatrixEventEvent.SentinelUpdated, 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.props.mxEvent.on(MatrixEventEvent.SentinelUpdated, this.setTextFromEvent);
};
protected removeDownstreamSubscription = (): void => {
this.props.mxEvent.off(MatrixEventEvent.SentinelUpdated, this.setTextFromEvent);
};
}

View File

@@ -0,0 +1,57 @@
/*
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 { EventEmitter } from "events";
import { Disposables } from "../../../src/viewmodels/base/Disposables";
describe("Disposable", () => {
it("isDisposed is true after dispose() is called", () => {
const disposables = new Disposables();
expect(disposables.isDisposed).toEqual(false);
disposables.dispose();
expect(disposables.isDisposed).toEqual(true);
});
it("dispose() calls the correct disposing function", () => {
const disposables = new Disposables();
const item1 = {
foo: 5,
dispose: jest.fn(),
};
disposables.track(item1);
const item2 = jest.fn();
disposables.track(item2);
disposables.dispose();
expect(item1.dispose).toHaveBeenCalledTimes(1);
expect(item2).toHaveBeenCalledTimes(1);
});
it("Throws error if acting on already disposed disposables", () => {
const disposables = new Disposables();
disposables.dispose();
expect(() => {
disposables.track(jest.fn);
}).toThrow();
});
it("Removes tracked event listeners on dispose", () => {
const disposables = new Disposables();
const emitter = new EventEmitter();
const fn = jest.fn();
disposables.trackListener(emitter, "FooEvent", fn);
emitter.emit("FooEvent");
expect(fn).toHaveBeenCalled();
disposables.dispose();
expect(emitter.listenerCount("FooEvent", fn)).toEqual(0);
});
});