diff --git a/src/components/views/rooms/RoomHeader.tsx b/src/components/views/rooms/RoomHeader.tsx
index 552d43f982..bb8dd49182 100644
--- a/src/components/views/rooms/RoomHeader.tsx
+++ b/src/components/views/rooms/RoomHeader.tsx
@@ -23,7 +23,6 @@ import { Icon as NotificationsIcon } from "@vector-im/compound-design-tokens/ico
 import { Icon as VerifiedIcon } from "@vector-im/compound-design-tokens/icons/verified.svg";
 import { Icon as ErrorIcon } from "@vector-im/compound-design-tokens/icons/error.svg";
 import { Icon as PublicIcon } from "@vector-im/compound-design-tokens/icons/public.svg";
-import { CallType } from "matrix-js-sdk/src/webrtc/call";
 import { EventType, JoinRule, type Room } from "matrix-js-sdk/src/matrix";
 
 import { useRoomName } from "../../../hooks/useRoomName";
@@ -35,13 +34,12 @@ import { useRoomMemberCount, useRoomMembers } from "../../../hooks/useRoomMember
 import { _t } from "../../../languageHandler";
 import { Flex } from "../../utils/Flex";
 import { Box } from "../../utils/Box";
-import { useRoomCallStatus } from "../../../hooks/room/useRoomCallStatus";
+import { useRoomCall } from "../../../hooks/room/useRoomCall";
 import { useRoomThreadNotifications } from "../../../hooks/room/useRoomThreadNotifications";
 import { NotificationColor } from "../../../stores/notifications/NotificationColor";
 import { useGlobalNotificationState } from "../../../hooks/useGlobalNotificationState";
 import SdkConfig from "../../../SdkConfig";
 import { useFeatureEnabled } from "../../../hooks/useSettings";
-import { placeCall } from "../../../utils/room/placeCall";
 import { useEncryptionStatus } from "../../../hooks/useEncryptionStatus";
 import { E2EStatus } from "../../../utils/ShieldUtils";
 import FacePile from "../elements/FacePile";
