diff --git a/apps/web/res/css/_components.pcss b/apps/web/res/css/_components.pcss index 8f64e43de4..fc2f1be5ea 100644 --- a/apps/web/res/css/_components.pcss +++ b/apps/web/res/css/_components.pcss @@ -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"; diff --git a/apps/web/res/css/views/messages/_MJitsiWidgetEvent.pcss b/apps/web/res/css/views/messages/_MJitsiWidgetEvent.pcss deleted file mode 100644 index 7813354366..0000000000 --- a/apps/web/res/css/views/messages/_MJitsiWidgetEvent.pcss +++ /dev/null @@ -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 */ - } -} diff --git a/apps/web/src/components/views/messages/MJitsiWidgetEvent.tsx b/apps/web/src/components/views/messages/MJitsiWidgetEvent.tsx deleted file mode 100644 index dfed9d4378..0000000000 --- a/apps/web/src/components/views/messages/MJitsiWidgetEvent.tsx +++ /dev/null @@ -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 { - 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 ( - } - className="mx_EventTileBubble mx_MJitsiWidgetEvent" - title={_t("timeline|m.widget|jitsi_ended", { senderName })} - > - {this.props.timestamp} - - ); - } else if (prevUrl) { - // modified - return ( - } - className="mx_EventTileBubble mx_MJitsiWidgetEvent" - title={_t("timeline|m.widget|jitsi_updated", { senderName })} - subtitle={joinCopy} - > - {this.props.timestamp} - - ); - } else { - // assume added - return ( - } - className="mx_EventTileBubble mx_MJitsiWidgetEvent" - title={_t("timeline|m.widget|jitsi_started", { senderName })} - subtitle={joinCopy} - > - {this.props.timestamp} - - ); - } - } -} diff --git a/apps/web/src/events/EventTileFactory.tsx b/apps/web/src/events/EventTileFactory.tsx index dadea9099e..e707ee9d61 100644 --- a/apps/web/src/events/EventTileFactory.tsx +++ b/apps/web/src/events/EventTileFactory.tsx @@ -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) => ; +function MJitsiWidgetEventWrappedView({ mxEvent, ref }: IBodyProps): JSX.Element { + const cli = useMatrixClientContext(); + const vm = useCreateAutoDisposedViewModel(() => new MJitsiWidgetEventViewModel({ mxEvent, cli })); + + useEffect(() => { + vm.setEvent(mxEvent); + }, [mxEvent, vm]); + + return ; +} + 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) => ; +export const JitsiEventFactory: Factory = (ref, props) => ; export const JSONEventFactory: Factory = (ref, props) => ; export const RoomCreateEventFactory: Factory = (_ref, props) => ; diff --git a/apps/web/src/viewmodels/room/timeline/event-tile/MJitsiWidgetEventViewModel.ts b/apps/web/src/viewmodels/room/timeline/event-tile/MJitsiWidgetEventViewModel.ts new file mode 100644 index 0000000000..d622877af4 --- /dev/null +++ b/apps/web/src/viewmodels/room/timeline/event-tile/MJitsiWidgetEventViewModel.ts @@ -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> & + Omit; + +/** + * ViewModel for Jitsi widget events. + */ +export class MJitsiWidgetEventViewModel + extends BaseViewModel + 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); + } +} diff --git a/apps/web/test/viewmodels/event-tiles/MJitsiWidgetEventViewModel-test.tsx b/apps/web/test/viewmodels/event-tiles/MJitsiWidgetEventViewModel-test.tsx new file mode 100644 index 0000000000..46c41d96a6 --- /dev/null +++ b/apps/web/test/viewmodels/event-tiles/MJitsiWidgetEventViewModel-test.tsx @@ -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[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); + }); +}); diff --git a/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/EventTileView/MJitsiWidgetEventView/MJitsiWidgetEventView.stories.tsx/ended-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/EventTileView/MJitsiWidgetEventView/MJitsiWidgetEventView.stories.tsx/ended-auto.png new file mode 100644 index 0000000000..ce148ddc96 Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/EventTileView/MJitsiWidgetEventView/MJitsiWidgetEventView.stories.tsx/ended-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/EventTileView/MJitsiWidgetEventView/MJitsiWidgetEventView.stories.tsx/hidden-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/EventTileView/MJitsiWidgetEventView/MJitsiWidgetEventView.stories.tsx/hidden-auto.png new file mode 100644 index 0000000000..17fe39394f Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/EventTileView/MJitsiWidgetEventView/MJitsiWidgetEventView.stories.tsx/hidden-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/EventTileView/MJitsiWidgetEventView/MJitsiWidgetEventView.stories.tsx/started-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/EventTileView/MJitsiWidgetEventView/MJitsiWidgetEventView.stories.tsx/started-auto.png new file mode 100644 index 0000000000..80ef9c8ef9 Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/EventTileView/MJitsiWidgetEventView/MJitsiWidgetEventView.stories.tsx/started-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/EventTileView/MJitsiWidgetEventView/MJitsiWidgetEventView.stories.tsx/updated-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/EventTileView/MJitsiWidgetEventView/MJitsiWidgetEventView.stories.tsx/updated-auto.png new file mode 100644 index 0000000000..6aa12cc0f7 Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/EventTileView/MJitsiWidgetEventView/MJitsiWidgetEventView.stories.tsx/updated-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/EventTileView/MJitsiWidgetEventView/MJitsiWidgetEventView.stories.tsx/with-timestamp-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/EventTileView/MJitsiWidgetEventView/MJitsiWidgetEventView.stories.tsx/with-timestamp-auto.png new file mode 100644 index 0000000000..f7a924cbe3 Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/EventTileView/MJitsiWidgetEventView/MJitsiWidgetEventView.stories.tsx/with-timestamp-auto.png differ diff --git a/packages/shared-components/src/index.ts b/packages/shared-components/src/index.ts index 8c3f7a43c6..c81e1943d1 100644 --- a/packages/shared-components/src/index.ts +++ b/packages/shared-components/src/index.ts @@ -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"; diff --git a/packages/shared-components/src/room/timeline/event-tile/EventTileView/MJitsiWidgetEventView/MJitsiWidgetEventView.stories.tsx b/packages/shared-components/src/room/timeline/event-tile/EventTileView/MJitsiWidgetEventView/MJitsiWidgetEventView.stories.tsx new file mode 100644 index 0000000000..1fa6b3d5db --- /dev/null +++ b/packages/shared-components/src/room/timeline/event-tile/EventTileView/MJitsiWidgetEventView/MJitsiWidgetEventView.stories.tsx @@ -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 ; +}; + +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; + +export default meta; +type Story = StoryObj; + +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: 14:56, + }, +}; diff --git a/packages/shared-components/src/room/timeline/event-tile/EventTileView/MJitsiWidgetEventView/MJitsiWidgetEventView.test.tsx b/packages/shared-components/src/room/timeline/event-tile/EventTileView/MJitsiWidgetEventView/MJitsiWidgetEventView.test.tsx new file mode 100644 index 0000000000..7879ba7942 --- /dev/null +++ b/packages/shared-components/src/room/timeline/event-tile/EventTileView/MJitsiWidgetEventView/MJitsiWidgetEventView.test.tsx @@ -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(); + + 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(); + + 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(); + + 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(); + + expect(container).toMatchSnapshot(); + expect(screen.queryByText(/Video conference/)).not.toBeInTheDocument(); + }); + + it("renders a timestamp", () => { + const { container } = render(); + + 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(); + + expect(container.firstChild).toHaveClass("custom-jitsi"); + }); + + it("forwards the provided ref to the root element", () => { + const ref = React.createRef() as React.RefObject; + const vm = new MockViewModel({ + isVisible: true, + title: "Video conference started by Alice", + subtitle: null, + }); + + render(); + + expect(ref.current).toBeInstanceOf(HTMLDivElement); + expect(ref.current).toHaveTextContent("Video conference started by Alice"); + }); +}); diff --git a/packages/shared-components/src/room/timeline/event-tile/EventTileView/MJitsiWidgetEventView/MJitsiWidgetEventView.tsx b/packages/shared-components/src/room/timeline/event-tile/EventTileView/MJitsiWidgetEventView/MJitsiWidgetEventView.tsx new file mode 100644 index 0000000000..a6fc338fb4 --- /dev/null +++ b/packages/shared-components/src/room/timeline/event-tile/EventTileView/MJitsiWidgetEventView/MJitsiWidgetEventView.tsx @@ -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; + +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; +} + +/** + * Renders a timeline bubble describing a Jitsi widget state event. + */ +export function MJitsiWidgetEventView({ + vm, + className, + ref, +}: Readonly): JSX.Element | null { + const { isVisible, title, subtitle, timestamp } = useViewModel(vm); + + if (!isVisible) return null; + + return ( + } + className={className} + title={title} + subtitle={subtitle || undefined} + ref={ref} + > + {timestamp} + + ); +} diff --git a/packages/shared-components/src/room/timeline/event-tile/EventTileView/MJitsiWidgetEventView/__snapshots__/MJitsiWidgetEventView.test.tsx.snap b/packages/shared-components/src/room/timeline/event-tile/EventTileView/MJitsiWidgetEventView/__snapshots__/MJitsiWidgetEventView.test.tsx.snap new file mode 100644 index 0000000000..7644d05473 --- /dev/null +++ b/packages/shared-components/src/room/timeline/event-tile/EventTileView/MJitsiWidgetEventView/__snapshots__/MJitsiWidgetEventView.test.tsx.snap @@ -0,0 +1,125 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`MJitsiWidgetEventView > renders a timestamp 1`] = ` +
+
+ + + +
+ Video conference started by Alice +
+
+ Join the conference at the top of this room +
+ + 14:56 + +
+
+`; + +exports[`MJitsiWidgetEventView > renders nothing when hidden 1`] = `
`; + +exports[`MJitsiWidgetEventView > renders the Ended story without a subtitle 1`] = ` +
+
+ + + +
+ Video conference ended by Alice +
+
+
+`; + +exports[`MJitsiWidgetEventView > renders the Started story 1`] = ` +
+
+ + + +
+ Video conference started by Alice +
+
+ Join the conference at the top of this room +
+
+
+`; + +exports[`MJitsiWidgetEventView > renders the Updated story 1`] = ` +
+
+ + + +
+ Video conference updated by Alice +
+
+ Join the conference from the room information card on the right +
+
+
+`; diff --git a/packages/shared-components/src/room/timeline/event-tile/EventTileView/MJitsiWidgetEventView/index.ts b/packages/shared-components/src/room/timeline/event-tile/EventTileView/MJitsiWidgetEventView/index.ts new file mode 100644 index 0000000000..5566a78512 --- /dev/null +++ b/packages/shared-components/src/room/timeline/event-tile/EventTileView/MJitsiWidgetEventView/index.ts @@ -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";