Merge branch 'main' into livekit-experiment

This commit is contained in:
Robin Townsend
2023-06-09 17:22:34 -04:00
117 changed files with 6411 additions and 1414 deletions

View File

@@ -17,20 +17,31 @@ limitations under the License.
.headline {
text-align: center;
margin-bottom: 60px;
white-space: pre;
}
.callEndedContent {
text-align: center;
max-width: 360px;
max-width: 450px;
}
.callEndedContent p {
font-size: var(--font-size-subtitle);
}
.callEndedContent h3 {
margin-bottom: 32px;
}
.callEndedButton {
margin-top: 54px;
margin-left: 30px;
margin-right: 30px !important;
}
.submitButton {
width: 100%;
margin-top: 54px;
margin-left: 30px;
margin-right: 30px !important;
}
.container {

View File

@@ -14,19 +14,130 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React from "react";
import React, { FormEventHandler, useCallback, useState } from "react";
import { MatrixClient } from "matrix-js-sdk/src/client";
import { Trans, useTranslation } from "react-i18next";
import { useHistory } from "react-router-dom";
import styles from "./CallEndedView.module.css";
import { LinkButton } from "../button";
import feedbackStyle from "../input/FeedbackInput.module.css";
import { Button, LinkButton } from "../button";
import { useProfile } from "../profile/useProfile";
import { Subtitle, Body, Link, Headline } from "../typography/Typography";
import { Body, Link, Headline } from "../typography/Typography";
import { Header, HeaderLogo, LeftNav, RightNav } from "../Header";
import { PosthogAnalytics } from "../analytics/PosthogAnalytics";
import { FieldRow, InputField } from "../input/Input";
import { StarRatingInput } from "../input/StarRatingInput";
export function CallEndedView({ client }: { client: MatrixClient }) {
export function CallEndedView({
client,
isPasswordlessUser,
endedCallId,
}: {
client: MatrixClient;
isPasswordlessUser: boolean;
endedCallId: string;
}) {
const { t } = useTranslation();
const history = useHistory();
const { displayName } = useProfile(client);
const [surveySubmitted, setSurverySubmitted] = useState(false);
const [starRating, setStarRating] = useState(0);
const [submitting, setSubmitting] = useState(false);
const [submitDone, setSubmitDone] = useState(false);
const submitSurvery: FormEventHandler<HTMLFormElement> = useCallback(
(e) => {
e.preventDefault();
const data = new FormData(e.target as HTMLFormElement);
const feedbackText = data.get("feedbackText") as string;
PosthogAnalytics.instance.eventQualitySurvey.track(
endedCallId,
feedbackText,
starRating
);
setSubmitting(true);
setTimeout(() => {
setSubmitDone(true);
setTimeout(() => {
if (isPasswordlessUser) {
// setting this renders the callEndedView with the invitation to create an account
setSurverySubmitted(true);
} else {
// if the user already has an account immediately go back to the home screen
history.push("/");
}
}, 1000);
}, 1000);
},
[endedCallId, history, isPasswordlessUser, starRating]
);
const createAccountDialog = isPasswordlessUser && (
<div className={styles.callEndedContent}>
<Trans>
<p>Why not finish by setting up a password to keep your account?</p>
<p>
You'll be able to keep your name and set an avatar for use on future
calls
</p>
</Trans>
<LinkButton
className={styles.callEndedButton}
size="lg"
variant="default"
to="/register"
>
{t("Create account")}
</LinkButton>
</div>
);
const qualitySurveyDialog = (
<div className={styles.callEndedContent}>
<Trans>
<p>
We'd love to hear your feedback so we can improve your experience.
</p>
</Trans>
<form onSubmit={submitSurvery}>
<FieldRow>
<StarRatingInput starCount={5} onChange={setStarRating} required />
</FieldRow>
<FieldRow>
<InputField
className={feedbackStyle.feedback}
id="feedbackText"
name="feedbackText"
label={t("Your feedback")}
placeholder={t("Your feedback")}
type="textarea"
required
/>
</FieldRow>{" "}
<FieldRow>
{submitDone ? (
<Trans>
<p>Thanks for your feedback!</p>
</Trans>
) : (
<Button
type="submit"
className={styles.submitButton}
size="lg"
variant="default"
data-testid="home_go"
>
{submitting ? t("Submitting…") : t("Submit")}
</Button>
)}
</FieldRow>
</form>
</div>
);
return (
<>
@@ -39,27 +150,19 @@ export function CallEndedView({ client }: { client: MatrixClient }) {
<div className={styles.container}>
<main className={styles.main}>
<Headline className={styles.headline}>
{t("{{displayName}}, your call is now ended", { displayName })}
{surveySubmitted
? t("{{displayName}}, your call has ended.", {
displayName,
})
: t("{{displayName}}, your call has ended.", {
displayName,
}) +
"\n" +
t("How did it go?")}
</Headline>
<div className={styles.callEndedContent}>
<Trans>
<Subtitle>
Why not finish by setting up a password to keep your account?
</Subtitle>
<Subtitle>
You'll be able to keep your name and set an avatar for use on
future calls
</Subtitle>
</Trans>
<LinkButton
className={styles.callEndedButton}
size="lg"
variant="default"
to="/register"
>
{t("Create account")}
</LinkButton>
</div>
{!surveySubmitted && PosthogAnalytics.instance.isEnabled()
? qualitySurveyDialog
: createAccountDialog}
</main>
<Body className={styles.footer}>
<Link color="primary" to="/">

View File

@@ -1,114 +0,0 @@
/*
Copyright 2022 New Vector Ltd
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 React, { useCallback, useEffect } from "react";
import { randomString } from "matrix-js-sdk/src/randomstring";
import { useTranslation } from "react-i18next";
import { Modal, ModalContent } from "../Modal";
import { Button } from "../button";
import { FieldRow, InputField, ErrorMessage } from "../input/Input";
import {
useSubmitRageshake,
useRageshakeRequest,
} from "../settings/submit-rageshake";
import { Body } from "../typography/Typography";
interface Props {
inCall: boolean;
roomId: string;
onClose?: () => void;
// TODO: add all props for for <Modal>
[index: string]: unknown;
}
export function FeedbackModal({ inCall, roomId, onClose, ...rest }: Props) {
const { t } = useTranslation();
const { submitRageshake, sending, sent, error } = useSubmitRageshake();
const sendRageshakeRequest = useRageshakeRequest();
const onSubmitFeedback = useCallback(
(e) => {
e.preventDefault();
const data = new FormData(e.target);
const descriptionData = data.get("description");
const description =
typeof descriptionData === "string" ? descriptionData : "";
const sendLogs = Boolean(data.get("sendLogs"));
const rageshakeRequestId = randomString(16);
submitRageshake({
description,
sendLogs,
rageshakeRequestId,
roomId,
});
if (inCall && sendLogs) {
sendRageshakeRequest(roomId, rageshakeRequestId);
}
},
[inCall, submitRageshake, roomId, sendRageshakeRequest]
);
useEffect(() => {
if (sent) {
onClose();
}
}, [sent, onClose]);
return (
<Modal
title={t("Submit feedback")}
isDismissable
onClose={onClose}
{...rest}
>
<ModalContent>
<Body>{t("Having trouble? Help us fix it.")}</Body>
<form onSubmit={onSubmitFeedback}>
<FieldRow>
<InputField
id="description"
name="description"
label={t("Description (optional)")}
type="textarea"
/>
</FieldRow>
<FieldRow>
<InputField
id="sendLogs"
name="sendLogs"
label={t("Include debug logs")}
type="checkbox"
defaultChecked
/>
</FieldRow>
{error && (
<FieldRow>
<ErrorMessage error={error} />
</FieldRow>
)}
<FieldRow>
<Button type="submit" disabled={sending}>
{sending ? t("Submitting feedback…") : t("Submit feedback")}
</Button>
</FieldRow>
</form>
</ModalContent>
</Modal>
);
}

View File

@@ -28,15 +28,25 @@ import ReactJson, { CollapsedFieldProps } from "react-json-view";
import mermaid from "mermaid";
import { Item } from "@react-stately/collections";
import { MatrixEvent, IContent } from "matrix-js-sdk/src/models/event";
import { GroupCall } from "matrix-js-sdk/src/webrtc/groupCall";
import {
GroupCall,
GroupCallError,
GroupCallEvent,
} from "matrix-js-sdk/src/webrtc/groupCall";
import { ClientEvent, MatrixClient } from "matrix-js-sdk/src/client";
import { RoomStateEvent } from "matrix-js-sdk/src/models/room-state";
import { CallEvent } from "matrix-js-sdk/src/webrtc/call";
import {
CallEvent,
CallState,
CallError,
MatrixCall,
VoipEvent,
} from "matrix-js-sdk/src/webrtc/call";
import styles from "./GroupCallInspector.module.css";
import { SelectInput } from "../input/SelectInput";
import { PosthogAnalytics } from "../PosthogAnalytics";
import { MediaViewer } from "../inspectors/MediaInspector";
import { PosthogAnalytics } from "../analytics/PosthogAnalytics";
import { OTelGroupCallMembership } from "../otel/OTelGroupCallMembership";
interface InspectorContextState {
eventsByUserId?: { [userId: string]: SequenceDiagramMatrixEvent[] };
@@ -236,7 +246,7 @@ function reducer(
action: {
type?: CallEvent | ClientEvent | RoomStateEvent;
event?: MatrixEvent;
rawEvent?: Record<string, unknown>;
rawEvent?: VoipEvent;
callStateEvent?: MatrixEvent;
memberStateEvents?: MatrixEvent[];
}
@@ -354,7 +364,7 @@ function reducer(
function useGroupCallState(
client: MatrixClient,
groupCall: GroupCall,
showPollCallStats: boolean
otelGroupCallMembership: OTelGroupCallMembership
): InspectorContextState {
const [state, dispatch] = useReducer(reducer, {
localUserId: client.getUserId(),
@@ -382,28 +392,55 @@ function useGroupCallState(
callStateEvent,
memberStateEvents,
});
otelGroupCallMembership?.onUpdateRoomState(event);
}
function onReceivedVoipEvent(event: MatrixEvent) {
dispatch({ type: ClientEvent.ReceivedVoipEvent, event });
otelGroupCallMembership?.onReceivedVoipEvent(event);
}
function onSendVoipEvent(event: Record<string, unknown>) {
function onSendVoipEvent(event: VoipEvent, call: MatrixCall) {
dispatch({ type: CallEvent.SendVoipEvent, rawEvent: event });
otelGroupCallMembership?.onSendEvent(call, event);
}
function onCallStateChange(
newState: CallState,
_: CallState,
call: MatrixCall
) {
otelGroupCallMembership?.onCallStateChange(call, newState);
}
function onCallError(error: CallError, call: MatrixCall) {
otelGroupCallMembership.onCallError(error, call);
}
function onGroupCallError(error: GroupCallError) {
otelGroupCallMembership.onGroupCallError(error);
}
function onUndecryptableToDevice(event: MatrixEvent) {
dispatch({ type: ClientEvent.ReceivedVoipEvent, event });
Sentry.captureMessage("Undecryptable to-device Event");
// probably unnecessary if it's now captured via otel?
PosthogAnalytics.instance.eventUndecryptableToDevice.track(
groupCall.groupCallId
);
otelGroupCallMembership.onUndecryptableToDevice(event);
}
client.on(RoomStateEvent.Events, onUpdateRoomState);
//groupCall.on("calls_changed", onCallsChanged);
groupCall.on(CallEvent.SendVoipEvent, onSendVoipEvent);
groupCall.on(CallEvent.State, onCallStateChange);
groupCall.on(CallEvent.Error, onCallError);
groupCall.on(GroupCallEvent.Error, onGroupCallError);
//client.on("state", onCallsChanged);
//client.on("hangup", onCallHangup);
client.on(ClientEvent.ReceivedVoipEvent, onReceivedVoipEvent);
@@ -413,8 +450,10 @@ function useGroupCallState(
return () => {
client.removeListener(RoomStateEvent.Events, onUpdateRoomState);
//groupCall.removeListener("calls_changed", onCallsChanged);
groupCall.removeListener(CallEvent.SendVoipEvent, onSendVoipEvent);
groupCall.removeListener(CallEvent.State, onCallStateChange);
groupCall.removeListener(CallEvent.Error, onCallError);
groupCall.removeListener(GroupCallEvent.Error, onGroupCallError);
//client.removeListener("state", onCallsChanged);
//client.removeListener("hangup", onCallHangup);
client.removeListener(ClientEvent.ReceivedVoipEvent, onReceivedVoipEvent);
@@ -423,7 +462,7 @@ function useGroupCallState(
onUndecryptableToDevice
);
};
}, [client, groupCall]);
}, [client, groupCall, otelGroupCallMembership]);
return state;
}
@@ -431,17 +470,19 @@ function useGroupCallState(
interface GroupCallInspectorProps {
client: MatrixClient;
groupCall: GroupCall;
otelGroupCallMembership: OTelGroupCallMembership;
show: boolean;
}
export function GroupCallInspector({
client,
groupCall,
otelGroupCallMembership,
show,
}: GroupCallInspectorProps) {
const [currentTab, setCurrentTab] = useState("sequence-diagrams");
const [selectedUserId, setSelectedUserId] = useState<string>();
const state = useGroupCallState(client, groupCall, show);
const state = useGroupCallState(client, groupCall, otelGroupCallMembership);
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const [_, setState] = useContext(InspectorContext);
@@ -465,7 +506,6 @@ export function GroupCallInspector({
Sequence Diagrams
</button>
<button onClick={() => setCurrentTab("inspector")}>Inspector</button>
<button onClick={() => setCurrentTab("voip")}>Media</button>
</div>
{currentTab === "sequence-diagrams" && (
<SequenceDiagramViewer
@@ -489,14 +529,6 @@ export function GroupCallInspector({
style={{ height: "100%", overflowY: "scroll" }}
/>
)}
{currentTab === "voip" && (
<MediaViewer
client={client}
groupCall={groupCall}
userMediaFeeds={groupCall.userMediaFeeds}
screenshareFeeds={groupCall.screenshareFeeds}
/>
)}
</Resizable>
);
}

View File

@@ -51,7 +51,7 @@ export function GroupCallLoader({
if (loading) {
return (
<FullScreenView>
<h1>{t("Loading room…")}</h1>
<h1>{t("Loading…")}</h1>
</FullScreenView>
);
}

View File

@@ -29,7 +29,7 @@ import { MatrixInfo } from "./VideoPreview";
import { InCallView } from "./InCallView";
import { CallEndedView } from "./CallEndedView";
import { useSentryGroupCallHandler } from "./useSentryGroupCallHandler";
import { PosthogAnalytics } from "../PosthogAnalytics";
import { PosthogAnalytics } from "../analytics/PosthogAnalytics";
import { useProfile } from "../profile/useProfile";
import { useLiveKit } from "../livekit/useLiveKit";
@@ -65,7 +65,8 @@ export function GroupCallView({
leave,
participants,
unencryptedEventsFromUsers,
} = useGroupCall(groupCall);
otelGroupCallMembership,
} = useGroupCall(groupCall, client);
const { t } = useTranslation();
@@ -82,7 +83,7 @@ export function GroupCallView({
userName: displayName,
avatarUrl,
roomName: groupCall.room.name,
roomId: roomIdOrAlias,
roomIdOrAlias,
};
const lkState = useLiveKit();
@@ -91,7 +92,7 @@ export function GroupCallView({
if (widget && preload) {
// In preload mode, wait for a join action before entering
const onJoin = async (ev: CustomEvent<IWidgetApiRequest>) => {
await groupCall.enter();
await enter();
PosthogAnalytics.instance.eventCallEnded.cacheStartCall(new Date());
PosthogAnalytics.instance.eventCallStarted.track(groupCall.groupCallId);
@@ -107,17 +108,17 @@ export function GroupCallView({
widget.lazyActions.off(ElementWidgetActions.JoinCall, onJoin);
};
}
}, [groupCall, preload]);
}, [groupCall, preload, enter]);
useEffect(() => {
if (isEmbedded && !preload) {
// In embedded mode, bypass the lobby and just enter the call straight away
groupCall.enter();
enter();
PosthogAnalytics.instance.eventCallEnded.cacheStartCall(new Date());
PosthogAnalytics.instance.eventCallStarted.track(groupCall.groupCallId);
}
}, [groupCall, isEmbedded, preload]);
}, [groupCall, isEmbedded, preload, enter]);
useSentryGroupCallHandler(groupCall);
@@ -150,7 +151,11 @@ export function GroupCallView({
widget.api.transport.send(ElementWidgetActions.HangupCall, {});
}
if (!isPasswordlessUser && !isEmbedded) {
if (
!isPasswordlessUser &&
!isEmbedded &&
!PosthogAnalytics.instance.isEnabled()
) {
history.push("/");
}
}, [groupCall, leave, isPasswordlessUser, isEmbedded, history]);
@@ -183,11 +188,27 @@ export function GroupCallView({
matrixInfo={matrixInfo}
mediaDevices={lkState.mediaDevices}
livekitRoom={lkState.room}
otelGroupCallMembership={otelGroupCallMembership}
/>
);
} else if (left) {
if (isPasswordlessUser) {
return <CallEndedView client={client} />;
// The call ended view is shown for two reasons: prompting guests to create
// an account, and prompting users that have opted into analytics to provide
// feedback. We don't show a feedback prompt to widget users however (at
// least for now), because we don't yet have designs that would allow widget
// users to dismiss the feedback prompt and close the call window without
// submitting anything.
if (
isPasswordlessUser ||
(PosthogAnalytics.instance.isEnabled() && !isEmbedded)
) {
return (
<CallEndedView
endedCallId={groupCall.groupCallId}
client={client}
isPasswordlessUser={isPasswordlessUser}
/>
);
} else {
// If the user is a regular user, we'll have sent them back to the homepage,
// so just sit here & do nothing: otherwise we would (briefly) mount the
@@ -199,7 +220,7 @@ export function GroupCallView({
} else if (isEmbedded) {
return (
<FullScreenView>
<h1>{t("Loading room…")}</h1>
<h1>{t("Loading…")}</h1>
</FullScreenView>
);
} else if (lkState) {

View File

@@ -15,14 +15,20 @@ limitations under the License.
*/
.inRoom {
position: relative;
display: flex;
flex-direction: column;
overflow: hidden;
min-height: 100%;
position: fixed;
height: 100%;
width: 100%;
--footerPadding: 8px;
--footerHeight: calc(50px + 2 * var(--footerPadding));
}
.controlsOverlay {
position: relative;
flex: 1;
display: flex;
}
.centerMessage {
@@ -39,11 +45,27 @@ limitations under the License.
}
.footer {
position: relative;
position: absolute;
left: 0;
bottom: 0;
width: 100%;
display: flex;
justify-content: center;
align-items: center;
height: calc(50px + 2 * 8px);
padding: var(--footerPadding) 0;
/* TODO: Un-hardcode these colors */
background: linear-gradient(
360deg,
#15191e 0%,
rgba(21, 25, 30, 0.9) 37%,
rgba(21, 25, 30, 0.8) 49.68%,
rgba(21, 25, 30, 0.7) 56.68%,
rgba(21, 25, 30, 0.427397) 72.92%,
rgba(21, 25, 30, 0.257534) 81.06%,
rgba(21, 25, 30, 0.136986) 87.29%,
rgba(21, 25, 30, 0.0658079) 92.4%,
rgba(21, 25, 30, 0) 100%
);
}
.footer > * {
@@ -65,16 +87,22 @@ limitations under the License.
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
/* To make avatars scale smoothly with their tiles during animations, we
override the styles set on the element */
--avatarSize: calc(min(var(--tileWidth), var(--tileHeight)) / 2);
width: var(--avatarSize) !important;
height: var(--avatarSize) !important;
border-radius: 10000px !important;
}
@media (min-height: 300px) {
.footer {
height: calc(50px + 2 * 24px);
.inRoom {
--footerPadding: 24px;
}
}
@media (min-width: 800px) {
.footer {
height: calc(50px + 2 * 32px);
.inRoom {
--footerPadding: 32px;
}
}

View File

@@ -1,5 +1,5 @@
/*
Copyright 2022 New Vector Ltd
Copyright 2022 - 2023 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@@ -25,16 +25,24 @@ import {
import { usePreventScroll } from "@react-aria/overlays";
import classNames from "classnames";
import { Room, Track } from "livekit-client";
import { JoinRule } from "matrix-js-sdk/src/@types/partials";
import { MatrixClient } from "matrix-js-sdk/src/client";
import { RoomMember } from "matrix-js-sdk/src/models/room-member";
import { GroupCall } from "matrix-js-sdk/src/webrtc/groupCall";
import React, { useCallback, useEffect, useMemo, useRef } from "react";
import { useTranslation } from "react-i18next";
import useMeasure from "react-use-measure";
import { OverlayTriggerState } from "@react-stately/overlays";
import { JoinRule } from "matrix-js-sdk/src/@types/partials";
import type { IWidgetApiRequest } from "matrix-widget-api";
import { Avatar } from "../Avatar";
import {
HangupButton,
MicButton,
VideoButton,
ScreenshareButton,
SettingsButton,
InviteButton,
} from "../button";
import {
Header,
LeftNav,
@@ -42,38 +50,35 @@ import {
RoomHeaderInfo,
VersionMismatchWarning,
} from "../Header";
import { useModalTriggerState } from "../Modal";
import { PosthogAnalytics } from "../PosthogAnalytics";
import { useUrlParams } from "../UrlParams";
import { UserMenuContainer } from "../UserMenuContainer";
import {
HangupButton,
MicButton,
ScreenshareButton,
VideoButton,
} from "../button";
import { MediaDevicesState } from "../settings/mediaDevices";
import { useRageshakeRequestModal } from "../settings/submit-rageshake";
import { useShowInspector } from "../settings/useSetting";
import { useCallViewKeyboardShortcuts } from "../useCallViewKeyboardShortcuts";
import { usePrefersReducedMotion } from "../usePrefersReducedMotion";
import {
TileDescriptor,
VideoGrid,
useVideoGridLayout,
TileDescriptor,
} from "../video-grid/VideoGrid";
import { Avatar } from "../Avatar";
import { useNewGrid, useShowInspector } from "../settings/useSetting";
import { useModalTriggerState } from "../Modal";
import { PosthogAnalytics } from "../analytics/PosthogAnalytics";
import { useUrlParams } from "../UrlParams";
import { MediaDevicesState } from "../settings/mediaDevices";
import { useRageshakeRequestModal } from "../settings/submit-rageshake";
import { useCallViewKeyboardShortcuts } from "../useCallViewKeyboardShortcuts";
import { usePrefersReducedMotion } from "../usePrefersReducedMotion";
import { ItemData, VideoTileContainer } from "../video-grid/VideoTileContainer";
import { ElementWidgetActions, widget } from "../widget";
import { GridLayoutMenu } from "./GridLayoutMenu";
import { GroupCallInspector } from "./GroupCallInspector";
import styles from "./InCallView.module.css";
import { OverflowMenu } from "./OverflowMenu";
import { RageshakeRequestModal } from "./RageshakeRequestModal";
import { MatrixInfo } from "./VideoPreview";
import { useJoinRule } from "./useJoinRule";
import { ParticipantInfo } from "./useGroupCall";
import { TileContent } from "../video-grid/VideoTile";
import { Config } from "../config/Config";
import { NewVideoGrid } from "../video-grid/NewVideoGrid";
import { OTelGroupCallMembership } from "../otel/OTelGroupCallMembership";
import { SettingsModal } from "../settings/SettingsModal";
import { InviteModal } from "./InviteModal";
const canScreenshare = "getDisplayMedia" in (navigator.mediaDevices ?? {});
// There is currently a bug in Safari our our code with cloning and sending MediaStreams
@@ -88,10 +93,10 @@ interface Props {
onLeave: () => void;
unencryptedEventsFromUsers: Set<string>;
hideHeader: boolean;
matrixInfo: MatrixInfo;
mediaDevices: MediaDevicesState;
livekitRoom: Room;
otelGroupCallMembership: OTelGroupCallMembership;
}
export function InCallView({
@@ -104,10 +109,10 @@ export function InCallView({
matrixInfo,
mediaDevices,
livekitRoom,
otelGroupCallMembership,
}: Props) {
const { t } = useTranslation();
usePreventScroll();
const joinRule = useJoinRule(groupCall.room);
const containerRef1 = useRef<HTMLDivElement | null>(null);
const [containerRef2, bounds] = useMeasure({ polyfill: ResizeObserver });
@@ -168,9 +173,6 @@ export function InCallView({
const [showInspector] = useShowInspector();
const { modalState: feedbackModalState, modalProps: feedbackModalProps } =
useModalTriggerState();
const { hideScreensharing } = useUrlParams();
const {
@@ -186,12 +188,14 @@ export function InCallView({
const toggleCamera = useCallback(async () => {
await localParticipant.setCameraEnabled(!isCameraEnabled);
}, [localParticipant, isCameraEnabled]);
const toggleScreenSharing = useCallback(async () => {
const toggleScreensharing = useCallback(async () => {
await localParticipant.setScreenShareEnabled(!isScreenShareEnabled);
}, [localParticipant, isScreenShareEnabled]);
const joinRule = useJoinRule(groupCall.room);
useCallViewKeyboardShortcuts(
!feedbackModalState.isOpen,
containerRef1,
toggleMicrophone,
toggleCamera,
async (muted) => await localParticipant.setMicrophoneEnabled(!muted)
@@ -236,6 +240,18 @@ export function InCallView({
const reducedControls = boundsValid && bounds.width <= 400;
const noControls = reducedControls && bounds.height <= 400;
const items = useParticipantTiles(livekitRoom, participants);
// The maximised participant is the focused (active) participant, given the
// window is too small to show everyone
const maximisedParticipant = useMemo(
() =>
noControls
? items.find((item) => item.focused) ?? items.at(0) ?? null
: null,
[noControls, items]
);
const renderAvatar = useCallback(
(roomMember: RoomMember, width: number, height: number) => {
const avatarUrl = roomMember.getMxcAvatarUrl();
@@ -254,10 +270,10 @@ export function InCallView({
[]
);
const [newGrid] = useNewGrid();
const Grid = newGrid ? NewVideoGrid : VideoGrid;
const prefersReducedMotion = usePrefersReducedMotion();
const items = useParticipantTiles(livekitRoom, participants);
const renderContent = (): JSX.Element => {
if (items.length === 0) {
return (
@@ -266,9 +282,22 @@ export function InCallView({
</div>
);
}
if (maximisedParticipant) {
return (
<VideoTileContainer
targetHeight={bounds.height}
targetWidth={bounds.width}
key={maximisedParticipant.id}
item={maximisedParticipant.data}
getAvatar={renderAvatar}
disableSpeakingIndicator={true}
maximised={Boolean(maximisedParticipant)}
/>
);
}
return (
<VideoGrid
<Grid
items={items}
layout={layout}
disableAnimations={prefersReducedMotion || isSafari}
@@ -280,7 +309,7 @@ export function InCallView({
{...child}
/>
)}
</VideoGrid>
</Grid>
);
};
@@ -289,6 +318,36 @@ export function InCallView({
modalProps: rageshakeRequestModalProps,
} = useRageshakeRequestModal(groupCall.room.roomId);
const {
modalState: settingsModalState,
modalProps: settingsModalProps,
}: {
modalState: OverlayTriggerState;
modalProps: {
isOpen: boolean;
onClose: () => void;
};
} = useModalTriggerState();
const openSettings = useCallback(() => {
settingsModalState.open();
}, [settingsModalState]);
const {
modalState: inviteModalState,
modalProps: inviteModalProps,
}: {
modalState: OverlayTriggerState;
modalProps: {
isOpen: boolean;
onClose: () => void;
};
} = useModalTriggerState();
const openInvite = useCallback(() => {
inviteModalState.open();
}, [inviteModalState]);
const containerClasses = classNames(styles.inRoom, {
[styles.maximised]: undefined,
});
@@ -297,36 +356,44 @@ export function InCallView({
if (noControls) {
footer = null;
} else if (reducedControls) {
footer = (
<div className={styles.footer}>
<MicButton muted={!isMicrophoneEnabled} onPress={toggleMicrophone} />
<VideoButton muted={!isCameraEnabled} onPress={toggleCamera} />
<HangupButton onPress={onLeave} />
</div>
);
} else {
footer = (
<div className={styles.footer}>
<MicButton muted={!isMicrophoneEnabled} onPress={toggleMicrophone} />
<VideoButton muted={!isCameraEnabled} onPress={toggleCamera} />
{canScreenshare && !hideScreensharing && !isSafari && (
<ScreenshareButton
enabled={isScreenShareEnabled}
onPress={toggleScreenSharing}
/>
)}
<OverflowMenu
roomId={matrixInfo.roomId}
mediaDevices={mediaDevices}
inCall
showInvite={joinRule === JoinRule.Public}
feedbackModalState={feedbackModalState}
feedbackModalProps={feedbackModalProps}
/>
<HangupButton onPress={onLeave} />
</div>
const buttons: JSX.Element[] = [];
buttons.push(
<MicButton
key="1"
muted={!isMicrophoneEnabled}
onPress={toggleMicrophone}
data-testid="incall_mute"
/>,
<VideoButton
key="2"
muted={!isCameraEnabled}
onPress={toggleCamera}
data-testid="incall_videomute"
/>
);
if (!reducedControls) {
if (canScreenshare && !hideScreensharing && !isSafari) {
buttons.push(
<ScreenshareButton
key="3"
enabled={isScreenShareEnabled}
onPress={toggleScreensharing}
data-testid="incall_screenshare"
/>
);
}
if (!maximisedParticipant) {
buttons.push(<SettingsButton key="4" onPress={openSettings} />);
}
}
buttons.push(
<HangupButton key="6" onPress={onLeave} data-testid="incall_leave" />
);
footer = <div className={styles.footer}>{buttons}</div>;
}
return (
@@ -345,21 +412,40 @@ export function InCallView({
</LeftNav>
<RightNav>
<GridLayoutMenu layout={layout} setLayout={setLayout} />
<UserMenuContainer preventNavigation />
{joinRule === JoinRule.Public && (
<InviteButton variant="icon" onClick={openInvite} />
)}
</RightNav>
</Header>
)}
{renderContent()}
{footer}
<div className={styles.controlsOverlay}>
{renderContent()}
{footer}
</div>
<GroupCallInspector
client={client}
groupCall={groupCall}
otelGroupCallMembership={otelGroupCallMembership}
show={showInspector}
/>
{rageshakeRequestModalState.isOpen && (
{rageshakeRequestModalState.isOpen && !noControls && (
<RageshakeRequestModal
{...rageshakeRequestModalProps}
roomIdOrAlias={matrixInfo.roomId}
roomIdOrAlias={matrixInfo.roomIdOrAlias}
/>
)}
{settingsModalState.isOpen && (
<SettingsModal
client={client}
roomId={groupCall.room.roomId}
mediaDevices={mediaDevices}
{...settingsModalProps}
/>
)}
{inviteModalState.isOpen && (
<InviteModal
roomIdOrAlias={matrixInfo.roomIdOrAlias}
{...inviteModalProps}
/>
)}
</div>
@@ -392,6 +478,7 @@ function useParticipantTiles(
focused: false,
local: sfuParticipant.isLocal,
data: {
id,
member,
sfuParticipant,
content: TileContent.UserMedia,

View File

@@ -41,6 +41,7 @@ export const InviteModal: FC<Props> = ({ roomIdOrAlias, ...rest }) => {
<CopyButton
className={styles.copyButton}
value={getRoomUrl(roomIdOrAlias)}
data-testid="modal_inviteLink"
/>
</ModalContent>
</Modal>

View File

@@ -77,6 +77,7 @@ export function LobbyView(props: Props) {
className={styles.copyButton}
size="lg"
onPress={props.onEnter}
data-testid="lobby_joinCall"
>
Join call now
</Button>
@@ -86,6 +87,7 @@ export function LobbyView(props: Props) {
value={getRoomUrl(props.matrixInfo.roomName)}
className={styles.copyButton}
copiedMessage={t("Call link copied")}
data-testid="lobby_inviteLink"
>
Copy call link and join later
</CopyButton>

View File

@@ -1,141 +0,0 @@
/*
Copyright 2022 New Vector Ltd
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 React, { useCallback } from "react";
import { Item } from "@react-stately/collections";
import { OverlayTriggerState } from "@react-stately/overlays";
import { useTranslation } from "react-i18next";
import { Button } from "../button";
import { Menu } from "../Menu";
import { PopoverMenuTrigger } from "../popover/PopoverMenu";
import { ReactComponent as SettingsIcon } from "../icons/Settings.svg";
import { ReactComponent as AddUserIcon } from "../icons/AddUser.svg";
import { ReactComponent as OverflowIcon } from "../icons/Overflow.svg";
import { ReactComponent as FeedbackIcon } from "../icons/Feedback.svg";
import { useModalTriggerState } from "../Modal";
import { SettingsModal } from "../settings/SettingsModal";
import { InviteModal } from "./InviteModal";
import { TooltipTrigger } from "../Tooltip";
import { FeedbackModal } from "./FeedbackModal";
import { Config } from "../config/Config";
import { MediaDevicesState } from "../settings/mediaDevices";
interface Props {
roomId: string;
mediaDevices: MediaDevicesState;
inCall: boolean;
showInvite: boolean;
feedbackModalState: OverlayTriggerState;
feedbackModalProps: {
isOpen: boolean;
onClose: () => void;
};
}
export function OverflowMenu(props: Props) {
const { t } = useTranslation();
const {
modalState: inviteModalState,
modalProps: inviteModalProps,
}: {
modalState: OverlayTriggerState;
modalProps: {
isOpen: boolean;
onClose: () => void;
};
} = useModalTriggerState();
const {
modalState: settingsModalState,
modalProps: settingsModalProps,
}: {
modalState: OverlayTriggerState;
modalProps: {
isOpen: boolean;
onClose: () => void;
};
} = useModalTriggerState();
// TODO: On closing modal, focus should be restored to the trigger button
// https://github.com/adobe/react-spectrum/issues/2444
const onAction = useCallback(
(key) => {
switch (key) {
case "invite":
inviteModalState.open();
break;
case "settings":
settingsModalState.open();
break;
case "feedback":
props.feedbackModalState.open();
break;
}
},
[props.feedbackModalState, inviteModalState, settingsModalState]
);
const tooltip = useCallback(() => t("More"), [t]);
return (
<>
<PopoverMenuTrigger disableOnState>
<TooltipTrigger tooltip={tooltip} placement="top">
<Button variant="toolbar">
<OverflowIcon />
</Button>
</TooltipTrigger>
{(attr: JSX.IntrinsicAttributes) => (
<Menu {...attr} label={t("More menu")} onAction={onAction}>
{props.showInvite && (
<Item key="invite" textValue={t("Invite people")}>
<AddUserIcon />
<span>{t("Invite people")}</span>
</Item>
)}
<Item key="settings" textValue={t("Settings")}>
<SettingsIcon />
<span>{t("Settings")}</span>
</Item>
{Config.get().rageshake?.submit_url && (
<Item key="feedback" textValue={t("Submit feedback")}>
<FeedbackIcon />
<span>{t("Submit feedback")}</span>
</Item>
)}
</Menu>
)}
</PopoverMenuTrigger>
{settingsModalState.isOpen && (
<SettingsModal
mediaDevices={props.mediaDevices}
{...settingsModalProps}
/>
)}
{inviteModalState.isOpen && (
<InviteModal roomIdOrAlias={props.roomId} {...inviteModalProps} />
)}
{props.feedbackModalState.isOpen && (
<FeedbackModal
roomId={props.roomId}
inCall={props.inCall}
{...props.feedbackModalProps}
/>
)}
</>
);
}

View File

@@ -74,6 +74,7 @@ export function RoomAuthView() {
name="displayName"
label={t("Display name")}
placeholder={t("Display name")}
data-testid="joincall_displayName"
type="text"
required
autoComplete="off"
@@ -90,7 +91,12 @@ export function RoomAuthView() {
<ErrorMessage error={error} />
</FieldRow>
)}
<Button type="submit" size="lg" disabled={loading}>
<Button
type="submit"
size="lg"
disabled={loading}
data-testid="joincall_joincall"
>
{loading ? t("Loading…") : t("Join call now")}
</Button>
<div id={recaptchaId} />

View File

@@ -1,5 +1,5 @@
/*
Copyright 2021-2022 New Vector Ltd
Copyright 2021-2023 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@@ -26,6 +26,7 @@ import { GroupCallView } from "./GroupCallView";
import { useUrlParams } from "../UrlParams";
import { useRegisterPasswordlessUser } from "../auth/useRegisterPasswordlessUser";
import { translatedError } from "../TranslatedError";
import { useOptInAnalytics } from "../settings/useSetting";
export const RoomPage: FC = () => {
const { t } = useTranslation();
@@ -45,9 +46,15 @@ export const RoomPage: FC = () => {
const roomIdOrAlias = roomId ?? roomAlias;
if (!roomIdOrAlias) throw translatedError("No room specified", t);
const [optInAnalytics, setOptInAnalytics] = useOptInAnalytics();
const { registerPasswordlessUser } = useRegisterPasswordlessUser();
const [isRegistering, setIsRegistering] = useState(false);
useEffect(() => {
// During the beta, opt into analytics by default
if (optInAnalytics === null) setOptInAnalytics(true);
}, [optInAnalytics, setOptInAnalytics]);
useEffect(() => {
// If we've finished loading, are not already authed and we've been given a display name as
// a URL param, automatically register a passwordless user

View File

@@ -1,5 +1,5 @@
/*
Copyright 2022 New Vector Ltd
Copyright 2022 - 2023 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@@ -14,23 +14,25 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React from "react";
import React, { useCallback } from "react";
import useMeasure from "react-use-measure";
import { ResizeObserver } from "@juggle/resize-observer";
import { Track } from "livekit-client";
import { OverlayTriggerState } from "@react-stately/overlays";
import { MicButton, VideoButton } from "../button";
import { OverflowMenu } from "./OverflowMenu";
import { MicButton, SettingsButton, VideoButton } from "../button";
import { Avatar } from "../Avatar";
import styles from "./VideoPreview.module.css";
import { useModalTriggerState } from "../Modal";
import { SettingsModal } from "../settings/SettingsModal";
import { MediaDevicesState } from "../settings/mediaDevices";
import { useClient } from "../ClientContext";
export type MatrixInfo = {
userName: string;
avatarUrl: string;
roomName: string;
roomId: string;
roomIdOrAlias: string;
};
export type MediaInfo = {
@@ -55,9 +57,23 @@ export function VideoPreview({
mediaDevices,
localMediaInfo,
}: Props) {
const { client } = useClient();
const [previewRef, previewBounds] = useMeasure({ polyfill: ResizeObserver });
const { modalState: feedbackModalState, modalProps: feedbackModalProps } =
useModalTriggerState();
const {
modalState: settingsModalState,
modalProps: settingsModalProps,
}: {
modalState: OverlayTriggerState;
modalProps: {
isOpen: boolean;
onClose: () => void;
};
} = useModalTriggerState();
const openSettings = useCallback(() => {
settingsModalState.open();
}, [settingsModalState]);
const mediaElement = React.useRef(null);
React.useEffect(() => {
@@ -95,16 +111,16 @@ export function VideoPreview({
onPress={localMediaInfo.video?.toggle}
/>
)}
<OverflowMenu
roomId={matrixInfo.roomId}
mediaDevices={mediaDevices}
feedbackModalState={feedbackModalState}
feedbackModalProps={feedbackModalProps}
inCall={false}
showInvite={false}
/>
<SettingsButton onPress={openSettings} />
</div>
</>
{settingsModalState.isOpen && (
<SettingsModal
client={client}
mediaDevices={mediaDevices}
{...settingsModalProps}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,65 @@
/*
Copyright 2023 New Vector Ltd
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 { EventType } from "matrix-js-sdk/src/@types/event";
import { RoomState } from "matrix-js-sdk/src/models/room-state";
import { PosthogAnalytics } from "../analytics/PosthogAnalytics";
function isObject(x: unknown): x is Record<string, unknown> {
return typeof x === "object" && x !== null;
}
/**
* Checks the state of a room for multiple calls happening in parallel, sending
* the details to PostHog if that is indeed what's happening. (This is unwanted
* as it indicates a split-brain scenario.)
*/
export function checkForParallelCalls(state: RoomState): void {
const now = Date.now();
const participantsPerCall = new Map<string, number>();
// For each participant in each call, increment the participant count
for (const e of state.getStateEvents(EventType.GroupCallMemberPrefix)) {
const content = e.getContent<Record<string, unknown>>();
const calls: unknown[] = Array.isArray(content["m.calls"])
? content["m.calls"]
: [];
for (const call of calls) {
if (isObject(call) && typeof call["m.call_id"] === "string") {
const devices: unknown[] = Array.isArray(call["m.devices"])
? call["m.devices"]
: [];
for (const device of devices) {
if (isObject(device) && (device["expires_ts"] as number) > now) {
const participantCount =
participantsPerCall.get(call["m.call_id"]) ?? 0;
participantsPerCall.set(call["m.call_id"], participantCount + 1);
}
}
}
}
}
if (participantsPerCall.size > 1) {
PosthogAnalytics.instance.trackEvent({
eventName: "ParallelCalls",
participantsPerCall: Object.fromEntries(participantsPerCall),
});
}
}

View File

@@ -22,16 +22,28 @@ import {
GroupCallErrorCode,
GroupCallUnknownDeviceError,
GroupCallError,
GroupCallStatsReportEvent,
GroupCallStatsReport,
} from "matrix-js-sdk/src/webrtc/groupCall";
import { CallFeed, CallFeedEvent } from "matrix-js-sdk/src/webrtc/callFeed";
import { RoomMember } from "matrix-js-sdk/src/models/room-member";
import { useTranslation } from "react-i18next";
import { IWidgetApiRequest } from "matrix-widget-api";
import { MatrixClient, RoomStateEvent } from "matrix-js-sdk";
import {
ByteSentStatsReport,
ConnectionStatsReport,
SummaryStatsReport,
CallFeedReport,
} from "matrix-js-sdk/src/webrtc/stats/statsReport";
import { usePageUnload } from "./usePageUnload";
import { PosthogAnalytics } from "../PosthogAnalytics";
import { PosthogAnalytics } from "../analytics/PosthogAnalytics";
import { TranslatedError, translatedError } from "../TranslatedError";
import { ElementWidgetActions, ScreenshareStartData, widget } from "../widget";
import { OTelGroupCallMembership } from "../otel/OTelGroupCallMembership";
import { ElementCallOpenTelemetry } from "../otel/otel";
import { checkForParallelCalls } from "./checkForParallelCalls";
enum ConnectionState {
EstablishingCall = "establishing call", // call hasn't been established yet
@@ -53,7 +65,7 @@ interface UseGroupCallReturnType {
localVideoMuted: boolean;
error: TranslatedError | null;
initLocalCallFeed: () => void;
enter: () => void;
enter: () => Promise<void>;
leave: () => void;
toggleLocalVideoMuted: () => void;
toggleMicrophoneMuted: () => void;
@@ -66,6 +78,7 @@ interface UseGroupCallReturnType {
participants: Map<RoomMember, Map<string, ParticipantInfo>>;
hasLocalParticipant: boolean;
unencryptedEventsFromUsers: Set<string>;
otelGroupCallMembership: OTelGroupCallMembership;
}
interface State {
@@ -84,6 +97,13 @@ interface State {
hasLocalParticipant: boolean;
}
// This is a bit of a hack, but we keep the opentelemetry tracker object at the file
// level so that it doesn't pop in & out of existence as react mounts & unmounts
// components. The right solution is probably for this to live in the js-sdk and have
// the same lifetime as groupcalls themselves.
let groupCallOTelMembership: OTelGroupCallMembership;
let groupCallOTelMembershipGroupCallId: string;
function getParticipants(
groupCall: GroupCall
): Map<RoomMember, Map<string, ParticipantInfo>> {
@@ -98,12 +118,24 @@ function getParticipants(
(f) => f.userId === member.userId && f.deviceId === deviceId
);
participantInfoMap.set(deviceId, {
connectionState: feed
let connectionState: ConnectionState;
// If we allow calls without media, we have no feeds and cannot read the connection status from them.
// @TODO: The connection state should generally not be determined by the feed.
if (
groupCall.allowCallWithoutVideoAndAudio &&
!feed &&
!participant.screensharing
) {
connectionState = ConnectionState.Connected;
} else {
connectionState = feed
? feed.connected
? ConnectionState.Connected
: ConnectionState.WaitMedia
: ConnectionState.EstablishingCall,
: ConnectionState.EstablishingCall;
}
participantInfoMap.set(deviceId, {
connectionState,
presenter: participant.screensharing,
});
}
@@ -112,7 +144,10 @@ function getParticipants(
return participants;
}
export function useGroupCall(groupCall: GroupCall): UseGroupCallReturnType {
export function useGroupCall(
groupCall: GroupCall,
client: MatrixClient
): UseGroupCallReturnType {
const [
{
state,
@@ -146,6 +181,19 @@ export function useGroupCall(groupCall: GroupCall): UseGroupCallReturnType {
hasLocalParticipant: false,
});
if (groupCallOTelMembershipGroupCallId !== groupCall.groupCallId) {
if (groupCallOTelMembership) groupCallOTelMembership.dispose();
// If the user disables analytics, this will stay around until they leave the call
// so analytics will be disabled once they leave.
if (ElementCallOpenTelemetry.instance) {
groupCallOTelMembership = new OTelGroupCallMembership(groupCall, client);
groupCallOTelMembershipGroupCallId = groupCall.groupCallId;
} else {
groupCallOTelMembership = undefined;
}
}
const [unencryptedEventsFromUsers, addUnencryptedEventUser] = useReducer(
(state: Set<string>, newVal: string) => {
return new Set(state).add(newVal);
@@ -158,6 +206,43 @@ export function useGroupCall(groupCall: GroupCall): UseGroupCallReturnType {
[setState]
);
const doNothingMediaActionCallback = useCallback(
(details: MediaSessionActionDetails) => {},
[]
);
const leaveCall = useCallback(() => {
groupCallOTelMembership?.onLeaveCall();
groupCall.leave();
}, [groupCall]);
useEffect(() => {
// disable the media action keys, otherwise audio elements get paused when
// the user presses media keys or unplugs headphones, etc.
// Note there are actions for muting / unmuting a microphone & hanging up
// which we could wire up.
const mediaActions: MediaSessionAction[] = [
"play",
"pause",
"stop",
"nexttrack",
"previoustrack",
];
for (const mediaAction of mediaActions) {
navigator.mediaSession?.setActionHandler(
mediaAction,
doNothingMediaActionCallback
);
}
return () => {
for (const mediaAction of mediaActions) {
navigator.mediaSession?.setActionHandler(mediaAction, null);
}
};
}, [doNothingMediaActionCallback]);
useEffect(() => {
function onGroupCallStateChanged() {
updateState({
@@ -261,6 +346,30 @@ export function useGroupCall(groupCall: GroupCall): UseGroupCallReturnType {
}
}
function onConnectionStatsReport(
report: GroupCallStatsReport<ConnectionStatsReport>
): void {
groupCallOTelMembership?.onConnectionStatsReport(report);
}
function onByteSentStatsReport(
report: GroupCallStatsReport<ByteSentStatsReport>
): void {
groupCallOTelMembership?.onByteSentStatsReport(report);
}
function onSummaryStatsReport(
report: GroupCallStatsReport<SummaryStatsReport>
): void {
groupCallOTelMembership?.onSummaryStatsReport(report);
}
function onCallFeedStatsReport(
report: GroupCallStatsReport<CallFeedReport>
): void {
groupCallOTelMembership?.onCallFeedStatsReport(report);
}
groupCall.on(GroupCallEvent.GroupCallStateChanged, onGroupCallStateChanged);
groupCall.on(GroupCallEvent.UserMediaFeedsChanged, onUserMediaFeedsChanged);
groupCall.on(
@@ -276,6 +385,24 @@ export function useGroupCall(groupCall: GroupCall): UseGroupCallReturnType {
groupCall.on(GroupCallEvent.CallsChanged, onCallsChanged);
groupCall.on(GroupCallEvent.ParticipantsChanged, onParticipantsChanged);
groupCall.on(GroupCallEvent.Error, onError);
groupCall.on(
GroupCallStatsReportEvent.ConnectionStats,
onConnectionStatsReport
);
groupCall.on(
GroupCallStatsReportEvent.ByteSentStats,
onByteSentStatsReport
);
groupCall.on(GroupCallStatsReportEvent.SummaryStats, onSummaryStatsReport);
groupCall.on(
GroupCallStatsReportEvent.CallFeedStats,
onCallFeedStatsReport
);
groupCall.room.currentState.on(
RoomStateEvent.Update,
checkForParallelCalls
);
updateState({
error: null,
@@ -323,12 +450,32 @@ export function useGroupCall(groupCall: GroupCall): UseGroupCallReturnType {
onParticipantsChanged
);
groupCall.removeListener(GroupCallEvent.Error, onError);
groupCall.leave();
groupCall.removeListener(
GroupCallStatsReportEvent.ConnectionStats,
onConnectionStatsReport
);
groupCall.removeListener(
GroupCallStatsReportEvent.ByteSentStats,
onByteSentStatsReport
);
groupCall.removeListener(
GroupCallStatsReportEvent.SummaryStats,
onSummaryStatsReport
);
groupCall.removeListener(
GroupCallStatsReportEvent.CallFeedStats,
onCallFeedStatsReport
);
groupCall.room.currentState.off(
RoomStateEvent.Update,
checkForParallelCalls
);
leaveCall();
};
}, [groupCall, updateState]);
}, [groupCall, updateState, leaveCall]);
usePageUnload(() => {
groupCall.leave();
leaveCall();
});
const initLocalCallFeed = useCallback(
@@ -336,7 +483,7 @@ export function useGroupCall(groupCall: GroupCall): UseGroupCallReturnType {
[groupCall]
);
const enter = useCallback(() => {
const enter = useCallback(async () => {
if (
groupCall.state !== GroupCallState.LocalCallFeedUninitialized &&
groupCall.state !== GroupCallState.LocalCallFeedInitialized
@@ -347,17 +494,21 @@ export function useGroupCall(groupCall: GroupCall): UseGroupCallReturnType {
PosthogAnalytics.instance.eventCallEnded.cacheStartCall(new Date());
PosthogAnalytics.instance.eventCallStarted.track(groupCall.groupCallId);
groupCall.enter().catch((error) => {
// This must be called before we start trying to join the call, as we need to
// have started tracking by the time calls start getting created.
groupCallOTelMembership?.onJoinCall();
await groupCall.enter().catch((error) => {
console.error(error);
updateState({ error });
});
}, [groupCall, updateState]);
const leave = useCallback(() => groupCall.leave(), [groupCall]);
const toggleLocalVideoMuted = useCallback(() => {
const toggleToMute = !groupCall.isLocalVideoMuted();
groupCall.setLocalVideoMuted(toggleToMute);
groupCallOTelMembership?.onToggleLocalVideoMuted(toggleToMute);
// TODO: These explict posthog calls should be unnecessary now with the posthog otel exporter?
PosthogAnalytics.instance.eventMuteCamera.track(
toggleToMute,
groupCall.groupCallId
@@ -367,6 +518,7 @@ export function useGroupCall(groupCall: GroupCall): UseGroupCallReturnType {
const setMicrophoneMuted = useCallback(
(setMuted) => {
groupCall.setMicrophoneMuted(setMuted);
groupCallOTelMembership?.onSetMicrophoneMuted(setMuted);
PosthogAnalytics.instance.eventMuteMicrophone.track(
setMuted,
groupCall.groupCallId
@@ -377,10 +529,13 @@ export function useGroupCall(groupCall: GroupCall): UseGroupCallReturnType {
const toggleMicrophoneMuted = useCallback(() => {
const toggleToMute = !groupCall.isMicrophoneMuted();
groupCallOTelMembership?.onToggleMicrophoneMuted(toggleToMute);
setMicrophoneMuted(toggleToMute);
}, [groupCall, setMicrophoneMuted]);
const toggleScreensharing = useCallback(async () => {
groupCallOTelMembership?.onToggleScreensharing(!groupCall.isScreensharing);
if (!groupCall.isScreensharing()) {
// toggling on
updateState({ requestingScreenshare: true });
@@ -481,7 +636,7 @@ export function useGroupCall(groupCall: GroupCall): UseGroupCallReturnType {
error,
initLocalCallFeed,
enter,
leave,
leave: leaveCall,
toggleLocalVideoMuted,
toggleMicrophoneMuted,
toggleScreensharing,
@@ -493,5 +648,6 @@ export function useGroupCall(groupCall: GroupCall): UseGroupCallReturnType {
participants,
hasLocalParticipant,
unencryptedEventsFromUsers,
otelGroupCallMembership: groupCallOTelMembership,
};
}

View File

@@ -32,7 +32,9 @@ import { isLocalRoomId, createRoom, roomNameFromRoomId } from "../matrix-utils";
import { translatedError } from "../TranslatedError";
import { widget } from "../widget";
interface GroupCallLoadState {
const STATS_COLLECT_INTERVAL_TIME_MS = 10000;
export interface GroupCallLoadState {
loading: boolean;
error?: Error;
groupCall?: GroupCall;
@@ -94,10 +96,13 @@ export const useLoadGroupCall = (
const fetchOrCreateGroupCall = async (): Promise<GroupCall> => {
const room = await fetchOrCreateRoom();
logger.debug(`Fetched / joined room ${roomIdOrAlias}`);
const groupCall = client.getGroupCallForRoom(room.roomId);
let groupCall = client.getGroupCallForRoom(room.roomId);
logger.debug("Got group call", groupCall?.groupCallId);
if (groupCall) return groupCall;
if (groupCall) {
groupCall.setGroupCallStatsInterval(STATS_COLLECT_INTERVAL_TIME_MS);
return groupCall;
}
if (
!widget &&
@@ -112,12 +117,14 @@ export const useLoadGroupCall = (
createPtt ? "PTT" : "video"
} call`
);
return await client.createGroupCall(
groupCall = await client.createGroupCall(
room.roomId,
createPtt ? GroupCallType.Voice : GroupCallType.Video,
createPtt,
GroupCallIntent.Room
);
groupCall.setGroupCallStatsInterval(STATS_COLLECT_INTERVAL_TIME_MS);
return groupCall;
}
// We don't have permission to create the call, so all we can do is wait
@@ -126,6 +133,7 @@ export const useLoadGroupCall = (
const onGroupCallIncoming = (groupCall: GroupCall) => {
if (groupCall?.room.roomId === room.roomId) {
clearTimeout(timeout);
groupCall.setGroupCallStatsInterval(STATS_COLLECT_INTERVAL_TIME_MS);
client.off(
GroupCallEventHandlerEvent.Incoming,
onGroupCallIncoming