Refactor MJitsiWidgetEvent to shared view MVVM (#33457)

* Refactor Jitsi widget event to shared view

* Align Jitsi widget view model setters

* Inline Jitsi widget icon color

* Remove unused Jitsi timestamp setter

* Tighten Jitsi widget subtitle type
This commit is contained in:
Zack
2026-05-12 09:12:43 +00:00
committed by GitHub
parent 67ea6bfa53
commit b19025e578
17 changed files with 715 additions and 94 deletions
@@ -1,78 +0,0 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2020 The Matrix.org Foundation C.I.C.
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 React, { type JSX } from "react";
import { type MatrixEvent } from "matrix-js-sdk/src/matrix";
import { VideoCallSolidIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
import { EventTileBubble } from "@element-hq/web-shared-components";
import { _t } from "../../../languageHandler";
import WidgetStore from "../../../stores/WidgetStore";
import { MatrixClientPeg } from "../../../MatrixClientPeg";
import { WidgetLayoutStore } from "../../../stores/widgets/WidgetLayoutStore";
interface IProps {
mxEvent: MatrixEvent;
timestamp?: JSX.Element;
}
export default class MJitsiWidgetEvent extends React.PureComponent<IProps> {
public render(): React.ReactNode {
const url = this.props.mxEvent.getContent()["url"];
const prevUrl = this.props.mxEvent.getPrevContent()["url"];
const senderName = this.props.mxEvent.sender?.name || this.props.mxEvent.getSender();
const room = MatrixClientPeg.safeGet().getRoom(this.props.mxEvent.getRoomId());
if (!room) return null;
const widgetId = this.props.mxEvent.getStateKey();
const widget = WidgetStore.instance.getRoom(room.roomId, true).widgets.find((w) => w.id === widgetId);
let joinCopy: string | null = _t("timeline|m.widget|jitsi_join_top_prompt");
if (widget && WidgetLayoutStore.instance.isInContainer(room, widget, "right")) {
joinCopy = _t("timeline|m.widget|jitsi_join_right_prompt");
} else if (!widget) {
joinCopy = null;
}
if (!url) {
// removed
return (
<EventTileBubble
icon={<VideoCallSolidIcon />}
className="mx_EventTileBubble mx_MJitsiWidgetEvent"
title={_t("timeline|m.widget|jitsi_ended", { senderName })}
>
{this.props.timestamp}
</EventTileBubble>
);
} else if (prevUrl) {
// modified
return (
<EventTileBubble
icon={<VideoCallSolidIcon />}
className="mx_EventTileBubble mx_MJitsiWidgetEvent"
title={_t("timeline|m.widget|jitsi_updated", { senderName })}
subtitle={joinCopy}
>
{this.props.timestamp}
</EventTileBubble>
);
} else {
// assume added
return (
<EventTileBubble
icon={<VideoCallSolidIcon />}
className="mx_EventTileBubble mx_MJitsiWidgetEvent"
title={_t("timeline|m.widget|jitsi_started", { senderName })}
subtitle={joinCopy}
>
{this.props.timestamp}
</EventTileBubble>
);
}
}
}
+14 -2
View File
@@ -21,6 +21,7 @@ import {
CallStartedTileView,
EncryptionEventView,
HiddenBodyView,
MJitsiWidgetEventView,
MKeyVerificationRequestView,
RoomAvatarEventView,
TextualEventView,
@@ -41,7 +42,6 @@ import { ALL_RULE_TYPES } from "../mjolnir/BanList";
import { MatrixClientPeg } from "../MatrixClientPeg";
import { useMatrixClientContext } from "../contexts/MatrixClientContext";
import { WidgetType } from "../widgets/WidgetType";
import MJitsiWidgetEvent from "../components/views/messages/MJitsiWidgetEvent";
import { hasText } from "../TextForEvent";
import { getMessageModerationState, MessageModerationState } from "../utils/EventUtils";
import ViewSourceEvent from "../components/views/messages/ViewSourceEvent";
@@ -49,6 +49,7 @@ import { shouldDisplayAsBeaconTile } from "../utils/beacon/timeline";
import { type IBodyProps } from "../components/views/messages/IBodyProps";
import { ModuleApi } from "../modules/Api";
import { EncryptionEventViewModel } from "../viewmodels/room/timeline/event-tile/EncryptionEventViewModel";
import { MJitsiWidgetEventViewModel } from "../viewmodels/room/timeline/event-tile/MJitsiWidgetEventViewModel";
import { MKeyVerificationRequestViewModel } from "../viewmodels/room/timeline/event-tile/MKeyVerificationRequestViewModel";
import { RoomAvatarEventViewModel } from "../viewmodels/room/timeline/event-tile/RoomAvatarEventViewModel";
import { TextualEventViewModel } from "../viewmodels/room/timeline/event-tile/TextualEventViewModel";
@@ -126,6 +127,17 @@ function HiddenBodyWrappedView({ mxEvent, ref }: IBodyProps): JSX.Element {
}
const HiddenEventFactory: Factory = (ref, props) => <HiddenBodyWrappedView ref={ref} {...props} />;
function MJitsiWidgetEventWrappedView({ mxEvent, ref }: IBodyProps): JSX.Element {
const cli = useMatrixClientContext();
const vm = useCreateAutoDisposedViewModel(() => new MJitsiWidgetEventViewModel({ mxEvent, cli }));
useEffect(() => {
vm.setEvent(mxEvent);
}, [mxEvent, vm]);
return <MJitsiWidgetEventView vm={vm} ref={ref} className="mx_EventTileBubble" />;
}
function RoomAvatarEventWrappedView({ mxEvent, ref }: IBodyProps): JSX.Element {
const cli = useMatrixClientContext() ?? MatrixClientPeg.safeGet();
const vm = useCreateAutoDisposedViewModel(() => new RoomAvatarEventViewModel({ mxEvent, cli }));
@@ -166,7 +178,7 @@ export const CallStartedEventFactory: Factory = (ref, props) => {
};
// These factories are exported for reference comparison against pickFactory()
export const JitsiEventFactory: Factory = (ref, props) => <MJitsiWidgetEvent ref={ref} {...props} />;
export const JitsiEventFactory: Factory = (ref, props) => <MJitsiWidgetEventWrappedView ref={ref} {...props} />;
export const JSONEventFactory: Factory = (ref, props) => <ViewSourceEvent ref={ref} {...props} />;
export const RoomCreateEventFactory: Factory = (_ref, props) => <RoomPredecessorTile {...props} />;
@@ -0,0 +1,154 @@
/*
* Copyright 2026 Element Creations 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 JSX } from "react";
import { type MatrixClient, type MatrixEvent } from "matrix-js-sdk/src/matrix";
import {
BaseViewModel,
type MJitsiWidgetEventViewModel as MJitsiWidgetEventViewModelInterface,
type MJitsiWidgetEventViewSnapshot,
} from "@element-hq/web-shared-components";
import { _t } from "../../../../languageHandler";
import WidgetStore, { type IApp } from "../../../../stores/WidgetStore";
import { UPDATE_EVENT } from "../../../../stores/AsyncStore";
import { WidgetLayoutStore } from "../../../../stores/widgets/WidgetLayoutStore";
export interface MJitsiWidgetEventViewModelProps {
/**
* Caller-provided client.
*/
cli: MatrixClient;
/**
* Jitsi widget state event to derive tile state from.
*/
mxEvent: MatrixEvent;
/**
* Optional timestamp element rendered in the tile footer slot.
*/
timestamp?: JSX.Element;
/**
* Widget store used to resolve the widget referenced by the state event.
*/
widgetStore?: WidgetStore;
/**
* Widget layout store used to resolve the current join prompt.
*/
widgetLayoutStore?: WidgetLayoutStore;
}
type InternalProps = Required<Pick<MJitsiWidgetEventViewModelProps, "widgetStore" | "widgetLayoutStore">> &
Omit<MJitsiWidgetEventViewModelProps, "widgetStore" | "widgetLayoutStore">;
/**
* ViewModel for Jitsi widget events.
*/
export class MJitsiWidgetEventViewModel
extends BaseViewModel<MJitsiWidgetEventViewSnapshot, InternalProps>
implements MJitsiWidgetEventViewModelInterface
{
public constructor(props: MJitsiWidgetEventViewModelProps) {
const internalProps = {
...props,
widgetStore: props.widgetStore ?? WidgetStore.instance,
widgetLayoutStore: props.widgetLayoutStore ?? WidgetLayoutStore.instance,
};
super(internalProps, MJitsiWidgetEventViewModel.computeSnapshot(internalProps));
this.trackStoreUpdates();
}
public setEvent(mxEvent: MatrixEvent): void {
this.props = { ...this.props, mxEvent };
this.updateSnapshotFromProps();
}
private trackStoreUpdates(): void {
const roomId = this.props.mxEvent.getRoomId();
const room = roomId ? this.props.cli.getRoom(roomId) : null;
this.disposables.trackListener(this.props.widgetStore, UPDATE_EVENT, (updatedRoomId?: unknown) => {
if (typeof updatedRoomId === "string" && updatedRoomId !== this.props.mxEvent.getRoomId()) return;
this.updateSnapshotFromProps();
});
if (roomId) {
this.disposables.trackListener(this.props.widgetStore, roomId, () => this.updateSnapshotFromProps());
}
if (room) {
this.disposables.trackListener(this.props.widgetLayoutStore, WidgetLayoutStore.emissionForRoom(room), () =>
this.updateSnapshotFromProps(),
);
}
}
private updateSnapshotFromProps(): void {
this.snapshot.merge(MJitsiWidgetEventViewModel.computeSnapshot(this.props));
}
private static computeSnapshot(props: InternalProps): MJitsiWidgetEventViewSnapshot {
const { mxEvent, timestamp } = props;
const roomId = mxEvent.getRoomId();
const room = roomId ? props.cli.getRoom(roomId) : null;
if (!room) {
return {
isVisible: false,
title: "",
subtitle: null,
timestamp,
};
}
const content = mxEvent.getContent<{ url?: string }>();
const prevContent = mxEvent.getPrevContent() as { url?: string };
const senderName = mxEvent.sender?.name || mxEvent.getSender() || "";
const widget = MJitsiWidgetEventViewModel.getWidget(props);
let subtitle: string | null = null;
if (content.url && widget) {
subtitle = props.widgetLayoutStore.isInContainer(room, widget, "right")
? _t("timeline|m.widget|jitsi_join_right_prompt")
: _t("timeline|m.widget|jitsi_join_top_prompt");
}
if (!content.url) {
return {
isVisible: true,
title: _t("timeline|m.widget|jitsi_ended", { senderName }),
subtitle: null,
timestamp,
};
}
if (prevContent.url) {
return {
isVisible: true,
title: _t("timeline|m.widget|jitsi_updated", { senderName }),
subtitle,
timestamp,
};
}
return {
isVisible: true,
title: _t("timeline|m.widget|jitsi_started", { senderName }),
subtitle,
timestamp,
};
}
private static getWidget(props: InternalProps): IApp | undefined {
const roomId = props.mxEvent.getRoomId();
const widgetId = props.mxEvent.getStateKey();
if (!roomId || widgetId === undefined) return undefined;
return props.widgetStore.getRoom(roomId, true).widgets.find((widget) => widget.id === widgetId);
}
}