Merge pull request #11576 from matrix-org/t3chguy/cr/72

Make video & voice call buttons pin conference widget if unpinned
This commit is contained in:
Andy Balaam 2023-09-19 12:16:14 +01:00 committed by GitHub
commit f9f2e79fd9
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 332 additions and 227 deletions

View file

@ -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 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 ErrorIcon } from "@vector-im/compound-design-tokens/icons/error.svg";
import { Icon as PublicIcon } from "@vector-im/compound-design-tokens/icons/public.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 { EventType, JoinRule, type Room } from "matrix-js-sdk/src/matrix";
import { useRoomName } from "../../../hooks/useRoomName"; import { useRoomName } from "../../../hooks/useRoomName";
@ -35,13 +34,12 @@ import { useRoomMemberCount, useRoomMembers } from "../../../hooks/useRoomMember
import { _t } from "../../../languageHandler"; import { _t } from "../../../languageHandler";
import { Flex } from "../../utils/Flex"; import { Flex } from "../../utils/Flex";
import { Box } from "../../utils/Box"; import { Box } from "../../utils/Box";
import { useRoomCallStatus } from "../../../hooks/room/useRoomCallStatus"; import { useRoomCall } from "../../../hooks/room/useRoomCall";
import { useRoomThreadNotifications } from "../../../hooks/room/useRoomThreadNotifications"; import { useRoomThreadNotifications } from "../../../hooks/room/useRoomThreadNotifications";
import { NotificationColor } from "../../../stores/notifications/NotificationColor"; import { NotificationColor } from "../../../stores/notifications/NotificationColor";
import { useGlobalNotificationState } from "../../../hooks/useGlobalNotificationState"; import { useGlobalNotificationState } from "../../../hooks/useGlobalNotificationState";
import SdkConfig from "../../../SdkConfig"; import SdkConfig from "../../../SdkConfig";
import { useFeatureEnabled } from "../../../hooks/useSettings"; import { useFeatureEnabled } from "../../../hooks/useSettings";
import { placeCall } from "../../../utils/room/placeCall";
import { useEncryptionStatus } from "../../../hooks/useEncryptionStatus"; import { useEncryptionStatus } from "../../../hooks/useEncryptionStatus";
import { E2EStatus } from "../../../utils/ShieldUtils"; import { E2EStatus } from "../../../utils/ShieldUtils";
import FacePile from "../elements/FacePile"; import FacePile from "../elements/FacePile";
@ -74,7 +72,7 @@ export default function RoomHeader({ room }: { room: Room }): JSX.Element {
const members = useRoomMembers(room, 2500); const members = useRoomMembers(room, 2500);
const memberCount = useRoomMemberCount(room, { throttleWait: 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"); 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!}> <Tooltip label={!voiceCallDisabledReason ? _t("voip|voice_call") : voiceCallDisabledReason!}>
<IconButton <IconButton
disabled={!!voiceCallDisabledReason} disabled={!!voiceCallDisabledReason}
title={!voiceCallDisabledReason ? _t("voip|voice_call") : voiceCallDisabledReason!} aria-label={!voiceCallDisabledReason ? _t("voip|voice_call") : voiceCallDisabledReason!}
onClick={(evt) => { onClick={voiceCallClick}
evt.stopPropagation();
placeCall(room, CallType.Voice, voiceCallType);
}}
> >
<VoiceCallIcon /> <VoiceCallIcon />
</IconButton> </IconButton>
@ -183,11 +178,8 @@ export default function RoomHeader({ room }: { room: Room }): JSX.Element {
<Tooltip label={!videoCallDisabledReason ? _t("voip|video_call") : videoCallDisabledReason!}> <Tooltip label={!videoCallDisabledReason ? _t("voip|video_call") : videoCallDisabledReason!}>
<IconButton <IconButton
disabled={!!videoCallDisabledReason} disabled={!!videoCallDisabledReason}
title={!videoCallDisabledReason ? _t("voip|video_call") : videoCallDisabledReason!} aria-label={!videoCallDisabledReason ? _t("voip|video_call") : videoCallDisabledReason!}
onClick={(evt) => { onClick={videoCallClick}
evt.stopPropagation();
placeCall(room, CallType.Video, videoCallType);
}}
> >
<VideoCallIcon /> <VideoCallIcon />
</IconButton> </IconButton>
@ -199,7 +191,7 @@ export default function RoomHeader({ room }: { room: Room }): JSX.Element {
evt.stopPropagation(); evt.stopPropagation();
RightPanelStore.instance.showOrHidePanel(RightPanelPhases.ThreadPanel); RightPanelStore.instance.showOrHidePanel(RightPanelPhases.ThreadPanel);
}} }}
title={_t("common|threads")} aria-label={_t("common|threads")}
> >
<ThreadsIcon /> <ThreadsIcon />
</IconButton> </IconButton>
@ -212,7 +204,7 @@ export default function RoomHeader({ room }: { room: Room }): JSX.Element {
evt.stopPropagation(); evt.stopPropagation();
RightPanelStore.instance.showOrHidePanel(RightPanelPhases.NotificationPanel); RightPanelStore.instance.showOrHidePanel(RightPanelPhases.NotificationPanel);
}} }}
title={_t("Notifications")} aria-label={_t("Notifications")}
> >
<NotificationsIcon /> <NotificationsIcon />
</IconButton> </IconButton>

