Skip to content

Commit 77facd0

Browse files
Half-Shothughns
andauthored
Add support for playing a sound when the user exits a call. (#2860)
* Refactor to use AudioContext * Remove unused audio format. * Reduce update frequency for volume * Port to useAudioContext * Port reactionaudiorenderer to useAudioContext * Integrate raise hand sound into call event renderer. * Simplify reaction sounds * only play one sound per reaction type * Start to build out tests * fixup tests / comments * Fix reaction sound * remove console line * Remove another debug line. * fix lint * Use testing library click * lint * Add support for playing a sound when the user exits a call. * Port GroupCallView to useAudioContext * Remove debug bits. * asyncify * lint * lint * lint * tidy * Add test for group call view * Test widget mode too. * fix ?. * Format * Lint * Lint --------- Co-authored-by: Hugh Nimmo-Smith <hughns@element.io>
1 parent 6c81f69 commit 77facd0

9 files changed

+242
-38
lines changed

src/room/CallEventAudioRenderer.tsx

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ import { useLatest } from "../useLatest";
2525
export const MAX_PARTICIPANT_COUNT_FOR_SOUND = 8;
2626
export const THROTTLE_SOUND_EFFECT_MS = 500;
2727

28-
const sounds = prefetchSounds({
28+
export const callEventAudioSounds = prefetchSounds({
2929
join: {
3030
mp3: joinCallSoundMp3,
3131
ogg: joinCallSoundOgg,
@@ -46,7 +46,7 @@ export function CallEventAudioRenderer({
4646
vm: CallViewModel;
4747
}): ReactNode {
4848
const audioEngineCtx = useAudioContext({
49-
sounds,
49+
sounds: callEventAudioSounds,
5050
latencyHint: "interactive",
5151
});
5252
const audioEngineRef = useLatest(audioEngineCtx);
@@ -60,7 +60,7 @@ export function CallEventAudioRenderer({
6060

6161
useEffect(() => {
6262
if (audioEngineRef.current && previousRaisedHandCount < raisedHandCount) {
63-
audioEngineRef.current.playSound("raiseHand");
63+
void audioEngineRef.current.playSound("raiseHand");
6464
}
6565
}, [audioEngineRef, previousRaisedHandCount, raisedHandCount]);
6666

@@ -74,7 +74,7 @@ export function CallEventAudioRenderer({
7474
throttle(() => interval(THROTTLE_SOUND_EFFECT_MS)),
7575
)
7676
.subscribe(() => {
77-
audioEngineRef.current?.playSound("join");
77+
void audioEngineRef.current?.playSound("join");
7878
});
7979

8080
const leftSub = vm.memberChanges
@@ -86,7 +86,7 @@ export function CallEventAudioRenderer({
8686
throttle(() => interval(THROTTLE_SOUND_EFFECT_MS)),
8787
)
8888
.subscribe(() => {
89-
audioEngineRef.current?.playSound("left");
89+
void audioEngineRef.current?.playSound("left");
9090
});
9191

9292
return (): void => {

src/room/GroupCallView.test.tsx

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
/*
2+
Copyright 2024 New Vector Ltd.
3+
4+
SPDX-License-Identifier: AGPL-3.0-only
5+
Please see LICENSE in the repository root for full details.
6+
*/
7+
8+
import { beforeEach, expect, type MockedFunction, test, vitest } from "vitest";
9+
import { render } from "@testing-library/react";
10+
import { type MatrixClient } from "matrix-js-sdk/src/client";
11+
import { type MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc";
12+
import { of } from "rxjs";
13+
import { JoinRule, type RoomState } from "matrix-js-sdk/src/matrix";
14+
import { Router } from "react-router-dom";
15+
import { createBrowserHistory } from "history";
16+
import userEvent from "@testing-library/user-event";
17+
18+
import { type MuteStates } from "./MuteStates";
19+
import { prefetchSounds } from "../soundUtils";
20+
import { useAudioContext } from "../useAudioContext";
21+
import { ActiveCall } from "./InCallView";
22+
import {
23+
mockMatrixRoom,
24+
mockMatrixRoomMember,
25+
mockRtcMembership,
26+
MockRTCSession,
27+
} from "../utils/test";
28+
import { GroupCallView } from "./GroupCallView";
29+
import { leaveRTCSession } from "../rtcSessionHelpers";
30+
import { type WidgetHelpers } from "../widget";
31+
import { LazyEventEmitter } from "../LazyEventEmitter";
32+
33+
vitest.mock("../soundUtils");
34+
vitest.mock("../useAudioContext");
35+
vitest.mock("./InCallView");
36+
37+
vitest.mock("../rtcSessionHelpers", async (importOriginal) => {
38+
// TODO: perhaps there is a more elegant way to manage the type import here?
39+
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
40+
const orig = await importOriginal<typeof import("../rtcSessionHelpers")>();
41+
vitest.spyOn(orig, "leaveRTCSession");
42+
return orig;
43+
});
44+
45+
let playSound: MockedFunction<
46+
NonNullable<ReturnType<typeof useAudioContext>>["playSound"]
47+
>;
48+
49+
const localRtcMember = mockRtcMembership("@carol:example.org", "CCCC");
50+
const carol = mockMatrixRoomMember(localRtcMember);
51+
const roomMembers = new Map([carol].map((p) => [p.userId, p]));
52+
53+
const roomId = "!foo:bar";
54+
const soundPromise = Promise.resolve(true);
55+
56+
beforeEach(() => {
57+
(prefetchSounds as MockedFunction<typeof prefetchSounds>).mockResolvedValue({
58+
sound: new ArrayBuffer(0),
59+
});
60+
playSound = vitest.fn().mockReturnValue(soundPromise);
61+
(useAudioContext as MockedFunction<typeof useAudioContext>).mockReturnValue({
62+
playSound,
63+
});
64+
// A trivial implementation of Active call to ensure we are testing GroupCallView exclusively here.
65+
(ActiveCall as MockedFunction<typeof ActiveCall>).mockImplementation(
66+
({ onLeave }) => {
67+
return (
68+
<div>
69+
<button onClick={() => onLeave()}>Leave</button>
70+
</div>
71+
);
72+
},
73+
);
74+
});
75+
76+
function createGroupCallView(widget: WidgetHelpers | null): {
77+
rtcSession: MockRTCSession;
78+
getByText: ReturnType<typeof render>["getByText"];
79+
} {
80+
const history = createBrowserHistory();
81+
const client = {
82+
getUser: () => null,
83+
getUserId: () => localRtcMember.sender,
84+
getDeviceId: () => localRtcMember.deviceId,
85+
getRoom: (rId) => (rId === roomId ? room : null),
86+
} as Partial<MatrixClient> as MatrixClient;
87+
const room = mockMatrixRoom({
88+
client,
89+
roomId,
90+
getMember: (userId) => roomMembers.get(userId) ?? null,
91+
getMxcAvatarUrl: () => null,
92+
getCanonicalAlias: () => null,
93+
currentState: {
94+
getJoinRule: () => JoinRule.Invite,
95+
} as Partial<RoomState> as RoomState,
96+
});
97+
const rtcSession = new MockRTCSession(
98+
room,
99+
localRtcMember,
100+
[],
101+
).withMemberships(of([]));
102+
const muteState = {
103+
audio: { enabled: false },
104+
video: { enabled: false },
105+
} as MuteStates;
106+
const { getByText } = render(
107+
<Router history={history}>
108+
<GroupCallView
109+
client={client}
110+
isPasswordlessUser={false}
111+
confineToRoom={false}
112+
preload={false}
113+
skipLobby={false}
114+
hideHeader={true}
115+
rtcSession={rtcSession as unknown as MatrixRTCSession}
116+
muteStates={muteState}
117+
widget={widget}
118+
/>
119+
</Router>,
120+
);
121+
return {
122+
getByText,
123+
rtcSession,
124+
};
125+
}
126+
127+
test("will play a leave sound asynchronously in SPA mode", async () => {
128+
const user = userEvent.setup();
129+
const { getByText, rtcSession } = createGroupCallView(null);
130+
const leaveButton = getByText("Leave");
131+
await user.click(leaveButton);
132+
expect(playSound).toHaveBeenCalledWith("left");
133+
expect(leaveRTCSession).toHaveBeenCalledWith(rtcSession, undefined);
134+
expect(rtcSession.leaveRoomSession).toHaveBeenCalledOnce();
135+
});
136+
137+
test("will play a leave sound synchronously in widget mode", async () => {
138+
const user = userEvent.setup();
139+
const widget = {
140+
api: {
141+
setAlwaysOnScreen: async () => Promise.resolve(true),
142+
} as Partial<WidgetHelpers["api"]>,
143+
lazyActions: new LazyEventEmitter(),
144+
};
145+
const { getByText, rtcSession } = createGroupCallView(
146+
widget as WidgetHelpers,
147+
);
148+
const leaveButton = getByText("Leave");
149+
await user.click(leaveButton);
150+
expect(playSound).toHaveBeenCalledWith("left");
151+
expect(leaveRTCSession).toHaveBeenCalledWith(rtcSession, soundPromise);
152+
expect(rtcSession.leaveRoomSession).toHaveBeenCalledOnce();
153+
});

src/room/GroupCallView.tsx

Lines changed: 51 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,11 @@ import { Heading, Text } from "@vector-im/compound-web";
2626
import { useTranslation } from "react-i18next";
2727

2828
import type { IWidgetApiRequest } from "matrix-widget-api";
29-
import { widget, ElementWidgetActions, type JoinCallData } from "../widget";
29+
import {
30+
ElementWidgetActions,
31+
type JoinCallData,
32+
type WidgetHelpers,
33+
} from "../widget";
3034
import { FullScreenView } from "../FullScreenView";
3135
import { LobbyView } from "./LobbyView";
3236
import { type MatrixInfo } from "./VideoPreview";
@@ -51,6 +55,9 @@ import { InviteModal } from "./InviteModal";
5155
import { useUrlParams } from "../UrlParams";
5256
import { E2eeType } from "../e2ee/e2eeType";
5357
import { Link } from "../button/Link";
58+
import { useAudioContext } from "../useAudioContext";
59+
import { callEventAudioSounds } from "./CallEventAudioRenderer";
60+
import { useLatest } from "../useLatest";
5461

5562
declare global {
5663
interface Window {
@@ -67,6 +74,7 @@ interface Props {
6774
hideHeader: boolean;
6875
rtcSession: MatrixRTCSession;
6976
muteStates: MuteStates;
77+
widget: WidgetHelpers | null;
7078
}
7179

7280
export const GroupCallView: FC<Props> = ({
@@ -78,10 +86,16 @@ export const GroupCallView: FC<Props> = ({
7886
hideHeader,
7987
rtcSession,
8088
muteStates,
89+
widget,
8190
}) => {
8291
const memberships = useMatrixRTCSessionMemberships(rtcSession);
8392
const isJoined = useMatrixRTCSessionJoinState(rtcSession);
84-
93+
const leaveSoundContext = useLatest(
94+
useAudioContext({
95+
sounds: callEventAudioSounds,
96+
latencyHint: "interactive",
97+
}),
98+
);
8599
// This should use `useEffectEvent` (only available in experimental versions)
86100
useEffect(() => {
87101
if (memberships.length >= MUTE_PARTICIPANT_COUNT)
@@ -195,14 +209,14 @@ export const GroupCallView: FC<Props> = ({
195209
ev.detail.data as unknown as JoinCallData,
196210
);
197211
await enterRTCSession(rtcSession, perParticipantE2EE);
198-
widget!.api.transport.reply(ev.detail, {});
212+
widget.api.transport.reply(ev.detail, {});
199213
})().catch((e) => {
200214
logger.error("Error joining RTC session", e);
201215
});
202216
};
203217
widget.lazyActions.on(ElementWidgetActions.JoinCall, onJoin);
204218
return (): void => {
205-
widget!.lazyActions.off(ElementWidgetActions.JoinCall, onJoin);
219+
widget.lazyActions.off(ElementWidgetActions.JoinCall, onJoin);
206220
};
207221
} else {
208222
// No lobby and no preload: we enter the rtc session right away
@@ -216,29 +230,33 @@ export const GroupCallView: FC<Props> = ({
216230
void enterRTCSession(rtcSession, perParticipantE2EE);
217231
}
218232
}
219-
}, [rtcSession, preload, skipLobby, perParticipantE2EE]);
233+
}, [widget, rtcSession, preload, skipLobby, perParticipantE2EE]);
220234

221235
const [left, setLeft] = useState(false);
222236
const [leaveError, setLeaveError] = useState<Error | undefined>(undefined);
223237
const history = useHistory();
224238

225239
const onLeave = useCallback(
226240
(leaveError?: Error): void => {
227-
setLeaveError(leaveError);
228-
setLeft(true);
229-
241+
const audioPromise = leaveSoundContext.current?.playSound("left");
230242
// In embedded/widget mode the iFrame will be killed right after the call ended prohibiting the posthog event from getting sent,
231243
// therefore we want the event to be sent instantly without getting queued/batched.
232244
const sendInstantly = !!widget;
245+
setLeaveError(leaveError);
246+
setLeft(true);
233247
PosthogAnalytics.instance.eventCallEnded.track(
234248
rtcSession.room.roomId,
235249
rtcSession.memberships.length,
236250
sendInstantly,
237251
rtcSession,
238252
);
239253

240-
// Only sends matrix leave event. The Livekit session will disconnect once the ActiveCall-view unmounts.
241-
leaveRTCSession(rtcSession)
254+
leaveRTCSession(
255+
rtcSession,
256+
// Wait for the sound in widget mode (it's not long)
257+
sendInstantly && audioPromise ? audioPromise : undefined,
258+
)
259+
// Only sends matrix leave event. The Livekit session will disconnect once the ActiveCall-view unmounts.
242260
.then(() => {
243261
if (
244262
!isPasswordlessUser &&
@@ -252,29 +270,36 @@ export const GroupCallView: FC<Props> = ({
252270
logger.error("Error leaving RTC session", e);
253271
});
254272
},
255-
[rtcSession, isPasswordlessUser, confineToRoom, history],
273+
[
274+
widget,
275+
rtcSession,
276+
isPasswordlessUser,
277+
confineToRoom,
278+
leaveSoundContext,
279+
history,
280+
],
256281
);
257282

258283
useEffect(() => {
259284
if (widget && isJoined) {
260285
// set widget to sticky once joined.
261-
widget!.api.setAlwaysOnScreen(true).catch((e) => {
286+
widget.api.setAlwaysOnScreen(true).catch((e) => {
262287
logger.error("Error calling setAlwaysOnScreen(true)", e);
263288
});
264289

265290
const onHangup = (ev: CustomEvent<IWidgetApiRequest>): void => {
266-
widget!.api.transport.reply(ev.detail, {});
291+
widget.api.transport.reply(ev.detail, {});
267292
// Only sends matrix leave event. The Livekit session will disconnect once the ActiveCall-view unmounts.
268293
leaveRTCSession(rtcSession).catch((e) => {
269294
logger.error("Failed to leave RTC session", e);
270295
});
271296
};
272297
widget.lazyActions.once(ElementWidgetActions.HangupCall, onHangup);
273298
return (): void => {
274-
widget!.lazyActions.off(ElementWidgetActions.HangupCall, onHangup);
299+
widget.lazyActions.off(ElementWidgetActions.HangupCall, onHangup);
275300
};
276301
}
277-
}, [isJoined, rtcSession]);
302+
}, [widget, isJoined, rtcSession]);
278303

279304
const onReconnect = useCallback(() => {
280305
setLeft(false);
@@ -367,14 +392,17 @@ export const GroupCallView: FC<Props> = ({
367392
leaveError
368393
) {
369394
return (
370-
<CallEndedView
371-
endedCallId={rtcSession.room.roomId}
372-
client={client}
373-
isPasswordlessUser={isPasswordlessUser}
374-
confineToRoom={confineToRoom}
375-
leaveError={leaveError}
376-
reconnect={onReconnect}
377-
/>
395+
<>
396+
<CallEndedView
397+
endedCallId={rtcSession.room.roomId}
398+
client={client}
399+
isPasswordlessUser={isPasswordlessUser}
400+
confineToRoom={confineToRoom}
401+
leaveError={leaveError}
402+
reconnect={onReconnect}
403+
/>
404+
;
405+
</>
378406
);
379407
} else {
380408
// If the user is a regular user, we'll have sent them back to the homepage,

src/room/ReactionAudioRenderer.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,10 +60,10 @@ export function ReactionsAudioRenderer(): ReactNode {
6060
return;
6161
}
6262
if (soundMap[reactionName]) {
63-
audioEngineRef.current.playSound(reactionName);
63+
void audioEngineRef.current.playSound(reactionName);
6464
} else {
6565
// Fallback sounds.
66-
audioEngineRef.current.playSound("generic");
66+
void audioEngineRef.current.playSound("generic");
6767
}
6868
}
6969
}, [audioEngineRef, shouldPlay, oldReactions, reactions]);

src/room/RoomPage.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,7 @@ export const RoomPage: FC = () => {
9898
case "loaded":
9999
return (
100100
<GroupCallView
101+
widget={widget}
101102
client={client!}
102103
rtcSession={groupCallState.rtcSession}
103104
isPasswordlessUser={passwordlessUser}

0 commit comments

Comments
 (0)