From b7be3011da65b8e4e4ced048607010419e1a5994 Mon Sep 17 00:00:00 2001 From: Robin Townsend Date: Fri, 9 Sep 2022 02:10:45 -0400 Subject: [PATCH] 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. --- src/room/GroupCallView.tsx | 126 +++++++++++++++++++++++++++---------- src/room/InCallView.tsx | 40 +++++++++++- src/room/RoomPage.tsx | 7 ++- src/room/useRoomParams.ts | 4 ++ 4 files changed, 141 insertions(+), 36 deletions(-) diff --git a/src/room/GroupCallView.tsx b/src/room/GroupCallView.tsx index cf0b401..4098d0d 100644 --- a/src/room/GroupCallView.tsx +++ b/src/room/GroupCallView.tsx @@ -19,6 +19,8 @@ import { useHistory } from "react-router-dom"; import { GroupCall, GroupCallState } from "matrix-js-sdk/src/webrtc/groupCall"; 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 { ErrorView, FullScreenView } from "../FullScreenView"; import { LobbyView } from "./LobbyView"; @@ -28,6 +30,8 @@ import { CallEndedView } from "./CallEndedView"; import { useRoomAvatar } from "./useRoomAvatar"; import { useSentryGroupCallHandler } from "./useSentryGroupCallHandler"; import { useLocationNavigation } from "../useLocationNavigation"; +import { useMediaHandler } from "../settings/useMediaHandler"; + declare global { interface Window { groupCall?: GroupCall; @@ -38,6 +42,7 @@ interface Props { client: MatrixClient; isPasswordlessUser: boolean; isEmbedded: boolean; + preload: boolean; hideHeader: boolean; roomIdOrAlias: string; groupCall: GroupCall; @@ -47,6 +52,7 @@ export function GroupCallView({ client, isPasswordlessUser, isEmbedded, + preload, hideHeader, roomIdOrAlias, groupCall, @@ -73,14 +79,50 @@ export function GroupCallView({ unencryptedEventsFromUsers, } = useGroupCall(groupCall); + const { setAudioInput, setVideoInput } = useMediaHandler(); + const avatarUrl = useRoomAvatar(groupCall.room); useEffect(() => { window.groupCall = groupCall; + return () => { + delete window.groupCall; + }; + }, [groupCall]); - // In embedded mode, bypass the lobby and just enter the call straight away - if (isEmbedded) groupCall.enter(); - }, [groupCall, isEmbedded]); + useEffect(() => { + if (widget && preload) { + // In preload mode, wait for a join action before entering + const onJoin = async (ev: CustomEvent) => { + 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 + groupCall.enter(); + } + }, [groupCall, isEmbedded, preload]); useSentryGroupCallHandler(groupCall); @@ -90,13 +132,31 @@ export function GroupCallView({ const history = useHistory(); const onLeave = useCallback(() => { - setLeft(true); leave(); + if (widget) { + widget.api.transport.send(ElementWidgetActions.HangupCall, {}); + widget.api.setAlwaysOnScreen(false); + } - if (!isPasswordlessUser) { + if (isPasswordlessUser) { + setLeft(true); + } else if (!isEmbedded) { history.push("/"); } - }, [leave, isPasswordlessUser, history]); + }, [leave, isPasswordlessUser, isEmbedded, history]); + + useEffect(() => { + if (widget && state === GroupCallState.Entered) { + const onHangup = async (ev: CustomEvent) => { + 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) { return ; @@ -148,33 +208,33 @@ export function GroupCallView({ ); } else if (left) { return ; + } else if (preload) { + return null; + } else if (isEmbedded) { + return ( + +

Loading room...

+
+ ); } else { - if (isEmbedded) { - return ( - -

Loading room...

-
- ); - } else { - return ( - - ); - } + return ( + + ); } } diff --git a/src/room/InCallView.tsx b/src/room/InCallView.tsx index 347cb5d..647136e 100644 --- a/src/room/InCallView.tsx +++ b/src/room/InCallView.tsx @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and 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 { MatrixClient } from "matrix-js-sdk/src/client"; 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 classNames from "classnames"; +import type { IWidgetApiRequest } from "matrix-widget-api"; import styles from "./InCallView.module.css"; import { HangupButton, @@ -52,6 +53,7 @@ import { useAudioContext } from "../video-grid/useMediaStream"; import { useFullscreen } from "../video-grid/useFullscreen"; import { AudioContainer } from "../video-grid/AudioContainer"; import { useAudioOutputDevice } from "../video-grid/useAudioOutputDevice"; +import { widget, ElementWidgetActions } from "../widget"; const canScreenshare = "getDisplayMedia" in (navigator.mediaDevices ?? {}); // 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); + useEffect(() => { + widget?.api.transport.send( + layout === "freedom" + ? ElementWidgetActions.TileLayout + : ElementWidgetActions.SpotlightLayout, + {} + ); + }, [layout]); + + useEffect(() => { + if (widget) { + const onTileLayout = async (ev: CustomEvent) => { + setLayout("freedom"); + await widget.api.transport.reply(ev.detail, {}); + }; + const onSpotlightLayout = async (ev: CustomEvent) => { + 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 participants: Participant[] = []; diff --git a/src/room/RoomPage.tsx b/src/room/RoomPage.tsx index 3c41118..1adff57 100644 --- a/src/room/RoomPage.tsx +++ b/src/room/RoomPage.tsx @@ -14,8 +14,9 @@ See the License for the specific language governing permissions and 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 { ErrorView, LoadingView } from "../FullScreenView"; import { RoomAuthView } from "./RoomAuthView"; @@ -34,6 +35,7 @@ export const RoomPage: FC = () => { roomId, viaServers, isEmbedded, + preload, hideHeader, isPtt, displayName, @@ -68,10 +70,11 @@ export const RoomPage: FC = () => { groupCall={groupCall} isPasswordlessUser={isPasswordlessUser} isEmbedded={isEmbedded} + preload={preload} hideHeader={hideHeader} /> ), - [client, roomIdOrAlias, isPasswordlessUser, isEmbedded, hideHeader] + [client, roomIdOrAlias, isPasswordlessUser, isEmbedded, preload, hideHeader] ); if (loading || isRegistering) { diff --git a/src/room/useRoomParams.ts b/src/room/useRoomParams.ts index 5edfe4e..095bf5f 100644 --- a/src/room/useRoomParams.ts +++ b/src/room/useRoomParams.ts @@ -24,6 +24,9 @@ export interface RoomParams { // Whether the app is running in embedded mode, and should keep the user // confined to the current room 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 hideHeader: boolean; // Whether to start a walkie-talkie call instead of a video call @@ -77,6 +80,7 @@ export const getRoomParams = ( roomId: getParam("roomId"), viaServers: getAllParams("via"), isEmbedded: hasParam("embed"), + preload: hasParam("preload"), hideHeader: hasParam("hideHeader"), isPtt: hasParam("ptt"), e2eEnabled: getParam("enableE2e") !== "false", // Defaults to true