Add widget actions for joining and leaving calls and switching layouts
These actions are processed lazily to ensure that even if the app takes a while to start up, they won't be missed.
This commit is contained in:
		
					parent
					
						
							
								f0045c9406
							
						
					
				
			
			
				commit
				
					
						b7be3011da
					
				
			
		
					 4 changed files with 141 additions and 36 deletions
				
			
		| 
						 | 
					@ -19,6 +19,8 @@ import { useHistory } from "react-router-dom";
 | 
				
			||||||
import { GroupCall, GroupCallState } from "matrix-js-sdk/src/webrtc/groupCall";
 | 
					import { GroupCall, GroupCallState } from "matrix-js-sdk/src/webrtc/groupCall";
 | 
				
			||||||
import { MatrixClient } from "matrix-js-sdk/src/client";
 | 
					import { MatrixClient } from "matrix-js-sdk/src/client";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import type { IWidgetApiRequest } from "matrix-widget-api";
 | 
				
			||||||
 | 
					import { widget, ElementWidgetActions, JoinCallData } from "../widget";
 | 
				
			||||||
import { useGroupCall } from "./useGroupCall";
 | 
					import { useGroupCall } from "./useGroupCall";
 | 
				
			||||||
import { ErrorView, FullScreenView } from "../FullScreenView";
 | 
					import { ErrorView, FullScreenView } from "../FullScreenView";
 | 
				
			||||||
import { LobbyView } from "./LobbyView";
 | 
					import { LobbyView } from "./LobbyView";
 | 
				
			||||||
| 
						 | 
					@ -28,6 +30,8 @@ import { CallEndedView } from "./CallEndedView";
 | 
				
			||||||
import { useRoomAvatar } from "./useRoomAvatar";
 | 
					import { useRoomAvatar } from "./useRoomAvatar";
 | 
				
			||||||
import { useSentryGroupCallHandler } from "./useSentryGroupCallHandler";
 | 
					import { useSentryGroupCallHandler } from "./useSentryGroupCallHandler";
 | 
				
			||||||
import { useLocationNavigation } from "../useLocationNavigation";
 | 
					import { useLocationNavigation } from "../useLocationNavigation";
 | 
				
			||||||
 | 
					import { useMediaHandler } from "../settings/useMediaHandler";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
