Add support for Widget & Room Header Buttons module APIs (#32734)
* Add support for Widget & Room Header Buttons module APIs To support https://github.com/element-hq/element-modules/pull/217 * Update for new api * Test addRoomHeaderButtonCallback * Extra mock api * Test for widgetapi * Convert enum * Convert other enum usage * Add tests for widget context menu move buttons Which have just changed because of the enum * Add tests for moving the widgets * Fix copyright Co-authored-by: Florian Duros <florianduros@element.io> * Update module API * A little import/export --------- Co-authored-by: Florian Duros <florianduros@element.io>
This commit is contained in:
@@ -42,7 +42,7 @@ import { Action } from "./dispatcher/actions";
|
||||
import { addManagedHybridWidget, isManagedHybridWidgetEnabled } from "./widgets/ManagedHybrid";
|
||||
import SdkConfig from "./SdkConfig";
|
||||
import { ensureDMExists } from "./createRoom";
|
||||
import { Container, WidgetLayoutStore } from "./stores/widgets/WidgetLayoutStore";
|
||||
import { WidgetLayoutStore } from "./stores/widgets/WidgetLayoutStore";
|
||||
import IncomingLegacyCallToast, { getIncomingLegacyCallToastKey } from "./toasts/IncomingLegacyCallToast";
|
||||
import ToastStore from "./stores/ToastStore";
|
||||
import { type ViewRoomPayload } from "./dispatcher/payloads/ViewRoomPayload";
|
||||
@@ -1027,7 +1027,7 @@ export default class LegacyCallHandler extends TypedEventEmitter<LegacyCallHandl
|
||||
// If there already is a Jitsi widget, pin it
|
||||
const room = client.getRoom(roomId);
|
||||
if (isNotNull(room)) {
|
||||
WidgetLayoutStore.instance.moveToContainer(room, widget, Container.Top);
|
||||
WidgetLayoutStore.instance.moveToContainer(room, widget, "top");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -90,7 +90,7 @@ import { CallView } from "../views/voip/CallView";
|
||||
import { UPDATE_EVENT } from "../../stores/AsyncStore";
|
||||
import Notifier from "../../Notifier";
|
||||
import { showToast as showNotificationsToast } from "../../toasts/DesktopNotificationsToast";
|
||||
import { Container, WidgetLayoutStore } from "../../stores/widgets/WidgetLayoutStore";
|
||||
import { WidgetLayoutStore } from "../../stores/widgets/WidgetLayoutStore";
|
||||
import { getKeyBindingsManager } from "../../KeyBindingsManager";
|
||||
import { objectHasDiff } from "../../utils/objects";
|
||||
import SpaceRoomView from "./SpaceRoomView";
|
||||
@@ -141,6 +141,7 @@ import { isRoomEncrypted } from "../../hooks/useIsEncrypted";
|
||||
import { type RoomViewStore } from "../../stores/RoomViewStore.tsx";
|
||||
import { RoomStatusBarViewModel } from "../../viewmodels/room/RoomStatusBar.ts";
|
||||
import { EncryptionEventViewModel } from "../../viewmodels/event-tiles/EncryptionEventViewModel.ts";
|
||||
import { ModuleApi } from "../../modules/Api.ts";
|
||||
|
||||
const DEBUG = false;
|
||||
const PREVENT_MULTIPLE_JITSI_WITHIN = 30_000;
|
||||
@@ -953,7 +954,7 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
|
||||
// Otherwise (in case the user set hideWidgetDrawer by clicking the button) follow the parameter.
|
||||
const isManuallyShown = hideWidgetDrawer ? hideWidgetDrawer === "false" : true;
|
||||
|
||||
const widgets = this.context.widgetLayoutStore.getContainerWidgets(room, Container.Top);
|
||||
const widgets = this.context.widgetLayoutStore.getContainerWidgets(room, "top");
|
||||
return isManuallyShown && widgets.length > 0;
|
||||
}
|
||||
|
||||
@@ -2727,6 +2728,12 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
|
||||
this.state.mainSplitContentType === MainSplitContentType.Call ? "video_room" : "maximised_widget";
|
||||
}
|
||||
|
||||
const extraButtons: JSX.Element[] = [];
|
||||
for (const cb of ModuleApi.instance.extras.roomHeaderButtonsCallbacks) {
|
||||
const b = cb(this.state.room.roomId);
|
||||
if (b) extraButtons.push(b);
|
||||
}
|
||||
|
||||
return (
|
||||
<ScopedRoomContextProvider {...this.state} roomViewStore={this.roomViewStore}>
|
||||
<div
|
||||
@@ -2754,7 +2761,8 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
|
||||
{!this.props.hideHeader && (
|
||||
<RoomHeader
|
||||
room={this.state.room}
|
||||
additionalButtons={this.state.viewRoomOpts.buttons}
|
||||
legacyAdditionalButtons={this.state.viewRoomOpts.buttons}
|
||||
extraButtons={<>{extraButtons}</>}
|
||||
/>
|
||||
)}
|
||||
{mainSplitBody}
|
||||
|
||||
@@ -25,7 +25,7 @@ import QuestionDialog from "../dialogs/QuestionDialog";
|
||||
import ErrorDialog from "../dialogs/ErrorDialog";
|
||||
import { WidgetType } from "../../../widgets/WidgetType";
|
||||
import MatrixClientContext from "../../../contexts/MatrixClientContext";
|
||||
import { Container, WidgetLayoutStore } from "../../../stores/widgets/WidgetLayoutStore";
|
||||
import { WidgetLayoutStore } from "../../../stores/widgets/WidgetLayoutStore";
|
||||
import { getConfigLivestreamUrl, startJitsiAudioLivestream } from "../../../Livestream";
|
||||
import { ModuleRunner } from "../../../modules/ModuleRunner";
|
||||
import { ElementWidget, type WidgetMessaging } from "../../../stores/widgets/WidgetMessaging";
|
||||
@@ -79,7 +79,7 @@ const showSnapshotButton = (widgetMessaging: WidgetMessaging | undefined): boole
|
||||
const showMoveButtons = (app: IWidget, room: Room | undefined, showUnpin: boolean | undefined): [boolean, boolean] => {
|
||||
if (!showUnpin) return [false, false];
|
||||
|
||||
const pinnedWidgets = room ? WidgetLayoutStore.instance.getContainerWidgets(room, Container.Top) : [];
|
||||
const pinnedWidgets = room ? WidgetLayoutStore.instance.getContainerWidgets(room, "top") : [];
|
||||
const widgetIndex = pinnedWidgets.findIndex((widget) => widget.id === app.id);
|
||||
return [widgetIndex > 0, widgetIndex < pinnedWidgets.length - 1];
|
||||
};
|
||||
@@ -221,7 +221,7 @@ export const WidgetContextMenu: React.FC<IProps> = ({
|
||||
if (showMoveLeftButton) {
|
||||
const onClick = (): void => {
|
||||
if (!room) throw new Error("room must be defined");
|
||||
WidgetLayoutStore.instance.moveWithinContainer(room, Container.Top, app, -1);
|
||||
WidgetLayoutStore.instance.moveWithinContainer(room, "top", app, -1);
|
||||
onFinished();
|
||||
};
|
||||
|
||||
@@ -232,7 +232,7 @@ export const WidgetContextMenu: React.FC<IProps> = ({
|
||||
if (showMoveRightButton) {
|
||||
const onClick = (): void => {
|
||||
if (!room) throw new Error("room must be defined");
|
||||
WidgetLayoutStore.instance.moveWithinContainer(room, Container.Top, app, 1);
|
||||
WidgetLayoutStore.instance.moveWithinContainer(room, "top", app, 1);
|
||||
onFinished();
|
||||
};
|
||||
|
||||
|
||||
@@ -47,7 +47,7 @@ import { ElementWidget, WidgetMessaging, WidgetMessagingEvent } from "../../../s
|
||||
import WidgetAvatar from "../avatars/WidgetAvatar";
|
||||
import LegacyCallHandler from "../../../LegacyCallHandler";
|
||||
import { type IApp, isAppWidget } from "../../../stores/WidgetStore";
|
||||
import { Container, WidgetLayoutStore } from "../../../stores/widgets/WidgetLayoutStore";
|
||||
import { WidgetLayoutStore } from "../../../stores/widgets/WidgetLayoutStore";
|
||||
import { OwnProfileStore } from "../../../stores/OwnProfileStore";
|
||||
import { UPDATE_EVENT } from "../../../stores/AsyncStore";
|
||||
import WidgetUtils from "../../../utils/WidgetUtils";
|
||||
@@ -682,21 +682,17 @@ export default class AppTile extends React.Component<IProps, IState> {
|
||||
|
||||
private onToggleMaximisedClick = (): void => {
|
||||
if (!this.props.room) return; // ignore action - it shouldn't even be visible
|
||||
const targetContainer = WidgetLayoutStore.instance.isInContainer(
|
||||
this.props.room,
|
||||
this.props.app,
|
||||
Container.Center,
|
||||
)
|
||||
? Container.Top
|
||||
: Container.Center;
|
||||
const targetContainer = WidgetLayoutStore.instance.isInContainer(this.props.room, this.props.app, "center")
|
||||
? "top"
|
||||
: "center";
|
||||
WidgetLayoutStore.instance.moveToContainer(this.props.room, this.props.app, targetContainer);
|
||||
|
||||
if (targetContainer === Container.Top) this.closeChatCardIfNeeded();
|
||||
if (targetContainer === "top") this.closeChatCardIfNeeded();
|
||||
};
|
||||
|
||||
private onMinimiseClicked = (): void => {
|
||||
if (!this.props.room) return; // ignore action - it shouldn't even be visible
|
||||
WidgetLayoutStore.instance.moveToContainer(this.props.room, this.props.app, Container.Right);
|
||||
WidgetLayoutStore.instance.moveToContainer(this.props.room, this.props.app, "right");
|
||||
this.closeChatCardIfNeeded();
|
||||
};
|
||||
|
||||
@@ -822,8 +818,7 @@ export default class AppTile extends React.Component<IProps, IState> {
|
||||
const layoutButtons: ReactNode[] = [];
|
||||
if (this.props.showLayoutButtons) {
|
||||
const isMaximised =
|
||||
this.props.room &&
|
||||
WidgetLayoutStore.instance.isInContainer(this.props.room, this.props.app, Container.Center);
|
||||
this.props.room && WidgetLayoutStore.instance.isInContainer(this.props.room, this.props.app, "center");
|
||||
|
||||
layoutButtons.push(
|
||||
<AccessibleButton
|
||||
|
||||
@@ -14,7 +14,7 @@ import { EventTileBubble } from "@element-hq/web-shared-components";
|
||||
import { _t } from "../../../languageHandler";
|
||||
import WidgetStore from "../../../stores/WidgetStore";
|
||||
import { MatrixClientPeg } from "../../../MatrixClientPeg";
|
||||
import { Container, WidgetLayoutStore } from "../../../stores/widgets/WidgetLayoutStore";
|
||||
import { WidgetLayoutStore } from "../../../stores/widgets/WidgetLayoutStore";
|
||||
|
||||
interface IProps {
|
||||
mxEvent: MatrixEvent;
|
||||
@@ -32,7 +32,7 @@ export default class MJitsiWidgetEvent extends React.PureComponent<IProps> {
|
||||
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, Container.Right)) {
|
||||
if (widget && WidgetLayoutStore.instance.isInContainer(room, widget, "right")) {
|
||||
joinCopy = _t("timeline|m.widget|jitsi_join_right_prompt");
|
||||
} else if (!widget) {
|
||||
joinCopy = null;
|
||||
|
||||
@@ -24,7 +24,7 @@ import { useContextMenu } from "../../structures/ContextMenu";
|
||||
import RightPanelStore from "../../../stores/right-panel/RightPanelStore";
|
||||
import { type IApp } from "../../../stores/WidgetStore";
|
||||
import { RightPanelPhases } from "../../../stores/right-panel/RightPanelStorePhases";
|
||||
import { Container, MAX_PINNED, WidgetLayoutStore } from "../../../stores/widgets/WidgetLayoutStore";
|
||||
import { MAX_PINNED, WidgetLayoutStore } from "../../../stores/widgets/WidgetLayoutStore";
|
||||
import AccessibleButton from "../elements/AccessibleButton";
|
||||
import WidgetAvatar from "../avatars/WidgetAvatar";
|
||||
import { IntegrationManagers } from "../../../integrations/IntegrationManagers";
|
||||
@@ -58,18 +58,18 @@ const AppRow: React.FC<IAppRowProps> = ({ app, room }) => {
|
||||
});
|
||||
};
|
||||
|
||||
const isPinned = WidgetLayoutStore.instance.isInContainer(room, app, Container.Top);
|
||||
const isPinned = WidgetLayoutStore.instance.isInContainer(room, app, "top");
|
||||
const togglePin = isPinned
|
||||
? () => {
|
||||
WidgetLayoutStore.instance.moveToContainer(room, app, Container.Right);
|
||||
WidgetLayoutStore.instance.moveToContainer(room, app, "right");
|
||||
}
|
||||
: () => {
|
||||
WidgetLayoutStore.instance.moveToContainer(room, app, Container.Top);
|
||||
WidgetLayoutStore.instance.moveToContainer(room, app, "top");
|
||||
};
|
||||
|
||||
const [menuDisplayed, handle, openMenu, closeMenu] = useContextMenu<HTMLDivElement>();
|
||||
|
||||
const cannotPin = !isPinned && !WidgetLayoutStore.instance.canAddToContainer(room, Container.Top);
|
||||
const cannotPin = !isPinned && !WidgetLayoutStore.instance.canAddToContainer(room, "top");
|
||||
|
||||
let pinTitle: string;
|
||||
if (cannotPin) {
|
||||
@@ -78,7 +78,7 @@ const AppRow: React.FC<IAppRowProps> = ({ app, room }) => {
|
||||
pinTitle = isPinned ? _t("action|unpin") : _t("action|pin");
|
||||
}
|
||||
|
||||
const isMaximised = WidgetLayoutStore.instance.isInContainer(room, app, Container.Center);
|
||||
const isMaximised = WidgetLayoutStore.instance.isInContainer(room, app, "center");
|
||||
|
||||
let openTitle = "";
|
||||
if (isPinned) {
|
||||
|
||||
@@ -15,7 +15,7 @@ import WidgetUtils, { useWidgets } from "../../../utils/WidgetUtils";
|
||||
import AppTile from "../elements/AppTile";
|
||||
import { _t } from "../../../languageHandler";
|
||||
import { ContextMenuButton, useContextMenu } from "../../structures/ContextMenu";
|
||||
import { Container, WidgetLayoutStore } from "../../../stores/widgets/WidgetLayoutStore";
|
||||
import { WidgetLayoutStore } from "../../../stores/widgets/WidgetLayoutStore";
|
||||
import RightPanelStore from "../../../stores/right-panel/RightPanelStore";
|
||||
import Heading from "../typography/Heading";
|
||||
import { WidgetContextMenu } from "../../../viewmodels/right-panel/WidgetContextMenuViewModel";
|
||||
@@ -31,7 +31,7 @@ const WidgetCard: React.FC<IProps> = ({ room, widgetId, onClose }) => {
|
||||
|
||||
const apps = useWidgets(room);
|
||||
const app = apps.find((a) => a.id === widgetId);
|
||||
const isRight = app && WidgetLayoutStore.instance.isInContainer(room, app, Container.Right);
|
||||
const isRight = app && WidgetLayoutStore.instance.isInContainer(room, app, "right");
|
||||
|
||||
const [menuDisplayed, handle, openMenu, closeMenu] = useContextMenu();
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ import type ResizeNotifier from "../../../utils/ResizeNotifier";
|
||||
import ResizeHandle from "../elements/ResizeHandle";
|
||||
import Resizer, { type IConfig } from "../../../resizer/resizer";
|
||||
import PercentageDistributor from "../../../resizer/distributors/percentage";
|
||||
import { Container, WidgetLayoutStore } from "../../../stores/widgets/WidgetLayoutStore";
|
||||
import { WidgetLayoutStore } from "../../../stores/widgets/WidgetLayoutStore";
|
||||
import UIStore from "../../../stores/UIStore";
|
||||
import { type ActionPayload } from "../../../dispatcher/payloads";
|
||||
import Spinner from "../elements/Spinner";
|
||||
@@ -39,9 +39,9 @@ interface IProps {
|
||||
|
||||
interface IState {
|
||||
apps: {
|
||||
[Container.Top]: IWidget[];
|
||||
[Container.Center]: IWidget[];
|
||||
[Container.Right]?: IWidget[];
|
||||
["top"]: IWidget[];
|
||||
["center"]: IWidget[];
|
||||
["right"]?: IWidget[];
|
||||
};
|
||||
resizingVertical: boolean; // true when changing the height of the apps drawer
|
||||
resizingHorizontal: boolean; // true when changing the distribution of the width between widgets
|
||||
@@ -119,7 +119,7 @@ export default class AppsDrawer extends React.Component<IProps, IState> {
|
||||
this.resizeContainer?.classList.remove("mx_AppsDrawer--resizing");
|
||||
WidgetLayoutStore.instance.setResizerDistributions(
|
||||
this.props.room,
|
||||
Container.Top,
|
||||
"top",
|
||||
this.topApps()
|
||||
.slice(1)
|
||||
.map((_, i) => this.resizer.forHandleAt(i)!.size),
|
||||
@@ -152,7 +152,7 @@ export default class AppsDrawer extends React.Component<IProps, IState> {
|
||||
if (prevProps.userId !== this.props.userId || prevProps.room !== this.props.room) {
|
||||
// Room has changed, update apps
|
||||
this.updateApps();
|
||||
} else if (this.getAppsHash(this.topApps()) !== this.getAppsHash(prevState.apps[Container.Top])) {
|
||||
} else if (this.getAppsHash(this.topApps()) !== this.getAppsHash(prevState.apps["top"])) {
|
||||
this.loadResizerPreferences();
|
||||
}
|
||||
}
|
||||
@@ -166,7 +166,7 @@ export default class AppsDrawer extends React.Component<IProps, IState> {
|
||||
};
|
||||
|
||||
private loadResizerPreferences = (): void => {
|
||||
const distributions = WidgetLayoutStore.instance.getResizerDistributions(this.props.room, Container.Top);
|
||||
const distributions = WidgetLayoutStore.instance.getResizerDistributions(this.props.room, "top");
|
||||
if (this.state.apps && this.topApps().length - 1 === distributions.length) {
|
||||
distributions.forEach((size, i) => {
|
||||
const distributor = this.resizer.forHandleAt(i);
|
||||
@@ -206,11 +206,11 @@ export default class AppsDrawer extends React.Component<IProps, IState> {
|
||||
};
|
||||
|
||||
private getApps = (): IState["apps"] => ({
|
||||
[Container.Top]: WidgetLayoutStore.instance.getContainerWidgets(this.props.room, Container.Top),
|
||||
[Container.Center]: WidgetLayoutStore.instance.getContainerWidgets(this.props.room, Container.Center),
|
||||
["top"]: WidgetLayoutStore.instance.getContainerWidgets(this.props.room, "top"),
|
||||
["center"]: WidgetLayoutStore.instance.getContainerWidgets(this.props.room, "center"),
|
||||
});
|
||||
private topApps = (): IWidget[] => this.state.apps[Container.Top];
|
||||
private centerApps = (): IWidget[] => this.state.apps[Container.Center];
|
||||
private topApps = (): IWidget[] => this.state.apps["top"];
|
||||
private centerApps = (): IWidget[] => this.state.apps["center"];
|
||||
|
||||
private updateApps = (): void => {
|
||||
if (this.unmounted) return;
|
||||
@@ -321,7 +321,7 @@ const PersistentVResizer: React.FC<IPersistentResizerProps> = ({
|
||||
resizeNotifier,
|
||||
children,
|
||||
}) => {
|
||||
let defaultHeight = WidgetLayoutStore.instance.getContainerHeight(room, Container.Top);
|
||||
let defaultHeight = WidgetLayoutStore.instance.getContainerHeight(room, "top");
|
||||
|
||||
// Arbitrary defaults to avoid NaN problems. 100 px or 3/4 of the visible window.
|
||||
if (!minHeight) minHeight = 100;
|
||||
@@ -352,7 +352,7 @@ const PersistentVResizer: React.FC<IPersistentResizerProps> = ({
|
||||
let newHeight = defaultHeight! + d.height;
|
||||
newHeight = percentageOf(newHeight, minHeight, maxHeight) * 100;
|
||||
|
||||
WidgetLayoutStore.instance.setContainerHeight(room, Container.Top, newHeight);
|
||||
WidgetLayoutStore.instance.setContainerHeight(room, "top", newHeight);
|
||||
|
||||
resizeNotifier.stopResizing();
|
||||
}}
|
||||
|
||||
@@ -60,10 +60,12 @@ import { useIsEncrypted } from "../../../../hooks/useIsEncrypted.ts";
|
||||
|
||||
function RoomHeaderButtons({
|
||||
room,
|
||||
additionalButtons,
|
||||
legacyAdditionalButtons,
|
||||
extraButtons,
|
||||
}: {
|
||||
room: Room;
|
||||
additionalButtons?: ViewRoomOpts["buttons"];
|
||||
legacyAdditionalButtons?: ViewRoomOpts["buttons"];
|
||||
extraButtons?: JSX.Element;
|
||||
}): JSX.Element {
|
||||
const members = useRoomMembers(room, 2500);
|
||||
const memberCount = useRoomMemberCount(room, { throttleWait: 2500, includeInvited: true });
|
||||
@@ -297,7 +299,9 @@ function RoomHeaderButtons({
|
||||
roomContext.mainSplitContentType === MainSplitContentType.Call;
|
||||
return (
|
||||
<>
|
||||
{additionalButtons?.map((props) => {
|
||||
{extraButtons}
|
||||
|
||||
{legacyAdditionalButtons?.map((props) => {
|
||||
const label = props.label();
|
||||
|
||||
return (
|
||||
@@ -427,11 +431,15 @@ function historyVisibilityIcon(historyVisibility: HistoryVisibility): JSX.Elemen
|
||||
|
||||
export default function RoomHeader({
|
||||
room,
|
||||
additionalButtons,
|
||||
extraButtons,
|
||||
legacyAdditionalButtons,
|
||||
oobData,
|
||||
}: {
|
||||
room: Room | LocalRoom;
|
||||
additionalButtons?: ViewRoomOpts["buttons"];
|
||||
// Extra buttons added by a new element web module API module
|
||||
extraButtons?: JSX.Element;
|
||||
// DEPRECATED: Buttons added by a legacy react-sdk module API module.
|
||||
legacyAdditionalButtons?: ViewRoomOpts["buttons"];
|
||||
oobData?: IOOBData;
|
||||
}): JSX.Element {
|
||||
const client = useMatrixClientContext();
|
||||
@@ -530,7 +538,11 @@ export default function RoomHeader({
|
||||
</button>
|
||||
{/* If the room is local-only then we don't want to show any additional buttons, as it won't work */}
|
||||
{room instanceof LocalRoom === false && (
|
||||
<RoomHeaderButtons room={room} additionalButtons={additionalButtons} />
|
||||
<RoomHeaderButtons
|
||||
room={room}
|
||||
legacyAdditionalButtons={legacyAdditionalButtons}
|
||||
extraButtons={extraButtons}
|
||||
/>
|
||||
)}
|
||||
</Flex>
|
||||
{askToJoinEnabled && <RoomKnocksBar room={room} />}
|
||||
|
||||
@@ -22,7 +22,7 @@ import { useCall, useConnectionState, useParticipantCount } from "../useCall";
|
||||
import { useRoomMemberCount } from "../useRoomMembers";
|
||||
import { ConnectionState } from "../../models/Call";
|
||||
import { placeCall } from "../../utils/room/placeCall";
|
||||
import { Container, WidgetLayoutStore } from "../../stores/widgets/WidgetLayoutStore";
|
||||
import { WidgetLayoutStore } from "../../stores/widgets/WidgetLayoutStore";
|
||||
import { useRoomState } from "../useRoomState";
|
||||
import { _t } from "../../languageHandler";
|
||||
import { isManagedHybridWidget, isManagedHybridWidgetEnabled } from "../../widgets/ManagedHybrid";
|
||||
@@ -214,8 +214,8 @@ export const useRoomCall = (
|
||||
widget = groupCall?.widget ?? jitsiWidget;
|
||||
}
|
||||
const updateWidgetState = useCallback((): void => {
|
||||
setCanPinWidget(WidgetLayoutStore.instance.canAddToContainer(room, Container.Top));
|
||||
setWidgetPinned(!!widget && WidgetLayoutStore.instance.isInContainer(room, widget, Container.Top));
|
||||
setCanPinWidget(WidgetLayoutStore.instance.canAddToContainer(room, "top"));
|
||||
setWidgetPinned(!!widget && WidgetLayoutStore.instance.isInContainer(room, widget, "top"));
|
||||
}, [room, widget]);
|
||||
useEventEmitter(WidgetLayoutStore.instance, WidgetLayoutStore.emissionForRoom(room), updateWidgetState);
|
||||
useEffect(() => {
|
||||
@@ -266,7 +266,7 @@ export const useRoomCall = (
|
||||
(evt: React.MouseEvent | undefined, callPlatformType: PlatformCallType): void => {
|
||||
evt?.stopPropagation();
|
||||
if (widget && promptPinWidget) {
|
||||
WidgetLayoutStore.instance.moveToContainer(room, widget, Container.Top);
|
||||
WidgetLayoutStore.instance.moveToContainer(room, widget, "top");
|
||||
} else {
|
||||
placeCall(room, CallType.Voice, callPlatformType, evt?.shiftKey || undefined, true);
|
||||
}
|
||||
@@ -277,7 +277,7 @@ export const useRoomCall = (
|
||||
(evt: React.MouseEvent | undefined, callPlatformType: PlatformCallType): void => {
|
||||
evt?.stopPropagation();
|
||||
if (widget && promptPinWidget) {
|
||||
WidgetLayoutStore.instance.moveToContainer(room, widget, Container.Top);
|
||||
WidgetLayoutStore.instance.moveToContainer(room, widget, "top");
|
||||
} else {
|
||||
// If we have pressed shift then always skip the lobby, otherwise `undefined` will defer
|
||||
// to the defaults of the call implementation.
|
||||
|
||||
@@ -31,6 +31,7 @@ import { ElementWebBuiltinsApi } from "./BuiltinsApi.tsx";
|
||||
import { ClientApi } from "./ClientApi.ts";
|
||||
import { StoresApi } from "./StoresApi.ts";
|
||||
import { WidgetLifecycleApi } from "./WidgetLifecycleApi.ts";
|
||||
import { WidgetApi } from "./WidgetApi.ts";
|
||||
import { CustomisationsApi } from "./customisationsApi.ts";
|
||||
|
||||
const legacyCustomisationsFactory = <T extends object>(baseCustomisations: T) => {
|
||||
@@ -89,6 +90,7 @@ export class ModuleApi implements Api {
|
||||
public readonly extras = new ElementWebExtrasApi();
|
||||
public readonly builtins = new ElementWebBuiltinsApi();
|
||||
public readonly widgetLifecycle = new WidgetLifecycleApi();
|
||||
public readonly widget = new WidgetApi();
|
||||
public readonly rootNode = document.getElementById("matrixchat")!;
|
||||
public readonly client = new ClientApi();
|
||||
public readonly stores = new StoresApi();
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
Copyright 2025 New Vector Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
@@ -6,7 +7,11 @@ Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { type SpacePanelItemProps, type ExtrasApi } from "@element-hq/element-web-module-api";
|
||||
import {
|
||||
type SpacePanelItemProps,
|
||||
type ExtrasApi,
|
||||
type RoomHeaderButtonsCallback,
|
||||
} from "@element-hq/element-web-module-api";
|
||||
import { TypedEventEmitter } from "matrix-js-sdk/src/matrix";
|
||||
|
||||
import { useTypedEventEmitter } from "../hooks/useEventEmitter";
|
||||
@@ -26,6 +31,7 @@ interface EmittedEvents {
|
||||
export class ElementWebExtrasApi extends TypedEventEmitter<keyof EmittedEvents, EmittedEvents> implements ExtrasApi {
|
||||
public spacePanelItems = new Map<string, SpacePanelItemProps>();
|
||||
public visibleRoomBySpaceKey = new Map<string, () => string[]>();
|
||||
public roomHeaderButtonsCallbacks: RoomHeaderButtonsCallback[] = [];
|
||||
|
||||
public setSpacePanelItem(spacekey: string, item: SpacePanelItemProps): void {
|
||||
this.spacePanelItems.set(spacekey, item);
|
||||
@@ -35,6 +41,10 @@ export class ElementWebExtrasApi extends TypedEventEmitter<keyof EmittedEvents,
|
||||
public getVisibleRoomBySpaceKey(spaceKey: string, cb: () => string[]): void {
|
||||
this.visibleRoomBySpaceKey.set(spaceKey, cb);
|
||||
}
|
||||
|
||||
public addRoomHeaderButtonCallback(cb: RoomHeaderButtonsCallback): void {
|
||||
this.roomHeaderButtonsCallbacks.push(cb);
|
||||
}
|
||||
}
|
||||
|
||||
export function useModuleSpacePanelItems(api: ElementWebExtrasApi): ModuleSpacePanelItem[] {
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
/*
|
||||
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 Container, type WidgetApi as WidgetApiInterface } from "@element-hq/element-web-module-api";
|
||||
import { getHttpUriForMxc } from "matrix-js-sdk/src/matrix";
|
||||
|
||||
import type { IWidget } from "matrix-widget-api";
|
||||
import WidgetStore, { isAppWidget } from "../stores/WidgetStore";
|
||||
import { MatrixClientPeg } from "../MatrixClientPeg";
|
||||
import { WidgetLayoutStore } from "../stores/widgets/WidgetLayoutStore";
|
||||
|
||||
/**
|
||||
* Host-side implementation of the widget API.
|
||||
* Allows modules to interact with widgets, including listing widgets in rooms.
|
||||
*/
|
||||
export class WidgetApi implements WidgetApiInterface {
|
||||
public getWidgetsInRoom(roomId: string): IWidget[] {
|
||||
return WidgetStore.instance.getApps(roomId);
|
||||
}
|
||||
|
||||
public getAppAvatarUrl(app: IWidget, width?: number, height?: number, resizeMethod?: string): string | null {
|
||||
if (!isAppWidget(app) || !app.avatar_url) return null;
|
||||
return getHttpUriForMxc(
|
||||
MatrixClientPeg.safeGet().getHomeserverUrl(),
|
||||
app.avatar_url,
|
||||
width,
|
||||
height,
|
||||
resizeMethod,
|
||||
);
|
||||
}
|
||||
|
||||
public isAppInContainer(app: IWidget, container: Container, roomId: string): boolean {
|
||||
const room = MatrixClientPeg.safeGet().getRoom(roomId);
|
||||
if (!room) return false;
|
||||
return WidgetLayoutStore.instance.isInContainer(room, app, container);
|
||||
}
|
||||
|
||||
public moveAppToContainer(app: IWidget, container: Container, roomId: string): void {
|
||||
const room = MatrixClientPeg.safeGet().getRoom(roomId);
|
||||
if (!room) return;
|
||||
WidgetLayoutStore.instance.moveToContainer(room, app, container);
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import { type Room, RoomStateEvent, type MatrixEvent } from "matrix-js-sdk/src/m
|
||||
import { MapWithDefault, recursiveMapToObject } from "matrix-js-sdk/src/utils";
|
||||
import { type IWidget } from "matrix-widget-api";
|
||||
import { clamp, defaultNumber, sum } from "@element-hq/web-shared-components";
|
||||
import { type Container } from "@element-hq/element-web-module-api";
|
||||
|
||||
import SettingsStore from "../../settings/SettingsStore";
|
||||
import WidgetStore, { type IApp } from "../WidgetStore";
|
||||
@@ -19,16 +20,10 @@ import { ReadyWatchingStore } from "../ReadyWatchingStore";
|
||||
import { SettingLevel } from "../../settings/SettingLevel";
|
||||
import { arrayFastClone } from "../../utils/arrays";
|
||||
import { UPDATE_EVENT } from "../AsyncStore";
|
||||
import {
|
||||
Container,
|
||||
type IStoredLayout,
|
||||
type ILayoutStateEvent,
|
||||
WIDGET_LAYOUT_EVENT_TYPE,
|
||||
type IWidgetLayouts,
|
||||
} from "./types";
|
||||
import { type IStoredLayout, type ILayoutStateEvent, WIDGET_LAYOUT_EVENT_TYPE, type IWidgetLayouts } from "./types";
|
||||
|
||||
export type { IStoredLayout, ILayoutStateEvent };
|
||||
export { Container, WIDGET_LAYOUT_EVENT_TYPE };
|
||||
export { type Container, WIDGET_LAYOUT_EVENT_TYPE };
|
||||
|
||||
export type ILayoutSettings = Partial<ILayoutStateEvent> & {
|
||||
overrides?: string; // event ID for layout state event, if present
|
||||
@@ -173,8 +168,8 @@ export class WidgetLayoutStore extends ReadyWatchingStore {
|
||||
const stateContainer = roomLayout?.widgets?.[widget.id]?.container;
|
||||
const manualContainer = userLayout?.widgets?.[widget.id]?.container;
|
||||
const isLegacyPinned = !!legacyPinned?.[widget.id];
|
||||
const defaultContainer = WidgetType.JITSI.matches(widget.type) ? Container.Top : Container.Right;
|
||||
if (manualContainer ? manualContainer === Container.Center : stateContainer === Container.Center) {
|
||||
const defaultContainer = WidgetType.JITSI.matches(widget.type) ? "top" : "right";
|
||||
if (manualContainer ? manualContainer === "center" : stateContainer === "center") {
|
||||
if (centerWidgets.length) {
|
||||
console.error("Tried to push a second widget into the center container");
|
||||
} else {
|
||||
@@ -188,9 +183,9 @@ export class WidgetLayoutStore extends ReadyWatchingStore {
|
||||
targetContainer = manualContainer ?? stateContainer!;
|
||||
} else if (isLegacyPinned && !stateContainer) {
|
||||
// Special legacy case
|
||||
targetContainer = Container.Top;
|
||||
targetContainer = "top";
|
||||
}
|
||||
(targetContainer === Container.Top ? topWidgets : rightWidgets).push(widget);
|
||||
(targetContainer === "top" ? topWidgets : rightWidgets).push(widget);
|
||||
}
|
||||
|
||||
// Trim to MAX_PINNED
|
||||
@@ -291,19 +286,19 @@ export class WidgetLayoutStore extends ReadyWatchingStore {
|
||||
const newRoomContainers = new Map();
|
||||
this.byRoom.set(room.roomId, newRoomContainers);
|
||||
if (topWidgets.length) {
|
||||
newRoomContainers.set(Container.Top, {
|
||||
newRoomContainers.set("top", {
|
||||
ordered: topWidgets,
|
||||
distributions: widths,
|
||||
height: maxHeight,
|
||||
});
|
||||
}
|
||||
if (rightWidgets.length) {
|
||||
newRoomContainers.set(Container.Right, {
|
||||
newRoomContainers.set("right", {
|
||||
ordered: rightWidgets,
|
||||
});
|
||||
}
|
||||
if (centerWidgets.length) {
|
||||
newRoomContainers.set(Container.Center, {
|
||||
newRoomContainers.set("center", {
|
||||
ordered: centerWidgets,
|
||||
});
|
||||
}
|
||||
@@ -325,11 +320,11 @@ export class WidgetLayoutStore extends ReadyWatchingStore {
|
||||
|
||||
public canAddToContainer(room: Room, container: Container): boolean {
|
||||
switch (container) {
|
||||
case Container.Top:
|
||||
case "top":
|
||||
return this.getContainerWidgets(room, container).length < MAX_PINNED;
|
||||
case Container.Right:
|
||||
case "right":
|
||||
return this.getContainerWidgets(room, container).length < MAX_PINNED;
|
||||
case Container.Center:
|
||||
case "center":
|
||||
return this.getContainerWidgets(room, container).length < 1;
|
||||
}
|
||||
}
|
||||
@@ -349,7 +344,7 @@ export class WidgetLayoutStore extends ReadyWatchingStore {
|
||||
}
|
||||
|
||||
public setResizerDistributions(room: Room, container: Container, distributions: string[]): void {
|
||||
if (container !== Container.Top) return; // ignore - not relevant
|
||||
if (container !== "top") return; // ignore - not relevant
|
||||
|
||||
const numbers = distributions.map((d) => Number(Number(d.substring(0, d.length - 1)).toFixed(1)));
|
||||
const widgets = this.getContainerWidgets(room, container);
|
||||
@@ -419,23 +414,23 @@ export class WidgetLayoutStore extends ReadyWatchingStore {
|
||||
// Prepare other containers (potentially move widgets to obey the following rules)
|
||||
const newLayout: Record<string, IStoredLayout> = {};
|
||||
switch (toContainer) {
|
||||
case Container.Right:
|
||||
case "right":
|
||||
// new "right" widget
|
||||
break;
|
||||
case Container.Center:
|
||||
case "center":
|
||||
// new "center" widget => all other widgets go into "right"
|
||||
for (const w of this.getContainerWidgets(room, Container.Top)) {
|
||||
newLayout[w.id] = { container: Container.Right };
|
||||
for (const w of this.getContainerWidgets(room, "top")) {
|
||||
newLayout[w.id] = { container: "right" };
|
||||
}
|
||||
for (const w of this.getContainerWidgets(room, Container.Center)) {
|
||||
newLayout[w.id] = { container: Container.Right };
|
||||
for (const w of this.getContainerWidgets(room, "center")) {
|
||||
newLayout[w.id] = { container: "right" };
|
||||
}
|
||||
break;
|
||||
case Container.Top:
|
||||
case "top":
|
||||
// new "top" widget => the center widget moves into "right"
|
||||
if (this.hasMaximisedWidget(room)) {
|
||||
const centerWidget = this.getContainerWidgets(room, Container.Center)[0];
|
||||
newLayout[centerWidget.id] = { container: Container.Right };
|
||||
const centerWidget = this.getContainerWidgets(room, "center")[0];
|
||||
newLayout[centerWidget.id] = { container: "right" };
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -447,11 +442,11 @@ export class WidgetLayoutStore extends ReadyWatchingStore {
|
||||
}
|
||||
|
||||
public hasMaximisedWidget(room: Room): boolean {
|
||||
return this.getContainerWidgets(room, Container.Center).length > 0;
|
||||
return this.getContainerWidgets(room, "center").length > 0;
|
||||
}
|
||||
|
||||
public hasPinnedWidgets(room: Room): boolean {
|
||||
return this.getContainerWidgets(room, Container.Top).length > 0;
|
||||
return this.getContainerWidgets(room, "top").length > 0;
|
||||
}
|
||||
|
||||
public canCopyLayoutToRoom(room: Room): boolean {
|
||||
@@ -464,7 +459,7 @@ export class WidgetLayoutStore extends ReadyWatchingStore {
|
||||
const evContent: ILayoutStateEvent = { widgets: {} };
|
||||
for (const [widget, container] of allWidgets) {
|
||||
evContent.widgets[widget.id] = { container };
|
||||
if (container === Container.Top) {
|
||||
if (container === "top") {
|
||||
const containerWidgets = this.getContainerWidgets(room, container);
|
||||
const idx = containerWidgets.findIndex((w) => w.id === widget.id);
|
||||
const widths = this.byRoom.get(room.roomId)?.get(container)?.distributions;
|
||||
|
||||
@@ -6,6 +6,8 @@ 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 { type Container } from "@element-hq/element-web-module-api";
|
||||
|
||||
export interface IStoredLayout {
|
||||
// Where to store the widget. Required.
|
||||
container: Container;
|
||||
@@ -42,15 +44,3 @@ export interface ILayoutStateEvent {
|
||||
}
|
||||
|
||||
export const WIDGET_LAYOUT_EVENT_TYPE = "io.element.widgets.layout";
|
||||
|
||||
export enum Container {
|
||||
// "Top" is the app drawer, and currently the only sensible value.
|
||||
Top = "top",
|
||||
|
||||
// "Right" is the right panel, and the default for widgets. Setting
|
||||
// this as a container on a widget is essentially like saying "no
|
||||
// changes needed", though this may change in the future.
|
||||
Right = "right",
|
||||
|
||||
Center = "center",
|
||||
}
|
||||
|
||||
@@ -25,7 +25,6 @@ import { _t } from "../../languageHandler";
|
||||
import { getConfigLivestreamUrl, startJitsiAudioLivestream } from "../../Livestream";
|
||||
import Modal from "../../Modal";
|
||||
import SettingsStore from "../../settings/SettingsStore";
|
||||
import { Container } from "../../stores/widgets/types";
|
||||
import { WidgetLayoutStore } from "../../stores/widgets/WidgetLayoutStore";
|
||||
import { WidgetMessagingStore } from "../../stores/widgets/WidgetMessagingStore";
|
||||
import { isAppWidget } from "../../stores/WidgetStore";
|
||||
@@ -109,7 +108,7 @@ export class WidgetContextMenuViewModel
|
||||
|
||||
let showMoveButtons: [boolean, boolean] = [false, false];
|
||||
if (showUnpin) {
|
||||
const pinnedWidgets = room ? WidgetLayoutStore.instance.getContainerWidgets(room, Container.Top) : [];
|
||||
const pinnedWidgets = room ? WidgetLayoutStore.instance.getContainerWidgets(room, "top") : [];
|
||||
const widgetIndex = pinnedWidgets.findIndex((widget) => widget.id === app.id);
|
||||
showMoveButtons = [widgetIndex > 0, widgetIndex < pinnedWidgets.length - 1];
|
||||
}
|
||||
@@ -225,7 +224,7 @@ export class WidgetContextMenuViewModel
|
||||
public get onMoveButton(): (direction: number) => void {
|
||||
return (direction: number) => {
|
||||
if (!this._room) throw new Error("room must be defined");
|
||||
WidgetLayoutStore.instance.moveWithinContainer(this._room, Container.Top, this._app, direction);
|
||||
WidgetLayoutStore.instance.moveWithinContainer(this._room, "top", this._app, direction);
|
||||
this.props.onFinished!();
|
||||
};
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ 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 { WidgetLayoutStore } from "../../stores/widgets/WidgetLayoutStore";
|
||||
import PersistentApp from "../../components/views/elements/PersistentApp";
|
||||
|
||||
export interface Props {
|
||||
@@ -91,7 +91,7 @@ export class WidgetPipViewModel
|
||||
metricsTrigger: "WebFloatingCallWindow",
|
||||
});
|
||||
} else if (this.viewingRoom) {
|
||||
WidgetLayoutStore.instance.moveToContainer(this.props.room, this.widget, Container.Center);
|
||||
WidgetLayoutStore.instance.moveToContainer(this.props.room, this.widget, "center");
|
||||
} else {
|
||||
defaultDispatcher.dispatch<ViewRoomPayload>({
|
||||
action: Action.ViewRoom,
|
||||
|
||||
@@ -680,11 +680,13 @@ export function mkStubRoom(
|
||||
maySendStateEvent: jest.fn().mockReturnValue(true),
|
||||
maySendRedactionForEvent: jest.fn().mockReturnValue(true),
|
||||
maySendEvent: jest.fn().mockReturnValue(true),
|
||||
maySendMessage: jest.fn().mockReturnValue(true),
|
||||
members: {},
|
||||
getHistoryVisibility: jest.fn().mockReturnValue(HistoryVisibility.Shared),
|
||||
getJoinRule: jest.fn().mockReturnValue(JoinRule.Invite),
|
||||
on: jest.fn(),
|
||||
off: jest.fn(),
|
||||
removeListener: jest.fn(),
|
||||
} as unknown as RoomState,
|
||||
eventShouldLiveIn: jest.fn().mockReturnValue({ shouldLiveInRoom: true, shouldLiveInThread: false }),
|
||||
fetchRoomThreads: jest.fn().mockReturnValue(Promise.resolve()),
|
||||
@@ -713,6 +715,7 @@ export function mkStubRoom(
|
||||
isKicked: () => false,
|
||||
}),
|
||||
getMembers: jest.fn().mockReturnValue([]),
|
||||
getEncryptionTargetMembers: jest.fn().mockReturnValue([]),
|
||||
getMembersWithMembership: jest.fn().mockReturnValue([]),
|
||||
getMxcAvatarUrl: () => "mxc://avatar.url/room.png",
|
||||
getMyMembership: jest.fn().mockReturnValue(KnownMembership.Join),
|
||||
|
||||
@@ -43,7 +43,7 @@ import { Action } from "../../../../src/dispatcher/actions";
|
||||
import { type ViewRoomPayload } from "../../../../src/dispatcher/payloads/ViewRoomPayload";
|
||||
import { TestSdkContext } from "../../TestSdkContext";
|
||||
import { RoomViewStore } from "../../../../src/stores/RoomViewStore";
|
||||
import { Container, WidgetLayoutStore } from "../../../../src/stores/widgets/WidgetLayoutStore";
|
||||
import { WidgetLayoutStore } from "../../../../src/stores/widgets/WidgetLayoutStore";
|
||||
import WidgetStore from "../../../../src/stores/WidgetStore";
|
||||
import { WidgetType } from "../../../../src/widgets/WidgetType";
|
||||
import { SdkContextClass } from "../../../../src/contexts/SDKContext";
|
||||
@@ -234,7 +234,7 @@ describe("PipContainer", () => {
|
||||
// The return button should maximize the widget
|
||||
const moveSpy = jest.spyOn(WidgetLayoutStore.instance, "moveToContainer");
|
||||
await user.click(await screen.findByRole("button", { name: "Back" }));
|
||||
expect(moveSpy).toHaveBeenCalledWith(room, widget, Container.Center);
|
||||
expect(moveSpy).toHaveBeenCalledWith(room, widget, "center");
|
||||
|
||||
expect(screen.queryByRole("button", { name: "Leave" })).toBeNull();
|
||||
});
|
||||
|
||||
@@ -10,7 +10,7 @@ Please see LICENSE files in the repository root for full details.
|
||||
import React, { type JSX, type ComponentProps } from "react";
|
||||
import { screen, render } from "jest-matrix-react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { type MatrixClient } from "matrix-js-sdk/src/matrix";
|
||||
import { type Room, type MatrixClient } from "matrix-js-sdk/src/matrix";
|
||||
import { MatrixWidgetType } from "matrix-widget-api";
|
||||
import {
|
||||
type ApprovalOpts,
|
||||
@@ -24,6 +24,10 @@ import MatrixClientContext from "../../../../../src/contexts/MatrixClientContext
|
||||
import WidgetUtils from "../../../../../src/utils/WidgetUtils";
|
||||
import { ModuleRunner } from "../../../../../src/modules/ModuleRunner";
|
||||
import SettingsStore from "../../../../../src/settings/SettingsStore";
|
||||
import { WidgetLayoutStore } from "../../../../../src/stores/widgets/WidgetLayoutStore";
|
||||
import { mkStubRoom } from "../../../../test-utils/test-utils.ts";
|
||||
import { type RoomContextType } from "../../../../../src/contexts/RoomContext.ts";
|
||||
import { ScopedRoomContextProvider } from "../../../../../src/contexts/ScopedRoomContext.tsx";
|
||||
|
||||
describe("<WidgetContextMenu />", () => {
|
||||
const widgetId = "w1";
|
||||
@@ -44,8 +48,12 @@ describe("<WidgetContextMenu />", () => {
|
||||
|
||||
let mockClient: MatrixClient;
|
||||
|
||||
let room: Room;
|
||||
|
||||
let onFinished: () => void;
|
||||
|
||||
let roomContext: RoomContextType;
|
||||
|
||||
beforeEach(() => {
|
||||
onFinished = jest.fn();
|
||||
jest.spyOn(WidgetUtils, "canUserModifyWidgets").mockReturnValue(true);
|
||||
@@ -53,6 +61,13 @@ describe("<WidgetContextMenu />", () => {
|
||||
mockClient = {
|
||||
getUserId: jest.fn().mockReturnValue(userId),
|
||||
} as unknown as MatrixClient;
|
||||
|
||||
room = mkStubRoom(roomId, "Test Room", mockClient);
|
||||
|
||||
roomContext = {
|
||||
room,
|
||||
roomId,
|
||||
} as unknown as RoomContextType;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -62,7 +77,9 @@ describe("<WidgetContextMenu />", () => {
|
||||
function getComponent(props: Partial<ComponentProps<typeof WidgetContextMenu>> = {}): JSX.Element {
|
||||
return (
|
||||
<MatrixClientContext.Provider value={mockClient}>
|
||||
<WidgetContextMenu app={app} onFinished={onFinished} {...props} />
|
||||
<ScopedRoomContextProvider {...roomContext}>
|
||||
<WidgetContextMenu app={app} onFinished={onFinished} {...props} />
|
||||
</ScopedRoomContextProvider>
|
||||
</MatrixClientContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -89,4 +106,69 @@ describe("<WidgetContextMenu />", () => {
|
||||
expect(onFinished).toHaveBeenCalled();
|
||||
expect(SettingsStore.getValue("allowedWidgets", roomId)[eventId]).toBe(false);
|
||||
});
|
||||
|
||||
it("shows the move left button when the widget can be moved left", () => {
|
||||
// Place our widget second so it can move left but not right.
|
||||
jest.spyOn(WidgetLayoutStore.instance, "getContainerWidgets").mockReturnValue([
|
||||
{ id: "someOtherWidget", type: "m.custom", creatorUserId: userId, url: "" },
|
||||
{ id: widgetId, type: "m.custom", creatorUserId: userId, url: "" },
|
||||
]);
|
||||
|
||||
render(getComponent({ showUnpin: true }));
|
||||
|
||||
expect(screen.getByLabelText("Move left")).toBeInTheDocument();
|
||||
expect(screen.queryByLabelText("Move right")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the move right button when the widget can be moved right", () => {
|
||||
// Place our widget first so it can move right but not left.
|
||||
jest.spyOn(WidgetLayoutStore.instance, "getContainerWidgets").mockReturnValue([
|
||||
{ id: widgetId, type: "m.custom", creatorUserId: userId, url: "" },
|
||||
{ id: "someOtherWidget", type: "m.custom", creatorUserId: userId, url: "" },
|
||||
]);
|
||||
|
||||
render(getComponent({ showUnpin: true }));
|
||||
|
||||
expect(screen.getByLabelText("Move right")).toBeInTheDocument();
|
||||
expect(screen.queryByLabelText("Move left")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("moves widget left when move left button is clicked", async () => {
|
||||
// Place our widget second so move left is visible.
|
||||
jest.spyOn(WidgetLayoutStore.instance, "getContainerWidgets").mockReturnValue([
|
||||
{ id: "someOtherWidget", type: "m.custom", creatorUserId: userId, url: "" },
|
||||
{ id: widgetId, type: "m.custom", creatorUserId: userId, url: "" },
|
||||
]);
|
||||
|
||||
// Mock moveWithinContainer to verify it's called with the correct arguments.
|
||||
const moveWithinContainerSpy = jest
|
||||
.spyOn(WidgetLayoutStore.instance, "moveWithinContainer")
|
||||
.mockImplementation();
|
||||
|
||||
render(getComponent({ showUnpin: true }));
|
||||
|
||||
await userEvent.click(screen.getByLabelText("Move left"));
|
||||
|
||||
expect(moveWithinContainerSpy).toHaveBeenCalledWith(room, "top", app, -1);
|
||||
expect(onFinished).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("moves widget right when move right button is clicked", async () => {
|
||||
// Place our widget first so move right is visible.
|
||||
jest.spyOn(WidgetLayoutStore.instance, "getContainerWidgets").mockReturnValue([
|
||||
{ id: widgetId, type: "m.custom", creatorUserId: userId, url: "" },
|
||||
{ id: "someOtherWidget", type: "m.custom", creatorUserId: userId, url: "" },
|
||||
]);
|
||||
|
||||
// Mock moveWithinContainer to verify it's called with the correct arguments.
|
||||
const moveWithinContainerSpy = jest
|
||||
.spyOn(WidgetLayoutStore.instance, "moveWithinContainer")
|
||||
.mockImplementation();
|
||||
|
||||
render(getComponent({ showUnpin: true }));
|
||||
await userEvent.click(screen.getByLabelText("Move right"));
|
||||
|
||||
expect(moveWithinContainerSpy).toHaveBeenCalledWith(room, "top", app, 1);
|
||||
expect(onFinished).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -31,7 +31,7 @@ import RightPanelStore from "../../../../../src/stores/right-panel/RightPanelSto
|
||||
import WidgetStore, { type IApp } from "../../../../../src/stores/WidgetStore";
|
||||
import ActiveWidgetStore from "../../../../../src/stores/ActiveWidgetStore";
|
||||
import AppTile from "../../../../../src/components/views/elements/AppTile";
|
||||
import { Container, WidgetLayoutStore } from "../../../../../src/stores/widgets/WidgetLayoutStore";
|
||||
import { type Container, WidgetLayoutStore } from "../../../../../src/stores/widgets/WidgetLayoutStore";
|
||||
import AppsDrawer from "../../../../../src/components/views/rooms/AppsDrawer";
|
||||
import { ElementWidgetCapabilities } from "../../../../../src/stores/widgets/ElementWidgetCapabilities";
|
||||
import { ElementWidget, type WidgetMessaging } from "../../../../../src/stores/widgets/WidgetMessaging";
|
||||
@@ -303,7 +303,7 @@ describe("AppTile", () => {
|
||||
return {
|
||||
widgets: {
|
||||
1: {
|
||||
container: Container.Top,
|
||||
container: "top",
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -334,7 +334,7 @@ describe("AppTile", () => {
|
||||
mockSettings.mockRestore();
|
||||
act(() => {
|
||||
// Move widget to center
|
||||
WidgetLayoutStore.instance.moveToContainer(r1, app1, Container.Center);
|
||||
WidgetLayoutStore.instance.moveToContainer(r1, app1, "center");
|
||||
});
|
||||
|
||||
expect(renderResult.getByText("Example 1")).toBeInTheDocument();
|
||||
@@ -377,7 +377,7 @@ describe("AppTile", () => {
|
||||
);
|
||||
await waitForElementToBeRemoved(() => renderResult.queryByRole("progressbar"));
|
||||
await userEvent.click(renderResult.getByLabelText("Minimise"));
|
||||
expect(moveToContainerSpy).toHaveBeenCalledWith(r1, app1, Container.Right);
|
||||
expect(moveToContainerSpy).toHaveBeenCalledWith(r1, app1, "right");
|
||||
});
|
||||
|
||||
it("clicking 'maximise' should send the widget to the center", async () => {
|
||||
@@ -388,7 +388,7 @@ describe("AppTile", () => {
|
||||
);
|
||||
await waitForElementToBeRemoved(() => renderResult.queryByRole("progressbar"));
|
||||
await userEvent.click(renderResult.getByLabelText("Maximise"));
|
||||
expect(moveToContainerSpy).toHaveBeenCalledWith(r1, app1, Container.Center);
|
||||
expect(moveToContainerSpy).toHaveBeenCalledWith(r1, app1, "center");
|
||||
});
|
||||
|
||||
it("should render permission request", async () => {
|
||||
@@ -455,7 +455,7 @@ describe("AppTile", () => {
|
||||
beforeEach(() => {
|
||||
jest.spyOn(WidgetLayoutStore.instance, "isInContainer").mockImplementation(
|
||||
(room: Room | null, widget: IWidget, container: Container) => {
|
||||
return room === r1 && widget === app1 && container === Container.Center;
|
||||
return room === r1 && widget === app1 && container === "center";
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -472,7 +472,7 @@ describe("AppTile", () => {
|
||||
);
|
||||
await waitForElementToBeRemoved(() => renderResult.queryByRole("progressbar"));
|
||||
await userEvent.click(renderResult.getByLabelText("Un-maximise"));
|
||||
expect(moveToContainerSpy).toHaveBeenCalledWith(r1, app1, Container.Top);
|
||||
expect(moveToContainerSpy).toHaveBeenCalledWith(r1, app1, "top");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -53,7 +53,7 @@ import dispatcher from "../../../../../../src/dispatcher/dispatcher";
|
||||
import { CallStore } from "../../../../../../src/stores/CallStore";
|
||||
import { type Call } from "../../../../../../src/models/Call";
|
||||
import * as ShieldUtils from "../../../../../../src/utils/ShieldUtils";
|
||||
import { Container, WidgetLayoutStore } from "../../../../../../src/stores/widgets/WidgetLayoutStore";
|
||||
import { WidgetLayoutStore } from "../../../../../../src/stores/widgets/WidgetLayoutStore";
|
||||
import MatrixClientContext from "../../../../../../src/contexts/MatrixClientContext";
|
||||
import { _t } from "../../../../../../src/languageHandler";
|
||||
import WidgetStore, { type IApp } from "../../../../../../src/stores/WidgetStore";
|
||||
@@ -504,7 +504,7 @@ describe("RoomHeader", () => {
|
||||
const videoButton = screen.getByRole("button", { name: "Video call" });
|
||||
expect(videoButton).not.toHaveAttribute("aria-disabled", "true");
|
||||
await user.click(videoButton);
|
||||
expect(spy).toHaveBeenCalledWith(room, widget, Container.Top);
|
||||
expect(spy).toHaveBeenCalledWith(room, widget, "top");
|
||||
});
|
||||
|
||||
it("disables calling if there's a jitsi call", () => {
|
||||
@@ -869,7 +869,7 @@ describe("RoomHeader", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("renders additionalButtons", async () => {
|
||||
it("renders legacy additionalButtons", async () => {
|
||||
const additionalButtons: ViewRoomOpts["buttons"] = [
|
||||
{
|
||||
icon: () => <>test-icon</>,
|
||||
@@ -878,11 +878,11 @@ describe("RoomHeader", () => {
|
||||
onClick: () => {},
|
||||
},
|
||||
];
|
||||
render(<RoomHeader room={room} additionalButtons={additionalButtons} />, getWrapper());
|
||||
render(<RoomHeader room={room} legacyAdditionalButtons={additionalButtons} />, getWrapper());
|
||||
expect(screen.getByRole("button", { name: "test-label" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("calls onClick-callback on additionalButtons", () => {
|
||||
it("calls onClick-callback on legacyAdditionalButtons", () => {
|
||||
const callback = jest.fn();
|
||||
const additionalButtons: ViewRoomOpts["buttons"] = [
|
||||
{
|
||||
@@ -893,7 +893,7 @@ describe("RoomHeader", () => {
|
||||
},
|
||||
];
|
||||
|
||||
render(<RoomHeader room={room} additionalButtons={additionalButtons} />, getWrapper());
|
||||
render(<RoomHeader room={room} legacyAdditionalButtons={additionalButtons} />, getWrapper());
|
||||
|
||||
const button = screen.getByRole("button", { name: "test-label" });
|
||||
const event = createEvent.click(button);
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
/*
|
||||
Copyright 2026 New Vector 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, { act } from "react";
|
||||
import { render, type RenderOptions } from "jest-matrix-react";
|
||||
import { type MatrixClient, PendingEventOrdering, Room } from "matrix-js-sdk/src/matrix";
|
||||
import { EventEmitter } from "events";
|
||||
|
||||
import { stubClient } from "../../test-utils";
|
||||
import DMRoomMap from "../../../src/utils/DMRoomMap";
|
||||
import { SDKContext, SdkContextClass } from "../../../src/contexts/SDKContext";
|
||||
import { ScopedRoomContextProvider } from "../../../src/contexts/ScopedRoomContext";
|
||||
import RoomContext, { type RoomContextType } from "../../../src/contexts/RoomContext";
|
||||
import MatrixClientContext from "../../../src/contexts/MatrixClientContext";
|
||||
import { RoomView } from "../../../src/components/structures/RoomView";
|
||||
import { ModuleApi } from "../../../src/modules/Api";
|
||||
|
||||
describe("ExtrasApi", () => {
|
||||
let client: MatrixClient;
|
||||
let sdkContext: SdkContextClass;
|
||||
let room: Room;
|
||||
let roomContext: RoomContextType;
|
||||
|
||||
beforeEach(() => {
|
||||
client = stubClient();
|
||||
room = new Room("!test:room", client, "@alice:example.org", {
|
||||
pendingEventOrdering: PendingEventOrdering.Detached,
|
||||
});
|
||||
sdkContext = new SdkContextClass();
|
||||
sdkContext.client = client;
|
||||
jest.spyOn(sdkContext.roomViewStore, "getRoomId").mockReturnValue(room.roomId);
|
||||
|
||||
const mockRoomViewStore = new (class extends EventEmitter {
|
||||
isViewingCall = jest.fn().mockReturnValue(false);
|
||||
})();
|
||||
|
||||
roomContext = {
|
||||
...RoomContext,
|
||||
roomId: "!test:room",
|
||||
roomViewStore: mockRoomViewStore,
|
||||
} as unknown as RoomContextType;
|
||||
|
||||
DMRoomMap.setShared({
|
||||
getUserIdForRoomId: jest.fn(),
|
||||
getRoomIds: jest.fn().mockReturnValue(new Set()),
|
||||
} as unknown as DMRoomMap);
|
||||
});
|
||||
|
||||
function getWrapper(): RenderOptions {
|
||||
return {
|
||||
wrapper: ({ children }) => (
|
||||
<SDKContext.Provider value={sdkContext}>
|
||||
<ScopedRoomContextProvider {...roomContext}>
|
||||
<MatrixClientContext.Provider value={client}>{children}</MatrixClientContext.Provider>
|
||||
</ScopedRoomContextProvider>
|
||||
</SDKContext.Provider>
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
it("addRoomHeaderButtonCallback stores and uses the provided callback", () => {
|
||||
const callback = jest.fn();
|
||||
ModuleApi.instance.extras.addRoomHeaderButtonCallback(callback);
|
||||
|
||||
render(<RoomView />, getWrapper());
|
||||
|
||||
act(() => {
|
||||
sdkContext.roomViewStore.emit("update");
|
||||
});
|
||||
|
||||
expect(callback).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -23,7 +23,7 @@ import { registerMockModule } from "./MockModule";
|
||||
import defaultDispatcher from "../../../src/dispatcher/dispatcher";
|
||||
import { Action } from "../../../src/dispatcher/actions";
|
||||
import WidgetStore, { type IApp } from "../../../src/stores/WidgetStore";
|
||||
import { Container, WidgetLayoutStore } from "../../../src/stores/widgets/WidgetLayoutStore";
|
||||
import { WidgetLayoutStore } from "../../../src/stores/widgets/WidgetLayoutStore";
|
||||
import * as navigator from "../../../src/utils/permalinks/navigator.ts";
|
||||
|
||||
describe("ProxiedApiModule", () => {
|
||||
@@ -319,18 +319,18 @@ describe("ProxiedApiModule", () => {
|
||||
it("should return false if there is no room", () => {
|
||||
client.getRoom = jest.fn().mockReturnValue(null);
|
||||
|
||||
expect(api.isAppInContainer(app, Container.Top, roomId)).toBe(false);
|
||||
expect(api.isAppInContainer(app, "top", roomId)).toBe(false);
|
||||
expect(WidgetLayoutStore.instance.isInContainer).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should return false if the app is not in the container", () => {
|
||||
jest.spyOn(WidgetLayoutStore.instance, "isInContainer").mockReturnValue(false);
|
||||
expect(api.isAppInContainer(app, Container.Top, roomId)).toBe(false);
|
||||
expect(api.isAppInContainer(app, "top", roomId)).toBe(false);
|
||||
});
|
||||
|
||||
it("should return true if the app is in the container", () => {
|
||||
jest.spyOn(WidgetLayoutStore.instance, "isInContainer").mockReturnValue(true);
|
||||
expect(api.isAppInContainer(app, Container.Top, roomId)).toBe(true);
|
||||
expect(api.isAppInContainer(app, "top", roomId)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -350,7 +350,7 @@ describe("ProxiedApiModule", () => {
|
||||
|
||||
it("should not move if there is no room", () => {
|
||||
client.getRoom = jest.fn().mockReturnValue(null);
|
||||
api.moveAppToContainer(app, Container.Top, roomId);
|
||||
api.moveAppToContainer(app, "top", roomId);
|
||||
expect(WidgetLayoutStore.instance.moveToContainer).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -358,8 +358,8 @@ describe("ProxiedApiModule", () => {
|
||||
const room = mkRoom(client, roomId);
|
||||
client.getRoom = jest.fn().mockReturnValue(room);
|
||||
|
||||
api.moveAppToContainer(app, Container.Top, roomId);
|
||||
expect(WidgetLayoutStore.instance.moveToContainer).toHaveBeenCalledWith(room, app, Container.Top);
|
||||
api.moveAppToContainer(app, "top", roomId);
|
||||
expect(WidgetLayoutStore.instance.moveToContainer).toHaveBeenCalledWith(room, app, "top");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
/*
|
||||
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 { mocked } from "jest-mock";
|
||||
|
||||
import type { IWidget } from "matrix-widget-api";
|
||||
import type { MatrixClient, Room } from "matrix-js-sdk/src/matrix";
|
||||
import { WidgetApi } from "../../../src/modules/WidgetApi";
|
||||
import WidgetStore from "../../../src/stores/WidgetStore";
|
||||
import { WidgetLayoutStore } from "../../../src/stores/widgets/WidgetLayoutStore";
|
||||
import { stubClient } from "../../test-utils";
|
||||
|
||||
describe("WidgetApi", () => {
|
||||
let client: MatrixClient;
|
||||
let api: WidgetApi;
|
||||
|
||||
const mkWidget = (overrides: Partial<IWidget> = {}): IWidget => ({
|
||||
id: "widget-id",
|
||||
creatorUserId: "@alice:example.org",
|
||||
type: "m.custom",
|
||||
url: "https://example.org/widget",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
client = stubClient();
|
||||
|
||||
api = new WidgetApi();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("getWidgetsInRoom returns widgets from WidgetStore", () => {
|
||||
const widgets = [{ id: "w1" }, { id: "w2" }] as unknown as IWidget[];
|
||||
const getAppsSpy = jest.spyOn(WidgetStore.instance, "getApps").mockReturnValue(widgets as any);
|
||||
|
||||
expect(api.getWidgetsInRoom("!room:example.org")).toBe(widgets);
|
||||
expect(getAppsSpy).toHaveBeenCalledWith("!room:example.org");
|
||||
});
|
||||
|
||||
it("getAppAvatarUrl returns the http avatar URL for a widget if it has one", () => {
|
||||
const app = {
|
||||
...mkWidget(),
|
||||
roomId: "!room:example.org",
|
||||
avatar_url: "mxc://example.org/avatar",
|
||||
} as unknown as IWidget;
|
||||
|
||||
mocked(client.getHomeserverUrl).mockReturnValue("https://hs.example.org");
|
||||
const avatarUrl = api.getAppAvatarUrl(app, 32, 32, "scale");
|
||||
|
||||
expect(avatarUrl).toContain("https://hs.example.org/_matrix/media/");
|
||||
expect(avatarUrl).toContain("/thumbnail/example.org/avatar");
|
||||
expect(avatarUrl).toContain("width=32");
|
||||
expect(avatarUrl).toContain("height=32");
|
||||
expect(avatarUrl).toContain("method=scale");
|
||||
});
|
||||
|
||||
it("getAppAvatarUrl returns null when app is not an app widget", () => {
|
||||
const nonAppWidget = {
|
||||
...mkWidget(),
|
||||
avatar_url: "mxc://example.org/avatar",
|
||||
};
|
||||
|
||||
expect(api.getAppAvatarUrl(nonAppWidget)).toBeNull();
|
||||
});
|
||||
|
||||
it("getAppAvatarUrl returns null when app has no avatar URL", () => {
|
||||
const appWithoutAvatar = {
|
||||
...mkWidget(),
|
||||
roomId: "!room:example.org",
|
||||
} as unknown as IWidget;
|
||||
|
||||
expect(api.getAppAvatarUrl(appWithoutAvatar)).toBeNull();
|
||||
});
|
||||
|
||||
it("isAppInContainer returns false when room is not found", () => {
|
||||
const isInContainerSpy = jest.spyOn(WidgetLayoutStore.instance, "isInContainer");
|
||||
const app = mkWidget();
|
||||
|
||||
mocked(client.getRoom).mockReturnValue(null);
|
||||
expect(api.isAppInContainer(app, "top", "!missing:example.org")).toBe(false);
|
||||
expect(isInContainerSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("isAppInContainer delegates to WidgetLayoutStore when room exists", () => {
|
||||
const room = { roomId: "!room:example.org" } as Room;
|
||||
mocked(client.getRoom).mockReturnValue(room);
|
||||
const isInContainerSpy = jest.spyOn(WidgetLayoutStore.instance, "isInContainer").mockReturnValue(true);
|
||||
const app = mkWidget();
|
||||
|
||||
expect(api.isAppInContainer(app, "top", room.roomId)).toBe(true);
|
||||
expect(isInContainerSpy).toHaveBeenCalledWith(room, app, "top");
|
||||
});
|
||||
|
||||
it("moveAppToContainer does nothing when room is not found", () => {
|
||||
const moveToContainerSpy = jest.spyOn(WidgetLayoutStore.instance, "moveToContainer");
|
||||
const app = mkWidget();
|
||||
|
||||
mocked(client.getRoom).mockReturnValue(null);
|
||||
api.moveAppToContainer(app, "right", "!missing:example.org");
|
||||
|
||||
expect(moveToContainerSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("moveAppToContainer delegates to WidgetLayoutStore when room exists", () => {
|
||||
const room = { roomId: "!room:example.org" } as Room;
|
||||
mocked(client.getRoom).mockReturnValue(room);
|
||||
const moveToContainerSpy = jest.spyOn(WidgetLayoutStore.instance, "moveToContainer").mockImplementation();
|
||||
const app = mkWidget();
|
||||
|
||||
api.moveAppToContainer(app, "right", room.roomId);
|
||||
|
||||
expect(moveToContainerSpy).toHaveBeenCalledWith(room, app, "right");
|
||||
});
|
||||
});
|
||||
@@ -10,7 +10,7 @@ import { type MatrixClient, type Room } from "matrix-js-sdk/src/matrix";
|
||||
import { mocked } from "jest-mock";
|
||||
|
||||
import WidgetStore, { type IApp } from "../../../src/stores/WidgetStore";
|
||||
import { Container, WidgetLayoutStore } from "../../../src/stores/widgets/WidgetLayoutStore";
|
||||
import { WidgetLayoutStore } from "../../../src/stores/widgets/WidgetLayoutStore";
|
||||
import { stubClient } from "../../test-utils";
|
||||
import defaultDispatcher from "../../../src/dispatcher/dispatcher";
|
||||
import SettingsStore from "../../../src/settings/SettingsStore";
|
||||
@@ -74,14 +74,14 @@ describe("WidgetLayoutStore", () => {
|
||||
|
||||
it("all widgets should be in the right container by default", () => {
|
||||
store.recalculateRoom(mockRoom);
|
||||
expect(store.getContainerWidgets(mockRoom, Container.Right).length).toStrictEqual(mockApps.length);
|
||||
expect(store.getContainerWidgets(mockRoom, "right").length).toStrictEqual(mockApps.length);
|
||||
});
|
||||
|
||||
it("add widget to top container", async () => {
|
||||
store.recalculateRoom(mockRoom);
|
||||
store.moveToContainer(mockRoom, mockApps[0], Container.Top);
|
||||
expect(store.getContainerWidgets(mockRoom, Container.Top)).toStrictEqual([mockApps[0]]);
|
||||
expect(store.getContainerHeight(mockRoom, Container.Top)).toBeNull();
|
||||
store.moveToContainer(mockRoom, mockApps[0], "top");
|
||||
expect(store.getContainerWidgets(mockRoom, "top")).toStrictEqual([mockApps[0]]);
|
||||
expect(store.getContainerHeight(mockRoom, "top")).toBeNull();
|
||||
});
|
||||
|
||||
it("ordering of top container widgets should be consistent even if no index specified", async () => {
|
||||
@@ -97,71 +97,71 @@ describe("WidgetLayoutStore", () => {
|
||||
};
|
||||
|
||||
store.recalculateRoom(mockRoom);
|
||||
expect(store.getContainerWidgets(mockRoom, Container.Top)).toStrictEqual([mockApps[0], mockApps[1]]);
|
||||
expect(store.getContainerWidgets(mockRoom, "top")).toStrictEqual([mockApps[0], mockApps[1]]);
|
||||
});
|
||||
|
||||
it("add three widgets to top container", async () => {
|
||||
store.recalculateRoom(mockRoom);
|
||||
store.moveToContainer(mockRoom, mockApps[0], Container.Top);
|
||||
store.moveToContainer(mockRoom, mockApps[1], Container.Top);
|
||||
store.moveToContainer(mockRoom, mockApps[2], Container.Top);
|
||||
expect(new Set(store.getContainerWidgets(mockRoom, Container.Top))).toEqual(
|
||||
store.moveToContainer(mockRoom, mockApps[0], "top");
|
||||
store.moveToContainer(mockRoom, mockApps[1], "top");
|
||||
store.moveToContainer(mockRoom, mockApps[2], "top");
|
||||
expect(new Set(store.getContainerWidgets(mockRoom, "top"))).toEqual(
|
||||
new Set([mockApps[0], mockApps[1], mockApps[2]]),
|
||||
);
|
||||
});
|
||||
|
||||
it("cannot add more than three widgets to top container", async () => {
|
||||
store.recalculateRoom(mockRoom);
|
||||
store.moveToContainer(mockRoom, mockApps[0], Container.Top);
|
||||
store.moveToContainer(mockRoom, mockApps[1], Container.Top);
|
||||
store.moveToContainer(mockRoom, mockApps[2], Container.Top);
|
||||
expect(store.canAddToContainer(mockRoom, Container.Top)).toEqual(false);
|
||||
store.moveToContainer(mockRoom, mockApps[0], "top");
|
||||
store.moveToContainer(mockRoom, mockApps[1], "top");
|
||||
store.moveToContainer(mockRoom, mockApps[2], "top");
|
||||
expect(store.canAddToContainer(mockRoom, "top")).toEqual(false);
|
||||
});
|
||||
|
||||
it("remove pins when maximising (other widget)", async () => {
|
||||
store.recalculateRoom(mockRoom);
|
||||
store.moveToContainer(mockRoom, mockApps[0], Container.Top);
|
||||
store.moveToContainer(mockRoom, mockApps[1], Container.Top);
|
||||
store.moveToContainer(mockRoom, mockApps[2], Container.Top);
|
||||
store.moveToContainer(mockRoom, mockApps[3], Container.Center);
|
||||
expect(store.getContainerWidgets(mockRoom, Container.Top)).toEqual([]);
|
||||
expect(new Set(store.getContainerWidgets(mockRoom, Container.Right))).toEqual(
|
||||
store.moveToContainer(mockRoom, mockApps[0], "top");
|
||||
store.moveToContainer(mockRoom, mockApps[1], "top");
|
||||
store.moveToContainer(mockRoom, mockApps[2], "top");
|
||||
store.moveToContainer(mockRoom, mockApps[3], "center");
|
||||
expect(store.getContainerWidgets(mockRoom, "top")).toEqual([]);
|
||||
expect(new Set(store.getContainerWidgets(mockRoom, "right"))).toEqual(
|
||||
new Set([mockApps[0], mockApps[1], mockApps[2]]),
|
||||
);
|
||||
expect(store.getContainerWidgets(mockRoom, Container.Center)).toEqual([mockApps[3]]);
|
||||
expect(store.getContainerWidgets(mockRoom, "center")).toEqual([mockApps[3]]);
|
||||
});
|
||||
|
||||
it("remove pins when maximising (one of the pinned widgets)", async () => {
|
||||
store.recalculateRoom(mockRoom);
|
||||
store.moveToContainer(mockRoom, mockApps[0], Container.Top);
|
||||
store.moveToContainer(mockRoom, mockApps[1], Container.Top);
|
||||
store.moveToContainer(mockRoom, mockApps[2], Container.Top);
|
||||
store.moveToContainer(mockRoom, mockApps[0], Container.Center);
|
||||
expect(store.getContainerWidgets(mockRoom, Container.Top)).toEqual([]);
|
||||
expect(store.getContainerWidgets(mockRoom, Container.Center)).toEqual([mockApps[0]]);
|
||||
expect(new Set(store.getContainerWidgets(mockRoom, Container.Right))).toEqual(
|
||||
store.moveToContainer(mockRoom, mockApps[0], "top");
|
||||
store.moveToContainer(mockRoom, mockApps[1], "top");
|
||||
store.moveToContainer(mockRoom, mockApps[2], "top");
|
||||
store.moveToContainer(mockRoom, mockApps[0], "center");
|
||||
expect(store.getContainerWidgets(mockRoom, "top")).toEqual([]);
|
||||
expect(store.getContainerWidgets(mockRoom, "center")).toEqual([mockApps[0]]);
|
||||
expect(new Set(store.getContainerWidgets(mockRoom, "right"))).toEqual(
|
||||
new Set([mockApps[1], mockApps[2], mockApps[3]]),
|
||||
);
|
||||
});
|
||||
|
||||
it("remove maximised when pinning (other widget)", async () => {
|
||||
store.recalculateRoom(mockRoom);
|
||||
store.moveToContainer(mockRoom, mockApps[0], Container.Center);
|
||||
store.moveToContainer(mockRoom, mockApps[1], Container.Top);
|
||||
expect(store.getContainerWidgets(mockRoom, Container.Top)).toEqual([mockApps[1]]);
|
||||
expect(store.getContainerWidgets(mockRoom, Container.Center)).toEqual([]);
|
||||
expect(new Set(store.getContainerWidgets(mockRoom, Container.Right))).toEqual(
|
||||
store.moveToContainer(mockRoom, mockApps[0], "center");
|
||||
store.moveToContainer(mockRoom, mockApps[1], "top");
|
||||
expect(store.getContainerWidgets(mockRoom, "top")).toEqual([mockApps[1]]);
|
||||
expect(store.getContainerWidgets(mockRoom, "center")).toEqual([]);
|
||||
expect(new Set(store.getContainerWidgets(mockRoom, "right"))).toEqual(
|
||||
new Set([mockApps[2], mockApps[3], mockApps[0]]),
|
||||
);
|
||||
});
|
||||
|
||||
it("remove maximised when pinning (same widget)", async () => {
|
||||
store.recalculateRoom(mockRoom);
|
||||
store.moveToContainer(mockRoom, mockApps[0], Container.Center);
|
||||
store.moveToContainer(mockRoom, mockApps[0], Container.Top);
|
||||
expect(store.getContainerWidgets(mockRoom, Container.Top)).toEqual([mockApps[0]]);
|
||||
expect(store.getContainerWidgets(mockRoom, Container.Center)).toEqual([]);
|
||||
expect(new Set(store.getContainerWidgets(mockRoom, Container.Right))).toEqual(
|
||||
store.moveToContainer(mockRoom, mockApps[0], "center");
|
||||
store.moveToContainer(mockRoom, mockApps[0], "top");
|
||||
expect(store.getContainerWidgets(mockRoom, "top")).toEqual([mockApps[0]]);
|
||||
expect(store.getContainerWidgets(mockRoom, "center")).toEqual([]);
|
||||
expect(new Set(store.getContainerWidgets(mockRoom, "right"))).toEqual(
|
||||
new Set([mockApps[2], mockApps[3], mockApps[1]]),
|
||||
);
|
||||
});
|
||||
@@ -171,9 +171,9 @@ describe("WidgetLayoutStore", () => {
|
||||
await store.start();
|
||||
|
||||
expect(roomUpdateListener).toHaveBeenCalled();
|
||||
expect(store.getContainerWidgets(mockRoom, Container.Top)).toEqual([]);
|
||||
expect(store.getContainerWidgets(mockRoom, Container.Center)).toEqual([]);
|
||||
expect(store.getContainerWidgets(mockRoom, Container.Right)).toEqual([
|
||||
expect(store.getContainerWidgets(mockRoom, "top")).toEqual([]);
|
||||
expect(store.getContainerWidgets(mockRoom, "center")).toEqual([]);
|
||||
expect(store.getContainerWidgets(mockRoom, "right")).toEqual([
|
||||
mockApps[0],
|
||||
mockApps[1],
|
||||
mockApps[2],
|
||||
@@ -190,58 +190,50 @@ describe("WidgetLayoutStore", () => {
|
||||
));
|
||||
store.recalculateRoom(mockRoom);
|
||||
expect(roomUpdateListener).toHaveBeenCalled();
|
||||
expect(store.getContainerWidgets(mockRoom, Container.Top)).toEqual([]);
|
||||
expect(store.getContainerWidgets(mockRoom, Container.Center)).toEqual([]);
|
||||
expect(store.getContainerWidgets(mockRoom, Container.Right)).toEqual([]);
|
||||
expect(store.getContainerWidgets(mockRoom, "top")).toEqual([]);
|
||||
expect(store.getContainerWidgets(mockRoom, "center")).toEqual([]);
|
||||
expect(store.getContainerWidgets(mockRoom, "right")).toEqual([]);
|
||||
});
|
||||
|
||||
it("should clear the layout if the client is not viable", () => {
|
||||
store.recalculateRoom(mockRoom);
|
||||
defaultDispatcher.dispatch({ action: Action.ClientNotViable }, true);
|
||||
|
||||
expect(store.getContainerWidgets(mockRoom, Container.Top)).toEqual([]);
|
||||
expect(store.getContainerWidgets(mockRoom, Container.Center)).toEqual([]);
|
||||
expect(store.getContainerWidgets(mockRoom, Container.Right)).toEqual([]);
|
||||
expect(store.getContainerWidgets(mockRoom, "top")).toEqual([]);
|
||||
expect(store.getContainerWidgets(mockRoom, "center")).toEqual([]);
|
||||
expect(store.getContainerWidgets(mockRoom, "right")).toEqual([]);
|
||||
});
|
||||
|
||||
it("should return the expected resizer distributions", () => {
|
||||
// this only works for top widgets
|
||||
store.recalculateRoom(mockRoom);
|
||||
store.moveToContainer(mockRoom, mockApps[0], Container.Top);
|
||||
store.moveToContainer(mockRoom, mockApps[1], Container.Top);
|
||||
expect(store.getResizerDistributions(mockRoom, Container.Top)).toEqual(["50.0%"]);
|
||||
store.moveToContainer(mockRoom, mockApps[0], "top");
|
||||
store.moveToContainer(mockRoom, mockApps[1], "top");
|
||||
expect(store.getResizerDistributions(mockRoom, "top")).toEqual(["50.0%"]);
|
||||
});
|
||||
|
||||
it("should set and return container height", () => {
|
||||
store.recalculateRoom(mockRoom);
|
||||
store.moveToContainer(mockRoom, mockApps[0], Container.Top);
|
||||
store.moveToContainer(mockRoom, mockApps[1], Container.Top);
|
||||
store.setContainerHeight(mockRoom, Container.Top, 23);
|
||||
expect(store.getContainerHeight(mockRoom, Container.Top)).toBe(23);
|
||||
store.moveToContainer(mockRoom, mockApps[0], "top");
|
||||
store.moveToContainer(mockRoom, mockApps[1], "top");
|
||||
store.setContainerHeight(mockRoom, "top", 23);
|
||||
expect(store.getContainerHeight(mockRoom, "top")).toBe(23);
|
||||
});
|
||||
|
||||
it("should move a widget within a container", () => {
|
||||
store.recalculateRoom(mockRoom);
|
||||
store.moveToContainer(mockRoom, mockApps[0], Container.Top);
|
||||
store.moveToContainer(mockRoom, mockApps[1], Container.Top);
|
||||
store.moveToContainer(mockRoom, mockApps[2], Container.Top);
|
||||
expect(store.getContainerWidgets(mockRoom, Container.Top)).toStrictEqual([
|
||||
mockApps[0],
|
||||
mockApps[1],
|
||||
mockApps[2],
|
||||
]);
|
||||
store.moveWithinContainer(mockRoom, Container.Top, mockApps[0], 1);
|
||||
expect(store.getContainerWidgets(mockRoom, Container.Top)).toStrictEqual([
|
||||
mockApps[1],
|
||||
mockApps[0],
|
||||
mockApps[2],
|
||||
]);
|
||||
store.moveToContainer(mockRoom, mockApps[0], "top");
|
||||
store.moveToContainer(mockRoom, mockApps[1], "top");
|
||||
store.moveToContainer(mockRoom, mockApps[2], "top");
|
||||
expect(store.getContainerWidgets(mockRoom, "top")).toStrictEqual([mockApps[0], mockApps[1], mockApps[2]]);
|
||||
store.moveWithinContainer(mockRoom, "top", mockApps[0], 1);
|
||||
expect(store.getContainerWidgets(mockRoom, "top")).toStrictEqual([mockApps[1], mockApps[0], mockApps[2]]);
|
||||
});
|
||||
|
||||
it("should copy the layout to the room", async () => {
|
||||
await store.start();
|
||||
store.recalculateRoom(mockRoom);
|
||||
store.moveToContainer(mockRoom, mockApps[0], Container.Top);
|
||||
store.moveToContainer(mockRoom, mockApps[0], "top");
|
||||
store.copyLayoutToRoom(mockRoom);
|
||||
|
||||
expect(mocked(client.sendStateEvent).mock.calls).toMatchInlineSnapshot(`
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
import { stubClient } from "../../test-utils";
|
||||
import WidgetUtils from "../../../src/utils/WidgetUtils";
|
||||
import { type IApp } from "../../../src/utils/WidgetUtils-types";
|
||||
import { Container, WidgetLayoutStore } from "../../../src/stores/widgets/WidgetLayoutStore";
|
||||
import { WidgetLayoutStore } from "../../../src/stores/widgets/WidgetLayoutStore";
|
||||
import * as livestream from "../../../src/Livestream";
|
||||
import Modal from "../../../src/Modal";
|
||||
import SettingsStore from "../../../src/settings/SettingsStore";
|
||||
@@ -138,12 +138,7 @@ describe("WidgetContextMenuViewModel", () => {
|
||||
const vm = new WidgetContextMenuViewModel(props);
|
||||
vm.onMoveButton(1);
|
||||
|
||||
expect(WidgetLayoutStore.instance.moveWithinContainer).toHaveBeenCalledWith(
|
||||
props.room,
|
||||
Container.Top,
|
||||
props.app,
|
||||
1,
|
||||
);
|
||||
expect(WidgetLayoutStore.instance.moveWithinContainer).toHaveBeenCalledWith(props.room, "top", props.app, 1);
|
||||
expect(props.onFinished).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ import { WidgetPipViewModel } from "../../../src/viewmodels/room/WidgetPipViewMo
|
||||
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 { WidgetLayoutStore } from "../../../src/stores/widgets/WidgetLayoutStore";
|
||||
import { CallStore, CallStoreEvent } from "../../../src/stores/CallStore";
|
||||
import { type Call } from "../../../src/models/Call";
|
||||
|
||||
@@ -96,7 +96,7 @@ describe("WidgetPipViewModel", () => {
|
||||
|
||||
vm.setViewingRoom(true);
|
||||
vm.onBackClick(createBackClickEvent());
|
||||
expect(moveSpy).toHaveBeenCalledWith(room, widget, Container.Center);
|
||||
expect(moveSpy).toHaveBeenCalledWith(room, widget, "center");
|
||||
|
||||
moveSpy.mockClear();
|
||||
vm.setViewingRoom(false);
|
||||
|
||||
Reference in New Issue
Block a user