Merge branch 'main' into livekit-experiment
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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="/">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -51,7 +51,7 @@ export function GroupCallLoader({
|
||||
if (loading) {
|
||||
return (
|
||||
<FullScreenView>
|
||||
<h1>{t("Loading room…")}</h1>
|
||||
<h1>{t("Loading…")}</h1>
|
||||
</FullScreenView>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -41,6 +41,7 @@ export const InviteModal: FC<Props> = ({ roomIdOrAlias, ...rest }) => {
|
||||
<CopyButton
|
||||
className={styles.copyButton}
|
||||
value={getRoomUrl(roomIdOrAlias)}
|
||||
data-testid="modal_inviteLink"
|
||||
/>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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} />
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
65
src/room/checkForParallelCalls.ts
Normal file
65
src/room/checkForParallelCalls.ts
Normal 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),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user