Merge branch 'livekit' into device-fall-back

This commit is contained in:
Robin 2024-11-21 11:04:36 -05:00
commit 465a784345
19 changed files with 510 additions and 358 deletions

View File

@ -37,6 +37,7 @@ module.exports = {
"@typescript-eslint/promise-function-async": "error", "@typescript-eslint/promise-function-async": "error",
"@typescript-eslint/require-await": "error", "@typescript-eslint/require-await": "error",
"@typescript-eslint/await-thenable": "error", "@typescript-eslint/await-thenable": "error",
"rxjs/no-exposed-subjects": "error",
}, },
settings: { settings: {
react: { react: {

View File

@ -90,7 +90,7 @@
"livekit-client": "^2.5.7", "livekit-client": "^2.5.7",
"lodash-es": "^4.17.21", "lodash-es": "^4.17.21",
"loglevel": "^1.9.1", "loglevel": "^1.9.1",
"matrix-js-sdk": "matrix-org/matrix-js-sdk#8e9a04cdec0f88fc876bbbf406db55b0677f005d", "matrix-js-sdk": "matrix-org/matrix-js-sdk#2210255d6ffc909c574fb8ef16f92140b2fb7797",
"matrix-widget-api": "^1.10.0", "matrix-widget-api": "^1.10.0",
"normalize.css": "^8.0.1", "normalize.css": "^8.0.1",
"observable-hooks": "^4.2.3", "observable-hooks": "^4.2.3",

View File

@ -7,7 +7,6 @@ Please see LICENSE in the repository root for full details.
import { render } from "@testing-library/react"; import { render } from "@testing-library/react";
import { expect, test } from "vitest"; import { expect, test } from "vitest";
import { MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc";
import { TooltipProvider } from "@vector-im/compound-web"; import { TooltipProvider } from "@vector-im/compound-web";
import { userEvent } from "@testing-library/user-event"; import { userEvent } from "@testing-library/user-event";
import { ReactNode } from "react"; import { ReactNode } from "react";
@ -29,18 +28,13 @@ const membership: Record<string, string> = {
function TestComponent({ function TestComponent({
rtcSession, rtcSession,
room,
}: { }: {
rtcSession: MockRTCSession; rtcSession: MockRTCSession;
room: MockRoom;
}): ReactNode { }): ReactNode {
return ( return (
<TooltipProvider> <TooltipProvider>
<TestReactionsWrapper rtcSession={rtcSession}> <TestReactionsWrapper rtcSession={rtcSession}>
<ReactionToggleButton <ReactionToggleButton userId={memberUserIdAlice} />
rtcSession={rtcSession as unknown as MatrixRTCSession}
client={room.client}
/>
</TestReactionsWrapper> </TestReactionsWrapper>
</TooltipProvider> </TooltipProvider>
); );
@ -51,7 +45,7 @@ test("Can open menu", async () => {
const room = new MockRoom(memberUserIdAlice); const room = new MockRoom(memberUserIdAlice);
const rtcSession = new MockRTCSession(room, membership); const rtcSession = new MockRTCSession(room, membership);
const { getByLabelText, container } = render( const { getByLabelText, container } = render(
<TestComponent rtcSession={rtcSession} room={room} />, <TestComponent rtcSession={rtcSession} />,
); );
await user.click(getByLabelText("common.reactions")); await user.click(getByLabelText("common.reactions"));
expect(container).toMatchSnapshot(); expect(container).toMatchSnapshot();
@ -62,7 +56,7 @@ test("Can raise hand", async () => {
const room = new MockRoom(memberUserIdAlice); const room = new MockRoom(memberUserIdAlice);
const rtcSession = new MockRTCSession(room, membership); const rtcSession = new MockRTCSession(room, membership);
const { getByLabelText, container } = render( const { getByLabelText, container } = render(
<TestComponent rtcSession={rtcSession} room={room} />, <TestComponent rtcSession={rtcSession} />,
); );
await user.click(getByLabelText("common.reactions")); await user.click(getByLabelText("common.reactions"));
await user.click(getByLabelText("action.raise_hand")); await user.click(getByLabelText("action.raise_hand"));
@ -87,7 +81,7 @@ test("Can lower hand", async () => {
const room = new MockRoom(memberUserIdAlice); const room = new MockRoom(memberUserIdAlice);
const rtcSession = new MockRTCSession(room, membership); const rtcSession = new MockRTCSession(room, membership);
const { getByLabelText, container } = render( const { getByLabelText, container } = render(
<TestComponent rtcSession={rtcSession} room={room} />, <TestComponent rtcSession={rtcSession} />,
); );
const reactionEvent = room.testSendHandRaise(memberEventAlice, membership); const reactionEvent = room.testSendHandRaise(memberEventAlice, membership);
await user.click(getByLabelText("common.reactions")); await user.click(getByLabelText("common.reactions"));
@ -101,7 +95,7 @@ test("Can react with emoji", async () => {
const room = new MockRoom(memberUserIdAlice); const room = new MockRoom(memberUserIdAlice);
const rtcSession = new MockRTCSession(room, membership); const rtcSession = new MockRTCSession(room, membership);
const { getByLabelText, getByText } = render( const { getByLabelText, getByText } = render(
<TestComponent rtcSession={rtcSession} room={room} />, <TestComponent rtcSession={rtcSession} />,
); );
await user.click(getByLabelText("common.reactions")); await user.click(getByLabelText("common.reactions"));
await user.click(getByText("🐶")); await user.click(getByText("🐶"));
@ -126,7 +120,7 @@ test("Can fully expand emoji picker", async () => {
const room = new MockRoom(memberUserIdAlice); const room = new MockRoom(memberUserIdAlice);
const rtcSession = new MockRTCSession(room, membership); const rtcSession = new MockRTCSession(room, membership);
const { getByText, container, getByLabelText } = render( const { getByText, container, getByLabelText } = render(
<TestComponent rtcSession={rtcSession} room={room} />, <TestComponent rtcSession={rtcSession} />,
); );
await user.click(getByLabelText("common.reactions")); await user.click(getByLabelText("common.reactions"));
await user.click(getByLabelText("action.show_more")); await user.click(getByLabelText("action.show_more"));
@ -149,12 +143,12 @@ test("Can fully expand emoji picker", async () => {
]); ]);
}); });
test("Can close search", async () => { test("Can close reaction dialog", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
const room = new MockRoom(memberUserIdAlice); const room = new MockRoom(memberUserIdAlice);
const rtcSession = new MockRTCSession(room, membership); const rtcSession = new MockRTCSession(room, membership);
const { getByLabelText, container } = render( const { getByLabelText, container } = render(
<TestComponent rtcSession={rtcSession} room={room} />, <TestComponent rtcSession={rtcSession} />,
); );
await user.click(getByLabelText("common.reactions")); await user.click(getByLabelText("common.reactions"));
await user.click(getByLabelText("action.show_more")); await user.click(getByLabelText("action.show_more"));

View File

@ -23,19 +23,11 @@ import {
} from "react"; } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { logger } from "matrix-js-sdk/src/logger"; import { logger } from "matrix-js-sdk/src/logger";
import { EventType, RelationType } from "matrix-js-sdk/src/matrix";
import { MatrixClient } from "matrix-js-sdk/src/client";
import { MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc/MatrixRTCSession";
import classNames from "classnames"; import classNames from "classnames";
import { useReactions } from "../useReactions"; import { useReactions } from "../useReactions";
import { useMatrixRTCSessionMemberships } from "../useMatrixRTCSessionMemberships";
import styles from "./ReactionToggleButton.module.css"; import styles from "./ReactionToggleButton.module.css";
import { import { ReactionOption, ReactionSet, ReactionsRowSize } from "../reactions";
ReactionOption,
ReactionSet,
ElementCallReactionEventType,
} from "../reactions";
import { Modal } from "../Modal"; import { Modal } from "../Modal";
interface InnerButtonProps extends ComponentPropsWithoutRef<"button"> { interface InnerButtonProps extends ComponentPropsWithoutRef<"button"> {
@ -95,9 +87,10 @@ export function ReactionPopupMenu({
)} )}
<div className={styles.reactionPopupMenu}> <div className={styles.reactionPopupMenu}>
<section className={styles.handRaiseSection}> <section className={styles.handRaiseSection}>
<Tooltip label={label}> <Tooltip label={label} caption="H">
<CpdButton <CpdButton
kind={isHandRaised ? "primary" : "secondary"} kind={isHandRaised ? "primary" : "secondary"}
aria-keyshortcuts="H"
aria-pressed={isHandRaised} aria-pressed={isHandRaised}
aria-label={label} aria-label={label}
onClick={() => toggleRaisedHand()} onClick={() => toggleRaisedHand()}
@ -114,14 +107,26 @@ export function ReactionPopupMenu({
styles.reactionsMenu, styles.reactionsMenu,
)} )}
> >
{filteredReactionSet.map((reaction) => ( {filteredReactionSet.map((reaction, index) => (
<li key={reaction.name}> <li key={reaction.name}>
<Tooltip label={reaction.name}> <Tooltip
label={reaction.name}
caption={
index < ReactionsRowSize
? (index + 1).toString()
: undefined
}
>
<CpdButton <CpdButton
kind="secondary" kind="secondary"
className={styles.reactionButton} className={styles.reactionButton}
disabled={!canReact} disabled={!canReact}
onClick={() => sendReaction(reaction)} onClick={() => sendReaction(reaction)}
aria-keyshortcuts={
index < ReactionsRowSize
? (index + 1).toString()
: undefined
}
> >
{reaction.emoji} {reaction.emoji}
</CpdButton> </CpdButton>
@ -153,52 +158,33 @@ export function ReactionPopupMenu({
} }
interface ReactionToggleButtonProps extends ComponentPropsWithoutRef<"button"> { interface ReactionToggleButtonProps extends ComponentPropsWithoutRef<"button"> {
rtcSession: MatrixRTCSession; userId: string;
client: MatrixClient;
} }
export function ReactionToggleButton({ export function ReactionToggleButton({
client, userId,
rtcSession,
...props ...props
}: ReactionToggleButtonProps): ReactNode { }: ReactionToggleButtonProps): ReactNode {
const { t } = useTranslation(); const { t } = useTranslation();
const { raisedHands, lowerHand, reactions } = useReactions(); const { raisedHands, toggleRaisedHand, sendReaction, reactions } =
useReactions();
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const userId = client.getUserId()!;
const isHandRaised = !!raisedHands[userId];
const memberships = useMatrixRTCSessionMemberships(rtcSession);
const [showReactionsMenu, setShowReactionsMenu] = useState(false); const [showReactionsMenu, setShowReactionsMenu] = useState(false);
const [errorText, setErrorText] = useState<string>(); const [errorText, setErrorText] = useState<string>();
const isHandRaised = !!raisedHands[userId];
const canReact = !reactions[userId];
useEffect(() => { useEffect(() => {
// Clear whenever the reactions menu state changes. // Clear whenever the reactions menu state changes.
setErrorText(undefined); setErrorText(undefined);
}, [showReactionsMenu]); }, [showReactionsMenu]);
const canReact = !reactions[userId];
const sendRelation = useCallback( const sendRelation = useCallback(
async (reaction: ReactionOption) => { async (reaction: ReactionOption) => {
try { try {
const myMembership = memberships.find((m) => m.sender === userId);
if (!myMembership?.eventId) {
throw new Error("Cannot find own membership event");
}
const parentEventId = myMembership.eventId;
setBusy(true); setBusy(true);
await client.sendEvent( await sendReaction(reaction);
rtcSession.room.roomId,
ElementCallReactionEventType,
{
"m.relates_to": {
rel_type: RelationType.Reference,
event_id: parentEventId,
},
emoji: reaction.emoji,
name: reaction.name,
},
);
setErrorText(undefined); setErrorText(undefined);
setShowReactionsMenu(false); setShowReactionsMenu(false);
} catch (ex) { } catch (ex) {
@ -208,59 +194,25 @@ export function ReactionToggleButton({
setBusy(false); setBusy(false);
} }
}, },
[memberships, client, userId, rtcSession], [sendReaction],
); );
const toggleRaisedHand = useCallback(() => { const wrappedToggleRaisedHand = useCallback(() => {
const raiseHand = async (): Promise<void> => { const toggleHand = async (): Promise<void> => {
if (isHandRaised) { try {
try { setBusy(true);
setBusy(true); await toggleRaisedHand();
await lowerHand(); setShowReactionsMenu(false);
setShowReactionsMenu(false); } catch (ex) {
} finally { setErrorText(ex instanceof Error ? ex.message : "Unknown error");
setBusy(false); logger.error("Failed to raise/lower hand", ex);
} } finally {
} else { setBusy(false);
try {
const myMembership = memberships.find((m) => m.sender === userId);
if (!myMembership?.eventId) {
throw new Error("Cannot find own membership event");
}
const parentEventId = myMembership.eventId;
setBusy(true);
const reaction = await client.sendEvent(
rtcSession.room.roomId,
EventType.Reaction,
{
"m.relates_to": {
rel_type: RelationType.Annotation,
event_id: parentEventId,
key: "🖐️",
},
},
);
logger.debug("Sent raise hand event", reaction.event_id);
setErrorText(undefined);
setShowReactionsMenu(false);
} catch (ex) {
setErrorText(ex instanceof Error ? ex.message : "Unknown error");
logger.error("Failed to raise hand", ex);
} finally {
setBusy(false);
}
} }
}; };
void raiseHand(); void toggleHand();
}, [ }, [toggleRaisedHand]);
client,
isHandRaised,
memberships,
lowerHand,
rtcSession.room.roomId,
userId,
]);
return ( return (
<> <>
@ -284,7 +236,7 @@ export function ReactionToggleButton({
isHandRaised={isHandRaised} isHandRaised={isHandRaised}
canReact={!busy && canReact} canReact={!busy && canReact}
sendReaction={(reaction) => void sendRelation(reaction)} sendReaction={(reaction) => void sendRelation(reaction)}
toggleRaisedHand={toggleRaisedHand} toggleRaisedHand={wrappedToggleRaisedHand}
/> />
</Modal> </Modal>
</> </>

View File

@ -1,6 +1,6 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`Can close search 1`] = ` exports[`Can close reaction dialog 1`] = `
<div <div
aria-hidden="true" aria-hidden="true"
data-aria-hidden="true" data-aria-hidden="true"

View File

@ -73,7 +73,9 @@ export const GenericReaction: ReactionOption = {
}, },
}; };
// The first 6 reactions are always visible. export const ReactionsRowSize = 5;
// The first {ReactionsRowSize} reactions are always visible.
export const ReactionSet: ReactionOption[] = [ export const ReactionSet: ReactionOption[] = [
{ {
emoji: "👍", emoji: "👍",

View File

@ -183,7 +183,8 @@ export const InCallView: FC<InCallViewProps> = ({
onShareClick, onShareClick,
}) => { }) => {
const [soundEffectVolume] = useSetting(soundEffectVolumeSetting); const [soundEffectVolume] = useSetting(soundEffectVolumeSetting);
const { supportsReactions, raisedHands } = useReactions(); const { supportsReactions, raisedHands, sendReaction, toggleRaisedHand } =
useReactions();
const raisedHandCount = useMemo( const raisedHandCount = useMemo(
() => Object.keys(raisedHands).length, () => Object.keys(raisedHands).length,
[raisedHands], [raisedHands],
@ -227,6 +228,8 @@ export const InCallView: FC<InCallViewProps> = ({
toggleMicrophone, toggleMicrophone,
toggleCamera, toggleCamera,
(muted) => muteStates.audio.setEnabled?.(!muted), (muted) => muteStates.audio.setEnabled?.(!muted),
(reaction) => void sendReaction(reaction),
() => void toggleRaisedHand(),
); );
const windowMode = useObservableEagerState(vm.windowMode); const windowMode = useObservableEagerState(vm.windowMode);
@ -572,8 +575,7 @@ export const InCallView: FC<InCallViewProps> = ({
<ReactionToggleButton <ReactionToggleButton
key="raise_hand" key="raise_hand"
className={styles.raiseHand} className={styles.raiseHand}
client={client} userId={client.getUserId()!}
rtcSession={rtcSession}
onTouchEnd={onControlsTouchEnd} onTouchEnd={onControlsTouchEnd}
/>, />,
); );

View File

@ -0,0 +1,18 @@
.selection {
gap: 0;
}
.title {
color: var(--cpd-color-text-secondary);
margin-block: var(--cpd-space-3x) 0;
}
.separator {
margin-block: 6px var(--cpd-space-4x);
}
.options {
display: flex;
flex-direction: column;
gap: var(--cpd-space-4x);
}

View File

@ -0,0 +1,71 @@
/*
Copyright 2024 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only
Please see LICENSE in the repository root for full details.
*/
import { ChangeEvent, FC, useCallback, useId } from "react";
import {
Heading,
InlineField,
Label,
RadioControl,
Separator,
} from "@vector-im/compound-web";
import { MediaDevice } from "../livekit/MediaDevicesContext";
import styles from "./DeviceSelection.module.css";
interface Props {
devices: MediaDevice;
caption: string;
}
export const DeviceSelection: FC<Props> = ({ devices, caption }) => {
const groupId = useId();
const onChange = useCallback(
(e: ChangeEvent<HTMLInputElement>) => {
devices.select(e.target.value);
},
[devices],
);
if (devices.available.length == 0) return null;
return (
<div className={styles.selection}>
<Heading
type="body"
weight="semibold"
size="sm"
as="h4"
className={styles.title}
>
{caption}
</Heading>
<Separator className={styles.separator} />
<div className={styles.options}>
{devices.available.map(({ deviceId, label }, index) => (
<InlineField
key={deviceId}
name={groupId}
control={
<RadioControl
checked={deviceId === devices.selectedId}
onChange={onChange}
value={deviceId}
/>
}
>
<Label>
{!!label && label.trim().length > 0
? label
: `${caption} ${index + 1}`}
</Label>
</InlineField>
))}
</div>
</div>
);
};

View File

@ -5,10 +5,10 @@ SPDX-License-Identifier: AGPL-3.0-only
Please see LICENSE in the repository root for full details. Please see LICENSE in the repository root for full details.
*/ */
import { ChangeEvent, FC, ReactNode, useCallback } from "react"; import { ChangeEvent, FC, useCallback } from "react";
import { Trans, useTranslation } from "react-i18next"; import { Trans, useTranslation } from "react-i18next";
import { MatrixClient } from "matrix-js-sdk/src/matrix"; import { MatrixClient } from "matrix-js-sdk/src/matrix";
import { Dropdown, Separator, Text } from "@vector-im/compound-web"; import { Root as Form, Text } from "@vector-im/compound-web";
import { Modal } from "../Modal"; import { Modal } from "../Modal";
import styles from "./SettingsModal.module.css"; import styles from "./SettingsModal.module.css";
@ -19,7 +19,6 @@ import { ProfileSettingsTab } from "./ProfileSettingsTab";
import { FeedbackSettingsTab } from "./FeedbackSettingsTab"; import { FeedbackSettingsTab } from "./FeedbackSettingsTab";
import { import {
useMediaDevices, useMediaDevices,
MediaDevice,
useMediaDeviceNames, useMediaDeviceNames,
} from "../livekit/MediaDevicesContext"; } from "../livekit/MediaDevicesContext";
import { widget } from "../widget"; import { widget } from "../widget";
@ -33,6 +32,7 @@ import {
import { isFirefox } from "../Platform"; import { isFirefox } from "../Platform";
import { PreferencesSettingsTab } from "./PreferencesSettingsTab"; import { PreferencesSettingsTab } from "./PreferencesSettingsTab";
import { Slider } from "../Slider"; import { Slider } from "../Slider";
import { DeviceSelection } from "./DeviceSelection";
type SettingsTab = type SettingsTab =
| "audio" | "audio"
@ -70,40 +70,6 @@ export const SettingsModal: FC<Props> = ({
); );
const [duplicateTiles, setDuplicateTiles] = useSetting(duplicateTilesSetting); const [duplicateTiles, setDuplicateTiles] = useSetting(duplicateTilesSetting);
// Generate a `SelectInput` with a list of devices for a given device kind.
const generateDeviceSelection = (
devices: MediaDevice,
caption: string,
): ReactNode => {
if (devices.available.length == 0) return null;
const values = devices.available.map(
({ deviceId, label }, index) =>
[
deviceId,
!!label && label.trim().length > 0
? label
: `${caption} ${index + 1}`,
] as [string, string],
);
return (
<Dropdown
label={caption}
defaultValue={
devices.selectedId === "" || !devices.selectedId
? "default"
: devices.selectedId
}
onValueChange={(id): void => devices.select(id)}
values={values}
// XXX This is unused because we set a defaultValue. The component
// shouldn't require this prop.
placeholder=""
/>
);
};
const optInDescription = ( const optInDescription = (
<Text size="sm"> <Text size="sm">
<Trans i18nKey="settings.opt_in_description"> <Trans i18nKey="settings.opt_in_description">
@ -125,25 +91,30 @@ export const SettingsModal: FC<Props> = ({
name: t("common.audio"), name: t("common.audio"),
content: ( content: (
<> <>
{generateDeviceSelection(devices.audioInput, t("common.microphone"))} <Form>
{!isFirefox() && <DeviceSelection
generateDeviceSelection( devices={devices.audioInput}
devices.audioOutput, caption={t("common.microphone")}
t("settings.speaker_device_selection_label"),
)}
<Separator />
<div className={styles.volumeSlider}>
<label>{t("settings.audio_tab.effect_volume_label")}</label>
<p>{t("settings.audio_tab.effect_volume_description")}</p>
<Slider
label={t("video_tile.volume")}
value={soundVolume}
onValueChange={setSoundVolume}
min={0}
max={1}
step={0.01}
/> />
</div> {!isFirefox() && (
<DeviceSelection
devices={devices.audioOutput}
caption={t("settings.speaker_device_selection_label")}
/>
)}
<div className={styles.volumeSlider}>
<label>{t("settings.audio_tab.effect_volume_label")}</label>
<p>{t("settings.audio_tab.effect_volume_description")}</p>
<Slider
label={t("video_tile.volume")}
value={soundVolume}
onValueChange={setSoundVolume}
min={0}
max={1}
step={0.01}
/>
</div>
</Form>
</> </>
), ),
}; };
@ -151,7 +122,14 @@ export const SettingsModal: FC<Props> = ({
const videoTab: Tab<SettingsTab> = { const videoTab: Tab<SettingsTab> = {
key: "video", key: "video",
name: t("common.video"), name: t("common.video"),
content: generateDeviceSelection(devices.videoInput, t("common.camera")), content: (
<Form>
<DeviceSelection
devices={devices.videoInput}
caption={t("common.camera")}
/>
</Form>
),
}; };
const preferencesTab: Tab<SettingsTab> = { const preferencesTab: Tab<SettingsTab> = {

View File

@ -30,7 +30,7 @@ import {
mockLivekitRoom, mockLivekitRoom,
mockLocalParticipant, mockLocalParticipant,
mockMatrixRoom, mockMatrixRoom,
mockMember, mockMatrixRoomMember,
mockRemoteParticipant, mockRemoteParticipant,
withTestScheduler, withTestScheduler,
} from "../utils/test"; } from "../utils/test";
@ -42,10 +42,10 @@ import { E2eeType } from "../e2ee/e2eeType";
vi.mock("@livekit/components-core"); vi.mock("@livekit/components-core");
const alice = mockMember({ userId: "@alice:example.org" }); const alice = mockMatrixRoomMember({ userId: "@alice:example.org" });
const bob = mockMember({ userId: "@bob:example.org" }); const bob = mockMatrixRoomMember({ userId: "@bob:example.org" });
const carol = mockMember({ userId: "@carol:example.org" }); const carol = mockMatrixRoomMember({ userId: "@carol:example.org" });
const dave = mockMember({ userId: "@dave:example.org" }); const dave = mockMatrixRoomMember({ userId: "@dave:example.org" });
const aliceId = `${alice.userId}:AAAA`; const aliceId = `${alice.userId}:AAAA`;
const bobId = `${bob.userId}:BBBB`; const bobId = `${bob.userId}:BBBB`;
@ -229,52 +229,55 @@ function withCallViewModel(
} }
test("participants are retained during a focus switch", () => { test("participants are retained during a focus switch", () => {
withTestScheduler(({ cold, expectObservable }) => { withTestScheduler(({ hot, expectObservable }) => {
// Participants disappear on frame 2 and come back on frame 3 // Participants disappear on frame 2 and come back on frame 3
const participantMarbles = "a-ba"; const participantInputMarbles = "a-ba";
// Start switching focus on frame 1 and reconnect on frame 3 // Start switching focus on frame 1 and reconnect on frame 3
const connectionMarbles = " cs-c"; const connectionInputMarbles = " cs-c";
// The visible participants should remain the same throughout the switch // The visible participants should remain the same throughout the switch
const layoutMarbles = " a"; const expectedLayoutMarbles = " a";
withCallViewModel( withCallViewModel(
cold(participantMarbles, { hot(participantInputMarbles, {
a: [aliceParticipant, bobParticipant], a: [aliceParticipant, bobParticipant],
b: [], b: [],
}), }),
cold(connectionMarbles, { hot(connectionInputMarbles, {
c: ConnectionState.Connected, c: ConnectionState.Connected,
s: ECAddonConnectionState.ECSwitchingFocus, s: ECAddonConnectionState.ECSwitchingFocus,
}), }),
new Map(), new Map(),
(vm) => { (vm) => {
expectObservable(summarizeLayout(vm.layout)).toBe(layoutMarbles, { expectObservable(summarizeLayout(vm.layout)).toBe(
a: { expectedLayoutMarbles,
type: "grid", {
spotlight: undefined, a: {
grid: ["local:0", `${aliceId}:0`, `${bobId}:0`], type: "grid",
spotlight: undefined,
grid: ["local:0", `${aliceId}:0`, `${bobId}:0`],
},
}, },
}); );
}, },
); );
}); });
}); });
test("screen sharing activates spotlight layout", () => { test("screen sharing activates spotlight layout", () => {
withTestScheduler(({ cold, schedule, expectObservable }) => { withTestScheduler(({ hot, schedule, expectObservable }) => {
// Start with no screen shares, then have Alice and Bob share their screens, // Start with no screen shares, then have Alice and Bob share their screens,
// then return to no screen shares, then have just Alice share for a bit // then return to no screen shares, then have just Alice share for a bit
const participantMarbles = " abcda-ba"; const participantInputMarbles = " abcda-ba";
// While there are no screen shares, switch to spotlight manually, and then // While there are no screen shares, switch to spotlight manually, and then
// switch back to grid at the end // switch back to grid at the end
const modeMarbles = " -----s--g"; const modeInputMarbles = " -----s--g";
// We should automatically enter spotlight for the first round of screen // We should automatically enter spotlight for the first round of screen
// sharing, then return to grid, then manually go into spotlight, and // sharing, then return to grid, then manually go into spotlight, and
// remain in spotlight until we manually go back to grid // remain in spotlight until we manually go back to grid
const layoutMarbles = " abcdaefeg"; const expectedLayoutMarbles = " abcdaefeg";
const showSpeakingMarbles = "y----nyny"; const expectedShowSpeakingMarbles = "y----nyny";
withCallViewModel( withCallViewModel(
cold(participantMarbles, { hot(participantInputMarbles, {
a: [aliceParticipant, bobParticipant], a: [aliceParticipant, bobParticipant],
b: [aliceSharingScreen, bobParticipant], b: [aliceSharingScreen, bobParticipant],
c: [aliceSharingScreen, bobSharingScreen], c: [aliceSharingScreen, bobSharingScreen],
@ -283,80 +286,89 @@ test("screen sharing activates spotlight layout", () => {
of(ConnectionState.Connected), of(ConnectionState.Connected),
new Map(), new Map(),
(vm) => { (vm) => {
schedule(modeMarbles, { schedule(modeInputMarbles, {
s: () => vm.setGridMode("spotlight"), s: () => vm.setGridMode("spotlight"),
g: () => vm.setGridMode("grid"), g: () => vm.setGridMode("grid"),
}); });
expectObservable(summarizeLayout(vm.layout)).toBe(layoutMarbles, { expectObservable(summarizeLayout(vm.layout)).toBe(
a: { expectedLayoutMarbles,
type: "grid", {
spotlight: undefined, a: {
grid: ["local:0", `${aliceId}:0`, `${bobId}:0`], type: "grid",
spotlight: undefined,
grid: ["local:0", `${aliceId}:0`, `${bobId}:0`],
},
b: {
type: "spotlight-landscape",
spotlight: [`${aliceId}:0:screen-share`],
grid: ["local:0", `${aliceId}:0`, `${bobId}:0`],
},
c: {
type: "spotlight-landscape",
spotlight: [
`${aliceId}:0:screen-share`,
`${bobId}:0:screen-share`,
],
grid: ["local:0", `${aliceId}:0`, `${bobId}:0`],
},
d: {
type: "spotlight-landscape",
spotlight: [`${bobId}:0:screen-share`],
grid: ["local:0", `${aliceId}:0`, `${bobId}:0`],
},
e: {
type: "spotlight-landscape",
spotlight: [`${aliceId}:0`],
grid: ["local:0", `${bobId}:0`],
},
f: {
type: "spotlight-landscape",
spotlight: [`${aliceId}:0:screen-share`],
grid: ["local:0", `${bobId}:0`, `${aliceId}:0`],
},
g: {
type: "grid",
spotlight: undefined,
grid: ["local:0", `${bobId}:0`, `${aliceId}:0`],
},
}, },
b: { );
type: "spotlight-landscape", expectObservable(vm.showSpeakingIndicators).toBe(
spotlight: [`${aliceId}:0:screen-share`], expectedShowSpeakingMarbles,
grid: ["local:0", `${aliceId}:0`, `${bobId}:0`], {
y: true,
n: false,
}, },
c: { );
type: "spotlight-landscape",
spotlight: [`${aliceId}:0:screen-share`, `${bobId}:0:screen-share`],
grid: ["local:0", `${aliceId}:0`, `${bobId}:0`],
},
d: {
type: "spotlight-landscape",
spotlight: [`${bobId}:0:screen-share`],
grid: ["local:0", `${aliceId}:0`, `${bobId}:0`],
},
e: {
type: "spotlight-landscape",
spotlight: [`${aliceId}:0`],
grid: ["local:0", `${bobId}:0`],
},
f: {
type: "spotlight-landscape",
spotlight: [`${aliceId}:0:screen-share`],
grid: ["local:0", `${bobId}:0`, `${aliceId}:0`],
},
g: {
type: "grid",
spotlight: undefined,
grid: ["local:0", `${bobId}:0`, `${aliceId}:0`],
},
});
expectObservable(vm.showSpeakingIndicators).toBe(showSpeakingMarbles, {
y: true,
n: false,
});
}, },
); );
}); });
}); });
test("participants stay in the same order unless to appear/disappear", () => { test("participants stay in the same order unless to appear/disappear", () => {
withTestScheduler(({ cold, schedule, expectObservable }) => { withTestScheduler(({ hot, schedule, expectObservable }) => {
const modeMarbles = "a"; const modeInputMarbles = " a";
// First Bob speaks, then Dave, then Alice // First Bob speaks, then Dave, then Alice
const aSpeakingMarbles = "n- 1998ms - 1999ms y"; const aSpeakingInputMarbles = "n- 1998ms - 1999ms y";
const bSpeakingMarbles = "ny 1998ms n 1999ms "; const bSpeakingInputMarbles = "ny 1998ms n 1999ms ";
const dSpeakingMarbles = "n- 1998ms y 1999ms n"; const dSpeakingInputMarbles = "n- 1998ms y 1999ms n";
// Nothing should change when Bob speaks, because Bob is already on screen. // Nothing should change when Bob speaks, because Bob is already on screen.
// When Dave speaks he should switch with Alice because she's the one who // When Dave speaks he should switch with Alice because she's the one who
// hasn't spoken at all. Then when Alice speaks, she should return to her // hasn't spoken at all. Then when Alice speaks, she should return to her
// place at the top. // place at the top.
const layoutMarbles = " a 1999ms b 1999ms a 57999ms c 1999ms a"; const expectedLayoutMarbles = "a 1999ms b 1999ms a 57999ms c 1999ms a";
withCallViewModel( withCallViewModel(
of([aliceParticipant, bobParticipant, daveParticipant]), of([aliceParticipant, bobParticipant, daveParticipant]),
of(ConnectionState.Connected), of(ConnectionState.Connected),
new Map([ new Map([
[aliceParticipant, cold(aSpeakingMarbles, { y: true, n: false })], [aliceParticipant, hot(aSpeakingInputMarbles, { y: true, n: false })],
[bobParticipant, cold(bSpeakingMarbles, { y: true, n: false })], [bobParticipant, hot(bSpeakingInputMarbles, { y: true, n: false })],
[daveParticipant, cold(dSpeakingMarbles, { y: true, n: false })], [daveParticipant, hot(dSpeakingInputMarbles, { y: true, n: false })],
]), ]),
(vm) => { (vm) => {
schedule(modeMarbles, { schedule(modeInputMarbles, {
a: () => { a: () => {
// We imagine that only three tiles (the first three) will be visible // We imagine that only three tiles (the first three) will be visible
// on screen at a time // on screen at a time
@ -369,23 +381,26 @@ test("participants stay in the same order unless to appear/disappear", () => {
}, },
}); });
expectObservable(summarizeLayout(vm.layout)).toBe(layoutMarbles, { expectObservable(summarizeLayout(vm.layout)).toBe(
a: { expectedLayoutMarbles,
type: "grid", {
spotlight: undefined, a: {
grid: ["local:0", `${aliceId}:0`, `${bobId}:0`, `${daveId}:0`], type: "grid",
spotlight: undefined,
grid: ["local:0", `${aliceId}:0`, `${bobId}:0`, `${daveId}:0`],
},
b: {
type: "grid",
spotlight: undefined,
grid: ["local:0", `${daveId}:0`, `${bobId}:0`, `${aliceId}:0`],
},
c: {
type: "grid",
spotlight: undefined,
grid: ["local:0", `${aliceId}:0`, `${daveId}:0`, `${bobId}:0`],
},
}, },
b: { );
type: "grid",
spotlight: undefined,
grid: ["local:0", `${daveId}:0`, `${bobId}:0`, `${aliceId}:0`],
},
c: {
type: "grid",
spotlight: undefined,
grid: ["local:0", `${aliceId}:0`, `${daveId}:0`, `${bobId}:0`],
},
});
}, },
); );
}); });
@ -394,50 +409,53 @@ test("participants stay in the same order unless to appear/disappear", () => {
test("spotlight speakers swap places", () => { test("spotlight speakers swap places", () => {
withTestScheduler(({ cold, schedule, expectObservable }) => { withTestScheduler(({ cold, schedule, expectObservable }) => {
// Go immediately into spotlight mode for the test // Go immediately into spotlight mode for the test
const modeMarbles = " s"; const modeInputMarbles = " s";
// First Bob speaks, then Dave, then Alice // First Bob speaks, then Dave, then Alice
const aSpeakingMarbles = "n--y"; const aSpeakingInputMarbles = "n--y";
const bSpeakingMarbles = "nyn"; const bSpeakingInputMarbles = "nyn";
const dSpeakingMarbles = "n-yn"; const dSpeakingInputMarbles = "n-yn";
// Alice should start in the spotlight, then Bob, then Dave, then Alice // Alice should start in the spotlight, then Bob, then Dave, then Alice
// again. However, the positions of Dave and Bob in the grid should be // again. However, the positions of Dave and Bob in the grid should be
// reversed by the end because they've been swapped in and out of the // reversed by the end because they've been swapped in and out of the
// spotlight. // spotlight.
const layoutMarbles = " abcd"; const expectedLayoutMarbles = "abcd";
withCallViewModel( withCallViewModel(
of([aliceParticipant, bobParticipant, daveParticipant]), of([aliceParticipant, bobParticipant, daveParticipant]),
of(ConnectionState.Connected), of(ConnectionState.Connected),
new Map([ new Map([
[aliceParticipant, cold(aSpeakingMarbles, { y: true, n: false })], [aliceParticipant, cold(aSpeakingInputMarbles, { y: true, n: false })],
[bobParticipant, cold(bSpeakingMarbles, { y: true, n: false })], [bobParticipant, cold(bSpeakingInputMarbles, { y: true, n: false })],
[daveParticipant, cold(dSpeakingMarbles, { y: true, n: false })], [daveParticipant, cold(dSpeakingInputMarbles, { y: true, n: false })],
]), ]),
(vm) => { (vm) => {
schedule(modeMarbles, { s: () => vm.setGridMode("spotlight") }); schedule(modeInputMarbles, { s: () => vm.setGridMode("spotlight") });
expectObservable(summarizeLayout(vm.layout)).toBe(layoutMarbles, { expectObservable(summarizeLayout(vm.layout)).toBe(
a: { expectedLayoutMarbles,
type: "spotlight-landscape", {
spotlight: [`${aliceId}:0`], a: {
grid: ["local:0", `${bobId}:0`, `${daveId}:0`], type: "spotlight-landscape",
spotlight: [`${aliceId}:0`],
grid: ["local:0", `${bobId}:0`, `${daveId}:0`],
},
b: {
type: "spotlight-landscape",
spotlight: [`${bobId}:0`],
grid: ["local:0", `${aliceId}:0`, `${daveId}:0`],
},
c: {
type: "spotlight-landscape",
spotlight: [`${daveId}:0`],
grid: ["local:0", `${aliceId}:0`, `${bobId}:0`],
},
d: {
type: "spotlight-landscape",
spotlight: [`${aliceId}:0`],
grid: ["local:0", `${daveId}:0`, `${bobId}:0`],
},
}, },
b: { );
type: "spotlight-landscape",
spotlight: [`${bobId}:0`],
grid: ["local:0", `${aliceId}:0`, `${daveId}:0`],
},
c: {
type: "spotlight-landscape",
spotlight: [`${daveId}:0`],
grid: ["local:0", `${aliceId}:0`, `${bobId}:0`],
},
d: {
type: "spotlight-landscape",
spotlight: [`${aliceId}:0`],
grid: ["local:0", `${daveId}:0`, `${bobId}:0`],
},
});
}, },
); );
}); });
@ -446,31 +464,34 @@ test("spotlight speakers swap places", () => {
test("layout enters picture-in-picture mode when requested", () => { test("layout enters picture-in-picture mode when requested", () => {
withTestScheduler(({ schedule, expectObservable }) => { withTestScheduler(({ schedule, expectObservable }) => {
// Enable then disable picture-in-picture // Enable then disable picture-in-picture
const pipControlMarbles = "-ed"; const pipControlInputMarbles = "-ed";
// Should go into picture-in-picture layout then back to grid // Should go into picture-in-picture layout then back to grid
const layoutMarbles = " aba"; const expectedLayoutMarbles = " aba";
withCallViewModel( withCallViewModel(
of([aliceParticipant, bobParticipant]), of([aliceParticipant, bobParticipant]),
of(ConnectionState.Connected), of(ConnectionState.Connected),
new Map(), new Map(),
(vm) => { (vm) => {
schedule(pipControlMarbles, { schedule(pipControlInputMarbles, {
e: () => window.controls.enablePip(), e: () => window.controls.enablePip(),
d: () => window.controls.disablePip(), d: () => window.controls.disablePip(),
}); });
expectObservable(summarizeLayout(vm.layout)).toBe(layoutMarbles, { expectObservable(summarizeLayout(vm.layout)).toBe(
a: { expectedLayoutMarbles,
type: "grid", {
spotlight: undefined, a: {
grid: ["local:0", `${aliceId}:0`, `${bobId}:0`], type: "grid",
spotlight: undefined,
grid: ["local:0", `${aliceId}:0`, `${bobId}:0`],
},
b: {
type: "pip",
spotlight: [`${aliceId}:0`],
},
}, },
b: { );
type: "pip",
spotlight: [`${aliceId}:0`],
},
});
}, },
); );
}); });
@ -480,23 +501,23 @@ test("spotlight remembers whether it's expanded", () => {
withTestScheduler(({ schedule, expectObservable }) => { withTestScheduler(({ schedule, expectObservable }) => {
// Start in spotlight mode, then switch to grid and back to spotlight a // Start in spotlight mode, then switch to grid and back to spotlight a
// couple times // couple times
const modeMarbles = " s-gs-gs"; const modeInputMarbles = " s-gs-gs";
// Expand and collapse the spotlight // Expand and collapse the spotlight
const expandMarbles = "-a--a"; const expandInputMarbles = " -a--a";
// Spotlight should stay expanded during the first mode switch, and stay // Spotlight should stay expanded during the first mode switch, and stay
// collapsed during the second mode switch // collapsed during the second mode switch
const layoutMarbles = "abcbada"; const expectedLayoutMarbles = "abcbada";
withCallViewModel( withCallViewModel(
of([aliceParticipant, bobParticipant]), of([aliceParticipant, bobParticipant]),
of(ConnectionState.Connected), of(ConnectionState.Connected),
new Map(), new Map(),
(vm) => { (vm) => {
schedule(modeMarbles, { schedule(modeInputMarbles, {
s: () => vm.setGridMode("spotlight"), s: () => vm.setGridMode("spotlight"),
g: () => vm.setGridMode("grid"), g: () => vm.setGridMode("grid"),
}); });
schedule(expandMarbles, { schedule(expandInputMarbles, {
a: () => { a: () => {
let toggle: () => void; let toggle: () => void;
vm.toggleSpotlightExpanded.subscribe((val) => (toggle = val!)); vm.toggleSpotlightExpanded.subscribe((val) => (toggle = val!));
@ -504,28 +525,31 @@ test("spotlight remembers whether it's expanded", () => {
}, },
}); });
expectObservable(summarizeLayout(vm.layout)).toBe(layoutMarbles, { expectObservable(summarizeLayout(vm.layout)).toBe(
a: { expectedLayoutMarbles,
type: "spotlight-landscape", {
spotlight: [`${aliceId}:0`], a: {
grid: ["local:0", `${bobId}:0`], type: "spotlight-landscape",
spotlight: [`${aliceId}:0`],
grid: ["local:0", `${bobId}:0`],
},
b: {
type: "spotlight-expanded",
spotlight: [`${aliceId}:0`],
pip: "local:0",
},
c: {
type: "grid",
spotlight: undefined,
grid: ["local:0", `${aliceId}:0`, `${bobId}:0`],
},
d: {
type: "grid",
spotlight: undefined,
grid: ["local:0", `${bobId}:0`, `${aliceId}:0`],
},
}, },
b: { );
type: "spotlight-expanded",
spotlight: [`${aliceId}:0`],
pip: "local:0",
},
c: {
type: "grid",
spotlight: undefined,
grid: ["local:0", `${aliceId}:0`, `${bobId}:0`],
},
d: {
type: "grid",
spotlight: undefined,
grid: ["local:0", `${bobId}:0`, `${aliceId}:0`],
},
});
}, },
); );
}); });

View File

@ -260,7 +260,6 @@ class UserMedia {
), ),
), ),
startWith(false), startWith(false),
distinctUntilChanged(),
// Make this Observable hot so that the timers don't reset when you // Make this Observable hot so that the timers don't reset when you
// resubscribe // resubscribe
this.scope.state(), this.scope.state(),
@ -307,7 +306,7 @@ class ScreenShare {
type MediaItem = UserMedia | ScreenShare; type MediaItem = UserMedia | ScreenShare;
function findMatrixMember( function findMatrixRoomMember(
room: MatrixRoom, room: MatrixRoom,
id: string, id: string,
): RoomMember | undefined { ): RoomMember | undefined {
@ -342,12 +341,16 @@ export class CallViewModel extends ViewModel {
}), }),
); );
private readonly rawRemoteParticipants = connectedParticipantsObserver( /**
this.livekitRoom, * The raw list of RemoteParticipants as reported by LiveKit
).pipe(this.scope.state()); */
private readonly rawRemoteParticipants: Observable<RemoteParticipant[]> =
connectedParticipantsObserver(this.livekitRoom).pipe(this.scope.state());
// Lists of participants to "hold" on display, even if LiveKit claims that /**
// they've left * Lists of RemoteParticipants to "hold" on display, even if LiveKit claims that
* they've left
*/
private readonly remoteParticipantHolds: Observable<RemoteParticipant[][]> = private readonly remoteParticipantHolds: Observable<RemoteParticipant[][]> =
this.connectionState.pipe( this.connectionState.pipe(
withLatestFrom(this.rawRemoteParticipants), withLatestFrom(this.rawRemoteParticipants),
@ -382,6 +385,9 @@ export class CallViewModel extends ViewModel {
), ),
); );
/**
* The RemoteParticipants including those that are being "held" on the screen
*/
private readonly remoteParticipants: Observable<RemoteParticipant[]> = private readonly remoteParticipants: Observable<RemoteParticipant[]> =
combineLatest( combineLatest(
[this.rawRemoteParticipants, this.remoteParticipantHolds], [this.rawRemoteParticipants, this.remoteParticipantHolds],
@ -403,6 +409,9 @@ export class CallViewModel extends ViewModel {
}, },
); );
/**
* List of MediaItems that we want to display
*/
private readonly mediaItems: Observable<MediaItem[]> = combineLatest([ private readonly mediaItems: Observable<MediaItem[]> = combineLatest([
this.remoteParticipants, this.remoteParticipants,
observeParticipantMedia(this.livekitRoom.localParticipant), observeParticipantMedia(this.livekitRoom.localParticipant),
@ -419,7 +428,7 @@ export class CallViewModel extends ViewModel {
function* (this: CallViewModel): Iterable<[string, MediaItem]> { function* (this: CallViewModel): Iterable<[string, MediaItem]> {
for (const p of [localParticipant, ...remoteParticipants]) { for (const p of [localParticipant, ...remoteParticipants]) {
const id = p === localParticipant ? "local" : p.identity; const id = p === localParticipant ? "local" : p.identity;
const member = findMatrixMember(this.matrixRoom, id); const member = findMatrixRoomMember(this.matrixRoom, id);
if (member === undefined) if (member === undefined)
logger.warn( logger.warn(
`Ruh, roh! No matrix member found for SFU participant '${p.identity}': creating g-g-g-ghost!`, `Ruh, roh! No matrix member found for SFU participant '${p.identity}': creating g-g-g-ghost!`,
@ -472,6 +481,9 @@ export class CallViewModel extends ViewModel {
this.scope.state(), this.scope.state(),
); );
/**
* List of MediaItems that we want to display, that are of type UserMedia
*/
private readonly userMedia: Observable<UserMedia[]> = this.mediaItems.pipe( private readonly userMedia: Observable<UserMedia[]> = this.mediaItems.pipe(
map((mediaItems) => map((mediaItems) =>
mediaItems.filter((m): m is UserMedia => m instanceof UserMedia), mediaItems.filter((m): m is UserMedia => m instanceof UserMedia),
@ -483,6 +495,9 @@ export class CallViewModel extends ViewModel {
map((ms) => ms.find((m) => m.vm.local)!.vm as LocalUserMediaViewModel), map((ms) => ms.find((m) => m.vm.local)!.vm as LocalUserMediaViewModel),
); );
/**
* List of MediaItems that we want to display, that are of type ScreenShare
*/
private readonly screenShares: Observable<ScreenShare[]> = private readonly screenShares: Observable<ScreenShare[]> =
this.mediaItems.pipe( this.mediaItems.pipe(
map((mediaItems) => map((mediaItems) =>
@ -941,7 +956,7 @@ export class CallViewModel extends ViewModel {
this.scope.state(), this.scope.state(),
); );
public readonly showFooter = this.windowMode.pipe( public readonly showFooter: Observable<boolean> = this.windowMode.pipe(
switchMap((mode) => { switchMap((mode) => {
switch (mode) { switch (mode) {
case "pip": case "pip":

View File

@ -97,7 +97,7 @@ const UserMediaTile = forwardRef<HTMLDivElement, UserMediaTileProps>(
}, },
[vm], [vm],
); );
const { raisedHands, lowerHand, reactions } = useReactions(); const { raisedHands, toggleRaisedHand, reactions } = useReactions();
const AudioIcon = locallyMuted const AudioIcon = locallyMuted
? VolumeOffSolidIcon ? VolumeOffSolidIcon
@ -127,8 +127,9 @@ const UserMediaTile = forwardRef<HTMLDivElement, UserMediaTileProps>(
const handRaised: Date | undefined = raisedHands[vm.member?.userId ?? ""]; const handRaised: Date | undefined = raisedHands[vm.member?.userId ?? ""];
const currentReaction: ReactionOption | undefined = const currentReaction: ReactionOption | undefined =
reactions[vm.member?.userId ?? ""]; reactions[vm.member?.userId ?? ""];
const raisedHandOnClick = const raisedHandOnClick = vm.local
vm.local && handRaised ? (): void => void lowerHand() : undefined; ? (): void => void toggleRaisedHand()
: undefined;
const showSpeaking = showSpeakingIndicators && speaking; const showSpeaking = showSpeakingIndicators && speaking;

View File

@ -12,19 +12,24 @@ import { Button } from "@vector-im/compound-web";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
import { useCallViewKeyboardShortcuts } from "../src/useCallViewKeyboardShortcuts"; import { useCallViewKeyboardShortcuts } from "../src/useCallViewKeyboardShortcuts";
import { ReactionOption, ReactionSet, ReactionsRowSize } from "./reactions";
// Test Explanation: // Test Explanation:
// - The main objective is to test `useCallViewKeyboardShortcuts`. // - The main objective is to test `useCallViewKeyboardShortcuts`.
// The TestComponent just wraps a button around that hook. // The TestComponent just wraps a button around that hook.
interface TestComponentProps { interface TestComponentProps {
setMicrophoneMuted: (muted: boolean) => void; setMicrophoneMuted?: (muted: boolean) => void;
onButtonClick?: () => void; onButtonClick?: () => void;
sendReaction?: () => void;
toggleHandRaised?: () => void;
} }
const TestComponent: FC<TestComponentProps> = ({ const TestComponent: FC<TestComponentProps> = ({
setMicrophoneMuted, setMicrophoneMuted = (): void => {},
onButtonClick = (): void => {}, onButtonClick = (): void => {},
sendReaction = (reaction: ReactionOption): void => {},
toggleHandRaised = (): void => {},
}) => { }) => {
const ref = useRef<HTMLDivElement | null>(null); const ref = useRef<HTMLDivElement | null>(null);
useCallViewKeyboardShortcuts( useCallViewKeyboardShortcuts(
@ -32,6 +37,8 @@ const TestComponent: FC<TestComponentProps> = ({
() => {}, () => {},
() => {}, () => {},
setMicrophoneMuted, setMicrophoneMuted,
sendReaction,
toggleHandRaised,
); );
return ( return (
<div ref={ref}> <div ref={ref}>
@ -74,6 +81,28 @@ test("spacebar prioritizes pressing a button", async () => {
expect(onClick).toBeCalled(); expect(onClick).toBeCalled();
}); });
test("reactions can be sent via keyboard presses", async () => {
const user = userEvent.setup();
const sendReaction = vi.fn();
render(<TestComponent sendReaction={sendReaction} />);
for (let index = 1; index <= ReactionsRowSize; index++) {
await user.keyboard(index.toString());
expect(sendReaction).toHaveBeenNthCalledWith(index, ReactionSet[index - 1]);
}
});
test("raised hand can be sent via keyboard presses", async () => {
const user = userEvent.setup();
const toggleHandRaised = vi.fn();
render(<TestComponent toggleHandRaised={toggleHandRaised} />);
await user.keyboard("h");
expect(toggleHandRaised).toHaveBeenCalledOnce();
});
test("unmuting happens in place of the default action", async () => { test("unmuting happens in place of the default action", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
const defaultPrevented = vi.fn(); const defaultPrevented = vi.fn();

View File

@ -8,6 +8,7 @@ Please see LICENSE in the repository root for full details.
import { RefObject, useCallback, useMemo, useRef } from "react"; import { RefObject, useCallback, useMemo, useRef } from "react";
import { useEventTarget } from "./useEvents"; import { useEventTarget } from "./useEvents";
import { ReactionOption, ReactionSet, ReactionsRowSize } from "./reactions";
/** /**
* Determines whether focus is in the same part of the tree as the given * Determines whether focus is in the same part of the tree as the given
@ -18,11 +19,17 @@ const mayReceiveKeyEvents = (e: HTMLElement): boolean => {
return focusedElement !== null && focusedElement.contains(e); return focusedElement !== null && focusedElement.contains(e);
}; };
const KeyToReactionMap: Record<string, ReactionOption> = Object.fromEntries(
ReactionSet.slice(0, ReactionsRowSize).map((r, i) => [(i + 1).toString(), r]),
);
export function useCallViewKeyboardShortcuts( export function useCallViewKeyboardShortcuts(
focusElement: RefObject<HTMLElement | null>, focusElement: RefObject<HTMLElement | null>,
toggleMicrophoneMuted: () => void, toggleMicrophoneMuted: () => void,
toggleLocalVideoMuted: () => void, toggleLocalVideoMuted: () => void,
setMicrophoneMuted: (muted: boolean) => void, setMicrophoneMuted: (muted: boolean) => void,
sendReaction: (reaction: ReactionOption) => void,
toggleHandRaised: () => void,
): void { ): void {
const spacebarHeld = useRef(false); const spacebarHeld = useRef(false);
@ -49,6 +56,12 @@ export function useCallViewKeyboardShortcuts(
spacebarHeld.current = true; spacebarHeld.current = true;
setMicrophoneMuted(false); setMicrophoneMuted(false);
} }
} else if (event.key === "h") {
event.preventDefault();
toggleHandRaised();
} else if (KeyToReactionMap[event.key]) {
event.preventDefault();
sendReaction(KeyToReactionMap[event.key]);
} }
}, },
[ [
@ -56,6 +69,8 @@ export function useCallViewKeyboardShortcuts(
toggleLocalVideoMuted, toggleLocalVideoMuted,
toggleMicrophoneMuted, toggleMicrophoneMuted,
setMicrophoneMuted, setMicrophoneMuted,
sendReaction,
toggleHandRaised,
], ],
), ),
// Because this is set on the window, to prevent shortcuts from activating // Because this is set on the window, to prevent shortcuts from activating

View File

@ -40,7 +40,8 @@ interface ReactionsContextType {
raisedHands: Record<string, Date>; raisedHands: Record<string, Date>;
supportsReactions: boolean; supportsReactions: boolean;
reactions: Record<string, ReactionOption>; reactions: Record<string, ReactionOption>;
lowerHand: () => Promise<void>; toggleRaisedHand: () => Promise<void>;
sendReaction: (reaction: ReactionOption) => Promise<void>;
} }
const ReactionsContext = createContext<ReactionsContextType | undefined>( const ReactionsContext = createContext<ReactionsContextType | undefined>(
@ -104,7 +105,6 @@ export const ReactionsProvider = ({
), ),
[raisedHands], [raisedHands],
); );
const addRaisedHand = useCallback((userId: string, info: RaisedHandInfo) => { const addRaisedHand = useCallback((userId: string, info: RaisedHandInfo) => {
setRaisedHands((prevRaisedHands) => ({ setRaisedHands((prevRaisedHands) => ({
...prevRaisedHands, ...prevRaisedHands,
@ -181,6 +181,11 @@ export const ReactionsProvider = ({
const latestMemberships = useLatest(memberships); const latestMemberships = useLatest(memberships);
const latestRaisedHands = useLatest(raisedHands); const latestRaisedHands = useLatest(raisedHands);
const myMembership = useMemo(
() => memberships.find((m) => m.sender === myUserId)?.eventId,
[memberships, myUserId],
);
// This effect handles any *live* reaction/redactions in the room. // This effect handles any *live* reaction/redactions in the room.
useEffect(() => { useEffect(() => {
const reactionTimeouts = new Set<number>(); const reactionTimeouts = new Set<number>();
@ -322,22 +327,67 @@ export const ReactionsProvider = ({
latestRaisedHands, latestRaisedHands,
]); ]);
const lowerHand = useCallback(async () => { const toggleRaisedHand = useCallback(async () => {
if (!myUserId || !raisedHands[myUserId]) { if (!myUserId) {
return; return;
} }
const myReactionId = raisedHands[myUserId].reactionEventId; const myReactionId = raisedHands[myUserId]?.reactionEventId;
if (!myReactionId) { if (!myReactionId) {
logger.warn(`Hand raised but no reaction event to redact!`); try {
return; if (!myMembership) {
throw new Error("Cannot find own membership event");
}
const reaction = await room.client.sendEvent(
rtcSession.room.roomId,
EventType.Reaction,
{
"m.relates_to": {
rel_type: RelationType.Annotation,
event_id: myMembership,
key: "🖐️",
},
},
);
logger.debug("Sent raise hand event", reaction.event_id);
} catch (ex) {
logger.error("Failed to send raised hand", ex);
}
} else {
try {
await room.client.redactEvent(rtcSession.room.roomId, myReactionId);
logger.debug("Redacted raise hand event");
} catch (ex) {
logger.error("Failed to redact reaction event", myReactionId, ex);
throw ex;
}
} }
try { }, [myMembership, myUserId, raisedHands, rtcSession, room]);
await room.client.redactEvent(rtcSession.room.roomId, myReactionId);
logger.debug("Redacted raise hand event"); const sendReaction = useCallback(
} catch (ex) { async (reaction: ReactionOption) => {
logger.error("Failed to redact reaction event", myReactionId, ex); if (!myUserId || reactions[myUserId]) {
} // We're still reacting
}, [myUserId, raisedHands, rtcSession, room]); return;
}
if (!myMembership) {
throw new Error("Cannot find own membership event");
}
await room.client.sendEvent(
rtcSession.room.roomId,
ElementCallReactionEventType,
{
"m.relates_to": {
rel_type: RelationType.Reference,
event_id: myMembership,
},
emoji: reaction.emoji,
name: reaction.name,
},
);
},
[myMembership, reactions, room, myUserId, rtcSession],
);
return ( return (
<ReactionsContext.Provider <ReactionsContext.Provider
@ -345,7 +395,8 @@ export const ReactionsProvider = ({
raisedHands: resultRaisedHands, raisedHands: resultRaisedHands,
supportsReactions, supportsReactions,
reactions, reactions,
lowerHand, toggleRaisedHand,
sendReaction,
}} }}
> >
{children} {children}

View File

@ -123,7 +123,6 @@ export async function initClient(
localTimeoutMs: 5000, localTimeoutMs: 5000,
useE2eForGroupCall: e2eEnabled, useE2eForGroupCall: e2eEnabled,
fallbackICEServerAllowed: fallbackICEServerAllowed, fallbackICEServerAllowed: fallbackICEServerAllowed,
store: new MemoryStore(),
}); });
// In case of logging in a new matrix account but there is still crypto local store. This is needed for: // In case of logging in a new matrix account but there is still crypto local store. This is needed for:

View File

@ -99,7 +99,7 @@ function mockEmitter<T>(): EmitterMock<T> {
// Maybe it'd be good to move this to matrix-js-sdk? Our testing needs are // Maybe it'd be good to move this to matrix-js-sdk? Our testing needs are
// rather simple, but if one util to mock a member is good enough for us, maybe // rather simple, but if one util to mock a member is good enough for us, maybe
// it's useful for matrix-js-sdk consumers in general. // it's useful for matrix-js-sdk consumers in general.
export function mockMember(member: Partial<RoomMember>): RoomMember { export function mockMatrixRoomMember(member: Partial<RoomMember>): RoomMember {
return { ...mockEmitter(), ...member } as RoomMember; return { ...mockEmitter(), ...member } as RoomMember;
} }
@ -149,7 +149,7 @@ export async function withLocalMedia(
const localParticipant = mockLocalParticipant({}); const localParticipant = mockLocalParticipant({});
const vm = new LocalUserMediaViewModel( const vm = new LocalUserMediaViewModel(
"local", "local",
mockMember(member), mockMatrixRoomMember(member),
localParticipant, localParticipant,
{ {
kind: E2eeType.PER_PARTICIPANT, kind: E2eeType.PER_PARTICIPANT,
@ -184,7 +184,7 @@ export async function withRemoteMedia(
const remoteParticipant = mockRemoteParticipant(participant); const remoteParticipant = mockRemoteParticipant(participant);
const vm = new RemoteUserMediaViewModel( const vm = new RemoteUserMediaViewModel(
"remote", "remote",
mockMember(member), mockMatrixRoomMember(member),
remoteParticipant, remoteParticipant,
{ {
kind: E2eeType.PER_PARTICIPANT, kind: E2eeType.PER_PARTICIPANT,

View File

@ -6209,9 +6209,9 @@ matrix-events-sdk@0.0.1:
resolved "https://registry.yarnpkg.com/matrix-events-sdk/-/matrix-events-sdk-0.0.1.tgz#c8c38911e2cb29023b0bbac8d6f32e0de2c957dd" resolved "https://registry.yarnpkg.com/matrix-events-sdk/-/matrix-events-sdk-0.0.1.tgz#c8c38911e2cb29023b0bbac8d6f32e0de2c957dd"
integrity sha512-1QEOsXO+bhyCroIe2/A5OwaxHvBm7EsSQ46DEDn8RBIfQwN5HWBpFvyWWR4QY0KHPPnnJdI99wgRiAl7Ad5qaA== integrity sha512-1QEOsXO+bhyCroIe2/A5OwaxHvBm7EsSQ46DEDn8RBIfQwN5HWBpFvyWWR4QY0KHPPnnJdI99wgRiAl7Ad5qaA==
matrix-js-sdk@matrix-org/matrix-js-sdk#8e9a04cdec0f88fc876bbbf406db55b0677f005d: matrix-js-sdk@matrix-org/matrix-js-sdk#2210255d6ffc909c574fb8ef16f92140b2fb7797:
version "34.10.0" version "34.12.0"
resolved "https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/8e9a04cdec0f88fc876bbbf406db55b0677f005d" resolved "https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/2210255d6ffc909c574fb8ef16f92140b2fb7797"
dependencies: dependencies:
"@babel/runtime" "^7.12.5" "@babel/runtime" "^7.12.5"
"@matrix-org/matrix-sdk-crypto-wasm" "^9.0.0" "@matrix-org/matrix-sdk-crypto-wasm" "^9.0.0"