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:
@@ -508,15 +508,16 @@ test.describe("Element Call", () => {
|
|||||||
|
|
||||||
await openAndJoinCall(page);
|
await openAndJoinCall(page);
|
||||||
await app.viewRoomByName("OtherRoom");
|
await app.viewRoomByName("OtherRoom");
|
||||||
const pipContainer = page.locator(".mx_WidgetPip");
|
const pipContainer = page.getByTestId("widget-pip-container");
|
||||||
|
|
||||||
// We should have a PiP container here.
|
// We should have a PiP container here.
|
||||||
await expect(pipContainer).toBeVisible();
|
await expect(pipContainer).toBeVisible();
|
||||||
|
|
||||||
// Leave the call.
|
// Leave the call.
|
||||||
const overlay = page.locator(".mx_WidgetPip_overlay");
|
const fakeWidget = page.locator('iframe[title="Element Call"]').contentFrame();
|
||||||
await overlay.hover({ timeout: 2000 }); // Show the call footer.
|
|
||||||
await overlay.getByRole("button", { name: "Leave", exact: true }).click();
|
// await overlay.hover({ timeout: 2000 }); // Show the call footer.
|
||||||
|
await fakeWidget.getByRole("button", { name: "Close", exact: true }).click();
|
||||||
|
|
||||||
// PiP container goes.
|
// PiP container goes.
|
||||||
await expect(pipContainer).not.toBeVisible();
|
await expect(pipContainer).not.toBeVisible();
|
||||||
@@ -541,15 +542,14 @@ test.describe("Element Call", () => {
|
|||||||
|
|
||||||
await openAndJoinCall(page);
|
await openAndJoinCall(page);
|
||||||
await app.viewRoomByName("OtherRoom");
|
await app.viewRoomByName("OtherRoom");
|
||||||
const pipContainer = page.locator(".mx_WidgetPip");
|
const pipContainer = page.getByTestId("widget-pip-container");
|
||||||
|
|
||||||
// We should have a PiP container here.
|
// We should have a PiP container here.
|
||||||
await expect(pipContainer).toBeVisible();
|
await expect(pipContainer).toBeVisible();
|
||||||
|
|
||||||
// Leave the call.
|
// Leave the call.
|
||||||
const overlay = page.locator(".mx_WidgetPip_overlay");
|
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();
|
||||||
await overlay.getByRole("button", { name: "Leave", exact: true }).click();
|
|
||||||
|
|
||||||
// PiP container goes.
|
// PiP container goes.
|
||||||
await expect(pipContainer).not.toBeVisible();
|
await expect(pipContainer).not.toBeVisible();
|
||||||
@@ -578,15 +578,16 @@ test.describe("Element Call", () => {
|
|||||||
await openAndJoinCall(page, true);
|
await openAndJoinCall(page, true);
|
||||||
|
|
||||||
await app.viewRoomByName("OtherRoom");
|
await app.viewRoomByName("OtherRoom");
|
||||||
const pipContainer = page.locator(".mx_WidgetPip");
|
const pipContainer = page.getByTestId("widget-pip-container");
|
||||||
|
|
||||||
// We should have a PiP container here.
|
// We should have a PiP container here.
|
||||||
await expect(pipContainer).toBeVisible();
|
await expect(pipContainer).toBeVisible();
|
||||||
|
|
||||||
// Leave the call.
|
// Leave the call.
|
||||||
const overlay = page.locator(".mx_WidgetPip_overlay");
|
const fakeWidget = page.locator('iframe[title="Element Call"]').contentFrame();
|
||||||
await overlay.hover({ timeout: 2000 }); // Show the call footer.
|
|
||||||
await overlay.getByRole("button", { name: "Leave", exact: true }).click();
|
// await overlay.hover({ timeout: 2000 }); // Show the call footer.
|
||||||
|
await fakeWidget.getByRole("button", { name: "Close", exact: true }).click();
|
||||||
|
|
||||||
// PiP container goes.
|
// PiP container goes.
|
||||||
await expect(pipContainer).not.toBeVisible();
|
await expect(pipContainer).not.toBeVisible();
|
||||||
|
|||||||
@@ -139,7 +139,7 @@ test.describe("Widget PIP", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// checks that pip window is opened
|
// 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
|
// checks that widget is opened in pip
|
||||||
const iframe = page.frameLocator(`iframe[title="${DEMO_WIDGET_NAME}"]`);
|
const iframe = page.frameLocator(`iframe[title="${DEMO_WIDGET_NAME}"]`);
|
||||||
@@ -155,7 +155,7 @@ test.describe("Widget PIP", () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// checks that pip window is closed
|
// checks that pip window is closed
|
||||||
await expect(iframe.locator(".mx_WidgetPip")).not.toBeVisible();
|
await expect(iframe.getByTestId("widget-pip-container")).not.toBeVisible();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -34,7 +34,6 @@
|
|||||||
@import "./components/views/location/_ZoomButtons.pcss";
|
@import "./components/views/location/_ZoomButtons.pcss";
|
||||||
@import "./components/views/messages/_MBeaconBody.pcss";
|
@import "./components/views/messages/_MBeaconBody.pcss";
|
||||||
@import "./components/views/messages/shared/_MediaProcessingError.pcss";
|
@import "./components/views/messages/shared/_MediaProcessingError.pcss";
|
||||||
@import "./components/views/pips/_WidgetPip.pcss";
|
|
||||||
@import "./components/views/polls/_PollOption.pcss";
|
@import "./components/views/polls/_PollOption.pcss";
|
||||||
@import "./components/views/settings/_AddRemoveThreepids.pcss";
|
@import "./components/views/settings/_AddRemoveThreepids.pcss";
|
||||||
@import "./components/views/settings/devices/_CurrentDeviceSection.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));
|
|
||||||
}
|
|
||||||
@@ -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.
|
Please see LICENSE files in the repository root for full details.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
:root {
|
|
||||||
--AppTile_mini-height: 220px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mx_AppsDrawer {
|
.mx_AppsDrawer {
|
||||||
--minWidth: 240px; /* TODO this should be 300px but that's too large */
|
--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 {
|
.mx_AppTile_mini {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
height: var(--AppTile_mini-height);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.mx_AppTile .mx_AppTile_persistedWrapper,
|
.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--large,
|
||||||
&.mx_AppTileBody--mini {
|
&.mx_AppTileBody--mini {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
height: var(--AppTileBody-height);
|
|
||||||
|
|
||||||
iframe {
|
iframe {
|
||||||
border: none;
|
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 {
|
&.mx_AppTileBody--loading {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -18,10 +18,10 @@ const MOVING_AMT = 0.2;
|
|||||||
const SNAPPING_AMT = 0.1;
|
const SNAPPING_AMT = 0.1;
|
||||||
|
|
||||||
const PADDING = {
|
const PADDING = {
|
||||||
top: 58,
|
top: 80,
|
||||||
bottom: 58,
|
bottom: 87,
|
||||||
left: 76,
|
left: 84,
|
||||||
right: 8,
|
right: 16,
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -53,7 +53,7 @@ export default class PictureInPictureDragger extends React.Component<IProps> {
|
|||||||
private initX = 0;
|
private initX = 0;
|
||||||
private initY = 0;
|
private initY = 0;
|
||||||
private desiredTranslationX = UIStore.instance.windowWidth - PADDING.right - PIP_VIEW_WIDTH;
|
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 translationX = this.desiredTranslationX;
|
||||||
private translationY = this.desiredTranslationY;
|
private translationY = this.desiredTranslationY;
|
||||||
private mouseHeld = false;
|
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.
|
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 { CallEvent, CallState, type MatrixCall } from "matrix-js-sdk/src/webrtc/call";
|
||||||
import { logger } from "matrix-js-sdk/src/logger";
|
import { logger } from "matrix-js-sdk/src/logger";
|
||||||
|
import { useCreateAutoDisposedViewModel, WidgetPipView } from "@element-hq/web-shared-components";
|
||||||
|
|
||||||
import LegacyCallView from "../views/voip/LegacyCallView";
|
import LegacyCallView from "../views/voip/LegacyCallView";
|
||||||
import LegacyCallHandler, { LegacyCallHandlerEvent } from "../../LegacyCallHandler";
|
import LegacyCallHandler, { LegacyCallHandlerEvent } from "../../LegacyCallHandler";
|
||||||
@@ -21,7 +22,8 @@ import ActiveWidgetStore, { ActiveWidgetStoreEvent } from "../../stores/ActiveWi
|
|||||||
import { type ViewRoomPayload } from "../../dispatcher/payloads/ViewRoomPayload";
|
import { type ViewRoomPayload } from "../../dispatcher/payloads/ViewRoomPayload";
|
||||||
import { UPDATE_EVENT } from "../../stores/AsyncStore";
|
import { UPDATE_EVENT } from "../../stores/AsyncStore";
|
||||||
import { SdkContextClass } from "../../contexts/SDKContext";
|
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 = [
|
const SHOW_CALL_IN_STATES = [
|
||||||
CallState.Connected,
|
CallState.Connected,
|
||||||
@@ -46,7 +48,7 @@ interface IState {
|
|||||||
// they belong to
|
// they belong to
|
||||||
secondaryCall: MatrixCall;
|
secondaryCall: MatrixCall;
|
||||||
|
|
||||||
// widget candidate to be displayed in the pip view.
|
// Widget candidate to be displayed in the PiP view.
|
||||||
persistentWidgetId: string | null;
|
persistentWidgetId: string | null;
|
||||||
persistentRoomId: string | null;
|
persistentRoomId: string | null;
|
||||||
showWidgetInPip: boolean;
|
showWidgetInPip: boolean;
|
||||||
@@ -251,7 +253,7 @@ class PipContainerInner extends React.Component<IProps, IState> {
|
|||||||
|
|
||||||
if (this.state.showWidgetInPip && this.state.persistentWidgetId) {
|
if (this.state.showWidgetInPip && this.state.persistentWidgetId) {
|
||||||
pipContent.push(({ onStartMoving }) => (
|
pipContent.push(({ onStartMoving }) => (
|
||||||
<WidgetPip
|
<WidgetPipWrappedView
|
||||||
key="widget-pip"
|
key="widget-pip"
|
||||||
widgetId={this.state.persistentWidgetId!}
|
widgetId={this.state.persistentWidgetId!}
|
||||||
room={MatrixClientPeg.safeGet().getRoom(this.state.persistentRoomId ?? undefined)!}
|
room={MatrixClientPeg.safeGet().getRoom(this.state.persistentRoomId ?? undefined)!}
|
||||||
@@ -284,3 +286,30 @@ export const PipContainer: React.FC = () => {
|
|||||||
|
|
||||||
return <PipContainerInner movePersistedElement={movePersistedElement} />;
|
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 (
|
return (
|
||||||
<SettingsToggleInput
|
<SettingsToggleInput
|
||||||
name="element-call-switch"
|
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 })}
|
label={_t("room_settings|voip|enable_element_call_label", { brand })}
|
||||||
helpMessage={_t("room_settings|voip|enable_element_call_caption", {
|
helpMessage={_t("room_settings|voip|enable_element_call_caption", {
|
||||||
brand,
|
brand,
|
||||||
|
|||||||
@@ -27,11 +27,6 @@ export const useCall = (roomId: string): Call | null => {
|
|||||||
return call;
|
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 =>
|
export const useConnectionState = (call: Call | null): ConnectionState =>
|
||||||
useTypedEventEmitterState(
|
useTypedEventEmitterState(
|
||||||
call ?? undefined,
|
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,
|
RoomStateEvent,
|
||||||
type RoomMember,
|
type RoomMember,
|
||||||
} from "matrix-js-sdk/src/matrix";
|
} from "matrix-js-sdk/src/matrix";
|
||||||
import { Widget, type ClientWidgetApi } from "matrix-widget-api";
|
import { Widget } from "matrix-widget-api";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
useMockedCalls,
|
useMockedCalls,
|
||||||
@@ -47,7 +47,6 @@ import { Container, WidgetLayoutStore } from "../../../../src/stores/widgets/Wid
|
|||||||
import WidgetStore from "../../../../src/stores/WidgetStore";
|
import WidgetStore from "../../../../src/stores/WidgetStore";
|
||||||
import { WidgetType } from "../../../../src/widgets/WidgetType";
|
import { WidgetType } from "../../../../src/widgets/WidgetType";
|
||||||
import { SdkContextClass } from "../../../../src/contexts/SDKContext";
|
import { SdkContextClass } from "../../../../src/contexts/SDKContext";
|
||||||
import { ElementWidgetActions } from "../../../../src/stores/widgets/ElementWidgetActions";
|
|
||||||
import { type WidgetMessaging } from "../../../../src/stores/widgets/WidgetMessaging";
|
import { type WidgetMessaging } from "../../../../src/stores/widgets/WidgetMessaging";
|
||||||
|
|
||||||
jest.mock("../../../../src/stores/OwnProfileStore", () => ({
|
jest.mock("../../../../src/stores/OwnProfileStore", () => ({
|
||||||
@@ -194,7 +193,7 @@ describe("PipContainer", () => {
|
|||||||
expect(screen.queryByRole("complementary")).toBeNull();
|
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();
|
renderPip();
|
||||||
|
|
||||||
await withCall(async (call) => {
|
await withCall(async (call) => {
|
||||||
@@ -211,11 +210,6 @@ describe("PipContainer", () => {
|
|||||||
metricsTrigger: expect.any(String),
|
metricsTrigger: expect.any(String),
|
||||||
});
|
});
|
||||||
defaultDispatcher.unregister(dispatcherRef);
|
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 });
|
mockPlatformPeg({ supportsJitsiScreensharing: () => true });
|
||||||
setUpRoomViewStore();
|
setUpRoomViewStore();
|
||||||
viewRoom(room2.roomId);
|
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();
|
renderPip();
|
||||||
|
|
||||||
await withWidget(async () => {
|
await withWidget(async () => {
|
||||||
@@ -277,25 +262,6 @@ describe("PipContainer", () => {
|
|||||||
metricsTrigger: expect.any(String),
|
metricsTrigger: expect.any(String),
|
||||||
});
|
});
|
||||||
defaultDispatcher.unregister(dispatcherRef);
|
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);
|
WidgetStore.instance.removeVirtualWidget("1", room.roomId);
|
||||||
|
|||||||
+4
-4
@@ -3,7 +3,7 @@
|
|||||||
exports[`PictureInPictureDragger when rendering the dragger with PiP content 1 and 2 should render both contents 1`] = `
|
exports[`PictureInPictureDragger when rendering the dragger with PiP content 1 and 2 should render both contents 1`] = `
|
||||||
<div>
|
<div>
|
||||||
<aside
|
<aside
|
||||||
style="transform: translateX(680px) translateY(478px);"
|
style="transform: translateX(672px) translateY(80px);"
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
content 1
|
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`] = `
|
exports[`PictureInPictureDragger when rendering the dragger with PiP content 1 and rendering PiP content 2 should update the PiP content 1`] = `
|
||||||
<div>
|
<div>
|
||||||
<aside
|
<aside
|
||||||
style="transform: translateX(680px) translateY(478px);"
|
style="transform: translateX(672px) translateY(80px);"
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
content 2
|
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`] = `
|
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>
|
<div>
|
||||||
<aside
|
<aside
|
||||||
style="transform: translateX(680px) translateY(478px);"
|
style="transform: translateX(672px) translateY(80px);"
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
content 1
|
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`] = `
|
exports[`PictureInPictureDragger when rendering the dragger with PiP content 1 should render the PiP content: pip-content-1 1`] = `
|
||||||
<div>
|
<div>
|
||||||
<aside
|
<aside
|
||||||
style="transform: translateX(680px) translateY(478px);"
|
style="transform: translateX(672px) translateY(80px);"
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
content 1
|
content 1
|
||||||
|
|||||||
@@ -237,7 +237,7 @@ exports[`RoomView for a local room in state ERROR should match the snapshot 1`]
|
|||||||
class="_content_193k4_38"
|
class="_content_193k4_38"
|
||||||
>
|
>
|
||||||
<p
|
<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_"
|
id="_r_1c3_"
|
||||||
>
|
>
|
||||||
Could not start a chat with this user
|
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"
|
class="_actions_193k4_60"
|
||||||
>
|
>
|
||||||
<button
|
<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-kind="primary"
|
||||||
data-size="sm"
|
data-size="sm"
|
||||||
role="button"
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 26 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
@@ -3,6 +3,7 @@
|
|||||||
"seek_bar_label": "Audio seek bar"
|
"seek_bar_label": "Audio seek bar"
|
||||||
},
|
},
|
||||||
"action": {
|
"action": {
|
||||||
|
"back": "Back",
|
||||||
"delete": "Delete",
|
"delete": "Delete",
|
||||||
"dismiss": "Dismiss",
|
"dismiss": "Dismiss",
|
||||||
"edit": "Edit",
|
"edit": "Edit",
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ export * from "./message-body/TimelineSeparator/";
|
|||||||
export * from "./pill-input/Pill";
|
export * from "./pill-input/Pill";
|
||||||
export * from "./pill-input/PillInput";
|
export * from "./pill-input/PillInput";
|
||||||
export * from "./room/RoomStatusBar";
|
export * from "./room/RoomStatusBar";
|
||||||
|
export * from "./room/WidgetPip";
|
||||||
export * from "./profile/DisambiguatedProfile";
|
export * from "./profile/DisambiguatedProfile";
|
||||||
export * from "./room/HistoryVisibilityBadge";
|
export * from "./room/HistoryVisibilityBadge";
|
||||||
export * from "./rich-list/RichItem";
|
export * from "./rich-list/RichItem";
|
||||||
|
|||||||
@@ -1,3 +1,10 @@
|
|||||||
|
/*
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
|
||||||
.container {
|
.container {
|
||||||
color: var(--cpd-color-text-primary);
|
color: var(--cpd-color-text-primary);
|
||||||
svg {
|
svg {
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
/*
|
||||||
|
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.
|
||||||
|
*/
|
||||||
|
|
||||||
|
.container {
|
||||||
|
width: 304px; /* hardcoded to match figma -> might become a resizable pip in the future */
|
||||||
|
height: 278px; /* hardcoded to match figma -> might become a resizable pip in the future */
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: var(--cpd-space-3x);
|
||||||
|
background: var(--cpd-color-bg-canvas-default);
|
||||||
|
border: 1px solid var(--cpd-color-border-interactive-secondary);
|
||||||
|
border-radius: var(--cpd-space-6x);
|
||||||
|
gap: var(--cpd-space-2x);
|
||||||
|
box-shadow: 0 0 var(--cpd-space-2x) rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
top: 0;
|
||||||
|
gap: var(--cpd-space-2x);
|
||||||
|
display: flex;
|
||||||
|
font: var(--cpd-font-body-md-semibold);
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.roundedCornerContainer {
|
||||||
|
border-radius: var(--cpd-space-4x);
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-grow: 1;
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
/*
|
||||||
|
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 { type Meta, type StoryFn } from "@storybook/react-vite";
|
||||||
|
import React, { type JSX } from "react";
|
||||||
|
import { fn } from "storybook/test";
|
||||||
|
|
||||||
|
import { useMockedViewModel } from "../../viewmodel";
|
||||||
|
import { WidgetPipView, type WidgetPipViewActions, type WidgetPipViewSnapshot } from "./WidgetPipView";
|
||||||
|
import { withViewDocs } from "../../../.storybook/withViewDocs";
|
||||||
|
|
||||||
|
type WidgetPipViewProps = WidgetPipViewSnapshot & WidgetPipViewActions;
|
||||||
|
|
||||||
|
// Helper components that are provided outside of this storybook
|
||||||
|
const RoomAvatarMock: React.FC = () => (
|
||||||
|
<div style={{ width: 20, height: 20, borderRadius: "50%", backgroundColor: "grey" }} />
|
||||||
|
);
|
||||||
|
const PersistentAppMock: React.FC = () => <div style={{ backgroundColor: "grey", flexGrow: 1 }} />;
|
||||||
|
|
||||||
|
const WidgetPipViewWrapperImpl = ({
|
||||||
|
onBackClick,
|
||||||
|
persistentAppComponent,
|
||||||
|
onStartMoving,
|
||||||
|
...rest
|
||||||
|
}: WidgetPipViewProps): JSX.Element => {
|
||||||
|
const vm = useMockedViewModel(rest, {
|
||||||
|
onBackClick,
|
||||||
|
persistentAppComponent,
|
||||||
|
onStartMoving,
|
||||||
|
});
|
||||||
|
return <WidgetPipView vm={vm} RoomAvatar={RoomAvatarMock} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
const WidgetPipViewWrapper = withViewDocs(WidgetPipViewWrapperImpl, WidgetPipView);
|
||||||
|
|
||||||
|
export default {
|
||||||
|
title: "room/WidgetPipView",
|
||||||
|
component: WidgetPipViewWrapper,
|
||||||
|
tags: ["autodocs"],
|
||||||
|
argTypes: {},
|
||||||
|
args: {
|
||||||
|
widgetId: "xyz",
|
||||||
|
roomId: "roomId",
|
||||||
|
roomName: "Room Name",
|
||||||
|
onBackClick: fn(),
|
||||||
|
persistentAppComponent: PersistentAppMock,
|
||||||
|
onStartMoving: fn(),
|
||||||
|
},
|
||||||
|
parameters: {
|
||||||
|
design: {
|
||||||
|
type: "figma",
|
||||||
|
url: "https://www.figma.com/design/aOEkaJtaBmPy058V7uoqVr/Element-Call-Updates---Q1-2026--New-?node-id=21-31333&p=f&t=zBuFi63PKdQ0Nhab-0",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} satisfies Meta<typeof WidgetPipViewWrapper>;
|
||||||
|
|
||||||
|
const Template: StoryFn<typeof WidgetPipViewWrapper> = (args) => <WidgetPipViewWrapper {...args} />;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rendered when using a widget with just a grey background.
|
||||||
|
*/
|
||||||
|
export const WithGreyWidget = Template.bind({});
|
||||||
|
WithGreyWidget.args = {};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rendered when using a transparent background widget like Element Call.
|
||||||
|
*/
|
||||||
|
export const WithElementCallWidgetMock = Template.bind({});
|
||||||
|
const CallPill: React.FC = () => (
|
||||||
|
<div style={{ borderRadius: "50%", width: "50px", height: "50px", backgroundColor: "grey" }} />
|
||||||
|
);
|
||||||
|
WithElementCallWidgetMock.args = {
|
||||||
|
roomName: "Element Call Room",
|
||||||
|
persistentAppComponent: () => (
|
||||||
|
<div style={{ flexGrow: 1, display: "flex", flexDirection: "column" }}>
|
||||||
|
<div style={{ flexGrow: 4, backgroundColor: "gray", borderRadius: "24px" }} />
|
||||||
|
<div style={{ display: "flex", justifyContent: "space-between", margin: "10px" }}>
|
||||||
|
<CallPill />
|
||||||
|
<CallPill />
|
||||||
|
<CallPill />
|
||||||
|
<CallPill />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
};
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
/*
|
||||||
|
* 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 React from "react";
|
||||||
|
import { render } from "@test-utils";
|
||||||
|
import { composeStories } from "@storybook/react-vite";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { describe, it, vi, expect } from "vitest";
|
||||||
|
import { getByTestId } from "storybook/test";
|
||||||
|
|
||||||
|
import * as stories from "./WidgetPipView.stories.tsx";
|
||||||
|
|
||||||
|
const { WithGreyWidget } = composeStories(stories);
|
||||||
|
|
||||||
|
describe("WidgetPipView", () => {
|
||||||
|
it("renders with gray widget", () => {
|
||||||
|
const { container } = render(<WithGreyWidget />);
|
||||||
|
expect(container).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
it("detects back click action", async () => {
|
||||||
|
const onBackClick = vi.fn();
|
||||||
|
const { container, getByRole } = render(<WithGreyWidget onBackClick={onBackClick} />);
|
||||||
|
expect(container).toMatchSnapshot();
|
||||||
|
|
||||||
|
const button = getByRole("button", { name: "Back" });
|
||||||
|
await userEvent.click(button);
|
||||||
|
expect(onBackClick).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
it("detects double click triggers back", async () => {
|
||||||
|
const onBackClick = vi.fn();
|
||||||
|
const { container } = render(<WithGreyWidget onStartMoving={onBackClick} />);
|
||||||
|
expect(container).toMatchSnapshot();
|
||||||
|
|
||||||
|
const pipContainer = getByTestId(container, "widget-pip-container");
|
||||||
|
await userEvent.dblClick(pipContainer);
|
||||||
|
expect(onBackClick).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
it("detects on mouse down for drag", async () => {
|
||||||
|
const onStartMoving = vi.fn();
|
||||||
|
const { container } = render(<WithGreyWidget onStartMoving={onStartMoving} />);
|
||||||
|
expect(container).toMatchSnapshot();
|
||||||
|
|
||||||
|
const pipContainer = getByTestId(container, "widget-pip-container");
|
||||||
|
await userEvent.click(pipContainer);
|
||||||
|
expect(onStartMoving).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
/*
|
||||||
|
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 React, { type FC } from "react";
|
||||||
|
import { ChevronLeftIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||||
|
import { IconButton } from "@vector-im/compound-web";
|
||||||
|
|
||||||
|
import styles from "./WidgetPipView.module.css";
|
||||||
|
import { useViewModel, type ViewModel } from "../../viewmodel";
|
||||||
|
import { useI18n } from "../..";
|
||||||
|
|
||||||
|
export interface WidgetPipViewActions {
|
||||||
|
/**
|
||||||
|
* Call this once the back button is clicked in the pip view.
|
||||||
|
* The view model will handle navigating back to the associated room.
|
||||||
|
* @param ev The mouse event that triggered the back click.
|
||||||
|
*/
|
||||||
|
onBackClick: (ev: React.MouseEvent<Element, MouseEvent>) => void;
|
||||||
|
/**
|
||||||
|
* The view model exposes the `<PersistentApp />` component via this action.
|
||||||
|
* `PersistentApp` is not available in shared components.
|
||||||
|
* It can be any React component that renders a widget.
|
||||||
|
* It will be mounted inside the PipView.
|
||||||
|
*/
|
||||||
|
persistentAppComponent: React.FC<{
|
||||||
|
persistentWidgetId: string;
|
||||||
|
persistentRoomId: string;
|
||||||
|
}>;
|
||||||
|
/**
|
||||||
|
* Action that needs to be called when the pip view starts to get dragged.
|
||||||
|
* @param ev The mouse event that triggered the drag start.
|
||||||
|
*/
|
||||||
|
onStartMoving: (ev: React.MouseEvent<Element, MouseEvent>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WidgetPipViewSnapshot {
|
||||||
|
/**
|
||||||
|
* The widget ID this view is rendering.
|
||||||
|
*/
|
||||||
|
widgetId: string;
|
||||||
|
/**
|
||||||
|
* The room name the Pip View should use in the header.
|
||||||
|
*/
|
||||||
|
roomName: string;
|
||||||
|
/**
|
||||||
|
* The room ID this PiP view’s widget is associated with.
|
||||||
|
*/
|
||||||
|
roomId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The view model for the widget PiP view.
|
||||||
|
*/
|
||||||
|
export type WidgetPipViewModel = ViewModel<WidgetPipViewSnapshot> & WidgetPipViewActions;
|
||||||
|
|
||||||
|
export interface WidgetPipViewProps {
|
||||||
|
/**
|
||||||
|
* The WidgetPipViewModel to expose the WidgetPipViewSnapshot and to:
|
||||||
|
* - handling the back button callback.
|
||||||
|
* - exposing the persistentApp react component to the view.
|
||||||
|
*/
|
||||||
|
vm: WidgetPipViewModel;
|
||||||
|
/**
|
||||||
|
* The avatar is passed as a React component.
|
||||||
|
* This allows any avatar implementation to be used in this view (like RoomAvatar).
|
||||||
|
*/
|
||||||
|
// In the future the avatar component can/should also become a shared component.
|
||||||
|
// It would then be accessible in the shared component package and we could remove this prop.
|
||||||
|
RoomAvatar: React.FC<{ size: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A picture-in-picture view for a widget. Additional controls are shown if the
|
||||||
|
* widget represents a call.
|
||||||
|
*/
|
||||||
|
export const WidgetPipView: FC<WidgetPipViewProps> = ({ vm, RoomAvatar }) => {
|
||||||
|
const snapshot = useViewModel(vm);
|
||||||
|
const { translate: _t } = useI18n();
|
||||||
|
return (
|
||||||
|
// The interaction where we use the `onMouseDown` handler is only useful for dragging the widget around,
|
||||||
|
// which is not possible via the keyboard. The outcome of this interaction can only be changed
|
||||||
|
// if the user interacts with a mouse. Hence there is no use in providing an accessible alternative.
|
||||||
|
// In the future we might consider introducing alternative shortcuts for moving the PiP around
|
||||||
|
// with the keyboard.
|
||||||
|
// eslint-disable-next-line jsx-a11y/no-static-element-interactions
|
||||||
|
<div data-testid="widget-pip-container" className={styles.container} onMouseDown={vm.onStartMoving}>
|
||||||
|
<div className={styles.header}>
|
||||||
|
<IconButton
|
||||||
|
size="28px"
|
||||||
|
data-testid="base-card-back-button"
|
||||||
|
onClick={(ev) => vm.onBackClick(ev)}
|
||||||
|
tooltip={_t("action|back")}
|
||||||
|
kind="secondary"
|
||||||
|
>
|
||||||
|
<ChevronLeftIcon />
|
||||||
|
</IconButton>
|
||||||
|
<RoomAvatar size="20px" />
|
||||||
|
{snapshot.roomName}
|
||||||
|
</div>
|
||||||
|
<div className={styles.roundedCornerContainer}>
|
||||||
|
<vm.persistentAppComponent persistentWidgetId={snapshot.widgetId} persistentRoomId={snapshot.roomId} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
+205
@@ -0,0 +1,205 @@
|
|||||||
|
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||||
|
|
||||||
|
exports[`WidgetPipView > detects back click action 1`] = `
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
class="container"
|
||||||
|
data-testid="widget-pip-container"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="header"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
aria-labelledby="_r_6_"
|
||||||
|
class="_icon-button_1215g_8"
|
||||||
|
data-kind="secondary"
|
||||||
|
data-testid="base-card-back-button"
|
||||||
|
role="button"
|
||||||
|
style="--cpd-icon-button-size: 28px;"
|
||||||
|
tabindex="0"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="_indicator-icon_147l5_17"
|
||||||
|
style="--cpd-icon-button-size: 100%;"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
fill="currentColor"
|
||||||
|
height="1em"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
width="1em"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="m13.3 17.3-4.6-4.6a.9.9 0 0 1-.213-.325A1.1 1.1 0 0 1 8.425 12q0-.2.062-.375A.9.9 0 0 1 8.7 11.3l4.6-4.6a.95.95 0 0 1 .7-.275q.425 0 .7.275a.95.95 0 0 1 .275.7.95.95 0 0 1-.275.7L10.8 12l3.9 3.9a.95.95 0 0 1 .275.7.95.95 0 0 1-.275.7.95.95 0 0 1-.7.275.95.95 0 0 1-.7-.275"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
<div
|
||||||
|
style="width: 20px; height: 20px; border-radius: 50%; background-color: grey;"
|
||||||
|
/>
|
||||||
|
Room Name
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="roundedCornerContainer"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style="background-color: grey; flex-grow: 1;"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`WidgetPipView > detects double click triggers back 1`] = `
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
class="container"
|
||||||
|
data-testid="widget-pip-container"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="header"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
aria-labelledby="_r_c_"
|
||||||
|
class="_icon-button_1215g_8"
|
||||||
|
data-kind="secondary"
|
||||||
|
data-testid="base-card-back-button"
|
||||||
|
role="button"
|
||||||
|
style="--cpd-icon-button-size: 28px;"
|
||||||
|
tabindex="0"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="_indicator-icon_147l5_17"
|
||||||
|
style="--cpd-icon-button-size: 100%;"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
fill="currentColor"
|
||||||
|
height="1em"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
width="1em"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="m13.3 17.3-4.6-4.6a.9.9 0 0 1-.213-.325A1.1 1.1 0 0 1 8.425 12q0-.2.062-.375A.9.9 0 0 1 8.7 11.3l4.6-4.6a.95.95 0 0 1 .7-.275q.425 0 .7.275a.95.95 0 0 1 .275.7.95.95 0 0 1-.275.7L10.8 12l3.9 3.9a.95.95 0 0 1 .275.7.95.95 0 0 1-.275.7.95.95 0 0 1-.7.275.95.95 0 0 1-.7-.275"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
<div
|
||||||
|
style="width: 20px; height: 20px; border-radius: 50%; background-color: grey;"
|
||||||
|
/>
|
||||||
|
Room Name
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="roundedCornerContainer"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style="background-color: grey; flex-grow: 1;"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`WidgetPipView > detects on mouse down for drag 1`] = `
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
class="container"
|
||||||
|
data-testid="widget-pip-container"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="header"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
aria-labelledby="_r_i_"
|
||||||
|
class="_icon-button_1215g_8"
|
||||||
|
data-kind="secondary"
|
||||||
|
data-testid="base-card-back-button"
|
||||||
|
role="button"
|
||||||
|
style="--cpd-icon-button-size: 28px;"
|
||||||
|
tabindex="0"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="_indicator-icon_147l5_17"
|
||||||
|
style="--cpd-icon-button-size: 100%;"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
fill="currentColor"
|
||||||
|
height="1em"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
width="1em"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="m13.3 17.3-4.6-4.6a.9.9 0 0 1-.213-.325A1.1 1.1 0 0 1 8.425 12q0-.2.062-.375A.9.9 0 0 1 8.7 11.3l4.6-4.6a.95.95 0 0 1 .7-.275q.425 0 .7.275a.95.95 0 0 1 .275.7.95.95 0 0 1-.275.7L10.8 12l3.9 3.9a.95.95 0 0 1 .275.7.95.95 0 0 1-.275.7.95.95 0 0 1-.7.275.95.95 0 0 1-.7-.275"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
<div
|
||||||
|
style="width: 20px; height: 20px; border-radius: 50%; background-color: grey;"
|
||||||
|
/>
|
||||||
|
Room Name
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="roundedCornerContainer"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style="background-color: grey; flex-grow: 1;"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`WidgetPipView > renders with gray widget 1`] = `
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
class="container"
|
||||||
|
data-testid="widget-pip-container"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="header"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
aria-labelledby="_r_0_"
|
||||||
|
class="_icon-button_1215g_8"
|
||||||
|
data-kind="secondary"
|
||||||
|
data-testid="base-card-back-button"
|
||||||
|
role="button"
|
||||||
|
style="--cpd-icon-button-size: 28px;"
|
||||||
|
tabindex="0"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="_indicator-icon_147l5_17"
|
||||||
|
style="--cpd-icon-button-size: 100%;"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
fill="currentColor"
|
||||||
|
height="1em"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
width="1em"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="m13.3 17.3-4.6-4.6a.9.9 0 0 1-.213-.325A1.1 1.1 0 0 1 8.425 12q0-.2.062-.375A.9.9 0 0 1 8.7 11.3l4.6-4.6a.95.95 0 0 1 .7-.275q.425 0 .7.275a.95.95 0 0 1 .275.7.95.95 0 0 1-.275.7L10.8 12l3.9 3.9a.95.95 0 0 1 .275.7.95.95 0 0 1-.275.7.95.95 0 0 1-.7.275.95.95 0 0 1-.7-.275"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
<div
|
||||||
|
style="width: 20px; height: 20px; border-radius: 50%; background-color: grey;"
|
||||||
|
/>
|
||||||
|
Room Name
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="roundedCornerContainer"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style="background-color: grey; flex-grow: 1;"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
/*
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export * from "./WidgetPipView";
|
||||||
Reference in New Issue
Block a user