Merge branch 'livekit' into toger5/track-processor-blur
This commit is contained in:
@@ -15,43 +15,23 @@ import {
|
||||
vitest,
|
||||
afterEach,
|
||||
} from "vitest";
|
||||
import { type MatrixClient } from "matrix-js-sdk/src/client";
|
||||
import { ConnectionState } from "livekit-client";
|
||||
import { BehaviorSubject, of } from "rxjs";
|
||||
import { act, type ReactNode } from "react";
|
||||
import {
|
||||
type CallMembership,
|
||||
type MatrixRTCSession,
|
||||
} from "matrix-js-sdk/src/matrixrtc";
|
||||
import { type RoomMember } from "matrix-js-sdk/src/matrix";
|
||||
import { act } from "react";
|
||||
import { type CallMembership } from "matrix-js-sdk/src/matrixrtc";
|
||||
|
||||
import {
|
||||
mockLivekitRoom,
|
||||
mockLocalParticipant,
|
||||
mockMatrixRoom,
|
||||
mockMatrixRoomMember,
|
||||
mockRemoteParticipant,
|
||||
mockRtcMembership,
|
||||
MockRTCSession,
|
||||
} from "../utils/test";
|
||||
import { E2eeType } from "../e2ee/e2eeType";
|
||||
import { CallViewModel } from "../state/CallViewModel";
|
||||
import { mockRtcMembership } from "../utils/test";
|
||||
import {
|
||||
CallEventAudioRenderer,
|
||||
MAX_PARTICIPANT_COUNT_FOR_SOUND,
|
||||
} from "./CallEventAudioRenderer";
|
||||
import { useAudioContext } from "../useAudioContext";
|
||||
import { TestReactionsWrapper } from "../utils/testReactions";
|
||||
import { prefetchSounds } from "../soundUtils";
|
||||
|
||||
const localRtcMember = mockRtcMembership("@carol:example.org", "CCCC");
|
||||
const local = mockMatrixRoomMember(localRtcMember);
|
||||
const aliceRtcMember = mockRtcMembership("@alice:example.org", "AAAA");
|
||||
const alice = mockMatrixRoomMember(aliceRtcMember);
|
||||
const bobRtcMember = mockRtcMembership("@bob:example.org", "BBBB");
|
||||
const localParticipant = mockLocalParticipant({ identity: "" });
|
||||
const aliceId = `${alice.userId}:${aliceRtcMember.deviceId}`;
|
||||
const aliceParticipant = mockRemoteParticipant({ identity: aliceId });
|
||||
import { getBasicCallViewModelEnvironment } from "../utils/test-viewmodel";
|
||||
import {
|
||||
alice,
|
||||
aliceRtcMember,
|
||||
bobRtcMember,
|
||||
local,
|
||||
} from "../utils/test-fixtures";
|
||||
|
||||
vitest.mock("../useAudioContext");
|
||||
vitest.mock("../soundUtils");
|
||||
@@ -78,66 +58,6 @@ beforeEach(() => {
|
||||
});
|
||||
});
|
||||
|
||||
function TestComponent({
|
||||
rtcSession,
|
||||
vm,
|
||||
}: {
|
||||
rtcSession: MockRTCSession;
|
||||
vm: CallViewModel;
|
||||
}): ReactNode {
|
||||
return (
|
||||
<TestReactionsWrapper
|
||||
rtcSession={rtcSession as unknown as MatrixRTCSession}
|
||||
>
|
||||
<CallEventAudioRenderer vm={vm} />
|
||||
</TestReactionsWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
function getMockEnv(
|
||||
members: RoomMember[],
|
||||
initialRemoteRtcMemberships: CallMembership[] = [aliceRtcMember],
|
||||
): {
|
||||
vm: CallViewModel;
|
||||
session: MockRTCSession;
|
||||
remoteRtcMemberships$: BehaviorSubject<CallMembership[]>;
|
||||
} {
|
||||
const matrixRoomMembers = new Map(members.map((p) => [p.userId, p]));
|
||||
const remoteParticipants$ = of([aliceParticipant]);
|
||||
const liveKitRoom = mockLivekitRoom(
|
||||
{ localParticipant },
|
||||
{ remoteParticipants$ },
|
||||
);
|
||||
const matrixRoom = mockMatrixRoom({
|
||||
client: {
|
||||
getUserId: () => localRtcMember.sender,
|
||||
getDeviceId: () => localRtcMember.deviceId,
|
||||
on: vitest.fn(),
|
||||
off: vitest.fn(),
|
||||
} as Partial<MatrixClient> as MatrixClient,
|
||||
getMember: (userId) => matrixRoomMembers.get(userId) ?? null,
|
||||
});
|
||||
|
||||
const remoteRtcMemberships$ = new BehaviorSubject<CallMembership[]>(
|
||||
initialRemoteRtcMemberships,
|
||||
);
|
||||
|
||||
const session = new MockRTCSession(
|
||||
matrixRoom,
|
||||
localRtcMember,
|
||||
).withMemberships(remoteRtcMemberships$);
|
||||
|
||||
const vm = new CallViewModel(
|
||||
session as unknown as MatrixRTCSession,
|
||||
liveKitRoom,
|
||||
{
|
||||
kind: E2eeType.PER_PARTICIPANT,
|
||||
},
|
||||
of(ConnectionState.Connected),
|
||||
);
|
||||
return { vm, session, remoteRtcMemberships$ };
|
||||
}
|
||||
|
||||
/**
|
||||
* We don't want to play a sound when loading the call state
|
||||
* because typically this occurs in two stages. We first join
|
||||
@@ -146,8 +66,12 @@ function getMockEnv(
|
||||
* a noise every time.
|
||||
*/
|
||||
test("plays one sound when entering a call", () => {
|
||||
const { session, vm, remoteRtcMemberships$ } = getMockEnv([local, alice]);
|
||||
render(<TestComponent rtcSession={session} vm={vm} />);
|
||||
const { vm, remoteRtcMemberships$ } = getBasicCallViewModelEnvironment([
|
||||
local,
|
||||
alice,
|
||||
]);
|
||||
render(<CallEventAudioRenderer vm={vm} />);
|
||||
|
||||
// Joining a call usually means remote participants are added later.
|
||||
act(() => {
|
||||
remoteRtcMemberships$.next([aliceRtcMember, bobRtcMember]);
|
||||
@@ -155,10 +79,12 @@ test("plays one sound when entering a call", () => {
|
||||
expect(playSound).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
// TODO: Same test?
|
||||
test("plays a sound when a user joins", () => {
|
||||
const { session, vm, remoteRtcMemberships$ } = getMockEnv([local, alice]);
|
||||
render(<TestComponent rtcSession={session} vm={vm} />);
|
||||
const { vm, remoteRtcMemberships$ } = getBasicCallViewModelEnvironment([
|
||||
local,
|
||||
alice,
|
||||
]);
|
||||
render(<CallEventAudioRenderer vm={vm} />);
|
||||
|
||||
act(() => {
|
||||
remoteRtcMemberships$.next([aliceRtcMember, bobRtcMember]);
|
||||
@@ -168,8 +94,11 @@ test("plays a sound when a user joins", () => {
|
||||
});
|
||||
|
||||
test("plays a sound when a user leaves", () => {
|
||||
const { session, vm, remoteRtcMemberships$ } = getMockEnv([local, alice]);
|
||||
render(<TestComponent rtcSession={session} vm={vm} />);
|
||||
const { vm, remoteRtcMemberships$ } = getBasicCallViewModelEnvironment([
|
||||
local,
|
||||
alice,
|
||||
]);
|
||||
render(<CallEventAudioRenderer vm={vm} />);
|
||||
|
||||
act(() => {
|
||||
remoteRtcMemberships$.next([]);
|
||||
@@ -185,12 +114,12 @@ test("plays no sound when the participant list is more than the maximum size", (
|
||||
);
|
||||
}
|
||||
|
||||
const { session, vm, remoteRtcMemberships$ } = getMockEnv(
|
||||
const { vm, remoteRtcMemberships$ } = getBasicCallViewModelEnvironment(
|
||||
[local, alice],
|
||||
mockRtcMemberships,
|
||||
);
|
||||
|
||||
render(<TestComponent rtcSession={session} vm={vm} />);
|
||||
render(<CallEventAudioRenderer vm={vm} />);
|
||||
expect(playSound).not.toBeCalled();
|
||||
act(() => {
|
||||
remoteRtcMemberships$.next(
|
||||
@@ -199,3 +128,56 @@ test("plays no sound when the participant list is more than the maximum size", (
|
||||
});
|
||||
expect(playSound).toBeCalledWith("left");
|
||||
});
|
||||
|
||||
test("plays one sound when a hand is raised", () => {
|
||||
const { vm, handRaisedSubject$ } = getBasicCallViewModelEnvironment([
|
||||
local,
|
||||
alice,
|
||||
]);
|
||||
render(<CallEventAudioRenderer vm={vm} />);
|
||||
|
||||
act(() => {
|
||||
handRaisedSubject$.next({
|
||||
[bobRtcMember.callId]: {
|
||||
time: new Date(),
|
||||
membershipEventId: "",
|
||||
reactionEventId: "",
|
||||
},
|
||||
});
|
||||
});
|
||||
expect(playSound).toBeCalledWith("raiseHand");
|
||||
});
|
||||
|
||||
test("should not play a sound when a hand raise is retracted", () => {
|
||||
const { vm, handRaisedSubject$ } = getBasicCallViewModelEnvironment([
|
||||
local,
|
||||
alice,
|
||||
]);
|
||||
render(<CallEventAudioRenderer vm={vm} />);
|
||||
|
||||
act(() => {
|
||||
handRaisedSubject$.next({
|
||||
["foo"]: {
|
||||
time: new Date(),
|
||||
membershipEventId: "",
|
||||
reactionEventId: "",
|
||||
},
|
||||
["bar"]: {
|
||||
time: new Date(),
|
||||
membershipEventId: "",
|
||||
reactionEventId: "",
|
||||
},
|
||||
});
|
||||
});
|
||||
expect(playSound).toHaveBeenCalledTimes(2);
|
||||
act(() => {
|
||||
handRaisedSubject$.next({
|
||||
["foo"]: {
|
||||
time: new Date(),
|
||||
membershipEventId: "",
|
||||
reactionEventId: "",
|
||||
},
|
||||
});
|
||||
});
|
||||
expect(playSound).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
@@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { type ReactNode, useDeferredValue, useEffect, useMemo } from "react";
|
||||
import { type ReactNode, useEffect } from "react";
|
||||
import { filter, interval, throttle } from "rxjs";
|
||||
|
||||
import { type CallViewModel } from "../state/CallViewModel";
|
||||
@@ -13,11 +13,12 @@ import joinCallSoundMp3 from "../sound/join_call.mp3";
|
||||
import joinCallSoundOgg from "../sound/join_call.ogg";
|
||||
import leftCallSoundMp3 from "../sound/left_call.mp3";
|
||||
import leftCallSoundOgg from "../sound/left_call.ogg";
|
||||
import handSoundOgg from "../sound/raise_hand.ogg?url";
|
||||
import handSoundMp3 from "../sound/raise_hand.mp3?url";
|
||||
import handSoundOgg from "../sound/raise_hand.ogg";
|
||||
import handSoundMp3 from "../sound/raise_hand.mp3";
|
||||
import screenShareStartedOgg from "../sound/screen_share_started.ogg";
|
||||
import screenShareStartedMp3 from "../sound/screen_share_started.mp3";
|
||||
import { useAudioContext } from "../useAudioContext";
|
||||
import { prefetchSounds } from "../soundUtils";
|
||||
import { useReactions } from "../useReactions";
|
||||
import { useLatest } from "../useLatest";
|
||||
|
||||
// Do not play any sounds if the participant count has exceeded this
|
||||
@@ -38,6 +39,10 @@ export const callEventAudioSounds = prefetchSounds({
|
||||
mp3: handSoundMp3,
|
||||
ogg: handSoundOgg,
|
||||
},
|
||||
screenshareStarted: {
|
||||
mp3: screenShareStartedMp3,
|
||||
ogg: screenShareStartedOgg,
|
||||
},
|
||||
});
|
||||
|
||||
export function CallEventAudioRenderer({
|
||||
@@ -51,19 +56,6 @@ export function CallEventAudioRenderer({
|
||||
});
|
||||
const audioEngineRef = useLatest(audioEngineCtx);
|
||||
|
||||
const { raisedHands } = useReactions();
|
||||
const raisedHandCount = useMemo(
|
||||
() => Object.keys(raisedHands).length,
|
||||
[raisedHands],
|
||||
);
|
||||
const previousRaisedHandCount = useDeferredValue(raisedHandCount);
|
||||
|
||||
useEffect(() => {
|
||||
if (audioEngineRef.current && previousRaisedHandCount < raisedHandCount) {
|
||||
void audioEngineRef.current.playSound("raiseHand");
|
||||
}
|
||||
}, [audioEngineRef, previousRaisedHandCount, raisedHandCount]);
|
||||
|
||||
useEffect(() => {
|
||||
const joinSub = vm.memberChanges$
|
||||
.pipe(
|
||||
@@ -89,9 +81,19 @@ export function CallEventAudioRenderer({
|
||||
void audioEngineRef.current?.playSound("left");
|
||||
});
|
||||
|
||||
const handRaisedSub = vm.newHandRaised$.subscribe(() => {
|
||||
void audioEngineRef.current?.playSound("raiseHand");
|
||||
});
|
||||
|
||||
const screenshareSub = vm.newScreenShare$.subscribe(() => {
|
||||
void audioEngineRef.current?.playSound("screenshareStarted");
|
||||
});
|
||||
|
||||
return (): void => {
|
||||
joinSub.unsubscribe();
|
||||
leftSub.unsubscribe();
|
||||
handRaisedSub.unsubscribe();
|
||||
screenshareSub.unsubscribe();
|
||||
};
|
||||
}, [audioEngineRef, vm]);
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import { JoinRule, type RoomState } from "matrix-js-sdk/src/matrix";
|
||||
import { Router } from "react-router-dom";
|
||||
import { createBrowserHistory } from "history";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { type RelationsContainer } from "matrix-js-sdk/src/models/relations-container";
|
||||
|
||||
import { type MuteStates } from "./MuteStates";
|
||||
import { prefetchSounds } from "../soundUtils";
|
||||
@@ -85,6 +86,12 @@ function createGroupCallView(widget: WidgetHelpers | null): {
|
||||
getRoom: (rId) => (rId === roomId ? room : null),
|
||||
} as Partial<MatrixClient> as MatrixClient;
|
||||
const room = mockMatrixRoom({
|
||||
relations: {
|
||||
getChildEventsForEvent: () =>
|
||||
vitest.mocked({
|
||||
getRelations: () => [],
|
||||
}),
|
||||
} as unknown as RelationsContainer,
|
||||
client,
|
||||
roomId,
|
||||
getMember: (userId) => roomMembers.get(userId) ?? null,
|
||||
|
||||
@@ -366,7 +366,7 @@ export const GroupCallView: FC<Props> = ({
|
||||
<ActiveCall
|
||||
client={client}
|
||||
matrixInfo={matrixInfo}
|
||||
rtcSession={rtcSession as unknown as MatrixRTCSession}
|
||||
rtcSession={rtcSession as MatrixRTCSession}
|
||||
participantCount={participantCount}
|
||||
onLeave={onLeave}
|
||||
hideHeader={hideHeader}
|
||||
|
||||
@@ -83,7 +83,10 @@ import { makeSpotlightExpandedLayout } from "../grid/SpotlightExpandedLayout";
|
||||
import { makeSpotlightLandscapeLayout } from "../grid/SpotlightLandscapeLayout";
|
||||
import { makeSpotlightPortraitLayout } from "../grid/SpotlightPortraitLayout";
|
||||
import { GridTileViewModel, type TileViewModel } from "../state/TileViewModel";
|
||||
import { ReactionsProvider, useReactions } from "../useReactions";
|
||||
import {
|
||||
ReactionsSenderProvider,
|
||||
useReactionsSender,
|
||||
} from "../reactions/useReactionsSender";
|
||||
import { ReactionsAudioRenderer } from "./ReactionAudioRenderer";
|
||||
import { useSwitchCamera } from "./useSwitchCamera";
|
||||
import { ReactionsOverlay } from "./ReactionsOverlay";
|
||||
@@ -92,6 +95,7 @@ import {
|
||||
debugTileLayout as debugTileLayoutSetting,
|
||||
useSetting,
|
||||
} from "../settings/settings";
|
||||
import { ReactionsReader } from "../reactions/ReactionsReader";
|
||||
|
||||
const canScreenshare = "getDisplayMedia" in (navigator.mediaDevices ?? {});
|
||||
|
||||
@@ -127,14 +131,20 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
|
||||
|
||||
useEffect(() => {
|
||||
if (livekitRoom !== undefined) {
|
||||
const reactionsReader = new ReactionsReader(props.rtcSession);
|
||||
const vm = new CallViewModel(
|
||||
props.rtcSession,
|
||||
livekitRoom,
|
||||
props.e2eeSystem,
|
||||
connStateObservable$,
|
||||
reactionsReader.raisedHands$,
|
||||
reactionsReader.reactions$,
|
||||
);
|
||||
setVm(vm);
|
||||
return (): void => vm.destroy();
|
||||
return (): void => {
|
||||
vm.destroy();
|
||||
reactionsReader.destroy();
|
||||
};
|
||||
}
|
||||
}, [props.rtcSession, livekitRoom, props.e2eeSystem, connStateObservable$]);
|
||||
|
||||
@@ -142,14 +152,14 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
|
||||
|
||||
return (
|
||||
<RoomContext.Provider value={livekitRoom}>
|
||||
<ReactionsProvider rtcSession={props.rtcSession}>
|
||||
<ReactionsSenderProvider vm={vm} rtcSession={props.rtcSession}>
|
||||
<InCallView
|
||||
{...props}
|
||||
vm={vm}
|
||||
livekitRoom={livekitRoom}
|
||||
connState={connState}
|
||||
/>
|
||||
</ReactionsProvider>
|
||||
</ReactionsSenderProvider>
|
||||
</RoomContext.Provider>
|
||||
);
|
||||
};
|
||||
@@ -182,7 +192,8 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
connState,
|
||||
onShareClick,
|
||||
}) => {
|
||||
const { supportsReactions, sendReaction, toggleRaisedHand } = useReactions();
|
||||
const { supportsReactions, sendReaction, toggleRaisedHand } =
|
||||
useReactionsSender();
|
||||
|
||||
useWakeLock();
|
||||
|
||||
@@ -551,9 +562,10 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
if (supportsReactions) {
|
||||
buttons.push(
|
||||
<ReactionToggleButton
|
||||
vm={vm}
|
||||
key="raise_hand"
|
||||
className={styles.raiseHand}
|
||||
userId={client.getUserId()!}
|
||||
identifier={`${client.getUserId()}:${client.getDeviceId()}`}
|
||||
onTouchEnd={onControlsTouchEnd}
|
||||
/>,
|
||||
);
|
||||
@@ -653,8 +665,8 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
<RoomAudioRenderer />
|
||||
{renderContent()}
|
||||
<CallEventAudioRenderer vm={vm} />
|
||||
<ReactionsAudioRenderer />
|
||||
<ReactionsOverlay />
|
||||
<ReactionsAudioRenderer vm={vm} />
|
||||
<ReactionsOverlay vm={vm} />
|
||||
{footer}
|
||||
{layout.type !== "pip" && (
|
||||
<>
|
||||
|
||||
@@ -19,11 +19,6 @@ import {
|
||||
import { TooltipProvider } from "@vector-im/compound-web";
|
||||
import { act, type ReactNode } from "react";
|
||||
|
||||
import {
|
||||
MockRoom,
|
||||
MockRTCSession,
|
||||
TestReactionsWrapper,
|
||||
} from "../utils/testReactions";
|
||||
import { ReactionsAudioRenderer } from "./ReactionAudioRenderer";
|
||||
import {
|
||||
playReactionsSound,
|
||||
@@ -32,30 +27,20 @@ import {
|
||||
import { useAudioContext } from "../useAudioContext";
|
||||
import { GenericReaction, ReactionSet } from "../reactions";
|
||||
import { prefetchSounds } from "../soundUtils";
|
||||
import { type CallViewModel } from "../state/CallViewModel";
|
||||
import { getBasicCallViewModelEnvironment } from "../utils/test-viewmodel";
|
||||
import {
|
||||
alice,
|
||||
aliceRtcMember,
|
||||
bobRtcMember,
|
||||
local,
|
||||
localRtcMember,
|
||||
} from "../utils/test-fixtures";
|
||||
|
||||
const memberUserIdAlice = "@alice:example.org";
|
||||
const memberUserIdBob = "@bob:example.org";
|
||||
const memberUserIdCharlie = "@charlie:example.org";
|
||||
const memberEventAlice = "$membership-alice:example.org";
|
||||
const memberEventBob = "$membership-bob:example.org";
|
||||
const memberEventCharlie = "$membership-charlie:example.org";
|
||||
|
||||
const membership: Record<string, string> = {
|
||||
[memberEventAlice]: memberUserIdAlice,
|
||||
[memberEventBob]: memberUserIdBob,
|
||||
[memberEventCharlie]: memberUserIdCharlie,
|
||||
};
|
||||
|
||||
function TestComponent({
|
||||
rtcSession,
|
||||
}: {
|
||||
rtcSession: MockRTCSession;
|
||||
}): ReactNode {
|
||||
function TestComponent({ vm }: { vm: CallViewModel }): ReactNode {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<TestReactionsWrapper rtcSession={rtcSession}>
|
||||
<ReactionsAudioRenderer />
|
||||
</TestReactionsWrapper>
|
||||
<ReactionsAudioRenderer vm={vm} />
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
@@ -88,20 +73,19 @@ beforeEach(() => {
|
||||
});
|
||||
|
||||
test("preloads all audio elements", () => {
|
||||
const { vm } = getBasicCallViewModelEnvironment([local, alice]);
|
||||
playReactionsSound.setValue(true);
|
||||
const rtcSession = new MockRTCSession(
|
||||
new MockRoom(memberUserIdAlice),
|
||||
membership,
|
||||
);
|
||||
render(<TestComponent rtcSession={rtcSession} />);
|
||||
render(<TestComponent vm={vm} />);
|
||||
expect(prefetchSounds).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
test("will play an audio sound when there is a reaction", () => {
|
||||
const { vm, reactionsSubject$ } = getBasicCallViewModelEnvironment([
|
||||
local,
|
||||
alice,
|
||||
]);
|
||||
playReactionsSound.setValue(true);
|
||||
const room = new MockRoom(memberUserIdAlice);
|
||||
const rtcSession = new MockRTCSession(room, membership);
|
||||
render(<TestComponent rtcSession={rtcSession} />);
|
||||
render(<TestComponent vm={vm} />);
|
||||
|
||||
// Find the first reaction with a sound effect
|
||||
const chosenReaction = ReactionSet.find((r) => !!r.sound);
|
||||
@@ -111,16 +95,23 @@ test("will play an audio sound when there is a reaction", () => {
|
||||
);
|
||||
}
|
||||
act(() => {
|
||||
room.testSendReaction(memberEventAlice, chosenReaction, membership);
|
||||
reactionsSubject$.next({
|
||||
[aliceRtcMember.deviceId]: {
|
||||
reactionOption: chosenReaction,
|
||||
expireAfter: new Date(0),
|
||||
},
|
||||
});
|
||||
});
|
||||
expect(playSound).toHaveBeenCalledWith(chosenReaction.name);
|
||||
});
|
||||
|
||||
test("will play the generic audio sound when there is soundless reaction", () => {
|
||||
const { vm, reactionsSubject$ } = getBasicCallViewModelEnvironment([
|
||||
local,
|
||||
alice,
|
||||
]);
|
||||
playReactionsSound.setValue(true);
|
||||
const room = new MockRoom(memberUserIdAlice);
|
||||
const rtcSession = new MockRTCSession(room, membership);
|
||||
render(<TestComponent rtcSession={rtcSession} />);
|
||||
render(<TestComponent vm={vm} />);
|
||||
|
||||
// Find the first reaction with a sound effect
|
||||
const chosenReaction = ReactionSet.find((r) => !r.sound);
|
||||
@@ -130,17 +121,23 @@ test("will play the generic audio sound when there is soundless reaction", () =>
|
||||
);
|
||||
}
|
||||
act(() => {
|
||||
room.testSendReaction(memberEventAlice, chosenReaction, membership);
|
||||
reactionsSubject$.next({
|
||||
[aliceRtcMember.deviceId]: {
|
||||
reactionOption: chosenReaction,
|
||||
expireAfter: new Date(0),
|
||||
},
|
||||
});
|
||||
});
|
||||
expect(playSound).toHaveBeenCalledWith(GenericReaction.name);
|
||||
});
|
||||
|
||||
test("will play multiple audio sounds when there are multiple different reactions", () => {
|
||||
const { vm, reactionsSubject$ } = getBasicCallViewModelEnvironment([
|
||||
local,
|
||||
alice,
|
||||
]);
|
||||
playReactionsSound.setValue(true);
|
||||
|
||||
const room = new MockRoom(memberUserIdAlice);
|
||||
const rtcSession = new MockRTCSession(room, membership);
|
||||
render(<TestComponent rtcSession={rtcSession} />);
|
||||
render(<TestComponent vm={vm} />);
|
||||
|
||||
// Find the first reaction with a sound effect
|
||||
const [reaction1, reaction2] = ReactionSet.filter((r) => !!r.sound);
|
||||
@@ -150,9 +147,20 @@ test("will play multiple audio sounds when there are multiple different reaction
|
||||
);
|
||||
}
|
||||
act(() => {
|
||||
room.testSendReaction(memberEventAlice, reaction1, membership);
|
||||
room.testSendReaction(memberEventBob, reaction2, membership);
|
||||
room.testSendReaction(memberEventCharlie, reaction1, membership);
|
||||
reactionsSubject$.next({
|
||||
[aliceRtcMember.deviceId]: {
|
||||
reactionOption: reaction1,
|
||||
expireAfter: new Date(0),
|
||||
},
|
||||
[bobRtcMember.deviceId]: {
|
||||
reactionOption: reaction2,
|
||||
expireAfter: new Date(0),
|
||||
},
|
||||
[localRtcMember.deviceId]: {
|
||||
reactionOption: reaction1,
|
||||
expireAfter: new Date(0),
|
||||
},
|
||||
});
|
||||
});
|
||||
expect(playSound).toHaveBeenCalledWith(reaction1.name);
|
||||
expect(playSound).toHaveBeenCalledWith(reaction2.name);
|
||||
|
||||
@@ -5,14 +5,14 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { type ReactNode, useDeferredValue, useEffect, useState } from "react";
|
||||
import { type ReactNode, useEffect, useState } from "react";
|
||||
|
||||
import { useReactions } from "../useReactions";
|
||||
import { playReactionsSound, useSetting } from "../settings/settings";
|
||||
import { GenericReaction, ReactionSet } from "../reactions";
|
||||
import { useAudioContext } from "../useAudioContext";
|
||||
import { prefetchSounds } from "../soundUtils";
|
||||
import { useLatest } from "../useLatest";
|
||||
import { type CallViewModel } from "../state/CallViewModel";
|
||||
|
||||
const soundMap = Object.fromEntries([
|
||||
...ReactionSet.filter((v) => v.sound !== undefined).map((v) => [
|
||||
@@ -22,8 +22,11 @@ const soundMap = Object.fromEntries([
|
||||
[GenericReaction.name, GenericReaction.sound],
|
||||
]);
|
||||
|
||||
export function ReactionsAudioRenderer(): ReactNode {
|
||||
const { reactions } = useReactions();
|
||||
export function ReactionsAudioRenderer({
|
||||
vm,
|
||||
}: {
|
||||
vm: CallViewModel;
|
||||
}): ReactNode {
|
||||
const [shouldPlay] = useSetting(playReactionsSound);
|
||||
const [soundCache, setSoundCache] = useState<ReturnType<
|
||||
typeof prefetchSounds
|
||||
@@ -33,7 +36,6 @@ export function ReactionsAudioRenderer(): ReactNode {
|
||||
latencyHint: "interactive",
|
||||
});
|
||||
const audioEngineRef = useLatest(audioEngineCtx);
|
||||
const oldReactions = useDeferredValue(reactions);
|
||||
|
||||
useEffect(() => {
|
||||
if (!shouldPlay || soundCache) {
|
||||
@@ -46,26 +48,19 @@ export function ReactionsAudioRenderer(): ReactNode {
|
||||
}, [soundCache, shouldPlay]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!shouldPlay || !audioEngineRef.current) {
|
||||
return;
|
||||
}
|
||||
const oldReactionSet = new Set(
|
||||
Object.values(oldReactions).map((r) => r.name),
|
||||
);
|
||||
for (const reactionName of new Set(
|
||||
Object.values(reactions).map((r) => r.name),
|
||||
)) {
|
||||
if (oldReactionSet.has(reactionName)) {
|
||||
// Don't replay old reactions
|
||||
return;
|
||||
const sub = vm.audibleReactions$.subscribe((newReactions) => {
|
||||
for (const reactionName of newReactions) {
|
||||
if (soundMap[reactionName]) {
|
||||
void audioEngineRef.current?.playSound(reactionName);
|
||||
} else {
|
||||
// Fallback sounds.
|
||||
void audioEngineRef.current?.playSound("generic");
|
||||
}
|
||||
}
|
||||
if (soundMap[reactionName]) {
|
||||
void audioEngineRef.current.playSound(reactionName);
|
||||
} else {
|
||||
// Fallback sounds.
|
||||
void audioEngineRef.current.playSound("generic");
|
||||
}
|
||||
}
|
||||
}, [audioEngineRef, shouldPlay, oldReactions, reactions]);
|
||||
});
|
||||
return (): void => {
|
||||
sub.unsubscribe();
|
||||
};
|
||||
}, [vm, audioEngineRef]);
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -7,44 +7,18 @@ Please see LICENSE in the repository root for full details.
|
||||
|
||||
import { render } from "@testing-library/react";
|
||||
import { expect, test, afterEach } from "vitest";
|
||||
import { TooltipProvider } from "@vector-im/compound-web";
|
||||
import { act, type ReactNode } from "react";
|
||||
import { act } from "react";
|
||||
|
||||
import {
|
||||
MockRoom,
|
||||
MockRTCSession,
|
||||
TestReactionsWrapper,
|
||||
} from "../utils/testReactions";
|
||||
import { showReactions } from "../settings/settings";
|
||||
import { ReactionsOverlay } from "./ReactionsOverlay";
|
||||
import { ReactionSet } from "../reactions";
|
||||
|
||||
const memberUserIdAlice = "@alice:example.org";
|
||||
const memberUserIdBob = "@bob:example.org";
|
||||
const memberUserIdCharlie = "@charlie:example.org";
|
||||
const memberEventAlice = "$membership-alice:example.org";
|
||||
const memberEventBob = "$membership-bob:example.org";
|
||||
const memberEventCharlie = "$membership-charlie:example.org";
|
||||
|
||||
const membership: Record<string, string> = {
|
||||
[memberEventAlice]: memberUserIdAlice,
|
||||
[memberEventBob]: memberUserIdBob,
|
||||
[memberEventCharlie]: memberUserIdCharlie,
|
||||
};
|
||||
|
||||
function TestComponent({
|
||||
rtcSession,
|
||||
}: {
|
||||
rtcSession: MockRTCSession;
|
||||
}): ReactNode {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<TestReactionsWrapper rtcSession={rtcSession}>
|
||||
<ReactionsOverlay />
|
||||
</TestReactionsWrapper>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
import {
|
||||
local,
|
||||
alice,
|
||||
aliceRtcMember,
|
||||
bobRtcMember,
|
||||
} from "../utils/test-fixtures";
|
||||
import { getBasicCallViewModelEnvironment } from "../utils/test-viewmodel";
|
||||
|
||||
afterEach(() => {
|
||||
showReactions.setValue(showReactions.defaultValue);
|
||||
@@ -52,22 +26,26 @@ afterEach(() => {
|
||||
|
||||
test("defaults to showing no reactions", () => {
|
||||
showReactions.setValue(true);
|
||||
const rtcSession = new MockRTCSession(
|
||||
new MockRoom(memberUserIdAlice),
|
||||
membership,
|
||||
);
|
||||
const { container } = render(<TestComponent rtcSession={rtcSession} />);
|
||||
const { vm } = getBasicCallViewModelEnvironment([local, alice]);
|
||||
const { container } = render(<ReactionsOverlay vm={vm} />);
|
||||
expect(container.getElementsByTagName("span")).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("shows a reaction when sent", () => {
|
||||
showReactions.setValue(true);
|
||||
const { vm, reactionsSubject$ } = getBasicCallViewModelEnvironment([
|
||||
local,
|
||||
alice,
|
||||
]);
|
||||
const { getByRole } = render(<ReactionsOverlay vm={vm} />);
|
||||
const reaction = ReactionSet[0];
|
||||
const room = new MockRoom(memberUserIdAlice);
|
||||
const rtcSession = new MockRTCSession(room, membership);
|
||||
const { getByRole } = render(<TestComponent rtcSession={rtcSession} />);
|
||||
act(() => {
|
||||
room.testSendReaction(memberEventAlice, reaction, membership);
|
||||
reactionsSubject$.next({
|
||||
[aliceRtcMember.deviceId]: {
|
||||
reactionOption: reaction,
|
||||
expireAfter: new Date(0),
|
||||
},
|
||||
});
|
||||
});
|
||||
const span = getByRole("presentation");
|
||||
expect(getByRole("presentation")).toBeTruthy();
|
||||
@@ -77,29 +55,45 @@ test("shows a reaction when sent", () => {
|
||||
test("shows two of the same reaction when sent", () => {
|
||||
showReactions.setValue(true);
|
||||
const reaction = ReactionSet[0];
|
||||
const room = new MockRoom(memberUserIdAlice);
|
||||
const rtcSession = new MockRTCSession(room, membership);
|
||||
const { getAllByRole } = render(<TestComponent rtcSession={rtcSession} />);
|
||||
const { vm, reactionsSubject$ } = getBasicCallViewModelEnvironment([
|
||||
local,
|
||||
alice,
|
||||
]);
|
||||
const { getAllByRole } = render(<ReactionsOverlay vm={vm} />);
|
||||
act(() => {
|
||||
room.testSendReaction(memberEventAlice, reaction, membership);
|
||||
});
|
||||
act(() => {
|
||||
room.testSendReaction(memberEventBob, reaction, membership);
|
||||
reactionsSubject$.next({
|
||||
[aliceRtcMember.deviceId]: {
|
||||
reactionOption: reaction,
|
||||
expireAfter: new Date(0),
|
||||
},
|
||||
[bobRtcMember.deviceId]: {
|
||||
reactionOption: reaction,
|
||||
expireAfter: new Date(0),
|
||||
},
|
||||
});
|
||||
});
|
||||
expect(getAllByRole("presentation")).toHaveLength(2);
|
||||
});
|
||||
|
||||
test("shows two different reactions when sent", () => {
|
||||
showReactions.setValue(true);
|
||||
const room = new MockRoom(memberUserIdAlice);
|
||||
const rtcSession = new MockRTCSession(room, membership);
|
||||
const [reactionA, reactionB] = ReactionSet;
|
||||
const { getAllByRole } = render(<TestComponent rtcSession={rtcSession} />);
|
||||
const { vm, reactionsSubject$ } = getBasicCallViewModelEnvironment([
|
||||
local,
|
||||
alice,
|
||||
]);
|
||||
const { getAllByRole } = render(<ReactionsOverlay vm={vm} />);
|
||||
act(() => {
|
||||
room.testSendReaction(memberEventAlice, reactionA, membership);
|
||||
});
|
||||
act(() => {
|
||||
room.testSendReaction(memberEventBob, reactionB, membership);
|
||||
reactionsSubject$.next({
|
||||
[aliceRtcMember.deviceId]: {
|
||||
reactionOption: reactionA,
|
||||
expireAfter: new Date(0),
|
||||
},
|
||||
[bobRtcMember.deviceId]: {
|
||||
reactionOption: reactionB,
|
||||
expireAfter: new Date(0),
|
||||
},
|
||||
});
|
||||
});
|
||||
const [reactionElementA, reactionElementB] = getAllByRole("presentation");
|
||||
expect(reactionElementA.innerHTML).toEqual(reactionA.emoji);
|
||||
@@ -109,11 +103,18 @@ test("shows two different reactions when sent", () => {
|
||||
test("hides reactions when reaction animations are disabled", () => {
|
||||
showReactions.setValue(false);
|
||||
const reaction = ReactionSet[0];
|
||||
const room = new MockRoom(memberUserIdAlice);
|
||||
const rtcSession = new MockRTCSession(room, membership);
|
||||
const { vm, reactionsSubject$ } = getBasicCallViewModelEnvironment([
|
||||
local,
|
||||
alice,
|
||||
]);
|
||||
const { container } = render(<ReactionsOverlay vm={vm} />);
|
||||
act(() => {
|
||||
room.testSendReaction(memberEventAlice, reaction, membership);
|
||||
reactionsSubject$.next({
|
||||
[aliceRtcMember.deviceId]: {
|
||||
reactionOption: reaction,
|
||||
expireAfter: new Date(0),
|
||||
},
|
||||
});
|
||||
});
|
||||
const { container } = render(<TestComponent rtcSession={rtcSession} />);
|
||||
expect(container.getElementsByTagName("span")).toHaveLength(0);
|
||||
});
|
||||
|
||||
@@ -5,33 +5,17 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { type ReactNode, useMemo } from "react";
|
||||
import { type ReactNode } from "react";
|
||||
import { useObservableState } from "observable-hooks";
|
||||
|
||||
import { useReactions } from "../useReactions";
|
||||
import {
|
||||
showReactions as showReactionsSetting,
|
||||
useSetting,
|
||||
} from "../settings/settings";
|
||||
import styles from "./ReactionsOverlay.module.css";
|
||||
import { type CallViewModel } from "../state/CallViewModel";
|
||||
|
||||
export function ReactionsOverlay(): ReactNode {
|
||||
const { reactions } = useReactions();
|
||||
const [showReactions] = useSetting(showReactionsSetting);
|
||||
const reactionsIcons = useMemo(
|
||||
() =>
|
||||
showReactions
|
||||
? Object.entries(reactions).map(([sender, { emoji }]) => ({
|
||||
sender,
|
||||
emoji,
|
||||
startX: Math.ceil(Math.random() * 80) + 10,
|
||||
}))
|
||||
: [],
|
||||
[showReactions, reactions],
|
||||
);
|
||||
|
||||
export function ReactionsOverlay({ vm }: { vm: CallViewModel }): ReactNode {
|
||||
const reactionsIcons = useObservableState(vm.visibleReactions$);
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
{reactionsIcons.map(({ sender, emoji, startX }) => (
|
||||
{reactionsIcons?.map(({ sender, emoji, startX }) => (
|
||||
<span
|
||||
// Reactions effects are considered presentation elements. The reaction
|
||||
// is also present on the sender's tile, which assistive technology can
|
||||
|
||||
@@ -25,6 +25,17 @@ video.mirror {
|
||||
transform: scaleX(-1);
|
||||
}
|
||||
|
||||
.preview .cameraStarting {
|
||||
position: absolute;
|
||||
top: var(--cpd-space-10x);
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
color: var(--cpd-color-text-secondary);
|
||||
}
|
||||
|
||||
.avatarContainer {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
|
||||
73
src/room/VideoPreview.test.tsx
Normal file
73
src/room/VideoPreview.test.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { expect, describe, it, vi, beforeAll } from "vitest";
|
||||
import { render } from "@testing-library/react";
|
||||
|
||||
import { type MatrixInfo, VideoPreview } from "./VideoPreview";
|
||||
import { type MuteStates } from "./MuteStates";
|
||||
import { E2eeType } from "../e2ee/e2eeType";
|
||||
|
||||
function mockMuteStates({ audio = true, video = true } = {}): MuteStates {
|
||||
return {
|
||||
audio: { enabled: audio, setEnabled: vi.fn() },
|
||||
video: { enabled: video, setEnabled: vi.fn() },
|
||||
};
|
||||
}
|
||||
|
||||
describe("VideoPreview", () => {
|
||||
const matrixInfo: MatrixInfo = {
|
||||
userId: "@a:example.org",
|
||||
displayName: "Alice",
|
||||
avatarUrl: "",
|
||||
roomId: "",
|
||||
roomName: "",
|
||||
e2eeSystem: { kind: E2eeType.NONE },
|
||||
roomAlias: null,
|
||||
roomAvatar: null,
|
||||
};
|
||||
|
||||
beforeAll(() => {
|
||||
window.ResizeObserver = class ResizeObserver {
|
||||
public observe(): void {
|
||||
// do nothing
|
||||
}
|
||||
public unobserve(): void {
|
||||
// do nothing
|
||||
}
|
||||
public disconnect(): void {
|
||||
// do nothing
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
it("shows avatar with video disabled", () => {
|
||||
const { queryByRole } = render(
|
||||
<VideoPreview
|
||||
matrixInfo={matrixInfo}
|
||||
muteStates={mockMuteStates({ video: false })}
|
||||
videoTrack={null}
|
||||
children={<></>}
|
||||
/>,
|
||||
);
|
||||
expect(queryByRole("img", { name: "@a:example.org" })).toBeVisible();
|
||||
});
|
||||
|
||||
it("shows loading status with video enabled but no track", () => {
|
||||
const { queryByRole } = render(
|
||||
<VideoPreview
|
||||
matrixInfo={matrixInfo}
|
||||
muteStates={mockMuteStates({ video: true })}
|
||||
videoTrack={null}
|
||||
children={<></>}
|
||||
/>,
|
||||
);
|
||||
expect(queryByRole("status")).toHaveTextContent(
|
||||
"video_tile.camera_starting",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -5,12 +5,13 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, type FC, type ReactNode } from "react";
|
||||
import { useEffect, useMemo, useRef, type FC, type ReactNode } from "react";
|
||||
import useMeasure from "react-use-measure";
|
||||
import { facingModeFromLocalTrack, type LocalVideoTrack } from "livekit-client";
|
||||
import classNames from "classnames";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { Avatar } from "../Avatar";
|
||||
import { TileAvatar } from "../tile/TileAvatar";
|
||||
import styles from "./VideoPreview.module.css";
|
||||
import { type MuteStates } from "./MuteStates";
|
||||
import { type EncryptionSystem } from "../e2ee/sharedKeyManagement";
|
||||
@@ -39,6 +40,7 @@ export const VideoPreview: FC<Props> = ({
|
||||
videoTrack,
|
||||
children,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [previewRef, previewBounds] = useMeasure();
|
||||
|
||||
const videoEl = useRef<HTMLVideoElement | null>(null);
|
||||
@@ -53,6 +55,11 @@ export const VideoPreview: FC<Props> = ({
|
||||
};
|
||||
}, [videoTrack]);
|
||||
|
||||
const cameraIsStarting = useMemo(
|
||||
() => muteStates.video.enabled && !videoTrack,
|
||||
[muteStates.video.enabled, videoTrack],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={classNames(styles.preview)} ref={previewRef}>
|
||||
<video
|
||||
@@ -69,15 +76,23 @@ export const VideoPreview: FC<Props> = ({
|
||||
tabIndex={-1}
|
||||
disablePictureInPicture
|
||||
/>
|
||||
{!muteStates.video.enabled && (
|
||||
<div className={styles.avatarContainer}>
|
||||
<Avatar
|
||||
id={matrixInfo.userId}
|
||||
name={matrixInfo.displayName}
|
||||
size={Math.min(previewBounds.width, previewBounds.height) / 2}
|
||||
src={matrixInfo.avatarUrl}
|
||||
/>
|
||||
</div>
|
||||
{(!muteStates.video.enabled || cameraIsStarting) && (
|
||||
<>
|
||||
<div className={styles.avatarContainer}>
|
||||
{cameraIsStarting && (
|
||||
<div className={styles.cameraStarting} role="status">
|
||||
{t("video_tile.camera_starting")}
|
||||
</div>
|
||||
)}
|
||||
<TileAvatar
|
||||
id={matrixInfo.userId}
|
||||
name={matrixInfo.displayName}
|
||||
size={Math.min(previewBounds.width, previewBounds.height) / 2}
|
||||
src={matrixInfo.avatarUrl}
|
||||
loading={cameraIsStarting}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div className={styles.buttonBar}>{children}</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user