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 11:12:43 +02:00
committed by GitHub
parent 67ea6bfa53
commit b19025e578
17 changed files with 715 additions and 94 deletions
+1
View File
@@ -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";
@@ -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>,
},
};
@@ -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");
});
});
@@ -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>
);
}
@@ -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>
`;
@@ -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";