mirror of
https://github.com/vector-im/element-call.git
synced 2024-11-30 00:50:48 +08:00
Merge branch 'livekit' into device-fall-back
This commit is contained in:
commit
465a784345
@ -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: {
|
||||||
|
@ -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",
|
||||||
|
@ -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"));
|
||||||
|
@ -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>
|
||||||
</>
|
</>
|
||||||
|
@ -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"
|
||||||
|
@ -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: "👍",
|
||||||
|
@ -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}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
18
src/settings/DeviceSelection.module.css
Normal file
18
src/settings/DeviceSelection.module.css
Normal 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);
|
||||||
|
}
|
71
src/settings/DeviceSelection.tsx
Normal file
71
src/settings/DeviceSelection.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
@ -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> = {
|
||||||
|
@ -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`],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
@ -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":
|
||||||
|
@ -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;
|
||||||
|
|
||||||
|
@ -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();
|
||||||
|
@ -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
|
||||||
|
@ -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}
|
||||||
|
@ -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:
|
||||||
|
@ -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,
|
||||||
|
@ -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"
|
||||||
|
Loading…
Reference in New Issue
Block a user