View file

@ -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,
};
};

View file

@ -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,
};
};

View file

@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License. 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 { Room, RoomState, RoomStateEvent } from "matrix-js-sdk/src/matrix";
import { useTypedEventEmitter } from "./useEventEmitter"; import { useTypedEventEmitter } from "./useEventEmitter";
@ -28,19 +28,27 @@ export const useRoomState = <T extends any = RoomState>(
room?: Room, room?: Room,
mapper: Mapper<T> = defaultMapper as Mapper<T>, mapper: Mapper<T> = defaultMapper as Mapper<T>,
): 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 [value, setValue] = useState<T>(room ? mapper(room.currentState) : (undefined as T));
const update = useCallback(() => { const update = useCallback(() => {
if (!room) return; if (!room) return;
setValue(mapper(room.currentState)); setValue(savedMapper.current(room.currentState));
}, [room, mapper]); }, [room]);
useTypedEventEmitter(room?.currentState, RoomStateEvent.Update, update); useTypedEventEmitter(room?.currentState, RoomStateEvent.Update, update);
useEffect(() => { useEffect(() => {
update(); update();
return () => { 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; return value;
}; };

View file

@ -1413,10 +1413,10 @@
"explore_rooms": "Explore Public Rooms", "explore_rooms": "Explore Public Rooms",
"create_room": "Create a Group Chat" "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 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": { "chat_effects": {
"confetti_description": "Sends the given message with confetti", "confetti_description": "Sends the given message with confetti",
"confetti_message": "sends confetti", "confetti_message": "sends confetti",

View file

@ -29,10 +29,12 @@ import { UPDATE_EVENT } from "./AsyncStore";
interface IState {} interface IState {}
export interface IApp extends IWidget { export interface IApp extends IWidget {
roomId: string; "roomId": string;
eventId?: string; // not present on virtual widgets "eventId"?: string; // not present on virtual widgets
// eslint-disable-next-line camelcase // 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 { export function isAppWidget(widget: IWidget | IApp): widget is IApp {

View file

@ -332,7 +332,7 @@ export default class WidgetUtils {
client: MatrixClient, client: MatrixClient,
roomId: string, roomId: string,
widgetId: string, widgetId: string,
content: IWidget, content: IWidget & Record<string, any>,
): Promise<void> { ): Promise<void> {
const addingWidget = !!content.url; const addingWidget = !!content.url;

View file

@ -18,7 +18,7 @@ import { CallType } from "matrix-js-sdk/src/webrtc/call";
import { Room } from "matrix-js-sdk/src/matrix"; import { Room } from "matrix-js-sdk/src/matrix";
import LegacyCallHandler from "../../LegacyCallHandler"; import LegacyCallHandler from "../../LegacyCallHandler";
import { PlatformCallType } from "../../hooks/room/useRoomCallStatus"; import { PlatformCallType } from "../../hooks/room/useRoomCall";
import defaultDispatcher from "../../dispatcher/dispatcher"; import defaultDispatcher from "../../dispatcher/dispatcher";
import { ViewRoomPayload } from "../../dispatcher/payloads/ViewRoomPayload"; import { ViewRoomPayload } from "../../dispatcher/payloads/ViewRoomPayload";
import { Action } from "../../dispatcher/actions"; import { Action } from "../../dispatcher/actions";

View file

@ -22,7 +22,7 @@ import { getCallBehaviourWellKnown } from "../utils/WellKnownUtils";
import WidgetUtils from "../utils/WidgetUtils"; import WidgetUtils from "../utils/WidgetUtils";
import { IStoredLayout, WidgetLayoutStore } from "../stores/widgets/WidgetLayoutStore"; import { IStoredLayout, WidgetLayoutStore } from "../stores/widgets/WidgetLayoutStore";
import WidgetEchoStore from "../stores/WidgetEchoStore"; import WidgetEchoStore from "../stores/WidgetEchoStore";
import WidgetStore from "../stores/WidgetStore"; import WidgetStore, { IApp } from "../stores/WidgetStore";
import SdkConfig from "../SdkConfig"; import SdkConfig from "../SdkConfig";
import DMRoomMap from "../utils/DMRoomMap"; import DMRoomMap from "../utils/DMRoomMap";
@ -97,7 +97,10 @@ export async function addManagedHybridWidget(roomId: string): Promise<void> {
// Add the widget // Add the widget
try { try {
await WidgetUtils.setRoomWidgetContent(cli, roomId, widgetId, widgetContent); await WidgetUtils.setRoomWidgetContent(cli, roomId, widgetId, {
...widgetContent,
"io.element.managed_hybrid": true,
});
} catch (e) { } catch (e) {
logger.error(`Unable to add managed hybrid widget in room ${roomId}`, e); logger.error(`Unable to add managed hybrid widget in room ${roomId}`, e);
return; return;
@ -116,3 +119,7 @@ export async function addManagedHybridWidget(roomId: string): Promise<void> {
WidgetLayoutStore.instance.setContainerHeight(room, layout.container, layout.height); WidgetLayoutStore.instance.setContainerHeight(room, layout.container, layout.height);
WidgetLayoutStore.instance.copyLayoutToRoom(room); WidgetLayoutStore.instance.copyLayoutToRoom(room);
} }
export function isManagedHybridWidget(widget: IApp): boolean {
return !!widget["io.element.managed_hybrid"];
}

View file

@ -15,12 +15,19 @@ limitations under the License.
*/ */
import React from "react"; import React from "react";
import userEvent from "@testing-library/user-event";
import { CallType, MatrixCall } from "matrix-js-sdk/src/webrtc/call"; import { CallType, MatrixCall } from "matrix-js-sdk/src/webrtc/call";
import { EventType, JoinRule, MatrixClient, MatrixEvent, PendingEventOrdering, Room } from "matrix-js-sdk/src/matrix"; 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 RoomHeader from "../../../../src/components/views/rooms/RoomHeader";
import DMRoomMap from "../../../../src/utils/DMRoomMap"; import DMRoomMap from "../../../../src/utils/DMRoomMap";
import { MatrixClientPeg } from "../../../../src/MatrixClientPeg"; import { MatrixClientPeg } from "../../../../src/MatrixClientPeg";
@ -33,10 +40,13 @@ import dispatcher from "../../../../src/dispatcher/dispatcher";
import { CallStore } from "../../../../src/stores/CallStore"; import { CallStore } from "../../../../src/stores/CallStore";
import { Call, ElementCall } from "../../../../src/models/Call"; import { Call, ElementCall } from "../../../../src/models/Call";
import * as ShieldUtils from "../../../../src/utils/ShieldUtils"; import * as ShieldUtils from "../../../../src/utils/ShieldUtils";
import { Container, WidgetLayoutStore } from "../../../../src/stores/widgets/WidgetLayoutStore";
jest.mock("../../../../src/utils/ShieldUtils"); jest.mock("../../../../src/utils/ShieldUtils");
describe("RoomHeader", () => { describe("RoomHeader", () => {
filterConsole("[getType] Room !1:example.org does not have an m.room.create event");
let room: Room; let room: Room;
const ROOM_ID = "!1:example.org"; const ROOM_ID = "!1:example.org";
@ -94,7 +104,7 @@ describe("RoomHeader", () => {
withClientContextRenderOptions(MatrixClientPeg.get()!), withClientContextRenderOptions(MatrixClientPeg.get()!),
); );
await userEvent.click(getByText(container, ROOM_ID)); fireEvent.click(getByText(container, ROOM_ID));
expect(setCardSpy).toHaveBeenCalledWith({ phase: RightPanelPhases.RoomSummary }); expect(setCardSpy).toHaveBeenCalledWith({ phase: RightPanelPhases.RoomSummary });
}); });
@ -184,7 +194,7 @@ describe("RoomHeader", () => {
const facePile = getByLabelText(container, "4 members"); const facePile = getByLabelText(container, "4 members");
expect(facePile).toHaveTextContent("4"); expect(facePile).toHaveTextContent("4");
await userEvent.click(facePile); fireEvent.click(facePile);
expect(setCardSpy).toHaveBeenCalledWith({ phase: RightPanelPhases.RoomMemberList }); expect(setCardSpy).toHaveBeenCalledWith({ phase: RightPanelPhases.RoomMemberList });
}); });
@ -195,7 +205,7 @@ describe("RoomHeader", () => {
withClientContextRenderOptions(MatrixClientPeg.get()!), withClientContextRenderOptions(MatrixClientPeg.get()!),
); );
await userEvent.click(getByTitle(container, "Threads")); fireEvent.click(getByLabelText(container, "Threads"));
expect(setCardSpy).toHaveBeenCalledWith({ phase: RightPanelPhases.ThreadPanel }); expect(setCardSpy).toHaveBeenCalledWith({ phase: RightPanelPhases.ThreadPanel });
}); });
@ -209,7 +219,7 @@ describe("RoomHeader", () => {
withClientContextRenderOptions(MatrixClientPeg.get()!), withClientContextRenderOptions(MatrixClientPeg.get()!),
); );
await userEvent.click(getByTitle(container, "Notifications")); fireEvent.click(getByLabelText(container, "Notifications"));
expect(setCardSpy).toHaveBeenCalledWith({ phase: RightPanelPhases.NotificationPanel }); expect(setCardSpy).toHaveBeenCalledWith({ phase: RightPanelPhases.NotificationPanel });
}); });
@ -220,7 +230,7 @@ describe("RoomHeader", () => {
<RoomHeader room={room} />, <RoomHeader room={room} />,
withClientContextRenderOptions(MatrixClientPeg.get()!), 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(); expect(button).toBeDisabled();
} }
}); });
@ -231,17 +241,17 @@ describe("RoomHeader", () => {
<RoomHeader room={room} />, <RoomHeader room={room} />,
withClientContextRenderOptions(MatrixClientPeg.get()!), withClientContextRenderOptions(MatrixClientPeg.get()!),
); );
const voiceButton = getByTitle(container, "Voice call"); const voiceButton = getByLabelText(container, "Voice call");
const videoButton = getByTitle(container, "Video call"); const videoButton = getByLabelText(container, "Video call");
expect(voiceButton).not.toBeDisabled(); expect(voiceButton).not.toBeDisabled();
expect(videoButton).not.toBeDisabled(); expect(videoButton).not.toBeDisabled();
const placeCallSpy = jest.spyOn(LegacyCallHandler.instance, "placeCall"); const placeCallSpy = jest.spyOn(LegacyCallHandler.instance, "placeCall");
await userEvent.click(voiceButton); fireEvent.click(voiceButton);
expect(placeCallSpy).toHaveBeenLastCalledWith(room.roomId, CallType.Voice); expect(placeCallSpy).toHaveBeenLastCalledWith(room.roomId, CallType.Voice);
await userEvent.click(videoButton); fireEvent.click(videoButton);
expect(placeCallSpy).toHaveBeenLastCalledWith(room.roomId, CallType.Video); expect(placeCallSpy).toHaveBeenLastCalledWith(room.roomId, CallType.Video);
}); });
@ -255,7 +265,7 @@ describe("RoomHeader", () => {
<RoomHeader room={room} />, <RoomHeader room={room} />,
withClientContextRenderOptions(MatrixClientPeg.get()!), withClientContextRenderOptions(MatrixClientPeg.get()!),
); );
for (const button of getAllByTitle(container, "Ongoing call")) { for (const button of getAllByLabelText(container, "Ongoing call")) {
expect(button).toBeDisabled(); expect(button).toBeDisabled();
} }
}); });
@ -268,8 +278,8 @@ describe("RoomHeader", () => {
withClientContextRenderOptions(MatrixClientPeg.get()!), withClientContextRenderOptions(MatrixClientPeg.get()!),
); );
expect(getByTitle(container, "Voice call")).not.toBeDisabled(); expect(getByLabelText(container, "Voice call")).not.toBeDisabled();
expect(getByTitle(container, "Video call")).not.toBeDisabled(); expect(getByLabelText(container, "Video call")).not.toBeDisabled();
}); });
it("disable calls in large rooms by default", () => { it("disable calls in large rooms by default", () => {
@ -279,8 +289,12 @@ describe("RoomHeader", () => {
<RoomHeader room={room} />, <RoomHeader room={room} />,
withClientContextRenderOptions(MatrixClientPeg.get()!), withClientContextRenderOptions(MatrixClientPeg.get()!),
); );
expect(getByTitle(container, "You do not have permission to start voice calls")).toBeDisabled(); expect(
expect(getByTitle(container, "You do not have permission to start video calls")).toBeDisabled(); 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 () => { it("renders only the video call element", async () => {
mockRoomMembers(room, 3);
jest.spyOn(SdkConfig, "get").mockReturnValue({ use_exclusively: true }); jest.spyOn(SdkConfig, "get").mockReturnValue({ use_exclusively: true });
// allow element calls // allow element calls
jest.spyOn(room.currentState, "mayClientSendStateEvent").mockReturnValue(true); jest.spyOn(room.currentState, "mayClientSendStateEvent").mockReturnValue(true);
@ -301,28 +316,48 @@ describe("RoomHeader", () => {
expect(screen.queryByTitle("Voice call")).toBeNull(); expect(screen.queryByTitle("Voice call")).toBeNull();
const videoCallButton = getByTitle(container, "Video call"); const videoCallButton = getByLabelText(container, "Video call");
expect(videoCallButton).not.toBeDisabled(); expect(videoCallButton).not.toBeDisabled();
const dispatcherSpy = jest.spyOn(dispatcher, "dispatch"); 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 })); 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 }); jest.spyOn(SdkConfig, "get").mockReturnValue({ use_exclusively: true });
// allow element calls // allow element calls
jest.spyOn(room.currentState, "mayClientSendStateEvent").mockReturnValue(true); 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( const { container } = render(
<RoomHeader room={room} />, <RoomHeader room={room} />,
withClientContextRenderOptions(MatrixClientPeg.get()!), 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", () => { it("disables calling if there's a jitsi call", () => {
@ -335,7 +370,7 @@ describe("RoomHeader", () => {
<RoomHeader room={room} />, <RoomHeader room={room} />,
withClientContextRenderOptions(MatrixClientPeg.get()!), withClientContextRenderOptions(MatrixClientPeg.get()!),
); );
for (const button of getAllByTitle(container, "Ongoing call")) { for (const button of getAllByLabelText(container, "Ongoing call")) {
expect(button).toBeDisabled(); expect(button).toBeDisabled();
} }
}); });
@ -346,7 +381,7 @@ describe("RoomHeader", () => {
<RoomHeader room={room} />, <RoomHeader room={room} />,
withClientContextRenderOptions(MatrixClientPeg.get()!), 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(); expect(button).toBeDisabled();
} }
}); });
@ -358,16 +393,16 @@ describe("RoomHeader", () => {
withClientContextRenderOptions(MatrixClientPeg.get()!), withClientContextRenderOptions(MatrixClientPeg.get()!),
); );
const voiceButton = getByTitle(container, "Voice call"); const voiceButton = getByLabelText(container, "Voice call");
const videoButton = getByTitle(container, "Video call"); const videoButton = getByLabelText(container, "Video call");
expect(voiceButton).not.toBeDisabled(); expect(voiceButton).not.toBeDisabled();
expect(videoButton).not.toBeDisabled(); expect(videoButton).not.toBeDisabled();
const placeCallSpy = jest.spyOn(LegacyCallHandler.instance, "placeCall"); const placeCallSpy = jest.spyOn(LegacyCallHandler.instance, "placeCall");
await userEvent.click(voiceButton); fireEvent.click(voiceButton);
expect(placeCallSpy).toHaveBeenLastCalledWith(room.roomId, CallType.Voice); expect(placeCallSpy).toHaveBeenLastCalledWith(room.roomId, CallType.Voice);
await userEvent.click(videoButton); fireEvent.click(videoButton);
expect(placeCallSpy).toHaveBeenLastCalledWith(room.roomId, CallType.Video); expect(placeCallSpy).toHaveBeenLastCalledWith(room.roomId, CallType.Video);
}); });
@ -384,16 +419,16 @@ describe("RoomHeader", () => {
withClientContextRenderOptions(MatrixClientPeg.get()!), withClientContextRenderOptions(MatrixClientPeg.get()!),
); );
const voiceButton = getByTitle(container, "Voice call"); const voiceButton = getByLabelText(container, "Voice call");
const videoButton = getByTitle(container, "Video call"); const videoButton = getByLabelText(container, "Video call");
expect(voiceButton).not.toBeDisabled(); expect(voiceButton).not.toBeDisabled();
expect(videoButton).not.toBeDisabled(); expect(videoButton).not.toBeDisabled();
const placeCallSpy = jest.spyOn(LegacyCallHandler.instance, "placeCall"); const placeCallSpy = jest.spyOn(LegacyCallHandler.instance, "placeCall");
await userEvent.click(voiceButton); fireEvent.click(voiceButton);
expect(placeCallSpy).toHaveBeenLastCalledWith(room.roomId, CallType.Voice); expect(placeCallSpy).toHaveBeenLastCalledWith(room.roomId, CallType.Voice);
await userEvent.click(videoButton); fireEvent.click(videoButton);
expect(placeCallSpy).toHaveBeenLastCalledWith(room.roomId, CallType.Video); expect(placeCallSpy).toHaveBeenLastCalledWith(room.roomId, CallType.Video);
}); });
@ -411,17 +446,13 @@ describe("RoomHeader", () => {
withClientContextRenderOptions(MatrixClientPeg.get()!), withClientContextRenderOptions(MatrixClientPeg.get()!),
); );
const voiceButton = getByTitle(container, "Voice call"); const voiceButton = getByLabelText(container, "Voice call");
const videoButton = getByTitle(container, "Video call"); const videoButton = getByLabelText(container, "Video call");
expect(voiceButton).not.toBeDisabled(); expect(voiceButton).not.toBeDisabled();
expect(videoButton).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"); const dispatcherSpy = jest.spyOn(dispatcher, "dispatch");
await userEvent.click(videoButton); fireEvent.click(videoButton);
expect(dispatcherSpy).toHaveBeenCalledWith(expect.objectContaining({ view_call: true })); expect(dispatcherSpy).toHaveBeenCalledWith(expect.objectContaining({ view_call: true }));
}); });
}); });

View file

@ -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);" 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 <button
aria-label="There's no one here to call"
class="_icon-button_1segd_17" class="_icon-button_1segd_17"
data-state="closed" data-state="closed"
disabled="" disabled=""
style="--cpd-icon-button-size: 32px;" style="--cpd-icon-button-size: 32px;"
title="There's no one here to call"
> >
<div /> <div />
</button> </button>
<button <button
aria-label="There's no one here to call"
class="_icon-button_1segd_17" class="_icon-button_1segd_17"
data-state="closed" data-state="closed"
disabled="" disabled=""
style="--cpd-icon-button-size: 32px;" style="--cpd-icon-button-size: 32px;"
title="There's no one here to call"
> >
<div /> <div />
</button> </button>
<button <button
aria-label="Threads"
class="_icon-button_1segd_17" class="_icon-button_1segd_17"
data-state="closed" data-state="closed"
style="--cpd-icon-button-size: 32px;" style="--cpd-icon-button-size: 32px;"
title="Threads"
> >
<div /> <div />
</button> </button>