declare global {
 | 
					declare global {
 | 
				
			||||||
  interface Window {
 | 
					  interface Window {
 | 
				
			||||||
    groupCall?: GroupCall;
 | 
					    groupCall?: GroupCall;
 | 
				
			||||||
| 
						 | 
					@ -38,6 +42,7 @@ interface Props {
 | 
				
			||||||
  client: MatrixClient;
 | 
					  client: MatrixClient;
 | 
				
			||||||
  isPasswordlessUser: boolean;
 | 
					  isPasswordlessUser: boolean;
 | 
				
			||||||
  isEmbedded: boolean;
 | 
					  isEmbedded: boolean;
 | 
				
			||||||
 | 
					  preload: boolean;
 | 
				
			||||||
  hideHeader: boolean;
 | 
					  hideHeader: boolean;
 | 
				
			||||||
  roomIdOrAlias: string;
 | 
					  roomIdOrAlias: string;
 | 
				
			||||||
  groupCall: GroupCall;
 | 
					  groupCall: GroupCall;
 | 
				
			||||||
| 
						 | 
					@ -47,6 +52,7 @@ export function GroupCallView({
 | 
				
			||||||
  client,
 | 
					  client,
 | 
				
			||||||
  isPasswordlessUser,
 | 
					  isPasswordlessUser,
 | 
				
			||||||
  isEmbedded,
 | 
					  isEmbedded,
 | 
				
			||||||
 | 
					  preload,
 | 
				
			||||||
  hideHeader,
 | 
					  hideHeader,
 | 
				
			||||||
  roomIdOrAlias,
 | 
					  roomIdOrAlias,
 | 
				
			||||||
  groupCall,
 | 
					  groupCall,
 | 
				
			||||||
| 
						 | 
					@ -73,14 +79,50 @@ export function GroupCallView({
 | 
				
			||||||
    unencryptedEventsFromUsers,
 | 
					    unencryptedEventsFromUsers,
 | 
				
			||||||
  } = useGroupCall(groupCall);
 | 
					  } = useGroupCall(groupCall);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const { setAudioInput, setVideoInput } = useMediaHandler();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const avatarUrl = useRoomAvatar(groupCall.room);
 | 
					  const avatarUrl = useRoomAvatar(groupCall.room);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  useEffect(() => {
 | 
					  useEffect(() => {
 | 
				
			||||||
    window.groupCall = groupCall;
 | 
					    window.groupCall = groupCall;
 | 
				
			||||||
 | 
					    return () => {
 | 
				
			||||||
 | 
					      delete window.groupCall;
 | 
				
			||||||
 | 
					    };
 | 
				
			||||||
 | 
					  }, [groupCall]);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  useEffect(() => {
 | 
				
			||||||
 | 
					    if (widget && preload) {
 | 
				
			||||||
 | 
					      // In preload mode, wait for a join action before entering
 | 
				
			||||||
 | 
					      const onJoin = async (ev: CustomEvent<IWidgetApiRequest>) => {
 | 
				
			||||||
 | 
					        const { audioInput, videoInput } = ev.detail
 | 
				
			||||||
 | 
					          .data as unknown as JoinCallData;
 | 
				
			||||||
 | 
					        if (audioInput !== null) setAudioInput(audioInput);
 | 
				
			||||||
 | 
					        if (videoInput !== null) setVideoInput(videoInput);
 | 
				
			||||||
 | 
					        await Promise.all([
 | 
				
			||||||
 | 
					          groupCall.setMicrophoneMuted(audioInput === null),
 | 
				
			||||||
 | 
					          groupCall.setLocalVideoMuted(videoInput === null),
 | 
				
			||||||
 | 
					        ]);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        await groupCall.enter();
 | 
				
			||||||
 | 
					        await Promise.all([
 | 
				
			||||||
 | 
					          widget.api.setAlwaysOnScreen(true),
 | 
				
			||||||
 | 
					          widget.api.transport.reply(ev.detail, {}),
 | 
				
			||||||
 | 
					        ]);
 | 
				
			||||||
 | 
					      };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      widget.lazyActions.on(ElementWidgetActions.JoinCall, onJoin);
 | 
				
			||||||
 | 
					      return () => {
 | 
				
			||||||
 | 
					        widget.lazyActions.off(ElementWidgetActions.JoinCall, onJoin);
 | 
				
			||||||
 | 
					      };
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }, [groupCall, preload, setAudioInput, setVideoInput]);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  useEffect(() => {
 | 
				
			||||||
 | 
					    if (isEmbedded && !preload) {
 | 
				
			||||||
      // In embedded mode, bypass the lobby and just enter the call straight away
 | 
					      // In embedded mode, bypass the lobby and just enter the call straight away
 | 
				
			||||||
    if (isEmbedded) groupCall.enter();
 | 
					      groupCall.enter();
 | 
				
			||||||
  }, [groupCall, isEmbedded]);
 | 
					    }
 | 
				
			||||||
 | 
					  }, [groupCall, isEmbedded, preload]);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  useSentryGroupCallHandler(groupCall);
 | 
					  useSentryGroupCallHandler(groupCall);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					@ -90,13 +132,31 @@ export function GroupCallView({
 | 
				
			||||||
  const history = useHistory();
 | 
					  const history = useHistory();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const onLeave = useCallback(() => {
 | 
					  const onLeave = useCallback(() => {
 | 
				
			||||||
    setLeft(true);
 | 
					 | 
				
			||||||
    leave();
 | 
					    leave();
 | 
				
			||||||
 | 
					    if (widget) {
 | 
				
			||||||
 | 
					      widget.api.transport.send(ElementWidgetActions.HangupCall, {});
 | 
				
			||||||
 | 
					      widget.api.setAlwaysOnScreen(false);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    if (!isPasswordlessUser) {
 | 
					    if (isPasswordlessUser) {
 | 
				
			||||||
 | 
					      setLeft(true);
 | 
				
			||||||
 | 
					    } else if (!isEmbedded) {
 | 
				
			||||||
      history.push("/");
 | 
					      history.push("/");
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
  }, [leave, isPasswordlessUser, history]);
 | 
					  }, [leave, isPasswordlessUser, isEmbedded, history]);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  useEffect(() => {
 | 
				
			||||||
 | 
					    if (widget && state === GroupCallState.Entered) {
 | 
				
			||||||
 | 
					      const onHangup = async (ev: CustomEvent<IWidgetApiRequest>) => {
 | 
				
			||||||
 | 
					        leave();
 | 
				
			||||||
 | 
					        await widget.api.transport.reply(ev.detail, {});
 | 
				
			||||||
 | 
					      };
 | 
				
			||||||
 | 
					      widget.lazyActions.once(ElementWidgetActions.HangupCall, onHangup);
 | 
				
			||||||
 | 
					      return () => {
 | 
				
			||||||
 | 
					        widget.lazyActions.off(ElementWidgetActions.HangupCall, onHangup);
 | 
				
			||||||
 | 
					      };
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }, [groupCall, state, leave]);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  if (error) {
 | 
					  if (error) {
 | 
				
			||||||
    return <ErrorView error={error} />;
 | 
					    return <ErrorView error={error} />;
 | 
				
			||||||
| 
						 | 
					@ -148,8 +208,9 @@ export function GroupCallView({
 | 
				
			||||||
    );
 | 
					    );
 | 
				
			||||||
  } else if (left) {
 | 
					  } else if (left) {
 | 
				
			||||||
    return <CallEndedView client={client} />;
 | 
					    return <CallEndedView client={client} />;
 | 
				
			||||||
  } else {
 | 
					  } else if (preload) {
 | 
				
			||||||
    if (isEmbedded) {
 | 
					    return null;
 | 
				
			||||||
 | 
					  } else if (isEmbedded) {
 | 
				
			||||||
    return (
 | 
					    return (
 | 
				
			||||||
      <FullScreenView>
 | 
					      <FullScreenView>
 | 
				
			||||||
        <h1>Loading room...</h1>
 | 
					        <h1>Loading room...</h1>
 | 
				
			||||||
| 
						 | 
					@ -177,4 +238,3 @@ export function GroupCallView({
 | 
				
			||||||
    );
 | 
					    );
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
}
 | 
					 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
 | 
				
			||||||
limitations under the License.
 | 
					limitations under the License.
 | 
				
			||||||
*/
 | 
					*/
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import React, { useCallback, useMemo, useRef } from "react";
 | 
					import React, { useEffect, useCallback, useMemo, useRef } from "react";
 | 
				
			||||||
import { usePreventScroll } from "@react-aria/overlays";
 | 
					import { usePreventScroll } from "@react-aria/overlays";
 | 
				
			||||||
import { MatrixClient } from "matrix-js-sdk/src/client";
 | 
					import { MatrixClient } from "matrix-js-sdk/src/client";
 | 
				
			||||||
import { RoomMember } from "matrix-js-sdk/src/models/room-member";
 | 
					import { RoomMember } from "matrix-js-sdk/src/models/room-member";
 | 
				
			||||||
| 
						 | 
					@ -22,6 +22,7 @@ import { GroupCall } from "matrix-js-sdk/src/webrtc/groupCall";
 | 
				
			||||||
import { CallFeed } from "matrix-js-sdk/src/webrtc/callFeed";
 | 
					import { CallFeed } from "matrix-js-sdk/src/webrtc/callFeed";
 | 
				
			||||||
import classNames from "classnames";
 | 
					import classNames from "classnames";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import type { IWidgetApiRequest } from "matrix-widget-api";
 | 
				
			||||||
import styles from "./InCallView.module.css";
 | 
					import styles from "./InCallView.module.css";
 | 
				
			||||||
import {
 | 
					import {
 | 
				
			||||||
  HangupButton,
 | 
					  HangupButton,
 | 
				
			||||||
| 
						 | 
					@ -52,6 +53,7 @@ import { useAudioContext } from "../video-grid/useMediaStream";
 | 
				
			||||||
import { useFullscreen } from "../video-grid/useFullscreen";
 | 
					import { useFullscreen } from "../video-grid/useFullscreen";
 | 
				
			||||||
import { AudioContainer } from "../video-grid/AudioContainer";
 | 
					import { AudioContainer } from "../video-grid/AudioContainer";
 | 
				
			||||||
import { useAudioOutputDevice } from "../video-grid/useAudioOutputDevice";
 | 
					import { useAudioOutputDevice } from "../video-grid/useAudioOutputDevice";
 | 
				
			||||||
 | 
					import { widget, ElementWidgetActions } from "../widget";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const canScreenshare = "getDisplayMedia" in (navigator.mediaDevices ?? {});
 | 
					const canScreenshare = "getDisplayMedia" in (navigator.mediaDevices ?? {});
 | 
				
			||||||
// There is currently a bug in Safari our our code with cloning and sending MediaStreams
 | 
					// There is currently a bug in Safari our our code with cloning and sending MediaStreams
 | 
				
			||||||
| 
						 | 
					@ -124,6 +126,42 @@ export function InCallView({
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  useAudioOutputDevice(audioRef, audioOutput);
 | 
					  useAudioOutputDevice(audioRef, audioOutput);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  useEffect(() => {
 | 
				
			||||||
 | 
					    widget?.api.transport.send(
 | 
				
			||||||
 | 
					      layout === "freedom"
 | 
				
			||||||
 | 
					        ? ElementWidgetActions.TileLayout
 | 
				
			||||||
 | 
					        : ElementWidgetActions.SpotlightLayout,
 | 
				
			||||||
 | 
					      {}
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					  }, [layout]);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  useEffect(() => {
 | 
				
			||||||
 | 
					    if (widget) {
 | 
				
			||||||
 | 
					      const onTileLayout = async (ev: CustomEvent<IWidgetApiRequest>) => {
 | 
				
			||||||
 | 
					        setLayout("freedom");
 | 
				
			||||||
 | 
					        await widget.api.transport.reply(ev.detail, {});
 | 
				
			||||||
 | 
					      };
 | 
				
			||||||
 | 
					      const onSpotlightLayout = async (ev: CustomEvent<IWidgetApiRequest>) => {
 | 
				
			||||||
 | 
					        setLayout("spotlight");
 | 
				
			||||||
 | 
					        await widget.api.transport.reply(ev.detail, {});
 | 
				
			||||||
 | 
					      };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      widget.lazyActions.on(ElementWidgetActions.TileLayout, onTileLayout);
 | 
				
			||||||
 | 
					      widget.lazyActions.on(
 | 
				
			||||||
 | 
					        ElementWidgetActions.SpotlightLayout,
 | 
				
			||||||
 | 
					        onSpotlightLayout
 | 
				
			||||||
 | 
					      );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      return () => {
 | 
				
			||||||
 | 
					        widget.lazyActions.off(ElementWidgetActions.TileLayout, onTileLayout);
 | 
				
			||||||
 | 
					        widget.lazyActions.off(
 | 
				
			||||||
 | 
					          ElementWidgetActions.SpotlightLayout,
 | 
				
			||||||
 | 
					          onSpotlightLayout
 | 
				
			||||||
 | 
					        );
 | 
				
			||||||
 | 
					      };
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }, [setLayout]);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const items = useMemo(() => {
 | 
					  const items = useMemo(() => {
 | 
				
			||||||
    const participants: Participant[] = [];
 | 
					    const participants: Participant[] = [];
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -14,8 +14,9 @@ See the License for the specific language governing permissions and
 | 
				
			||||||
limitations under the License.
 | 
					limitations under the License.
 | 
				
			||||||
*/
 | 
					*/
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import React, { FC, useEffect, useState } from "react";
 | 
					import React, { FC, useEffect, useState, useCallback } from "react";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import type { GroupCall } from "matrix-js-sdk/src/webrtc/groupCall";
 | 
				
			||||||
import { useClient } from "../ClientContext";
 | 
					import { useClient } from "../ClientContext";
 | 
				
			||||||
import { ErrorView, LoadingView } from "../FullScreenView";
 | 
					import { ErrorView, LoadingView } from "../FullScreenView";
 | 
				
			||||||
import { RoomAuthView } from "./RoomAuthView";
 | 
					import { RoomAuthView } from "./RoomAuthView";
 | 
				
			||||||
| 
						 | 
					@ -34,6 +35,7 @@ export const RoomPage: FC = () => {
 | 
				
			||||||
    roomId,
 | 
					    roomId,
 | 
				
			||||||
    viaServers,
 | 
					    viaServers,
 | 
				
			||||||
    isEmbedded,
 | 
					    isEmbedded,
 | 
				
			||||||
 | 
					    preload,
 | 
				
			||||||
    hideHeader,
 | 
					    hideHeader,
 | 
				
			||||||
    isPtt,
 | 
					    isPtt,
 | 
				
			||||||
    displayName,
 | 
					    displayName,
 | 
				
			||||||
| 
						 | 
					@ -68,10 +70,11 @@ export const RoomPage: FC = () => {
 | 
				
			||||||
        groupCall={groupCall}
 | 
					        groupCall={groupCall}
 | 
				
			||||||
        isPasswordlessUser={isPasswordlessUser}
 | 
					        isPasswordlessUser={isPasswordlessUser}
 | 
				
			||||||
        isEmbedded={isEmbedded}
 | 
					        isEmbedded={isEmbedded}
 | 
				
			||||||
 | 
					        preload={preload}
 | 
				
			||||||
        hideHeader={hideHeader}
 | 
					        hideHeader={hideHeader}
 | 
				
			||||||
      />
 | 
					      />
 | 
				
			||||||
    ),
 | 
					    ),
 | 
				
			||||||
    [client, roomIdOrAlias, isPasswordlessUser, isEmbedded, hideHeader]
 | 
					    [client, roomIdOrAlias, isPasswordlessUser, isEmbedded, preload, hideHeader]
 | 
				
			||||||
  );
 | 
					  );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  if (loading || isRegistering) {
 | 
					  if (loading || isRegistering) {
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -24,6 +24,9 @@ export interface RoomParams {
 | 
				
			||||||
  // Whether the app is running in embedded mode, and should keep the user
 | 
					  // Whether the app is running in embedded mode, and should keep the user
 | 
				
			||||||
  // confined to the current room
 | 
					  // confined to the current room
 | 
				
			||||||
  isEmbedded: boolean;
 | 
					  isEmbedded: boolean;
 | 
				
			||||||
 | 
					  // Whether the app should pause before joining the call until it sees an
 | 
				
			||||||
 | 
					  // io.element.join widget action, allowing it to be preloaded
 | 
				
			||||||
 | 
					  preload: boolean;
 | 
				
			||||||
  // Whether to hide the room header when in a call
 | 
					  // Whether to hide the room header when in a call
 | 
				
			||||||
  hideHeader: boolean;
 | 
					  hideHeader: boolean;
 | 
				
			||||||
  // Whether to start a walkie-talkie call instead of a video call
 | 
					  // Whether to start a walkie-talkie call instead of a video call
 | 
				
			||||||
| 
						 | 
					@ -77,6 +80,7 @@ export const getRoomParams = (
 | 
				
			||||||
    roomId: getParam("roomId"),
 | 
					    roomId: getParam("roomId"),
 | 
				
			||||||
    viaServers: getAllParams("via"),
 | 
					    viaServers: getAllParams("via"),
 | 
				
			||||||
    isEmbedded: hasParam("embed"),
 | 
					    isEmbedded: hasParam("embed"),
 | 
				
			||||||
 | 
					    preload: hasParam("preload"),
 | 
				
			||||||
    hideHeader: hasParam("hideHeader"),
 | 
					    hideHeader: hasParam("hideHeader"),
 | 
				
			||||||
    isPtt: hasParam("ptt"),
 | 
					    isPtt: hasParam("ptt"),
 | 
				
			||||||
    e2eEnabled: getParam("enableE2e") !== "false", // Defaults to true
 | 
					    e2eEnabled: getParam("enableE2e") !== "false", // Defaults to true
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue