Redesign widget pip and move into shared component (#32654)

* redesign widget pip and move into shared component

* fix onBackClick handler

* fix ci

* Update README.md prepare -> prepack

* add vm tests

* Update WidgetPipView.stories.tsx

* fix tests

* playwright tests

* fix test id

* remove unused files (reappeared after rebase)

* update storybook screenshot tests

* update playwright tests

* adjust padding

* review

* comment and docstring corrections

* fix imports and `this.props`

* fix double `complementary` item

* add WidgetPipView tests and revmoe `setViewingRoom` from
WidgetPipViewModelInterface.

* add doc sting to `setViewingRoom`

* Update RoomStatusBarView.test.tsx

* fix copyright

* Update RoomView-test.tsx.snap

* revert accidental Copyright year changes

* update snapshot RoomView-test
This commit is contained in:
Timo
2026-03-10 16:59:51 +00:00
committed by GitHub
parent 652b9f5b5b
commit 6d99678ade
26 changed files with 810 additions and 290 deletions
@@ -508,15 +508,16 @@ test.describe("Element Call", () => {
await openAndJoinCall(page);
await app.viewRoomByName("OtherRoom");
const pipContainer = page.locator(".mx_WidgetPip");
const pipContainer = page.getByTestId("widget-pip-container");
// We should have a PiP container here.
await expect(pipContainer).toBeVisible();
// Leave the call.
const overlay = page.locator(".mx_WidgetPip_overlay");
await overlay.hover({ timeout: 2000 }); // Show the call footer.
await overlay.getByRole("button", { name: "Leave", exact: true }).click();
const fakeWidget = page.locator('iframe[title="Element Call"]').contentFrame();
// await overlay.hover({ timeout: 2000 }); // Show the call footer.
await fakeWidget.getByRole("button", { name: "Close", exact: true }).click();
// PiP container goes.
await expect(pipContainer).not.toBeVisible();
@@ -541,15 +542,14 @@ test.describe("Element Call", () => {
await openAndJoinCall(page);
await app.viewRoomByName("OtherRoom");
const pipContainer = page.locator(".mx_WidgetPip");
const pipContainer = page.getByTestId("widget-pip-container");
// We should have a PiP container here.
await expect(pipContainer).toBeVisible();
// Leave the call.
const overlay = page.locator(".mx_WidgetPip_overlay");
await overlay.hover({ timeout: 2000 }); // Show the call footer.
await overlay.getByRole("button", { name: "Leave", exact: true }).click();
const fakeWidget = page.locator('iframe[title="Element Call"]').contentFrame();
await fakeWidget.getByRole("button", { name: "Close", exact: true }).click();
// PiP container goes.
await expect(pipContainer).not.toBeVisible();
@@ -578,15 +578,16 @@ test.describe("Element Call", () => {
await openAndJoinCall(page, true);
await app.viewRoomByName("OtherRoom");
const pipContainer = page.locator(".mx_WidgetPip");
const pipContainer = page.getByTestId("widget-pip-container");
// We should have a PiP container here.
await expect(pipContainer).toBeVisible();
// Leave the call.
const overlay = page.locator(".mx_WidgetPip_overlay");
await overlay.hover({ timeout: 2000 }); // Show the call footer.
await overlay.getByRole("button", { name: "Leave", exact: true }).click();
const fakeWidget = page.locator('iframe[title="Element Call"]').contentFrame();
// await overlay.hover({ timeout: 2000 }); // Show the call footer.
await fakeWidget.getByRole("button", { name: "Close", exact: true }).click();
// PiP container goes.
await expect(pipContainer).not.toBeVisible();
@@ -139,7 +139,7 @@ test.describe("Widget PIP", () => {
);
// checks that pip window is opened
await expect(page.locator(".mx_WidgetPip")).toBeVisible();
await expect(page.getByTestId("widget-pip-container")).toBeVisible();
// checks that widget is opened in pip
const iframe = page.frameLocator(`iframe[title="${DEMO_WIDGET_NAME}"]`);
@@ -155,7 +155,7 @@ test.describe("Widget PIP", () => {
}
// checks that pip window is closed
await expect(iframe.locator(".mx_WidgetPip")).not.toBeVisible();
await expect(iframe.getByTestId("widget-pip-container")).not.toBeVisible();
});
}
});
-1
View File
@@ -34,7 +34,6 @@
@import "./components/views/location/_ZoomButtons.pcss";
@import "./components/views/messages/_MBeaconBody.pcss";
@import "./components/views/messages/shared/_MediaProcessingError.pcss";
@import "./components/views/pips/_WidgetPip.pcss";
@import "./components/views/polls/_PollOption.pcss";
@import "./components/views/settings/_AddRemoveThreepids.pcss";
@import "./components/views/settings/devices/_CurrentDeviceSection.pcss";
@@ -1,73 +0,0 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 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.
*/
$width: 320px;
$height: 220px;
.mx_WidgetPip {
width: $width;
height: $height;
}
.mx_WidgetPip_overlay {
width: $width;
height: $height;
position: absolute;
top: 0;
border-radius: 8px;
overflow: hidden;
color: $call-primary-content;
cursor: pointer;
}
.mx_WidgetPip_header,
.mx_WidgetPip_footer {
position: absolute;
left: 0;
height: 60px;
width: 100%;
box-sizing: border-box;
transition: opacity ease 0.15s;
}
.mx_WidgetPip_overlay:not(:hover) {
.mx_WidgetPip_header,
.mx_WidgetPip_footer {
opacity: 0;
}
}
.mx_WidgetPip_header {
top: 0;
padding: $spacing-12;
display: flex;
font-size: $font-12px;
font-weight: var(--cpd-font-weight-semibold);
background: linear-gradient(rgb(0, 0, 0, 0.9), rgb(0, 0, 0, 0));
}
.mx_WidgetPip_backButton {
height: $spacing-24;
display: flex;
align-items: center;
gap: $spacing-12;
> .mx_Icon {
color: $call-light-quaternary-content;
padding: 0;
}
}
.mx_WidgetPip_footer {
bottom: 0;
padding: $spacing-12 $spacing-8;
display: flex;
justify-content: flex-end;
align-items: flex-end;
background: linear-gradient(rgb(0, 0, 0, 0), rgb(0, 0, 0, 0.9));
}
+2 -10
View File
@@ -7,10 +7,6 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
Please see LICENSE files in the repository root for full details.
*/
:root {
--AppTile_mini-height: 220px;
}
.mx_AppsDrawer {
--minWidth: 240px; /* TODO this should be 300px but that's too large */
@@ -168,11 +164,11 @@ Please see LICENSE files in the repository root for full details.
.mx_AppTile_mini {
width: 100%;
height: 100%;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
height: var(--AppTile_mini-height);
}
.mx_AppTile .mx_AppTile_persistedWrapper,
@@ -276,8 +272,8 @@ Please see LICENSE files in the repository root for full details.
&.mx_AppTileBody--large,
&.mx_AppTileBody--mini {
width: 100%;
height: 100%;
overflow: hidden;
height: var(--AppTileBody-height);
iframe {
border: none;
@@ -299,10 +295,6 @@ Please see LICENSE files in the repository root for full details.
}
}
&.mx_AppTileBody--mini {
--AppTileBody-height: var(--AppTile_mini-height);
}
&.mx_AppTileBody--loading {
display: flex;
flex-direction: column;
@@ -18,10 +18,10 @@ const MOVING_AMT = 0.2;
const SNAPPING_AMT = 0.1;
const PADDING = {
top: 58,
bottom: 58,
left: 76,
right: 8,
top: 80,
bottom: 87,
left: 84,
right: 16,
};
/**
@@ -53,7 +53,7 @@ export default class PictureInPictureDragger extends React.Component<IProps> {
private initX = 0;
private initY = 0;
private desiredTranslationX = UIStore.instance.windowWidth - PADDING.right - PIP_VIEW_WIDTH;
private desiredTranslationY = UIStore.instance.windowHeight - PADDING.bottom - PIP_VIEW_HEIGHT;
private desiredTranslationY = PADDING.top;
private translationX = this.desiredTranslationX;
private translationY = this.desiredTranslationY;
private mouseHeld = false;
@@ -6,9 +6,10 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
Please see LICENSE files in the repository root for full details.
*/
import React, { type RefObject, type ReactNode, useRef } from "react";
import React, { type RefObject, type ReactNode, useRef, useEffect } from "react";
import { CallEvent, CallState, type MatrixCall } from "matrix-js-sdk/src/webrtc/call";
import { logger } from "matrix-js-sdk/src/logger";
import { useCreateAutoDisposedViewModel, WidgetPipView } from "@element-hq/web-shared-components";
import LegacyCallView from "../views/voip/LegacyCallView";
import LegacyCallHandler, { LegacyCallHandlerEvent } from "../../LegacyCallHandler";
@@ -21,7 +22,8 @@ import ActiveWidgetStore, { ActiveWidgetStoreEvent } from "../../stores/ActiveWi
import { type ViewRoomPayload } from "../../dispatcher/payloads/ViewRoomPayload";
import { UPDATE_EVENT } from "../../stores/AsyncStore";
import { SdkContextClass } from "../../contexts/SDKContext";
import { WidgetPip } from "../views/pips/WidgetPip";
import RoomAvatar from "../views/avatars/RoomAvatar";
import { WidgetPipViewModel, type Props as WidgetPipViewModelProps } from "../../viewmodels/room/WidgetPipViewModel";
const SHOW_CALL_IN_STATES = [
CallState.Connected,
@@ -46,7 +48,7 @@ interface IState {
// they belong to
secondaryCall: MatrixCall;
// widget candidate to be displayed in the pip view.
// Widget candidate to be displayed in the PiP view.
persistentWidgetId: string | null;
persistentRoomId: string | null;
showWidgetInPip: boolean;
@@ -251,7 +253,7 @@ class PipContainerInner extends React.Component<IProps, IState> {
if (this.state.showWidgetInPip && this.state.persistentWidgetId) {
pipContent.push(({ onStartMoving }) => (
<WidgetPip
<WidgetPipWrappedView
key="widget-pip"
widgetId={this.state.persistentWidgetId!}
room={MatrixClientPeg.safeGet().getRoom(this.state.persistentRoomId ?? undefined)!}
@@ -284,3 +286,30 @@ export const PipContainer: React.FC = () => {
return <PipContainerInner movePersistedElement={movePersistedElement} />;
};
type Props = { viewingRoom: boolean } & WidgetPipViewModelProps;
/**
* A wrapper for the WidgetPipView component.
*
* This exposes the new shared WidgetPipView with the same API as before and how
* it is used in the PipContainerInner component.
* @param props The same props the legacy WidgetPip was using.
* @returns
*/
const WidgetPipWrappedView: React.FC<Props> = (props: Props) => {
const vm = useCreateAutoDisposedViewModel(() => new WidgetPipViewModel(props));
useEffect(() => {
// Use an effect to update viewingRoom. It is not required in the view but only in the view model.
vm.setViewingRoom(props.viewingRoom);
}, [vm, props.viewingRoom]);
return (
<WidgetPipView
vm={vm}
// Props only used in the view and not the view model get passed directly.
RoomAvatar={({ size }) => <RoomAvatar size={size} room={props.room} />}
/>
);
};
@@ -1,134 +0,0 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 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 FC, type RefObject, useCallback, useMemo } from "react";
import { type Room, RoomEvent } from "matrix-js-sdk/src/matrix";
import { ArrowLeftIcon, EndCallIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
import PersistentApp from "../elements/PersistentApp";
import defaultDispatcher from "../../../dispatcher/dispatcher";
import { type ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload";
import { Action } from "../../../dispatcher/actions";
import { useCallForWidget } from "../../../hooks/useCall";
import WidgetStore from "../../../stores/WidgetStore";
import { Container, WidgetLayoutStore } from "../../../stores/widgets/WidgetLayoutStore";
import { useTypedEventEmitterState } from "../../../hooks/useEventEmitter";
import Toolbar from "../../../accessibility/Toolbar";
import { RovingAccessibleButton } from "../../../accessibility/RovingTabIndex";
import { _t } from "../../../languageHandler";
import { WidgetType } from "../../../widgets/WidgetType";
import { WidgetMessagingStore } from "../../../stores/widgets/WidgetMessagingStore";
import WidgetUtils from "../../../utils/WidgetUtils";
import { ElementWidgetActions } from "../../../stores/widgets/ElementWidgetActions";
import { type ButtonEvent } from "../elements/AccessibleButton";
interface Props {
widgetId: string;
room: Room;
viewingRoom: boolean;
onStartMoving: (e: React.MouseEvent<Element, MouseEvent>) => void;
movePersistedElement: RefObject<(() => void) | null>;
}
/**
* A picture-in-picture view for a widget. Additional controls are shown if the
* widget is a call of some sort.
*/
export const WidgetPip: FC<Props> = ({ widgetId, room, viewingRoom, onStartMoving, movePersistedElement }) => {
const widget = useMemo(
() => WidgetStore.instance.getApps(room.roomId).find((app) => app.id === widgetId)!,
[room, widgetId],
);
const roomName = useTypedEventEmitterState(
room,
RoomEvent.Name,
useCallback(() => room.name, [room]),
);
const call = useCallForWidget(widgetId, room.roomId);
const onBackClick = useCallback(
(ev: ButtonEvent) => {
ev.preventDefault();
ev.stopPropagation();
if (call !== null) {
defaultDispatcher.dispatch<ViewRoomPayload>({
action: Action.ViewRoom,
room_id: room.roomId,
view_call: true,
metricsTrigger: "WebFloatingCallWindow",
});
} else if (viewingRoom) {
WidgetLayoutStore.instance.moveToContainer(room, widget, Container.Center);
} else {
defaultDispatcher.dispatch<ViewRoomPayload>({
action: Action.ViewRoom,
room_id: room.roomId,
metricsTrigger: "WebFloatingCallWindow",
});
}
},
[room, call, widget, viewingRoom],
);
const onLeaveClick = useCallback(
(ev: ButtonEvent) => {
ev.preventDefault();
ev.stopPropagation();
if (call !== null) {
call.disconnect().catch((e) => console.error("Failed to leave call", e));
} else {
// Assumed to be a Jitsi widget
WidgetMessagingStore.instance
.getMessagingForUid(WidgetUtils.getWidgetUid(widget))
?.widgetApi?.transport.send(ElementWidgetActions.HangupCall, {})
.catch((e) => console.error("Failed to leave Jitsi", e));
}
},
[call, widget],
);
return (
<div className="mx_WidgetPip" onMouseDown={onStartMoving} onClick={onBackClick}>
<PersistentApp
persistentWidgetId={widgetId}
persistentRoomId={room.roomId}
pointerEvents="none"
movePersistedElement={movePersistedElement}
>
<div onMouseDown={onStartMoving} className="mx_WidgetPip_overlay">
<Toolbar className="mx_WidgetPip_header">
<RovingAccessibleButton
onClick={onBackClick}
className="mx_WidgetPip_backButton"
aria-label={_t("action|back")}
>
<ArrowLeftIcon className="mx_Icon mx_Icon_16" />
{roomName}
</RovingAccessibleButton>
</Toolbar>
{(call !== null || WidgetType.JITSI.matches(widget?.type)) && (
<Toolbar className="mx_WidgetPip_footer">
<RovingAccessibleButton
onClick={onLeaveClick}
title={_t("action|leave")}
aria-label={_t("action|leave")}
placement="top"
>
<EndCallIcon className="mx_Icon mx_Icon_24" />
</RovingAccessibleButton>
</Toolbar>
)}
</div>
</PersistentApp>
</div>
);
};
@@ -76,7 +76,7 @@ const ElementCallSwitch: React.FC<ElementCallSwitchProps> = ({ room }) => {
return (
<SettingsToggleInput
name="element-call-switch"
data-test-id="element-call-switch"
data-testid="element-call-switch"
label={_t("room_settings|voip|enable_element_call_label", { brand })}
helpMessage={_t("room_settings|voip|enable_element_call_caption", {
brand,
-5
View File
@@ -27,11 +27,6 @@ export const useCall = (roomId: string): Call | null => {
return call;
};
export const useCallForWidget = (widgetId: string, roomId: string): Call | null => {
const call = useCall(roomId);
return call?.widget.id === widgetId ? call : null;
};
export const useConnectionState = (call: Call | null): ConnectionState =>
useTypedEventEmitterState(
call ?? undefined,
@@ -0,0 +1,131 @@
/*
* Copyright (c) 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 {
BaseViewModel,
type WidgetPipViewSnapshot,
type WidgetPipViewModel as WidgetPipViewModelInterface,
} from "@element-hq/web-shared-components";
import { type Room, RoomEvent } from "matrix-js-sdk/src/matrix";
import React from "react";
import type { RefObject, FC } from "react";
import { Action } from "../../dispatcher/actions";
import WidgetStore, { type IApp } from "../../stores/WidgetStore";
import { CallStore, CallStoreEvent } from "../../stores/CallStore";
import { type Call } from "../../models/Call";
import defaultDispatcher from "../../dispatcher/dispatcher";
import { type ViewRoomPayload } from "../../dispatcher/payloads/ViewRoomPayload";
import { Container, WidgetLayoutStore } from "../../stores/widgets/WidgetLayoutStore";
import PersistentApp from "../../components/views/elements/PersistentApp";
export interface Props {
/**
* The widgetId this widget PiP view is showing.
*/
widgetId: string;
/**
* The room this widget PiP view model is associated with.
*/
room: Room;
/**
* A callback which is called when a mouse event (most likely mouse down) occurs at the start of moving the PiP around.
*/
onStartMoving: (ev: React.MouseEvent<Element, MouseEvent>) => void;
/**
* This callback ref will be used by the ViewModel once the view is moving.
* Widgets might be implemented with a top-layer DOM tree path containing the widget iframe.
* This allows moving the iframe around (PiP/in-room) without remounting it.
* This callback allows any `PersistentApp` view/component to know when to update the iframe position of the widget.
*/
movePersistedElement: RefObject<(() => void) | null>;
}
export class WidgetPipViewModel
extends BaseViewModel<WidgetPipViewSnapshot, Props>
implements WidgetPipViewModelInterface
{
/** The widget this view model uses for the PipView */
private readonly widget: IApp;
/**
* The call associated with the widget (if the widget is a call widget)
* For non-call widgets, this will be `null`.
*/
private call: Call | null;
/** If the user is currently viewing the room associated with the PiP view (`this.props.room`) */
private viewingRoom?: boolean;
public constructor(props: Props) {
super(props, { widgetId: props.widgetId, roomName: props.room.name, roomId: props.room.roomId });
this.widget = WidgetStore.instance.getApps(props.room.roomId).find((app) => app.id === this.props.widgetId)!;
this.call = CallStore.instance.getCall(props.room.roomId) ?? null;
this.onStartMoving = props.onStartMoving;
this.disposables.trackListener(props.room, RoomEvent.Name, this.onRoomName);
this.disposables.trackListener(CallStore.instance, CallStoreEvent.Call, this.onCallChange);
}
public onStartMoving: (ev: React.MouseEvent<Element, MouseEvent>) => void;
/**
* The view model needs to know if the room is currently being viewed.
* @param viewing Whether we are currently viewing the room.
*/
public setViewingRoom(viewing: boolean): void {
this.viewingRoom = viewing;
}
public onBackClick(ev: React.MouseEvent<Element, MouseEvent>): void {
ev.preventDefault();
ev.stopPropagation();
if (this.call !== null) {
defaultDispatcher.dispatch<ViewRoomPayload>({
action: Action.ViewRoom,
room_id: this.props.room.roomId,
view_call: true,
metricsTrigger: "WebFloatingCallWindow",
});
} else if (this.viewingRoom) {
WidgetLayoutStore.instance.moveToContainer(this.props.room, this.widget, Container.Center);
} else {
defaultDispatcher.dispatch<ViewRoomPayload>({
action: Action.ViewRoom,
room_id: this.props.room.roomId,
metricsTrigger: "WebFloatingCallWindow",
});
}
}
/**
* The component to render as the persistent app by the WidgetPipView.
* @param props A copy of the `PersistentApp` component's props.
* @returns
*/
public persistentAppComponent: FC<
Pick<React.ComponentProps<typeof PersistentApp>, "persistentWidgetId" | "persistentRoomId">
> = (props) => {
return (
<PersistentApp
persistentWidgetId={props.persistentWidgetId}
persistentRoomId={props.persistentRoomId}
movePersistedElement={this.props.movePersistedElement}
/>
);
};
private readonly onRoomName = (): void => {
this.snapshot.merge({ roomName: this.props.room.name });
};
private readonly onCallChange = (...args: unknown[]): void => {
const [call, forRoomId] = args as [Call | null, string];
if (forRoomId === this.props.room.roomId) {
this.call = call?.widget.id === this.props.widgetId ? call : null;
}
};
}
@@ -17,7 +17,7 @@ import {
RoomStateEvent,
type RoomMember,
} from "matrix-js-sdk/src/matrix";
import { Widget, type ClientWidgetApi } from "matrix-widget-api";
import { Widget } from "matrix-widget-api";
import {
useMockedCalls,
@@ -47,7 +47,6 @@ import { Container, WidgetLayoutStore } from "../../../../src/stores/widgets/Wid
import WidgetStore from "../../../../src/stores/WidgetStore";
import { WidgetType } from "../../../../src/widgets/WidgetType";
import { SdkContextClass } from "../../../../src/contexts/SDKContext";
import { ElementWidgetActions } from "../../../../src/stores/widgets/ElementWidgetActions";
import { type WidgetMessaging } from "../../../../src/stores/widgets/WidgetMessaging";
jest.mock("../../../../src/stores/OwnProfileStore", () => ({
@@ -194,7 +193,7 @@ describe("PipContainer", () => {
expect(screen.queryByRole("complementary")).toBeNull();
});
it("shows an active call with back and leave buttons", async () => {
it("shows an active call with back buttons", async () => {
renderPip();
await withCall(async (call) => {
@@ -211,11 +210,6 @@ describe("PipContainer", () => {
metricsTrigger: expect.any(String),
});
defaultDispatcher.unregister(dispatcherRef);
// The leave button should disconnect from the call
const disconnectSpy = jest.spyOn(call, "disconnect");
await user.click(screen.getByRole("button", { name: "Leave" }));
expect(disconnectSpy).toHaveBeenCalled();
});
});
@@ -252,16 +246,7 @@ describe("PipContainer", () => {
mockPlatformPeg({ supportsJitsiScreensharing: () => true });
setUpRoomViewStore();
viewRoom(room2.roomId);
const widget = WidgetStore.instance.addVirtualWidget(
{
id: "1",
creatorUserId: "@alice:example.org",
type: WidgetType.JITSI.preferred,
url: "https://meet.example.org",
name: "Jitsi example",
},
room.roomId,
);
renderPip();
await withWidget(async () => {
@@ -277,25 +262,6 @@ describe("PipContainer", () => {
metricsTrigger: expect.any(String),
});
defaultDispatcher.unregister(dispatcherRef);
// The leave button should hangup the call
const sendSpy = jest
.fn<
ReturnType<ClientWidgetApi["transport"]["send"]>,
Parameters<ClientWidgetApi["transport"]["send"]>
>()
.mockResolvedValue({});
const mockMessaging = {
on: () => {},
off: () => {},
stop: () => {},
widgetApi: {
transport: { send: sendSpy },
},
} as unknown as WidgetMessaging;
WidgetMessagingStore.instance.storeMessaging(new Widget(widget), room.roomId, mockMessaging);
await user.click(screen.getByRole("button", { name: "Leave" }));
expect(sendSpy).toHaveBeenCalledWith(ElementWidgetActions.HangupCall, {});
});
WidgetStore.instance.removeVirtualWidget("1", room.roomId);
@@ -3,7 +3,7 @@
exports[`PictureInPictureDragger when rendering the dragger with PiP content 1 and 2 should render both contents 1`] = `
<div>
<aside
style="transform: translateX(680px) translateY(478px);"
style="transform: translateX(672px) translateY(80px);"
>
<div>
content 1
@@ -20,7 +20,7 @@ exports[`PictureInPictureDragger when rendering the dragger with PiP content 1 a
exports[`PictureInPictureDragger when rendering the dragger with PiP content 1 and rendering PiP content 2 should update the PiP content 1`] = `
<div>
<aside
style="transform: translateX(680px) translateY(478px);"
style="transform: translateX(672px) translateY(80px);"
>
<div>
content 2
@@ -34,7 +34,7 @@ exports[`PictureInPictureDragger when rendering the dragger with PiP content 1 a
exports[`PictureInPictureDragger when rendering the dragger with PiP content 1 and rerendering PiP content 1 should not change the PiP content: pip-content-1 1`] = `
<div>
<aside
style="transform: translateX(680px) translateY(478px);"
style="transform: translateX(672px) translateY(80px);"
>
<div>
content 1
@@ -46,7 +46,7 @@ exports[`PictureInPictureDragger when rendering the dragger with PiP content 1 a
exports[`PictureInPictureDragger when rendering the dragger with PiP content 1 should render the PiP content: pip-content-1 1`] = `
<div>
<aside
style="transform: translateX(680px) translateY(478px);"
style="transform: translateX(672px) translateY(80px);"
>
<div>
content 1
@@ -237,7 +237,7 @@ exports[`RoomView for a local room in state ERROR should match the snapshot 1`]
class="_content_193k4_38"
>
<p
class="_typography_6v6n8_153 _font-body-md-medium_6v6n8_60 _title_1rm46_17"
class="_typography_6v6n8_153 _font-body-md-medium_6v6n8_60 _title_1xryk_24"
id="_r_1c3_"
>
Could not start a chat with this user
@@ -247,7 +247,7 @@ exports[`RoomView for a local room in state ERROR should match the snapshot 1`]
class="_actions_193k4_60"
>
<button
class="_button_13vu4_8 _primaryAction_1rm46_13 _has-icon_13vu4_60"
class="_button_13vu4_8 _primaryAction_1xryk_20 _has-icon_13vu4_60"
data-kind="primary"
data-size="sm"
role="button"
@@ -0,0 +1,108 @@
/*
* Copyright (c) 2025 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 MatrixClient, type Room, RoomEvent } from "matrix-js-sdk/src/matrix";
import { type MockedObject } from "jest-mock";
import { mkRoom, stubClient } from "../../test-utils";
import { WidgetPipViewModel } from "../../../src/viewmodels/room/WidgetPipViewModel";
import WidgetStore, { type IApp } from "../../../src/stores/WidgetStore";
import defaultDispatcher from "../../../src/dispatcher/dispatcher";
import { Action } from "../../../src/dispatcher/actions";
import { Container, WidgetLayoutStore } from "../../../src/stores/widgets/WidgetLayoutStore";
import { CallStore, CallStoreEvent } from "../../../src/stores/CallStore";
import { type Call } from "../../../src/models/Call";
const userId = "@example:example.org";
const widgetId = "test-widget-id";
type BackClickEvent = Parameters<WidgetPipViewModel["onBackClick"]>[0];
const createBackClickEvent = (): BackClickEvent =>
({
preventDefault: jest.fn(),
stopPropagation: jest.fn(),
}) as unknown as BackClickEvent;
describe("WidgetPipViewModel", () => {
let client: MockedObject<MatrixClient>;
let vm: WidgetPipViewModel;
let room: MockedObject<Room>;
let widget: IApp;
beforeEach(() => {
client = stubClient() as MockedObject<MatrixClient>;
room = mkRoom(client, "!example");
widget = {
id: widgetId,
roomId: room.roomId,
creatorUserId: userId,
type: "m.custom",
name: "Test Widget",
data: {},
} as unknown as IApp;
jest.spyOn(WidgetStore.instance, "getApps").mockReturnValue([widget]);
vm = new WidgetPipViewModel({
room,
widgetId,
});
});
afterEach(() => {
vm.dispose();
jest.restoreAllMocks();
});
it("updates room name", () => {
room.name = "New Room Name";
room.emit(RoomEvent.Name, room);
expect(vm.getSnapshot().roomName).toBe("New Room Name");
});
it("updates onBackClick if call changes", () => {
const dispatchSpy = jest.spyOn(defaultDispatcher, "dispatch").mockImplementation(() => {});
vm.onBackClick(createBackClickEvent());
expect(dispatchSpy).toHaveBeenCalledWith({
action: Action.ViewRoom,
room_id: room.roomId,
metricsTrigger: "WebFloatingCallWindow",
});
dispatchSpy.mockClear();
const call = { widget: { id: widgetId } } as unknown as Call;
CallStore.instance.emit(CallStoreEvent.Call, call, room.roomId);
vm.onBackClick(createBackClickEvent());
expect(dispatchSpy).toHaveBeenCalledWith({
action: Action.ViewRoom,
room_id: room.roomId,
view_call: true,
metricsTrigger: "WebFloatingCallWindow",
});
});
it("updates onBackClick if viewingRoom changes", () => {
const dispatchSpy = jest.spyOn(defaultDispatcher, "dispatch").mockImplementation(() => {});
const moveSpy = jest.spyOn(WidgetLayoutStore.instance, "moveToContainer").mockImplementation(() => {});
vm.setViewingRoom(true);
vm.onBackClick(createBackClickEvent());
expect(moveSpy).toHaveBeenCalledWith(room, widget, Container.Center);
moveSpy.mockClear();
vm.setViewingRoom(false);
vm.onBackClick(createBackClickEvent());
expect(dispatchSpy).toHaveBeenCalledWith({
action: Action.ViewRoom,
room_id: room.roomId,
metricsTrigger: "WebFloatingCallWindow",
});
expect(moveSpy).not.toHaveBeenCalled();
});
});