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:
@@ -226,7 +226,6 @@
|
||||
@import "./views/messages/_MFileBody.pcss";
|
||||
@import "./views/messages/_MImageBody.pcss";
|
||||
@import "./views/messages/_MImageReplyBody.pcss";
|
||||
@import "./views/messages/_MJitsiWidgetEvent.pcss";
|
||||
@import "./views/messages/_MLocationBody.pcss";
|
||||
@import "./views/messages/_MPollBody.pcss";
|
||||
@import "./views/messages/_MStickerBody.pcss";
|
||||
|
||||
@@ -1,13 +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.
|
||||
*/
|
||||
|
||||
.mx_EventTileBubble.mx_MJitsiWidgetEvent {
|
||||
svg {
|
||||
color: $header-panel-text-primary-color; /* XXX: Variable abuse */
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
/*
|
||||
* 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 { EventEmitter } from "events";
|
||||
import { type MatrixClient, type Room } from "matrix-js-sdk/src/matrix";
|
||||
|
||||
import { MJitsiWidgetEventViewModel } from "../../../src/viewmodels/room/timeline/event-tile/MJitsiWidgetEventViewModel";
|
||||
import { UPDATE_EVENT } from "../../../src/stores/AsyncStore";
|
||||
import { WidgetLayoutStore } from "../../../src/stores/widgets/WidgetLayoutStore";
|
||||
import { mkEvent, stubClient } from "../../test-utils";
|
||||
import type WidgetStore from "../../../src/stores/WidgetStore";
|
||||
import type { IApp } from "../../../src/stores/WidgetStore";
|
||||
|
||||
describe("MJitsiWidgetEventViewModel", () => {
|
||||
const roomId = "!room:example.com";
|
||||
const widgetId = "jitsi";
|
||||
let cli: MatrixClient;
|
||||
let room: Room;
|
||||
let widget: IApp;
|
||||
let widgetStore: WidgetStore & EventEmitter;
|
||||
let widgetLayoutStore: WidgetLayoutStore & EventEmitter;
|
||||
|
||||
const createEvent = (content: { url?: string }, prevContent: { url?: string } = {}) =>
|
||||
mkEvent({
|
||||
event: true,
|
||||
room: roomId,
|
||||
user: "@alice:example.com",
|
||||
skey: widgetId,
|
||||
type: "im.vector.modular.widgets",
|
||||
content,
|
||||
prev_content: prevContent,
|
||||
});
|
||||
|
||||
const createVm = (
|
||||
props: Partial<ConstructorParameters<typeof MJitsiWidgetEventViewModel>[0]> = {},
|
||||
): MJitsiWidgetEventViewModel =>
|
||||
new MJitsiWidgetEventViewModel({
|
||||
cli,
|
||||
mxEvent: createEvent({ url: "https://jitsi.example.com/room" }),
|
||||
widgetStore,
|
||||
widgetLayoutStore,
|
||||
...props,
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
cli = stubClient();
|
||||
room = cli.getRoom(roomId)!;
|
||||
widget = {
|
||||
id: widgetId,
|
||||
roomId,
|
||||
type: "m.jitsi",
|
||||
} as IApp;
|
||||
widgetStore = Object.assign(new EventEmitter(), {
|
||||
getRoom: jest.fn().mockReturnValue({ widgets: [widget] }),
|
||||
}) as unknown as WidgetStore & EventEmitter;
|
||||
widgetLayoutStore = Object.assign(new EventEmitter(), {
|
||||
isInContainer: jest.fn().mockReturnValue(false),
|
||||
}) as unknown as WidgetLayoutStore & EventEmitter;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("renders a started Jitsi event with the top join prompt", () => {
|
||||
const vm = createVm();
|
||||
|
||||
expect(vm.getSnapshot()).toMatchObject({
|
||||
isVisible: true,
|
||||
title: "Video conference started by @alice:example.com",
|
||||
subtitle: "Join the conference at the top of this room",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses the right-panel join prompt when the widget is in the right container", () => {
|
||||
jest.mocked(widgetLayoutStore.isInContainer).mockReturnValue(true);
|
||||
|
||||
const vm = createVm();
|
||||
|
||||
expect(vm.getSnapshot().subtitle).toBe("Join the conference from the room information card on the right");
|
||||
});
|
||||
|
||||
it("omits the join prompt when the widget no longer exists", () => {
|
||||
jest.mocked(widgetStore.getRoom).mockReturnValue({ widgets: [] });
|
||||
|
||||
const vm = createVm();
|
||||
|
||||
expect(vm.getSnapshot()).toMatchObject({
|
||||
isVisible: true,
|
||||
title: "Video conference started by @alice:example.com",
|
||||
subtitle: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("renders an updated Jitsi event", () => {
|
||||
const vm = createVm({
|
||||
mxEvent: createEvent({ url: "https://jitsi.example.com/room" }, { url: "https://old.example.com/room" }),
|
||||
});
|
||||
|
||||
expect(vm.getSnapshot()).toMatchObject({
|
||||
isVisible: true,
|
||||
title: "Video conference updated by @alice:example.com",
|
||||
subtitle: "Join the conference at the top of this room",
|
||||
});
|
||||
});
|
||||
|
||||
it("renders an ended Jitsi event without a join prompt", () => {
|
||||
const vm = createVm({
|
||||
mxEvent: createEvent({}, { url: "https://old.example.com/room" }),
|
||||
});
|
||||
|
||||
expect(vm.getSnapshot()).toMatchObject({
|
||||
isVisible: true,
|
||||
title: "Video conference ended by @alice:example.com",
|
||||
subtitle: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("hides the event when the room is unavailable", () => {
|
||||
jest.spyOn(cli, "getRoom").mockReturnValue(null);
|
||||
|
||||
const vm = createVm();
|
||||
|
||||
expect(vm.getSnapshot()).toMatchObject({
|
||||
isVisible: false,
|
||||
title: "",
|
||||
subtitle: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("updates the snapshot when the event changes", () => {
|
||||
const vm = createVm();
|
||||
const listener = jest.fn();
|
||||
vm.subscribe(listener);
|
||||
|
||||
vm.setEvent(createEvent({ url: "https://jitsi.example.com/room" }, { url: "https://old.example.com/room" }));
|
||||
|
||||
expect(vm.getSnapshot().title).toBe("Video conference updated by @alice:example.com");
|
||||
expect(listener).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not emit updates when setEvent receives the current event", () => {
|
||||
const mxEvent = createEvent({ url: "https://jitsi.example.com/room" });
|
||||
const listener = jest.fn();
|
||||
const vm = createVm({ mxEvent });
|
||||
|
||||
vm.subscribe(listener);
|
||||
vm.setEvent(mxEvent);
|
||||
|
||||
expect(listener).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("updates when widget stores emit for the room", () => {
|
||||
const vm = createVm();
|
||||
const listener = jest.fn();
|
||||
vm.subscribe(listener);
|
||||
jest.mocked(widgetLayoutStore.isInContainer).mockReturnValue(true);
|
||||
|
||||
widgetStore.emit(UPDATE_EVENT, roomId);
|
||||
|
||||
expect(vm.getSnapshot().subtitle).toBe("Join the conference from the room information card on the right");
|
||||
expect(listener).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("updates when the widget layout store emits for the room", () => {
|
||||
const vm = createVm();
|
||||
const listener = jest.fn();
|
||||
vm.subscribe(listener);
|
||||
jest.mocked(widgetLayoutStore.isInContainer).mockReturnValue(true);
|
||||
|
||||
widgetLayoutStore.emit(WidgetLayoutStore.emissionForRoom(room));
|
||||
|
||||
expect(vm.getSnapshot().subtitle).toBe("Join the conference from the room information card on the right");
|
||||
expect(listener).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 20 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 24 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 24 KiB |
@@ -41,6 +41,7 @@ export * from "./room/timeline/event-tile/EventTileView/DisambiguatedProfile";
|
||||
export * from "./room/timeline/event-tile/EventTileView/EncryptionEventView";
|
||||
export * from "./room/timeline/event-tile/call";
|
||||
export * from "./room/timeline/event-tile/EventTileView/EventTileBubble";
|
||||
export * from "./room/timeline/event-tile/EventTileView/MJitsiWidgetEventView";
|
||||
export * from "./room/timeline/event-tile/EventTileView/MKeyVerificationRequestView";
|
||||
export * from "./room/timeline/event-tile/EventTileView/PinnedMessageBadge";
|
||||
export * from "./room/timeline/event-tile/EventTileView/RoomAvatarEventView";
|
||||
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
/*
|
||||
* 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 React, { type JSX } from "react";
|
||||
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { useMockedViewModel } from "../../../../../core/viewmodel";
|
||||
import { withViewDocs } from "../../../../../../.storybook/withViewDocs";
|
||||
import { MJitsiWidgetEventView, type MJitsiWidgetEventViewSnapshot } from "./MJitsiWidgetEventView";
|
||||
|
||||
type MJitsiWidgetEventViewProps = MJitsiWidgetEventViewSnapshot & {
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const MJitsiWidgetEventViewWrapperImpl = ({
|
||||
className,
|
||||
...snapshot
|
||||
}: MJitsiWidgetEventViewProps): JSX.Element | null => {
|
||||
const vm = useMockedViewModel(snapshot, {});
|
||||
|
||||
return <MJitsiWidgetEventView vm={vm} className={className} />;
|
||||
};
|
||||
|
||||
const MJitsiWidgetEventViewWrapper = withViewDocs(MJitsiWidgetEventViewWrapperImpl, MJitsiWidgetEventView);
|
||||
|
||||
const meta = {
|
||||
title: "Timeline/Timeline Event/MJitsiWidgetEventView",
|
||||
component: MJitsiWidgetEventViewWrapper,
|
||||
tags: ["autodocs"],
|
||||
args: {
|
||||
isVisible: true,
|
||||
title: "Video conference started by Alice",
|
||||
subtitle: "Join the conference at the top of this room",
|
||||
className: "",
|
||||
},
|
||||
} satisfies Meta<typeof MJitsiWidgetEventViewWrapper>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Started: Story = {};
|
||||
|
||||
export const Updated: Story = {
|
||||
args: {
|
||||
title: "Video conference updated by Alice",
|
||||
subtitle: "Join the conference from the room information card on the right",
|
||||
},
|
||||
};
|
||||
|
||||
export const Ended: Story = {
|
||||
args: {
|
||||
title: "Video conference ended by Alice",
|
||||
subtitle: null,
|
||||
},
|
||||
};
|
||||
|
||||
export const Hidden: Story = {
|
||||
args: {
|
||||
isVisible: false,
|
||||
title: "",
|
||||
subtitle: null,
|
||||
},
|
||||
};
|
||||
|
||||
export const WithTimestamp: Story = {
|
||||
args: {
|
||||
timestamp: <span>14:56</span>,
|
||||
},
|
||||
};
|
||||
+82
@@ -0,0 +1,82 @@
|
||||
/*
|
||||
* 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 { composeStories } from "@storybook/react-vite";
|
||||
import { render, screen } from "@test-utils";
|
||||
import React from "react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { MockViewModel } from "../../../../../core/viewmodel";
|
||||
import { MJitsiWidgetEventView } from "./MJitsiWidgetEventView";
|
||||
import * as stories from "./MJitsiWidgetEventView.stories";
|
||||
|
||||
const { Started, Updated, Ended, Hidden, WithTimestamp } = composeStories(stories);
|
||||
|
||||
describe("MJitsiWidgetEventView", () => {
|
||||
it("renders the Started story", () => {
|
||||
const { container } = render(<Started />);
|
||||
|
||||
expect(container).toMatchSnapshot();
|
||||
expect(screen.getByText("Video conference started by Alice")).toBeInTheDocument();
|
||||
expect(screen.getByText("Join the conference at the top of this room")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the Updated story", () => {
|
||||
const { container } = render(<Updated />);
|
||||
|
||||
expect(container).toMatchSnapshot();
|
||||
expect(screen.getByText("Video conference updated by Alice")).toBeInTheDocument();
|
||||
expect(screen.getByText("Join the conference from the room information card on the right")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the Ended story without a subtitle", () => {
|
||||
const { container } = render(<Ended />);
|
||||
|
||||
expect(container).toMatchSnapshot();
|
||||
expect(screen.getByText("Video conference ended by Alice")).toBeInTheDocument();
|
||||
expect(screen.queryByText(/Join the conference/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders nothing when hidden", () => {
|
||||
const { container } = render(<Hidden />);
|
||||
|
||||
expect(container).toMatchSnapshot();
|
||||
expect(screen.queryByText(/Video conference/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders a timestamp", () => {
|
||||
const { container } = render(<WithTimestamp />);
|
||||
|
||||
expect(container).toMatchSnapshot();
|
||||
expect(screen.getByText("14:56")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("applies a custom className to the root element", () => {
|
||||
const vm = new MockViewModel({
|
||||
isVisible: true,
|
||||
title: "Video conference started by Alice",
|
||||
subtitle: null,
|
||||
});
|
||||
const { container } = render(<MJitsiWidgetEventView vm={vm} className="custom-jitsi" />);
|
||||
|
||||
expect(container.firstChild).toHaveClass("custom-jitsi");
|
||||
});
|
||||
|
||||
it("forwards the provided ref to the root element", () => {
|
||||
const ref = React.createRef<HTMLDivElement>() as React.RefObject<HTMLDivElement>;
|
||||
const vm = new MockViewModel({
|
||||
isVisible: true,
|
||||
title: "Video conference started by Alice",
|
||||
subtitle: null,
|
||||
});
|
||||
|
||||
render(<MJitsiWidgetEventView vm={vm} ref={ref} />);
|
||||
|
||||
expect(ref.current).toBeInstanceOf(HTMLDivElement);
|
||||
expect(ref.current).toHaveTextContent("Video conference started by Alice");
|
||||
});
|
||||
});
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
/*
|
||||
* 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 React, { type JSX } from "react";
|
||||
import { VideoCallSolidIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||
|
||||
import { type ViewModel, useViewModel } from "../../../../../core/viewmodel";
|
||||
import { EventTileBubble } from "../EventTileBubble";
|
||||
|
||||
export interface MJitsiWidgetEventViewSnapshot {
|
||||
/**
|
||||
* Whether the event has enough context to render.
|
||||
*/
|
||||
isVisible: boolean;
|
||||
/**
|
||||
* Main title text for the Jitsi widget event.
|
||||
*/
|
||||
title: string;
|
||||
/**
|
||||
* Optional join prompt shown below the title.
|
||||
*/
|
||||
subtitle: string | null;
|
||||
/**
|
||||
* Optional timestamp element rendered in the EventTileBubble footer slot.
|
||||
*/
|
||||
timestamp?: JSX.Element;
|
||||
}
|
||||
|
||||
export type MJitsiWidgetEventViewModel = ViewModel<MJitsiWidgetEventViewSnapshot>;
|
||||
|
||||
export interface MJitsiWidgetEventViewProps {
|
||||
/**
|
||||
* ViewModel providing the current Jitsi widget event snapshot.
|
||||
*/
|
||||
vm: MJitsiWidgetEventViewModel;
|
||||
/**
|
||||
* Optional CSS classes passed through to EventTileBubble.
|
||||
*/
|
||||
className?: string;
|
||||
/**
|
||||
* Optional Ref forwarded to the root DOM element.
|
||||
*/
|
||||
ref?: React.RefObject<HTMLDivElement>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a timeline bubble describing a Jitsi widget state event.
|
||||
*/
|
||||
export function MJitsiWidgetEventView({
|
||||
vm,
|
||||
className,
|
||||
ref,
|
||||
}: Readonly<MJitsiWidgetEventViewProps>): JSX.Element | null {
|
||||
const { isVisible, title, subtitle, timestamp } = useViewModel(vm);
|
||||
|
||||
if (!isVisible) return null;
|
||||
|
||||
return (
|
||||
<EventTileBubble
|
||||
icon={<VideoCallSolidIcon color="var(--cpd-color-text-primary)" />}
|
||||
className={className}
|
||||
title={title}
|
||||
subtitle={subtitle || undefined}
|
||||
ref={ref}
|
||||
>
|
||||
{timestamp}
|
||||
</EventTileBubble>
|
||||
);
|
||||
}
|
||||
+125
@@ -0,0 +1,125 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`MJitsiWidgetEventView > renders a timestamp 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="EventTileBubble-module_container"
|
||||
>
|
||||
<svg
|
||||
color="var(--cpd-color-text-primary)"
|
||||
fill="currentColor"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
width="1em"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M6 4h10a2 2 0 0 1 2 2v4.286l3.35-2.871a1 1 0 0 1 1.65.76v7.65a1 1 0 0 1-1.65.76L18 13.715V18a2 2 0 0 1-2 2H6a4 4 0 0 1-4-4V8a4 4 0 0 1 4-4"
|
||||
/>
|
||||
</svg>
|
||||
<div
|
||||
class="EventTileBubble-module_title"
|
||||
>
|
||||
Video conference started by Alice
|
||||
</div>
|
||||
<div
|
||||
class="EventTileBubble-module_subtitle"
|
||||
>
|
||||
Join the conference at the top of this room
|
||||
</div>
|
||||
<span>
|
||||
14:56
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`MJitsiWidgetEventView > renders nothing when hidden 1`] = `<div />`;
|
||||
|
||||
exports[`MJitsiWidgetEventView > renders the Ended story without a subtitle 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="EventTileBubble-module_container"
|
||||
>
|
||||
<svg
|
||||
color="var(--cpd-color-text-primary)"
|
||||
fill="currentColor"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
width="1em"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M6 4h10a2 2 0 0 1 2 2v4.286l3.35-2.871a1 1 0 0 1 1.65.76v7.65a1 1 0 0 1-1.65.76L18 13.715V18a2 2 0 0 1-2 2H6a4 4 0 0 1-4-4V8a4 4 0 0 1 4-4"
|
||||
/>
|
||||
</svg>
|
||||
<div
|
||||
class="EventTileBubble-module_title"
|
||||
>
|
||||
Video conference ended by Alice
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`MJitsiWidgetEventView > renders the Started story 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="EventTileBubble-module_container"
|
||||
>
|
||||
<svg
|
||||
color="var(--cpd-color-text-primary)"
|
||||
fill="currentColor"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
width="1em"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M6 4h10a2 2 0 0 1 2 2v4.286l3.35-2.871a1 1 0 0 1 1.65.76v7.65a1 1 0 0 1-1.65.76L18 13.715V18a2 2 0 0 1-2 2H6a4 4 0 0 1-4-4V8a4 4 0 0 1 4-4"
|
||||
/>
|
||||
</svg>
|
||||
<div
|
||||
class="EventTileBubble-module_title"
|
||||
>
|
||||
Video conference started by Alice
|
||||
</div>
|
||||
<div
|
||||
class="EventTileBubble-module_subtitle"
|
||||
>
|
||||
Join the conference at the top of this room
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`MJitsiWidgetEventView > renders the Updated story 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="EventTileBubble-module_container"
|
||||
>
|
||||
<svg
|
||||
color="var(--cpd-color-text-primary)"
|
||||
fill="currentColor"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
width="1em"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M6 4h10a2 2 0 0 1 2 2v4.286l3.35-2.871a1 1 0 0 1 1.65.76v7.65a1 1 0 0 1-1.65.76L18 13.715V18a2 2 0 0 1-2 2H6a4 4 0 0 1-4-4V8a4 4 0 0 1 4-4"
|
||||
/>
|
||||
</svg>
|
||||
<div
|
||||
class="EventTileBubble-module_title"
|
||||
>
|
||||
Video conference updated by Alice
|
||||
</div>
|
||||
<div
|
||||
class="EventTileBubble-module_subtitle"
|
||||
>
|
||||
Join the conference from the room information card on the right
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
export {
|
||||
MJitsiWidgetEventView,
|
||||
type MJitsiWidgetEventViewProps,
|
||||
type MJitsiWidgetEventViewSnapshot,
|
||||
type MJitsiWidgetEventViewModel,
|
||||
} from "./MJitsiWidgetEventView";
|
||||
Reference in New Issue
Block a user