@@ -74,7 +72,7 @@ export default function RoomHeader({ room }: { room: Room }): JSX.Element {
     const members = useRoomMembers(room, 2500);
     const memberCount = useRoomMemberCount(room, { throttleWait: 2500 });
 
-    const { voiceCallDisabledReason, voiceCallType, videoCallDisabledReason, videoCallType } = useRoomCallStatus(room);
+    const { voiceCallDisabledReason, voiceCallClick, videoCallDisabledReason, videoCallClick } = useRoomCall(room);
 
     const groupCallsEnabled = useFeatureEnabled("feature_group_calls");
     /**
@@ -170,11 +168,8 @@ export default function RoomHeader({ room }: { room: Room }): JSX.Element {
                     <Tooltip label={!voiceCallDisabledReason ? _t("voip|voice_call") : voiceCallDisabledReason!}>
                         <IconButton
                             disabled={!!voiceCallDisabledReason}
-                            title={!voiceCallDisabledReason ? _t("voip|voice_call") : voiceCallDisabledReason!}
-                            onClick={(evt) => {
-                                evt.stopPropagation();
-                                placeCall(room, CallType.Voice, voiceCallType);
-                            }}
+                            aria-label={!voiceCallDisabledReason ? _t("voip|voice_call") : voiceCallDisabledReason!}
+                            onClick={voiceCallClick}
                         >
                             <VoiceCallIcon />
                         </IconButton>
@@ -183,11 +178,8 @@ export default function RoomHeader({ room }: { room: Room }): JSX.Element {
                 <Tooltip label={!videoCallDisabledReason ? _t("voip|video_call") : videoCallDisabledReason!}>
                     <IconButton
                         disabled={!!videoCallDisabledReason}
-                        title={!videoCallDisabledReason ? _t("voip|video_call") : videoCallDisabledReason!}
-                        onClick={(evt) => {
-                            evt.stopPropagation();
-                            placeCall(room, CallType.Video, videoCallType);
-                        }}
+                        aria-label={!videoCallDisabledReason ? _t("voip|video_call") : videoCallDisabledReason!}
+                        onClick={videoCallClick}
                     >
                         <VideoCallIcon />
                     </IconButton>
@@ -199,7 +191,7 @@ export default function RoomHeader({ room }: { room: Room }): JSX.Element {
                             evt.stopPropagation();
                             RightPanelStore.instance.showOrHidePanel(RightPanelPhases.ThreadPanel);
                         }}
-                        title={_t("common|threads")}
+                        aria-label={_t("common|threads")}
                     >
                         <ThreadsIcon />
                     </IconButton>
@@ -212,7 +204,7 @@ export default function RoomHeader({ room }: { room: Room }): JSX.Element {
                                 evt.stopPropagation();
                                 RightPanelStore.instance.showOrHidePanel(RightPanelPhases.NotificationPanel);
                             }}
-                            title={_t("Notifications")}
+                            aria-label={_t("Notifications")}
                         >
                             <NotificationsIcon />
                         </IconButton>
diff --git a/src/hooks/room/useRoomCall.ts b/src/hooks/room/useRoomCall.ts
new file mode 100644
index 0000000000..c369f5ab15
--- /dev/null
+++ b/src/hooks/room/useRoomCall.ts
@@ -0,0 +1,219 @@
+/*
+Copyright 2023 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import { Room } from "matrix-js-sdk/src/matrix";
+import React, { useCallback, useEffect, useMemo, useState } from "react";
+import { CallType } from "matrix-js-sdk/src/webrtc/call";
+
+import { useFeatureEnabled } from "../useSettings";
+import SdkConfig from "../../SdkConfig";
+import { useEventEmitter, useEventEmitterState } from "../useEventEmitter";
+import LegacyCallHandler, { LegacyCallHandlerEvent } from "../../LegacyCallHandler";
+import { useWidgets } from "../../components/views/right_panel/RoomSummaryCard";
+import { WidgetType } from "../../widgets/WidgetType";
+import { useCall } from "../useCall";
+import { useRoomMemberCount } from "../useRoomMembers";
+import { ElementCall } from "../../models/Call";
+import { placeCall } from "../../utils/room/placeCall";
+import { Container, WidgetLayoutStore } from "../../stores/widgets/WidgetLayoutStore";
+import { useRoomState } from "../useRoomState";
+import { _t } from "../../languageHandler";
+import { isManagedHybridWidget } from "../../widgets/ManagedHybrid";
+import { IApp } from "../../stores/WidgetStore";
+
+export type PlatformCallType = "element_call" | "jitsi_or_element_call" | "legacy_or_jitsi";
+
+const enum State {
+    NoCall,
+    NoOneHere,
+    NoPermission,
+    Unpinned,
+    Ongoing,
+}
+
+/**
+ * Utility hook for resolving state and click handlers for Voice & Video call buttons in the room header
+ * @param room the room to track
+ * @returns the call button attributes for the given room
+ */
+export const useRoomCall = (
+    room: Room,
+): {
+    voiceCallDisabledReason: string | null;
+    voiceCallClick(evt: React.MouseEvent): void;
+    videoCallDisabledReason: string | null;
+    videoCallClick(evt: React.MouseEvent): void;
+} => {
+    const groupCallsEnabled = useFeatureEnabled("feature_group_calls");
+    const useElementCallExclusively = useMemo(() => {
+        return SdkConfig.get("element_call").use_exclusively;
+    }, []);
+
+    const hasLegacyCall = useEventEmitterState(
+        LegacyCallHandler.instance,
+        LegacyCallHandlerEvent.CallsChanged,
+        () => LegacyCallHandler.instance.getCallForRoom(room.roomId) !== null,
+    );
+
+    const widgets = useWidgets(room);
+    const jitsiWidget = useMemo(() => widgets.find((widget) => WidgetType.JITSI.matches(widget.type)), [widgets]);
+    const hasJitsiWidget = !!jitsiWidget;
+    const managedHybridWidget = useMemo(() => widgets.find(isManagedHybridWidget), [widgets]);
+    const hasManagedHybridWidget = !!managedHybridWidget;
+
+    const groupCall = useCall(room.roomId);
+    const hasGroupCall = groupCall !== null;
+
+    const memberCount = useRoomMemberCount(room);
+
+    const [mayEditWidgets, mayCreateElementCalls] = useRoomState(room, () => [
+        room.currentState.mayClientSendStateEvent("im.vector.modular.widgets", room.client),
+        room.currentState.mayClientSendStateEvent(ElementCall.CALL_EVENT_TYPE.name, room.client),
+    ]);
+
+    const callType = useMemo((): PlatformCallType => {
+        if (groupCallsEnabled) {
+            if (hasGroupCall) {
+                return "jitsi_or_element_call";
+            }
+            if (mayCreateElementCalls && hasJitsiWidget) {
+                return "jitsi_or_element_call";
+            }
+            if (useElementCallExclusively) {
+                return "element_call";
+            }
+            if (memberCount <= 2) {
+                return "legacy_or_jitsi";
+            }
+            if (mayCreateElementCalls) {
+                return "element_call";
+            }
+        }
+        return "legacy_or_jitsi";
+    }, [
+        groupCallsEnabled,
+        hasGroupCall,
+        mayCreateElementCalls,
+        hasJitsiWidget,
+        useElementCallExclusively,
+        memberCount,
+    ]);
+
+    let widget: IApp | undefined;
+    if (callType === "legacy_or_jitsi") {
+        widget = jitsiWidget ?? managedHybridWidget;
+    } else if (callType === "element_call") {
+        widget = groupCall?.widget;
+    } else {
+        widget = groupCall?.widget ?? jitsiWidget;
+    }
+
+    const [canPinWidget, setCanPinWidget] = useState(false);
+    const [widgetPinned, setWidgetPinned] = useState(false);
+    const promptPinWidget = canPinWidget && !widgetPinned;
+
+    const updateWidgetState = useCallback((): void => {
+        setCanPinWidget(WidgetLayoutStore.instance.canAddToContainer(room, Container.Top));
+        setWidgetPinned(!!widget && WidgetLayoutStore.instance.isInContainer(room, widget, Container.Top));
+    }, [room, widget]);
+
+    useEventEmitter(WidgetLayoutStore.instance, WidgetLayoutStore.emissionForRoom(room), updateWidgetState);
+    useEffect(() => {
+        updateWidgetState();
+    }, [room, jitsiWidget, groupCall, updateWidgetState]);
+
+    const state = useMemo((): State => {
+        if (hasGroupCall || hasJitsiWidget || hasManagedHybridWidget) {
+            return promptPinWidget ? State.Unpinned : State.Ongoing;
+        }
+        if (hasLegacyCall) {
+            return State.Ongoing;
+        }
+
+        if (memberCount <= 1) {
+            return State.NoOneHere;
+        }
+
+        if (!mayCreateElementCalls && !mayEditWidgets) {
+            return State.NoPermission;
+        }
+
+        return State.NoCall;
+    }, [
+        hasGroupCall,
+        hasJitsiWidget,
+        hasLegacyCall,
+        hasManagedHybridWidget,
+        mayCreateElementCalls,
+        mayEditWidgets,
+        memberCount,
+        promptPinWidget,
+    ]);
+
+    const voiceCallClick = useCallback(
+        (evt: React.MouseEvent): void => {
+            evt.stopPropagation();
+            if (widget && promptPinWidget) {
+                WidgetLayoutStore.instance.moveToContainer(room, widget, Container.Top);
+            } else {
+                placeCall(room, CallType.Voice, callType);
+            }
+        },
+        [promptPinWidget, room, widget, callType],
+    );
+    const videoCallClick = useCallback(
+        (evt: React.MouseEvent): void => {
+            evt.stopPropagation();
+            if (widget && promptPinWidget) {
+                WidgetLayoutStore.instance.moveToContainer(room, widget, Container.Top);
+            } else {
+                placeCall(room, CallType.Video, callType);
+            }
+        },
+        [widget, promptPinWidget, room, callType],
+    );
+
+    let voiceCallDisabledReason: string | null;
+    let videoCallDisabledReason: string | null;
+    switch (state) {
+        case State.NoPermission:
+            voiceCallDisabledReason = _t("You do not have permission to start voice calls");
+            videoCallDisabledReason = _t("You do not have permission to start video calls");
+            break;
+        case State.Ongoing:
+            voiceCallDisabledReason = _t("Ongoing call");
+            videoCallDisabledReason = _t("Ongoing call");
+            break;
+        case State.NoOneHere:
+            voiceCallDisabledReason = _t("There's no one here to call");
+            videoCallDisabledReason = _t("There's no one here to call");
+            break;
+        case State.Unpinned:
+        case State.NoCall:
+            voiceCallDisabledReason = null;
+            videoCallDisabledReason = null;
+    }
+
+    /**
+     * We've gone through all the steps
+     */
+    return {
+        voiceCallDisabledReason,
+        voiceCallClick,
+        videoCallDisabledReason,
+        videoCallClick,
+    };
+};
diff --git a/src/hooks/room/useRoomCallStatus.ts b/src/hooks/room/useRoomCallStatus.ts
deleted file mode 100644
index 7afd1f9ce4..0000000000
--- a/src/hooks/room/useRoomCallStatus.ts
+++ /dev/null
@@ -1,154 +0,0 @@
-/*
-Copyright 2023 The Matrix.org Foundation C.I.C.
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-    http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
-*/
-
-import { Room, RoomStateEvent } from "matrix-js-sdk/src/matrix";
-import { useCallback, useEffect, useMemo, useState } from "react";
-
-import { useFeatureEnabled } from "../useSettings";
-import SdkConfig from "../../SdkConfig";
-import { useEventEmitterState, useTypedEventEmitterState } from "../useEventEmitter";
-import LegacyCallHandler, { LegacyCallHandlerEvent } from "../../LegacyCallHandler";
-import { useWidgets } from "../../components/views/right_panel/RoomSummaryCard";
-import { WidgetType } from "../../widgets/WidgetType";
-import { useCall } from "../useCall";
-import { _t } from "../../languageHandler";
-import { useRoomMemberCount } from "../useRoomMembers";
-import { ElementCall } from "../../models/Call";
-
-export type PlatformCallType = "element_call" | "jitsi_or_element_call" | "legacy_or_jitsi";
-
-const DEFAULT_DISABLED_REASON = null;
-const DEFAULT_CALL_TYPE = "jitsi_or_element_call";
-
-/**
- * Reports the call capabilities for the current room
- * @param room the room to track
- * @returns the call status for a room
- */
-export const useRoomCallStatus = (
-    room: Room,
-): {
-    voiceCallDisabledReason: string | null;
-    voiceCallType: PlatformCallType;
-    videoCallDisabledReason: string | null;
-    videoCallType: PlatformCallType;
-} => {
-    const [voiceCallDisabledReason, setVoiceCallDisabledReason] = useState<string | null>(DEFAULT_DISABLED_REASON);
-    const [videoCallDisabledReason, setVideoCallDisabledReason] = useState<string | null>(DEFAULT_DISABLED_REASON);
-    const [voiceCallType, setVoiceCallType] = useState<PlatformCallType>(DEFAULT_CALL_TYPE);
-    const [videoCallType, setVideoCallType] = useState<PlatformCallType>(DEFAULT_CALL_TYPE);
-
-    const groupCallsEnabled = useFeatureEnabled("feature_group_calls");
-    const useElementCallExclusively = useMemo(() => {
-        return SdkConfig.get("element_call").use_exclusively;
-    }, []);
-
-    const hasLegacyCall = useEventEmitterState(
-        LegacyCallHandler.instance,
-        LegacyCallHandlerEvent.CallsChanged,
-        () => LegacyCallHandler.instance.getCallForRoom(room.roomId) !== null,
-    );
-
-    const widgets = useWidgets(room);
-    const hasJitsiWidget = useMemo(() => widgets.some((widget) => WidgetType.JITSI.matches(widget.type)), [widgets]);
-
-    const hasGroupCall = useCall(room.roomId) !== null;
-
-    const memberCount = useRoomMemberCount(room);
-
-    const [mayEditWidgets, mayCreateElementCalls] = useTypedEventEmitterState(
-        room,
-        RoomStateEvent.Update,
-        useCallback(
-            () => [
-                room.currentState.mayClientSendStateEvent("im.vector.modular.widgets", room.client),
-                room.currentState.mayClientSendStateEvent(ElementCall.CALL_EVENT_TYPE.name, room.client),
-            ],
-            [room],
-        ),
-    );
-
-    useEffect(() => {
-        // First reset all state to their default value
-        setVoiceCallDisabledReason(DEFAULT_DISABLED_REASON);
-        setVideoCallDisabledReason(DEFAULT_DISABLED_REASON);
-        setVoiceCallType(DEFAULT_CALL_TYPE);
-        setVideoCallType(DEFAULT_CALL_TYPE);
-
-        // And then run the logic to figure out their correct state
-        if (groupCallsEnabled) {
-            if (useElementCallExclusively) {
-                if (hasGroupCall) {
-                    setVideoCallDisabledReason(_t("Ongoing call"));
-                } else if (mayCreateElementCalls) {
-                    setVideoCallType("element_call");
-                } else {
-                    setVideoCallDisabledReason(_t("You do not have permission to start video calls"));
-                }
-            } else if (hasLegacyCall || hasJitsiWidget || hasGroupCall) {
-                setVoiceCallDisabledReason(_t("Ongoing call"));
-                setVideoCallDisabledReason(_t("Ongoing call"));
-            } else if (memberCount <= 1) {
-                setVoiceCallDisabledReason(_t("There's no one here to call"));
-                setVideoCallDisabledReason(_t("There's no one here to call"));
-            } else if (memberCount === 2) {
-                setVoiceCallType("legacy_or_jitsi");
-                setVideoCallType("legacy_or_jitsi");
-            } else if (mayEditWidgets) {
-                setVoiceCallType("legacy_or_jitsi");
-                setVideoCallType(mayCreateElementCalls ? "jitsi_or_element_call" : "legacy_or_jitsi");
-            } else {
-                setVoiceCallDisabledReason(_t("You do not have permission to start voice calls"));
-                if (mayCreateElementCalls) {
-                    setVideoCallType("element_call");
-                } else {
-                    setVideoCallDisabledReason(_t("You do not have permission to start video calls"));
-                }
-            }
-        } else if (hasLegacyCall || hasJitsiWidget) {
-            setVoiceCallDisabledReason(_t("Ongoing call"));
-            setVideoCallDisabledReason(_t("Ongoing call"));
-        } else if (memberCount <= 1) {
-            setVoiceCallDisabledReason(_t("There's no one here to call"));
-            setVideoCallDisabledReason(_t("There's no one here to call"));
-        } else if (memberCount === 2 || mayEditWidgets) {
-            setVoiceCallType("legacy_or_jitsi");
-            setVideoCallType("legacy_or_jitsi");
-        } else {
-            setVoiceCallDisabledReason(_t("You do not have permission to start voice calls"));
-            setVideoCallDisabledReason(_t("You do not have permission to start video calls"));
-        }
-    }, [
-        memberCount,
-        groupCallsEnabled,
-        hasGroupCall,
-        hasJitsiWidget,
-        hasLegacyCall,
-        mayCreateElementCalls,
-        mayEditWidgets,
-        useElementCallExclusively,
-    ]);
-
-    /**
-     * We've gone through all the steps
-     */
-    return {
-        voiceCallDisabledReason,
-        voiceCallType,
-        videoCallDisabledReason,
-        videoCallType,
-    };
-};
diff --git a/src/hooks/useRoomState.ts b/src/hooks/useRoomState.ts
index 1c8c27c285..dc3e4c2df1 100644
--- a/src/hooks/useRoomState.ts
+++ b/src/hooks/useRoomState.ts
@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
 limitations under the License.
 */
 
-import { useCallback, useEffect, useState } from "react";
+import { useCallback, useEffect, useRef, useState } from "react";
 import { Room, RoomState, RoomStateEvent } from "matrix-js-sdk/src/matrix";
 
 import { useTypedEventEmitter } from "./useEventEmitter";
@@ -28,19 +28,27 @@ export const useRoomState = <T extends any = RoomState>(
     room?: Room,
     mapper: Mapper<T> = defaultMapper as Mapper<T>,
 ): T => {
+    // Create a ref that stores mapper
+    const savedMapper = useRef(mapper);
+
+    // Update ref.current value if mapper changes.
+    useEffect(() => {
+        savedMapper.current = mapper;
+    }, [mapper]);
+
     const [value, setValue] = useState<T>(room ? mapper(room.currentState) : (undefined as T));
 
     const update = useCallback(() => {
         if (!room) return;
-        setValue(mapper(room.currentState));
-    }, [room, mapper]);
+        setValue(savedMapper.current(room.currentState));
+    }, [room]);
 
     useTypedEventEmitter(room?.currentState, RoomStateEvent.Update, update);
     useEffect(() => {
         update();
         return () => {
-            setValue(room ? mapper(room.currentState) : (undefined as T));
+            setValue(room ? savedMapper.current(room.currentState) : (undefined as T));
         };
-    }, [room, mapper, update]);
+    }, [room, update]);
     return value;
 };
diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json
index 797a57b58a..fd8a4c513c 100644
--- a/src/i18n/strings/en_EN.json
+++ b/src/i18n/strings/en_EN.json
@@ -1413,10 +1413,10 @@
         "explore_rooms": "Explore Public Rooms",
         "create_room": "Create a Group Chat"
     },
-    "Ongoing call": "Ongoing call",
-    "You do not have permission to start video calls": "You do not have permission to start video calls",
-    "There's no one here to call": "There's no one here to call",
     "You do not have permission to start voice calls": "You do not have permission to start voice calls",
+    "You do not have permission to start video calls": "You do not have permission to start video calls",
+    "Ongoing call": "Ongoing call",
+    "There's no one here to call": "There's no one here to call",
     "chat_effects": {
         "confetti_description": "Sends the given message with confetti",
         "confetti_message": "sends confetti",
diff --git a/src/stores/WidgetStore.ts b/src/stores/WidgetStore.ts
index 4280e71947..fd092e7fbb 100644
--- a/src/stores/WidgetStore.ts
+++ b/src/stores/WidgetStore.ts
@@ -29,10 +29,12 @@ import { UPDATE_EVENT } from "./AsyncStore";
 interface IState {}
 
 export interface IApp extends IWidget {
-    roomId: string;
-    eventId?: string; // not present on virtual widgets
+    "roomId": string;
+    "eventId"?: string; // not present on virtual widgets
     // eslint-disable-next-line camelcase
-    avatar_url?: string; // MSC2765 https://github.com/matrix-org/matrix-doc/pull/2765
+    "avatar_url"?: string; // MSC2765 https://github.com/matrix-org/matrix-doc/pull/2765
+    // Whether the widget was created from `widget_build_url` and thus is a call widget of some kind
+    "io.element.managed_hybrid"?: boolean;
 }
 
 export function isAppWidget(widget: IWidget | IApp): widget is IApp {
diff --git a/src/utils/WidgetUtils.ts b/src/utils/WidgetUtils.ts
index 42a87507a4..2d5396fd1f 100644
--- a/src/utils/WidgetUtils.ts
+++ b/src/utils/WidgetUtils.ts
@@ -332,7 +332,7 @@ export default class WidgetUtils {
         client: MatrixClient,
         roomId: string,
         widgetId: string,
-        content: IWidget,
+        content: IWidget & Record<string, any>,
     ): Promise<void> {
         const addingWidget = !!content.url;
 
diff --git a/src/utils/room/placeCall.ts b/src/utils/room/placeCall.ts
index b684b494ea..a50a7f2725 100644
--- a/src/utils/room/placeCall.ts
+++ b/src/utils/room/placeCall.ts
@@ -18,7 +18,7 @@ import { CallType } from "matrix-js-sdk/src/webrtc/call";
 import { Room } from "matrix-js-sdk/src/matrix";
 
 import LegacyCallHandler from "../../LegacyCallHandler";
-import { PlatformCallType } from "../../hooks/room/useRoomCallStatus";
+import { PlatformCallType } from "../../hooks/room/useRoomCall";
 import defaultDispatcher from "../../dispatcher/dispatcher";
 import { ViewRoomPayload } from "../../dispatcher/payloads/ViewRoomPayload";
 import { Action } from "../../dispatcher/actions";
diff --git a/src/widgets/ManagedHybrid.ts b/src/widgets/ManagedHybrid.ts
index e171a31af7..ff06c295e6 100644
--- a/src/widgets/ManagedHybrid.ts
+++ b/src/widgets/ManagedHybrid.ts
@@ -22,7 +22,7 @@ import { getCallBehaviourWellKnown } from "../utils/WellKnownUtils";
 import WidgetUtils from "../utils/WidgetUtils";
 import { IStoredLayout, WidgetLayoutStore } from "../stores/widgets/WidgetLayoutStore";
 import WidgetEchoStore from "../stores/WidgetEchoStore";
-import WidgetStore from "../stores/WidgetStore";
+import WidgetStore, { IApp } from "../stores/WidgetStore";
 import SdkConfig from "../SdkConfig";
 import DMRoomMap from "../utils/DMRoomMap";
 
@@ -97,7 +97,10 @@ export async function addManagedHybridWidget(roomId: string): Promise<void> {
 
     // Add the widget
     try {
-        await WidgetUtils.setRoomWidgetContent(cli, roomId, widgetId, widgetContent);
+        await WidgetUtils.setRoomWidgetContent(cli, roomId, widgetId, {
+            ...widgetContent,
+            "io.element.managed_hybrid": true,
+        });
     } catch (e) {
         logger.error(`Unable to add managed hybrid widget in room ${roomId}`, e);
         return;
@@ -116,3 +119,7 @@ export async function addManagedHybridWidget(roomId: string): Promise<void> {
     WidgetLayoutStore.instance.setContainerHeight(room, layout.container, layout.height);
     WidgetLayoutStore.instance.copyLayoutToRoom(room);
 }
+
+export function isManagedHybridWidget(widget: IApp): boolean {
+    return !!widget["io.element.managed_hybrid"];
+}
diff --git a/test/components/views/rooms/RoomHeader-test.tsx b/test/components/views/rooms/RoomHeader-test.tsx
index cdecd493b3..3476e24453 100644
--- a/test/components/views/rooms/RoomHeader-test.tsx
+++ b/test/components/views/rooms/RoomHeader-test.tsx
@@ -15,12 +15,19 @@ limitations under the License.
 */
 
 import React from "react";
-import userEvent from "@testing-library/user-event";
 import { CallType, MatrixCall } from "matrix-js-sdk/src/webrtc/call";
 import { EventType, JoinRule, MatrixClient, MatrixEvent, PendingEventOrdering, Room } from "matrix-js-sdk/src/matrix";
-import { getAllByTitle, getByLabelText, getByText, getByTitle, render, screen, waitFor } from "@testing-library/react";
+import {
+    fireEvent,
+    getAllByLabelText,
+    getByLabelText,
+    getByText,
+    render,
+    screen,
+    waitFor,
+} from "@testing-library/react";
 
-import { mkEvent, stubClient, withClientContextRenderOptions } from "../../../test-utils";
+import { filterConsole, mkEvent, stubClient, withClientContextRenderOptions } from "../../../test-utils";
 import RoomHeader from "../../../../src/components/views/rooms/RoomHeader";
 import DMRoomMap from "../../../../src/utils/DMRoomMap";
 import { MatrixClientPeg } from "../../../../src/MatrixClientPeg";
@@ -33,10 +40,13 @@ import dispatcher from "../../../../src/dispatcher/dispatcher";
 import { CallStore } from "../../../../src/stores/CallStore";
 import { Call, ElementCall } from "../../../../src/models/Call";
 import * as ShieldUtils from "../../../../src/utils/ShieldUtils";
+import { Container, WidgetLayoutStore } from "../../../../src/stores/widgets/WidgetLayoutStore";
 
 jest.mock("../../../../src/utils/ShieldUtils");
 
 describe("RoomHeader", () => {
+    filterConsole("[getType] Room !1:example.org does not have an m.room.create event");
+
     let room: Room;
 
     const ROOM_ID = "!1:example.org";
@@ -94,7 +104,7 @@ describe("RoomHeader", () => {
             withClientContextRenderOptions(MatrixClientPeg.get()!),
         );
 
-        await userEvent.click(getByText(container, ROOM_ID));
+        fireEvent.click(getByText(container, ROOM_ID));
         expect(setCardSpy).toHaveBeenCalledWith({ phase: RightPanelPhases.RoomSummary });
     });
 
@@ -184,7 +194,7 @@ describe("RoomHeader", () => {
         const facePile = getByLabelText(container, "4 members");
         expect(facePile).toHaveTextContent("4");
 
-        await userEvent.click(facePile);
+        fireEvent.click(facePile);
 
         expect(setCardSpy).toHaveBeenCalledWith({ phase: RightPanelPhases.RoomMemberList });
     });
@@ -195,7 +205,7 @@ describe("RoomHeader", () => {
             withClientContextRenderOptions(MatrixClientPeg.get()!),
         );
 
-        await userEvent.click(getByTitle(container, "Threads"));
+        fireEvent.click(getByLabelText(container, "Threads"));
         expect(setCardSpy).toHaveBeenCalledWith({ phase: RightPanelPhases.ThreadPanel });
     });
 
@@ -209,7 +219,7 @@ describe("RoomHeader", () => {
             withClientContextRenderOptions(MatrixClientPeg.get()!),
         );
 
-        await userEvent.click(getByTitle(container, "Notifications"));
+        fireEvent.click(getByLabelText(container, "Notifications"));
         expect(setCardSpy).toHaveBeenCalledWith({ phase: RightPanelPhases.NotificationPanel });
     });
 
@@ -220,7 +230,7 @@ describe("RoomHeader", () => {
                 <RoomHeader room={room} />,
                 withClientContextRenderOptions(MatrixClientPeg.get()!),
             );
-            for (const button of getAllByTitle(container, "There's no one here to call")) {
+            for (const button of getAllByLabelText(container, "There's no one here to call")) {
                 expect(button).toBeDisabled();
             }
         });
@@ -231,17 +241,17 @@ describe("RoomHeader", () => {
                 <RoomHeader room={room} />,
                 withClientContextRenderOptions(MatrixClientPeg.get()!),
             );
-            const voiceButton = getByTitle(container, "Voice call");
-            const videoButton = getByTitle(container, "Video call");
+            const voiceButton = getByLabelText(container, "Voice call");
+            const videoButton = getByLabelText(container, "Video call");
             expect(voiceButton).not.toBeDisabled();
             expect(videoButton).not.toBeDisabled();
 
             const placeCallSpy = jest.spyOn(LegacyCallHandler.instance, "placeCall");
 
-            await userEvent.click(voiceButton);
+            fireEvent.click(voiceButton);
             expect(placeCallSpy).toHaveBeenLastCalledWith(room.roomId, CallType.Voice);
 
-            await userEvent.click(videoButton);
+            fireEvent.click(videoButton);
             expect(placeCallSpy).toHaveBeenLastCalledWith(room.roomId, CallType.Video);
         });
 
@@ -255,7 +265,7 @@ describe("RoomHeader", () => {
                 <RoomHeader room={room} />,
                 withClientContextRenderOptions(MatrixClientPeg.get()!),
             );
-            for (const button of getAllByTitle(container, "Ongoing call")) {
+            for (const button of getAllByLabelText(container, "Ongoing call")) {
                 expect(button).toBeDisabled();
             }
         });
@@ -268,8 +278,8 @@ describe("RoomHeader", () => {
                 withClientContextRenderOptions(MatrixClientPeg.get()!),
             );
 
-            expect(getByTitle(container, "Voice call")).not.toBeDisabled();
-            expect(getByTitle(container, "Video call")).not.toBeDisabled();
+            expect(getByLabelText(container, "Voice call")).not.toBeDisabled();
+            expect(getByLabelText(container, "Video call")).not.toBeDisabled();
         });
 
         it("disable calls in large rooms by default", () => {
@@ -279,8 +289,12 @@ describe("RoomHeader", () => {
                 <RoomHeader room={room} />,
                 withClientContextRenderOptions(MatrixClientPeg.get()!),
             );
-            expect(getByTitle(container, "You do not have permission to start voice calls")).toBeDisabled();
-            expect(getByTitle(container, "You do not have permission to start video calls")).toBeDisabled();
+            expect(
+                getByLabelText(container, "You do not have permission to start voice calls", { selector: "button" }),
+            ).toBeDisabled();
+            expect(
+                getByLabelText(container, "You do not have permission to start video calls", { selector: "button" }),
+            ).toBeDisabled();
         });
     });
 
@@ -290,6 +304,7 @@ describe("RoomHeader", () => {
         });
 
         it("renders only the video call element", async () => {
+            mockRoomMembers(room, 3);
             jest.spyOn(SdkConfig, "get").mockReturnValue({ use_exclusively: true });
             // allow element calls
             jest.spyOn(room.currentState, "mayClientSendStateEvent").mockReturnValue(true);
@@ -301,28 +316,48 @@ describe("RoomHeader", () => {
 
             expect(screen.queryByTitle("Voice call")).toBeNull();
 
-            const videoCallButton = getByTitle(container, "Video call");
+            const videoCallButton = getByLabelText(container, "Video call");
             expect(videoCallButton).not.toBeDisabled();
 
             const dispatcherSpy = jest.spyOn(dispatcher, "dispatch");
 
-            await userEvent.click(getByTitle(container, "Video call"));
+            fireEvent.click(getByLabelText(container, "Video call"));
 
             expect(dispatcherSpy).toHaveBeenCalledWith(expect.objectContaining({ view_call: true }));
         });
 
-        it("can call if there's an ongoing call", () => {
+        it("can't call if there's an ongoing (pinned) call", () => {
             jest.spyOn(SdkConfig, "get").mockReturnValue({ use_exclusively: true });
             // allow element calls
             jest.spyOn(room.currentState, "mayClientSendStateEvent").mockReturnValue(true);
+            jest.spyOn(WidgetLayoutStore.instance, "isInContainer").mockReturnValue(true);
 
-            jest.spyOn(CallStore.instance, "getCall").mockReturnValue({} as Call);
+            jest.spyOn(CallStore.instance, "getCall").mockReturnValue({ widget: {} } as Call);
 
             const { container } = render(
                 <RoomHeader room={room} />,
                 withClientContextRenderOptions(MatrixClientPeg.get()!),
             );
-            expect(getByTitle(container, "Ongoing call")).toBeDisabled();
+            expect(getByLabelText(container, "Ongoing call")).toBeDisabled();
+        });
+
+        it("clicking on ongoing (unpinned) call re-pins it", () => {
+            jest.spyOn(SdkConfig, "get").mockReturnValue({ use_exclusively: true });
+            // allow element calls
+            jest.spyOn(room.currentState, "mayClientSendStateEvent").mockReturnValue(true);
+            jest.spyOn(WidgetLayoutStore.instance, "isInContainer").mockReturnValue(false);
+            const spy = jest.spyOn(WidgetLayoutStore.instance, "moveToContainer");
+
+            const widget = {};
+            jest.spyOn(CallStore.instance, "getCall").mockReturnValue({ widget } as Call);
+
+            const { container } = render(
+                <RoomHeader room={room} />,
+                withClientContextRenderOptions(MatrixClientPeg.get()!),
+            );
+            expect(getByLabelText(container, "Video call")).not.toBeDisabled();
+            fireEvent.click(getByLabelText(container, "Video call"));
+            expect(spy).toHaveBeenCalledWith(room, widget, Container.Top);
         });
 
         it("disables calling if there's a jitsi call", () => {
@@ -335,7 +370,7 @@ describe("RoomHeader", () => {
                 <RoomHeader room={room} />,
                 withClientContextRenderOptions(MatrixClientPeg.get()!),
             );
-            for (const button of getAllByTitle(container, "Ongoing call")) {
+            for (const button of getAllByLabelText(container, "Ongoing call")) {
                 expect(button).toBeDisabled();
             }
         });
@@ -346,7 +381,7 @@ describe("RoomHeader", () => {
                 <RoomHeader room={room} />,
                 withClientContextRenderOptions(MatrixClientPeg.get()!),
             );
-            for (const button of getAllByTitle(container, "There's no one here to call")) {
+            for (const button of getAllByLabelText(container, "There's no one here to call")) {
                 expect(button).toBeDisabled();
             }
         });
@@ -358,16 +393,16 @@ describe("RoomHeader", () => {
                 withClientContextRenderOptions(MatrixClientPeg.get()!),
             );
 
-            const voiceButton = getByTitle(container, "Voice call");
-            const videoButton = getByTitle(container, "Video call");
+            const voiceButton = getByLabelText(container, "Voice call");
+            const videoButton = getByLabelText(container, "Video call");
             expect(voiceButton).not.toBeDisabled();
             expect(videoButton).not.toBeDisabled();
 
             const placeCallSpy = jest.spyOn(LegacyCallHandler.instance, "placeCall");
-            await userEvent.click(voiceButton);
+            fireEvent.click(voiceButton);
             expect(placeCallSpy).toHaveBeenLastCalledWith(room.roomId, CallType.Voice);
 
-            await userEvent.click(videoButton);
+            fireEvent.click(videoButton);
             expect(placeCallSpy).toHaveBeenLastCalledWith(room.roomId, CallType.Video);
         });
 
@@ -384,16 +419,16 @@ describe("RoomHeader", () => {
                 withClientContextRenderOptions(MatrixClientPeg.get()!),
             );
 
-            const voiceButton = getByTitle(container, "Voice call");
-            const videoButton = getByTitle(container, "Video call");
+            const voiceButton = getByLabelText(container, "Voice call");
+            const videoButton = getByLabelText(container, "Video call");
             expect(voiceButton).not.toBeDisabled();
             expect(videoButton).not.toBeDisabled();
 
             const placeCallSpy = jest.spyOn(LegacyCallHandler.instance, "placeCall");
-            await userEvent.click(voiceButton);
+            fireEvent.click(voiceButton);
             expect(placeCallSpy).toHaveBeenLastCalledWith(room.roomId, CallType.Voice);
 
-            await userEvent.click(videoButton);
+            fireEvent.click(videoButton);
             expect(placeCallSpy).toHaveBeenLastCalledWith(room.roomId, CallType.Video);
         });
 
@@ -411,17 +446,13 @@ describe("RoomHeader", () => {
                 withClientContextRenderOptions(MatrixClientPeg.get()!),
             );
 
-            const voiceButton = getByTitle(container, "Voice call");
-            const videoButton = getByTitle(container, "Video call");
+            const voiceButton = getByLabelText(container, "Voice call");
+            const videoButton = getByLabelText(container, "Video call");
             expect(voiceButton).not.toBeDisabled();
             expect(videoButton).not.toBeDisabled();
 
-            const placeCallSpy = jest.spyOn(LegacyCallHandler.instance, "placeCall");
-            await userEvent.click(voiceButton);
-            expect(placeCallSpy).toHaveBeenLastCalledWith(room.roomId, CallType.Voice);
-
             const dispatcherSpy = jest.spyOn(dispatcher, "dispatch");
-            await userEvent.click(videoButton);
+            fireEvent.click(videoButton);
             expect(dispatcherSpy).toHaveBeenCalledWith(expect.objectContaining({ view_call: true }));
         });
     });
diff --git a/test/components/views/rooms/__snapshots__/RoomHeader-test.tsx.snap b/test/components/views/rooms/__snapshots__/RoomHeader-test.tsx.snap
index 60f42b919d..3ac4decab9 100644
--- a/test/components/views/rooms/__snapshots__/RoomHeader-test.tsx.snap
+++ b/test/components/views/rooms/__snapshots__/RoomHeader-test.tsx.snap
@@ -35,28 +35,28 @@ exports[`RoomHeader does not show the face pile for DMs 1`] = `
       style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-2x);"
     >
       <button
+        aria-label="There's no one here to call"
         class="_icon-button_1segd_17"
         data-state="closed"
         disabled=""
         style="--cpd-icon-button-size: 32px;"
-        title="There's no one here to call"
       >
         <div />
       </button>
       <button
+        aria-label="There's no one here to call"
         class="_icon-button_1segd_17"
         data-state="closed"
         disabled=""
         style="--cpd-icon-button-size: 32px;"
-        title="There's no one here to call"
       >
         <div />
       </button>
       <button
+        aria-label="Threads"
         class="_icon-button_1segd_17"
         data-state="closed"
         style="--cpd-icon-button-size: 32px;"
-        title="Threads"
       >
         <div />
       </button>