Skip to content
This repository has been archived by the owner on Sep 11, 2024. It is now read-only.

Make video & voice call buttons pin conference widget if unpinned #11576

Merged
merged 11 commits into from
Sep 19, 2023
24 changes: 8 additions & 16 deletions src/components/views/rooms/RoomHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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";
Expand Down Expand Up @@ -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");
/**
Expand Down Expand Up @@ -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>
Expand All @@ -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>
Expand All @@ -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>
Expand All @@ -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>
Expand Down
219 changes: 219 additions & 0 deletions src/hooks/room/useRoomCall.ts
Original file line number Diff line number Diff line change
@@ -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,
};
};
Loading
Loading