Remove volume button in call interface in Firefox

Add click event for `CallingVolumeButton` and `CallingScreenShareButton`
in call interface to open their respective menu.

Remove localVideoRef and remoteVideoRef from CallProvider

Change-Id: I87601153cb3e24b38a5f764b11935e32402caa31
diff --git a/client/src/components/Button.tsx b/client/src/components/Button.tsx
index a89726c..4e0d5ac 100644
--- a/client/src/components/Button.tsx
+++ b/client/src/components/Button.tsx
@@ -108,6 +108,7 @@
 
 export type ExpandableButtonProps = IconButtonProps & {
   isVertical?: boolean;
+  expandMenuOnClick?: boolean;
   Icon?: ComponentType<SvgIconProps>;
   expandMenuOptions?: (ExpandMenuOption | ExpandMenuRadioOption)[];
   IconButtonComp?: ComponentType<IconButtonProps>;
@@ -116,6 +117,7 @@
 export const ExpandableButton = ({
   isVertical,
   Icon,
+  expandMenuOnClick,
   expandMenuOptions = undefined,
   IconButtonComp = IconButton,
   ...props
@@ -194,7 +196,18 @@
             />
           </IconButton>
         )}
-        <IconButtonComp {...props}>{Icon && <Icon />}</IconButtonComp>
+        <IconButtonComp
+          onClick={
+            expandMenuOnClick
+              ? (event) => {
+                  setAnchorEl(event.currentTarget);
+                }
+              : undefined
+          }
+          {...props}
+        >
+          {Icon && <Icon />}
+        </IconButtonComp>
       </Box>
     </>
   );
