mirror of
https://github.com/vector-im/element-call.git
synced 2026-03-19 06:20:25 +00:00
* Add media hints for notification events. * Prevent showing calling view when disconnected from Livekit. (#3491) * Refactor disconnection handling * Use "unknown" * Update signature * Add tests * Expose livekitConnectionState directly * fix whoopsie * Update dependency livekit-client to v2.15.7 (#3496) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> * Fix the interactivity of buttons while reconnecting or in earpiece mode (#3486) * Fix the interactivity of buttons while reconnecting or in earpiece mode When we're in one of these modes, we need to ensure that everything above the overlay (the header and footer buttons) is interactive, while everything obscured by the overlay (the media tiles) is non-interactive and removed from the accessibility tree. It's not a very easy task to trap focus *outside* an element, so the best solution I could come up with is to set tabindex="-1" manually on all interactive elements belonging to the media tiles. * Write a Playwright test for reconnecting * fix lints Signed-off-by: Timo K <toger5@hotmail.de> * fix test Signed-off-by: Timo K <toger5@hotmail.de> * enable http2 for matrx-rtc host to allow the jwt service to talk to the SFU * remove rate limit for delayed events * more time to connect to livekit SFU * Due to a Firefox issue we set the start anchor for the tab test to the Mute microphone button * adapt to most recent Element Web version * Use the "End call" button as proofe for a started call * Currrenty disabled due to recent Element Web - not indicating the number of participants - bypassing Lobby * linting * disable 'can only interact with header and footer while reconnecting' for firefox --------- Signed-off-by: Timo K <toger5@hotmail.de> Co-authored-by: Timo <16718859+toger5@users.noreply.github.com> Co-authored-by: Timo K <toger5@hotmail.de> Co-authored-by: fkwp <github-fkwp@w4ve.de> * Log when a track is unpublished or runs into an error (#3495) * default mute states (unmuted!) in widget mode (embedded + intent) (#3494) * default mute states (unmuted!) in widget mode (embedded + intent) Signed-off-by: Timo K <toger5@hotmail.de> * review Signed-off-by: Timo K <toger5@hotmail.de> * introduce a cache for the url params. Signed-off-by: Timo K <toger5@hotmail.de> * Add an option to skip the cache. Signed-off-by: Timo K <toger5@hotmail.de> --------- Signed-off-by: Timo K <toger5@hotmail.de> * Apply new hint code * missed a bit * fix intent * Automatically update intent on mute change * update packages * lint * Fix tests * fix merge fails --------- Signed-off-by: Timo K <toger5@hotmail.de> Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: Robin <robin@robin.town> Co-authored-by: Timo <16718859+toger5@users.noreply.github.com> Co-authored-by: Timo K <toger5@hotmail.de> Co-authored-by: fkwp <github-fkwp@w4ve.de>
535 lines
17 KiB
TypeScript
535 lines
17 KiB
TypeScript
/*
|
|
Copyright 2022-2024 New Vector Ltd.
|
|
|
|
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
|
Please see LICENSE in the repository root for full details.
|
|
*/
|
|
|
|
import {
|
|
type FC,
|
|
type ReactNode,
|
|
useCallback,
|
|
useEffect,
|
|
useMemo,
|
|
useState,
|
|
} from "react";
|
|
import { type MatrixClient, JoinRule, type Room } from "matrix-js-sdk";
|
|
import {
|
|
Room as LivekitRoom,
|
|
isE2EESupported as isE2EESupportedBrowser,
|
|
} from "livekit-client";
|
|
import { logger } from "matrix-js-sdk/lib/logger";
|
|
import {
|
|
MatrixRTCSessionEvent,
|
|
type MatrixRTCSession,
|
|
} from "matrix-js-sdk/lib/matrixrtc";
|
|
import { useNavigate } from "react-router-dom";
|
|
|
|
import type { IWidgetApiRequest } from "matrix-widget-api";
|
|
import {
|
|
ElementWidgetActions,
|
|
type JoinCallData,
|
|
type WidgetHelpers,
|
|
} from "../widget";
|
|
import { LobbyView } from "./LobbyView";
|
|
import { type MatrixInfo } from "./VideoPreview";
|
|
import { CallEndedView } from "./CallEndedView";
|
|
import { PosthogAnalytics } from "../analytics/PosthogAnalytics";
|
|
import { useProfile } from "../profile/useProfile";
|
|
import { findDeviceByName } from "../utils/media";
|
|
import { ActiveCall } from "./InCallView";
|
|
import { MUTE_PARTICIPANT_COUNT, type MuteStates } from "./MuteStates";
|
|
import { useMediaDevices } from "../MediaDevicesContext";
|
|
import { useMatrixRTCSessionMemberships } from "../useMatrixRTCSessionMemberships";
|
|
import { enterRTCSession, leaveRTCSession } from "../rtcSessionHelpers";
|
|
import {
|
|
saveKeyForRoom,
|
|
useRoomEncryptionSystem,
|
|
} from "../e2ee/sharedKeyManagement";
|
|
import { useRoomAvatar } from "./useRoomAvatar";
|
|
import { useRoomName } from "./useRoomName";
|
|
import { useJoinRule } from "./useJoinRule";
|
|
import { InviteModal } from "./InviteModal";
|
|
import { HeaderStyle, type UrlParams, useUrlParams } from "../UrlParams";
|
|
import { E2eeType } from "../e2ee/e2eeType";
|
|
import { useAudioContext } from "../useAudioContext";
|
|
import {
|
|
callEventAudioSounds,
|
|
type CallEventSounds,
|
|
} from "./CallEventAudioRenderer";
|
|
import { useLatest } from "../useLatest";
|
|
import { usePageTitle } from "../usePageTitle";
|
|
import {
|
|
ConnectionLostError,
|
|
E2EENotSupportedError,
|
|
ElementCallError,
|
|
UnknownCallError,
|
|
} from "../utils/errors.ts";
|
|
import { GroupCallErrorBoundary } from "./GroupCallErrorBoundary.tsx";
|
|
import {
|
|
useNewMembershipManager as useNewMembershipManagerSetting,
|
|
useExperimentalToDeviceTransport as useExperimentalToDeviceTransportSetting,
|
|
useSetting,
|
|
} from "../settings/settings";
|
|
import { useTypedEventEmitter } from "../useEvents";
|
|
import { muteAllAudio$ } from "../state/MuteAllAudioModel.ts";
|
|
import { useAppBarTitle } from "../AppBar.tsx";
|
|
import { useBehavior } from "../useBehavior.ts";
|
|
|
|
declare global {
|
|
interface Window {
|
|
rtcSession?: MatrixRTCSession;
|
|
}
|
|
}
|
|
|
|
interface Props {
|
|
client: MatrixClient;
|
|
isPasswordlessUser: boolean;
|
|
confineToRoom: boolean;
|
|
preload: UrlParams["preload"];
|
|
skipLobby: UrlParams["skipLobby"];
|
|
header: HeaderStyle;
|
|
rtcSession: MatrixRTCSession;
|
|
isJoined: boolean;
|
|
muteStates: MuteStates;
|
|
widget: WidgetHelpers | null;
|
|
}
|
|
|
|
export const GroupCallView: FC<Props> = ({
|
|
client,
|
|
isPasswordlessUser,
|
|
confineToRoom,
|
|
preload,
|
|
skipLobby,
|
|
header,
|
|
rtcSession,
|
|
isJoined,
|
|
muteStates,
|
|
widget,
|
|
}) => {
|
|
// Used to thread through any errors that occur outside the error boundary
|
|
const [externalError, setExternalError] = useState<ElementCallError | null>(
|
|
null,
|
|
);
|
|
const memberships = useMatrixRTCSessionMemberships(rtcSession);
|
|
|
|
const muteAllAudio = useBehavior(muteAllAudio$);
|
|
const leaveSoundContext = useLatest(
|
|
useAudioContext({
|
|
sounds: callEventAudioSounds,
|
|
latencyHint: "interactive",
|
|
muted: muteAllAudio,
|
|
}),
|
|
);
|
|
// This should use `useEffectEvent` (only available in experimental versions)
|
|
useEffect(() => {
|
|
if (memberships.length >= MUTE_PARTICIPANT_COUNT)
|
|
muteStates.audio.setEnabled?.(false);
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, []);
|
|
|
|
// Update our member event when our mute state changes.
|
|
useEffect(() => {
|
|
if (!isJoined) {
|
|
return;
|
|
}
|
|
void rtcSession.updateCallIntent(
|
|
muteStates.video.enabled ? "video" : "audio",
|
|
);
|
|
}, [rtcSession, isJoined, muteStates.video.enabled]);
|
|
|
|
useEffect(() => {
|
|
logger.info("[Lifecycle] GroupCallView Component mounted");
|
|
return (): void => {
|
|
logger.info("[Lifecycle] GroupCallView Component unmounted");
|
|
};
|
|
}, []);
|
|
|
|
// This CSS is the only way we could find to not make element call scroll for
|
|
// viewport sizes smaller than 122px width. (It is actually this exact number: 122px
|
|
// tested on different devices...)
|
|
useEffect(() => {
|
|
document.body.classList.add("no-scroll-body");
|
|
return (): void => {
|
|
document.body.classList.remove("no-scroll-body");
|
|
};
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
window.rtcSession = rtcSession;
|
|
return (): void => {
|
|
delete window.rtcSession;
|
|
};
|
|
}, [rtcSession]);
|
|
|
|
useTypedEventEmitter(
|
|
rtcSession,
|
|
MatrixRTCSessionEvent.MembershipManagerError,
|
|
(error) => setExternalError(new ConnectionLostError()),
|
|
);
|
|
useEffect(() => {
|
|
// Sanity check the room object
|
|
if (client.getRoom(rtcSession.room.roomId) !== rtcSession.room)
|
|
logger.warn(
|
|
`We've ended up with multiple rooms for the same ID (${rtcSession.room.roomId}). This indicates a bug in the group call loading code, and may lead to incomplete room state.`,
|
|
);
|
|
}, [client, rtcSession.room]);
|
|
|
|
const room = rtcSession.room as Room;
|
|
const { displayName, avatarUrl } = useProfile(client);
|
|
const roomName = useRoomName(room);
|
|
const roomAvatar = useRoomAvatar(room);
|
|
const {
|
|
perParticipantE2EE,
|
|
returnToLobby,
|
|
password: passwordFromUrl,
|
|
} = useUrlParams();
|
|
const e2eeSystem = useRoomEncryptionSystem(room.roomId);
|
|
const [useNewMembershipManager] = useSetting(useNewMembershipManagerSetting);
|
|
const [useExperimentalToDeviceTransport] = useSetting(
|
|
useExperimentalToDeviceTransportSetting,
|
|
);
|
|
|
|
// Save the password once we start the groupCallView
|
|
useEffect(() => {
|
|
if (passwordFromUrl) saveKeyForRoom(room.roomId, passwordFromUrl);
|
|
}, [passwordFromUrl, room.roomId]);
|
|
|
|
usePageTitle(roomName);
|
|
useAppBarTitle(roomName);
|
|
|
|
const matrixInfo = useMemo((): MatrixInfo => {
|
|
return {
|
|
userId: client.getUserId()!,
|
|
displayName: displayName!,
|
|
avatarUrl: avatarUrl!,
|
|
roomId: room.roomId,
|
|
roomName,
|
|
roomAlias: room.getCanonicalAlias(),
|
|
roomAvatar,
|
|
e2eeSystem,
|
|
};
|
|
}, [client, displayName, avatarUrl, roomName, room, roomAvatar, e2eeSystem]);
|
|
|
|
// Count each member only once, regardless of how many devices they use
|
|
const participantCount = useMemo(
|
|
() => new Set<string>(memberships.map((m) => m.sender!)).size,
|
|
[memberships],
|
|
);
|
|
|
|
const mediaDevices = useMediaDevices();
|
|
const latestMuteStates = useLatest(muteStates);
|
|
|
|
const enterRTCSessionOrError = useCallback(
|
|
async (rtcSession: MatrixRTCSession): Promise<void> => {
|
|
try {
|
|
await enterRTCSession(
|
|
rtcSession,
|
|
perParticipantE2EE,
|
|
useNewMembershipManager,
|
|
useExperimentalToDeviceTransport,
|
|
);
|
|
} catch (e) {
|
|
if (e instanceof ElementCallError) {
|
|
setExternalError(e);
|
|
} else {
|
|
logger.error(`Unknown Error while entering RTC session`, e);
|
|
const error = new UnknownCallError(
|
|
e instanceof Error ? e : new Error("Unknown error", { cause: e }),
|
|
);
|
|
setExternalError(error);
|
|
}
|
|
}
|
|
},
|
|
[
|
|
perParticipantE2EE,
|
|
useExperimentalToDeviceTransport,
|
|
useNewMembershipManager,
|
|
],
|
|
);
|
|
|
|
useEffect(() => {
|
|
const defaultDeviceSetup = async ({
|
|
audioInput,
|
|
videoInput,
|
|
}: JoinCallData): Promise<void> => {
|
|
// XXX: I think this is broken currently - LiveKit *won't* request
|
|
// permissions and give you device names unless you specify a kind, but
|
|
// here we want all kinds of devices. This needs a fix in livekit-client
|
|
// for the following name-matching logic to do anything useful.
|
|
const devices = await LivekitRoom.getLocalDevices(undefined, true);
|
|
|
|
if (audioInput) {
|
|
const deviceId = findDeviceByName(audioInput, "audioinput", devices);
|
|
if (!deviceId) {
|
|
logger.warn("Unknown audio input: " + audioInput);
|
|
// override the default mute state
|
|
latestMuteStates.current!.audio.setEnabled?.(false);
|
|
} else {
|
|
logger.debug(
|
|
`Found audio input ID ${deviceId} for name ${audioInput}`,
|
|
);
|
|
mediaDevices.audioInput.select(deviceId);
|
|
}
|
|
}
|
|
|
|
if (videoInput) {
|
|
const deviceId = findDeviceByName(videoInput, "videoinput", devices);
|
|
if (!deviceId) {
|
|
logger.warn("Unknown video input: " + videoInput);
|
|
// override the default mute state
|
|
latestMuteStates.current!.video.setEnabled?.(false);
|
|
} else {
|
|
logger.debug(
|
|
`Found video input ID ${deviceId} for name ${videoInput}`,
|
|
);
|
|
mediaDevices.videoInput.select(deviceId);
|
|
}
|
|
}
|
|
};
|
|
|
|
if (skipLobby) {
|
|
if (widget && preload) {
|
|
// In preload mode without lobby we wait for a join action before entering
|
|
const onJoin = (ev: CustomEvent<IWidgetApiRequest>): void => {
|
|
(async (): Promise<void> => {
|
|
await defaultDeviceSetup(ev.detail.data as unknown as JoinCallData);
|
|
await enterRTCSessionOrError(rtcSession);
|
|
widget.api.transport.reply(ev.detail, {});
|
|
})().catch((e) => {
|
|
logger.error("Error joining RTC session on preload", e);
|
|
});
|
|
};
|
|
widget.lazyActions.on(ElementWidgetActions.JoinCall, onJoin);
|
|
return (): void => {
|
|
widget.lazyActions.off(ElementWidgetActions.JoinCall, onJoin);
|
|
};
|
|
} else {
|
|
// No lobby and no preload: we enter the rtc session right away
|
|
(async (): Promise<void> => {
|
|
await enterRTCSessionOrError(rtcSession);
|
|
})().catch((e) => {
|
|
logger.error("Error joining RTC session immediately", e);
|
|
});
|
|
}
|
|
}
|
|
}, [
|
|
widget,
|
|
rtcSession,
|
|
preload,
|
|
skipLobby,
|
|
perParticipantE2EE,
|
|
mediaDevices,
|
|
latestMuteStates,
|
|
enterRTCSessionOrError,
|
|
useNewMembershipManager,
|
|
]);
|
|
|
|
const [left, setLeft] = useState(false);
|
|
|
|
const navigate = useNavigate();
|
|
|
|
const onLeave = useCallback(
|
|
(
|
|
cause: "user" | "error" = "user",
|
|
playSound: CallEventSounds = "left",
|
|
): void => {
|
|
const audioPromise = leaveSoundContext.current?.playSound(playSound);
|
|
// In embedded/widget mode the iFrame will be killed right after the call ended prohibiting the posthog event from getting sent,
|
|
// therefore we want the event to be sent instantly without getting queued/batched.
|
|
const sendInstantly = !!widget;
|
|
setLeft(true);
|
|
// we need to wait until the callEnded event is tracked on posthog.
|
|
// Otherwise the iFrame gets killed before the callEnded event got tracked.
|
|
const posthogRequest = new Promise((resolve) => {
|
|
PosthogAnalytics.instance.eventCallEnded.track(
|
|
room.roomId,
|
|
rtcSession.memberships.length,
|
|
sendInstantly,
|
|
rtcSession,
|
|
);
|
|
window.setTimeout(resolve, 10);
|
|
});
|
|
|
|
leaveRTCSession(
|
|
rtcSession,
|
|
cause,
|
|
// Wait for the sound in widget mode (it's not long)
|
|
Promise.all([audioPromise, posthogRequest]),
|
|
)
|
|
// Only sends matrix leave event. The Livekit session will disconnect once the ActiveCall-view unmounts.
|
|
.then(async () => {
|
|
if (
|
|
!isPasswordlessUser &&
|
|
!confineToRoom &&
|
|
!PosthogAnalytics.instance.isEnabled()
|
|
) {
|
|
await navigate("/");
|
|
}
|
|
})
|
|
.catch((e) => {
|
|
logger.error("Error leaving RTC session", e);
|
|
});
|
|
},
|
|
[
|
|
leaveSoundContext,
|
|
widget,
|
|
rtcSession,
|
|
room.roomId,
|
|
isPasswordlessUser,
|
|
confineToRoom,
|
|
navigate,
|
|
],
|
|
);
|
|
|
|
useEffect(() => {
|
|
if (widget && isJoined) {
|
|
// set widget to sticky once joined.
|
|
widget.api.setAlwaysOnScreen(true).catch((e) => {
|
|
logger.error("Error calling setAlwaysOnScreen(true)", e);
|
|
});
|
|
|
|
const onHangup = (ev: CustomEvent<IWidgetApiRequest>): void => {
|
|
widget.api.transport.reply(ev.detail, {});
|
|
// Only sends matrix leave event. The Livekit session will disconnect once the ActiveCall-view unmounts.
|
|
leaveRTCSession(rtcSession, "user").catch((e) => {
|
|
logger.error("Failed to leave RTC session", e);
|
|
});
|
|
};
|
|
widget.lazyActions.once(ElementWidgetActions.HangupCall, onHangup);
|
|
return (): void => {
|
|
widget.lazyActions.off(ElementWidgetActions.HangupCall, onHangup);
|
|
};
|
|
}
|
|
}, [widget, isJoined, rtcSession]);
|
|
|
|
const joinRule = useJoinRule(room);
|
|
|
|
const [shareModalOpen, setInviteModalOpen] = useState(false);
|
|
const onDismissInviteModal = useCallback(
|
|
() => setInviteModalOpen(false),
|
|
[setInviteModalOpen],
|
|
);
|
|
|
|
const onShareClickFn = useCallback(
|
|
() => setInviteModalOpen(true),
|
|
[setInviteModalOpen],
|
|
);
|
|
const onShareClick = joinRule === JoinRule.Public ? onShareClickFn : null;
|
|
|
|
if (!isE2EESupportedBrowser() && e2eeSystem.kind !== E2eeType.NONE) {
|
|
// If we have a encryption system but the browser does not support it.
|
|
throw new E2EENotSupportedError();
|
|
}
|
|
|
|
const shareModal = (
|
|
<InviteModal
|
|
room={room}
|
|
open={shareModalOpen}
|
|
onDismiss={onDismissInviteModal}
|
|
/>
|
|
);
|
|
const lobbyView = (
|
|
<>
|
|
{shareModal}
|
|
<LobbyView
|
|
client={client}
|
|
matrixInfo={matrixInfo}
|
|
muteStates={muteStates}
|
|
onEnter={() => void enterRTCSessionOrError(rtcSession)}
|
|
confineToRoom={confineToRoom}
|
|
hideHeader={header === HeaderStyle.None}
|
|
participantCount={participantCount}
|
|
onShareClick={onShareClick}
|
|
/>
|
|
</>
|
|
);
|
|
|
|
let body: ReactNode;
|
|
if (externalError) {
|
|
// If an error was recorded within this component but outside
|
|
// GroupCallErrorBoundary, create a component that rethrows the error from
|
|
// within the error boundary, so it can be handled uniformly
|
|
const ErrorComponent = (): ReactNode => {
|
|
throw externalError;
|
|
};
|
|
body = <ErrorComponent />;
|
|
} else if (isJoined) {
|
|
body = (
|
|
<>
|
|
{shareModal}
|
|
<ActiveCall
|
|
client={client}
|
|
matrixInfo={matrixInfo}
|
|
rtcSession={rtcSession as MatrixRTCSession}
|
|
matrixRoom={room}
|
|
onLeave={onLeave}
|
|
header={header}
|
|
muteStates={muteStates}
|
|
e2eeSystem={e2eeSystem}
|
|
//otelGroupCallMembership={otelGroupCallMembership}
|
|
onShareClick={onShareClick}
|
|
/>
|
|
</>
|
|
);
|
|
} else if (left && widget === null) {
|
|
// Left in SPA mode:
|
|
|
|
// 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() && widget === null)
|
|
) {
|
|
body = (
|
|
<CallEndedView
|
|
endedCallId={rtcSession.room.roomId}
|
|
client={client}
|
|
isPasswordlessUser={isPasswordlessUser}
|
|
hideHeader={header === HeaderStyle.None}
|
|
confineToRoom={confineToRoom}
|
|
/>
|
|
);
|
|
} 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
|
|
// LobbyView again which would open capture devices again.
|
|
body = null;
|
|
}
|
|
} else if (left && widget !== null) {
|
|
// Left in widget mode:
|
|
body = returnToLobby ? lobbyView : null;
|
|
} else if (preload || skipLobby) {
|
|
// The RTC session is not joined to yet (`isJoined`), but enterRTCSessionOrError should have been called.
|
|
body = null;
|
|
} else {
|
|
body = lobbyView;
|
|
}
|
|
|
|
return (
|
|
<GroupCallErrorBoundary
|
|
widget={widget}
|
|
recoveryActionHandler={async (action) => {
|
|
setExternalError(null);
|
|
if (action == "reconnect") {
|
|
setLeft(false);
|
|
await enterRTCSessionOrError(rtcSession).catch((e) => {
|
|
logger.error("Error re-entering RTC session", e);
|
|
});
|
|
}
|
|
}}
|
|
onError={
|
|
(/**error*/) => {
|
|
if (rtcSession.isJoined()) onLeave("error");
|
|
}
|
|
}
|
|
>
|
|
{body}
|
|
</GroupCallErrorBoundary>
|
|
);
|
|
};
|