diff --git a/client/src/components/CallButtons.tsx b/client/src/components/CallButtons.tsx
index 2ecc4fc..d1beab8 100644
--- a/client/src/components/CallButtons.tsx
+++ b/client/src/components/CallButtons.tsx
@@ -152,6 +152,7 @@
   return (
     <CallButton
       aria-label="screen share"
+      expandMenuOnClick
       Icon={ScreenShareArrowIcon}
       expandMenuOptions={[
         {
@@ -206,17 +207,13 @@
 
 export const CallingVolumeButton = (props: ExpandableButtonProps) => {
   const options = useMediaDeviceExpandMenuOptions('audiooutput');
-  const { remoteVideoRef } = useContext(CallContext);
-
-  // Audio out options are only available on chrome and other browsers that support `setSinkId`
-  // https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement/setSinkId#browser_compatibility
-  const hasSetSinkId = remoteVideoRef.current?.setSinkId != null;
 
   return (
     <CallButton
       aria-label="volume options"
+      expandMenuOnClick
       Icon={VolumeIcon}
-      expandMenuOptions={hasSetSinkId ? options : undefined}
+      expandMenuOptions={options}
       {...props}
     />
   );
diff --git a/client/src/contexts/CallProvider.tsx b/client/src/contexts/CallProvider.tsx
index fdb6935..13e0d24 100644
--- a/client/src/contexts/CallProvider.tsx
+++ b/client/src/contexts/CallProvider.tsx
@@ -16,7 +16,7 @@
  * <https://www.gnu.org/licenses/>.
  */
 import { CallAction, CallBegin, WebSocketMessageType } from 'jami-web-common';
-import { createContext, MutableRefObject, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
+import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
 import { Navigate, useNavigate } from 'react-router-dom';
 
 import LoadingPage from '../components/Loading';
@@ -46,24 +46,10 @@
 };
 type CurrentMediaDeviceIds = Record<MediaDeviceKind, MediaDeviceIdState>;
 
-/**
- * HTMLVideoElement with the `sinkId` and `setSinkId` optional properties.
- *
- * These properties are defined only on supported browsers
- * https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement/setSinkId#browser_compatibility
- */
-interface VideoElementWithSinkId extends HTMLVideoElement {
-  sinkId?: string;
-  setSinkId?: (deviceId: string) => void;
-}
-
 export interface ICallContext {
   mediaDevices: MediaDevicesInfo;
   currentMediaDeviceIds: CurrentMediaDeviceIds;
 
-  localVideoRef: MutableRefObject<VideoElementWithSinkId | null>;
-  remoteVideoRef: MutableRefObject<VideoElementWithSinkId | null>;
-
   isAudioOn: boolean;
   setIsAudioOn: SetState<boolean>;
   isVideoOn: boolean;
@@ -101,9 +87,6 @@
     },
   },
 
-  localVideoRef: { current: null },
-  remoteVideoRef: { current: null },
-
   isAudioOn: false,
   setIsAudioOn: () => {},
   isVideoOn: false,
@@ -144,9 +127,6 @@
   const { conversationId, conversation } = useContext(ConversationContext);
   const navigate = useNavigate();
 
-  const localVideoRef = useRef<HTMLVideoElement | null>(null);
-  const remoteVideoRef = useRef<HTMLVideoElement | null>(null);
-
   const [mediaDevices, setMediaDevices] = useState(defaultCallContext.mediaDevices);
   const [audioInputDeviceId, setAudioInputDeviceId] = useState<string>();
   const [audioOutputDeviceId, setAudioOutputDeviceId] = useState<string>();
@@ -404,8 +384,6 @@
       value={{
         mediaDevices,
         currentMediaDeviceIds,
-        localVideoRef,
-        remoteVideoRef,
         isAudioOn,
         setIsAudioOn,
         isVideoOn,
diff --git a/client/src/pages/CallInterface.tsx b/client/src/pages/CallInterface.tsx
index b58b326..54c9b10 100644
--- a/client/src/pages/CallInterface.tsx
+++ b/client/src/pages/CallInterface.tsx
@@ -49,6 +49,7 @@
 import { CallContext, CallStatus } from '../contexts/CallProvider';
 import { ConversationContext } from '../contexts/ConversationProvider';
 import { WebRtcContext } from '../contexts/WebRtcProvider';
+import { VideoElementWithSinkId } from '../utils/utils';
 import { CallPending } from './CallPending';
 
 export default () => {
@@ -90,9 +91,9 @@
     currentMediaDeviceIds: {
       audiooutput: { id: audioOutDeviceId },
     },
-    localVideoRef,
-    remoteVideoRef,
   } = useContext(CallContext);
+  const localVideoRef = useRef<VideoElementWithSinkId | null>(null);
+  const remoteVideoRef = useRef<VideoElementWithSinkId | null>(null);
   const gridItemRef = useRef<HTMLDivElement | null>(null);
 
   useEffect(() => {
@@ -122,6 +123,8 @@
     }
   }, [audioOutDeviceId, remoteVideoRef]);
 
+  const hasSetSinkId = remoteVideoRef.current?.setSinkId != null;
+
   return (
     <Box display="flex" flexGrow={1}>
       <video
@@ -157,7 +160,7 @@
             </div>
           </Grid>
           <Grid item xs sx={{ display: 'flex', justifyContent: 'flex-end' }} ref={gridItemRef}>
-            <CallInterfaceSecondaryButtons gridItemRef={gridItemRef} />
+            <CallInterfaceSecondaryButtons showVolumeButton={hasSetSinkId} gridItemRef={gridItemRef} />
           </Grid>
         </Grid>
       </Box>
@@ -233,24 +236,34 @@
   CallingFullScreenButton,
 ];
 
-const CallInterfaceSecondaryButtons = (props: Props & { gridItemRef: RefObject<HTMLElement> }) => {
+const CallInterfaceSecondaryButtons = ({
+  gridItemRef,
+  showVolumeButton,
+}: Props & { showVolumeButton: boolean; gridItemRef: RefObject<HTMLElement> }) => {
   const stackRef = useRef<HTMLElement>(null);
 
   const [initialMeasurementDone, setInitialMeasurementDone] = useState(false);
   const [hiddenStackCount, setHiddenStackCount] = useState(0);
   const [hiddenMenuVisible, setHiddenMenuVisible] = useState(false);
 
+  // Audio out options are only available on Chrome and other browsers that support `setSinkId`.
+  // This removes the `CallingVolumeButton` if `setSinkId` is not defined.
+  // https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement/setSinkId#browser_compatibility
+  const secondaryButtons = useMemo(() => {
+    return showVolumeButton ? SECONDARY_BUTTONS : SECONDARY_BUTTONS.slice(1);
+  }, [showVolumeButton]);
+
   const calculateStackCount = useCallback(() => {
-    if (stackRef?.current && props.gridItemRef?.current) {
+    if (stackRef?.current && gridItemRef?.current) {
       const buttonWidth = stackRef.current.children[0].clientWidth;
-      const availableSpace = props.gridItemRef.current.clientWidth;
+      const availableSpace = gridItemRef.current.clientWidth;
       let availableButtons = Math.floor((availableSpace - 1) / buttonWidth);
-      if (availableButtons < SECONDARY_BUTTONS.length) {
+      if (availableButtons < secondaryButtons.length) {
         availableButtons -= 1; // Leave room for CallingMoreVerticalButton
       }
-      setHiddenStackCount(SECONDARY_BUTTONS.length - availableButtons);
+      setHiddenStackCount(secondaryButtons.length - availableButtons);
     }
-  }, [props.gridItemRef]);
+  }, [gridItemRef, secondaryButtons]);
 
   useLayoutEffect(() => {
     // Run once, at the beginning, for initial measurements
@@ -271,19 +284,20 @@
   const { displayedButtons, hiddenButtons } = useMemo(() => {
     const displayedButtons: ComponentType<ExpandableButtonProps>[] = [];
     const hiddenButtons: ComponentType<ExpandableButtonProps>[] = [];
-    SECONDARY_BUTTONS.forEach((button, i) => {
-      if (i < SECONDARY_BUTTONS.length - hiddenStackCount) {
+    for (let i = 0; i < secondaryButtons.length; i++) {
+      const button = secondaryButtons[i];
+      if (i < secondaryButtons.length - hiddenStackCount) {
         displayedButtons.push(button);
       } else {
         hiddenButtons.push(button);
       }
-    });
+    }
 
     return {
       displayedButtons,
       hiddenButtons,
     };
-  }, [hiddenStackCount]);
+  }, [hiddenStackCount, secondaryButtons]);
 
   return (
     <Card sx={{ backgroundColor: '#00000088', overflow: 'visible', height: '100%' }}>
diff --git a/client/src/pages/CallPending.tsx b/client/src/pages/CallPending.tsx
index 02f06e4..4bdbef4 100644
--- a/client/src/pages/CallPending.tsx
+++ b/client/src/pages/CallPending.tsx
@@ -17,7 +17,7 @@
  */
 
 import { Box, CircularProgress, Grid, IconButtonProps, Stack, Typography } from '@mui/material';
-import { ComponentType, ReactNode, useContext, useEffect, useMemo } from 'react';
+import { ComponentType, ReactNode, useContext, useEffect, useMemo, useRef } from 'react';
 import { useTranslation } from 'react-i18next';
 import { useLocation } from 'react-router-dom';
 
@@ -31,11 +31,13 @@
 import { CallContext, CallStatus } from '../contexts/CallProvider';
 import { ConversationContext } from '../contexts/ConversationProvider';
 import { WebRtcContext } from '../contexts/WebRtcProvider';
+import { VideoElementWithSinkId } from '../utils/utils';
 
 export const CallPending = () => {
   const { localStream } = useContext(WebRtcContext);
   const { conversation } = useContext(ConversationContext);
-  const { callRole, localVideoRef } = useContext(CallContext);
+  const { callRole } = useContext(CallContext);
+  const localVideoRef = useRef<VideoElementWithSinkId | null>(null);
 
   useEffect(() => {
     if (localStream && localVideoRef.current) {
diff --git a/client/src/utils/utils.ts b/client/src/utils/utils.ts
index ffe37a4..770c7a8 100644
--- a/client/src/utils/utils.ts
+++ b/client/src/utils/utils.ts
@@ -22,3 +22,14 @@
 };
 
 export type SetState<T> = Dispatch<SetStateAction<T>>;
+
+/**
+ * HTMLVideoElement with the `sinkId` and `setSinkId` optional properties.
+ *
+ * These properties are defined only on supported browsers
+ * https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement/setSinkId#browser_compatibility
+ */
+export interface VideoElementWithSinkId extends HTMLVideoElement {
+  sinkId?: string;
+  setSinkId?: (deviceId: string) => void;
+}