Merge remote-tracking branch 'origin/livekit' into hs/emoji-reactions

This commit is contained in:
Will Hunt
2024-11-04 11:14:36 +00:00
28 changed files with 515 additions and 114 deletions

View File

@@ -23,7 +23,7 @@ jobs:
packages: write
steps:
- name: Check it out
uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
- name: 📥 Download artifact
uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4

View File

@@ -12,7 +12,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Check out test private repo
uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
with:
repository: element-hq/static-call-participant
ref: refs/heads/main

View File

@@ -21,9 +21,9 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
- name: Yarn cache
uses: actions/setup-node@0a44ba7841725637a19e28fa30b79a866c81b0a6 # v4
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4
with:
cache: "yarn"
node-version: "lts/*"

View File

@@ -7,9 +7,9 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
- name: Yarn cache
uses: actions/setup-node@0a44ba7841725637a19e28fa30b79a866c81b0a6 # v4
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4
with:
cache: "yarn"
node-version: "lts/*"

View File

@@ -9,9 +9,9 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
- name: Yarn cache
uses: actions/setup-node@0a44ba7841725637a19e28fa30b79a866c81b0a6 # v4
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4
with:
cache: "yarn"
node-version: "lts/*"

View File

@@ -13,9 +13,9 @@ jobs:
steps:
- name: Checkout the code
uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
- uses: actions/setup-node@0a44ba7841725637a19e28fa30b79a866c81b0a6 # v4
- uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4
with:
cache: "yarn"
node-version: "lts/*"

View File

@@ -14,7 +14,7 @@ jobs:
steps:
- name: Checkout the code
uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
- name: Upload
uses: localazy/upload@27e6b5c0fddf4551596b42226b1c24124335d24a # v1

View File

@@ -98,6 +98,8 @@ confineToRoom: boolean; (default: false)
Whether upon entering a room, the user should be prompted to launch the
native mobile app. (Affects only Android and iOS.)
The app prompt must also be enabled in the config for this to take effect.
```ts
appPrompt: boolean; (default: true)
```

View File

@@ -161,6 +161,7 @@
"stop_screenshare_button_label": "Sharing screen",
"stop_video_button_label": "Stop video",
"submitting": "Submitting…",
"switch_camera": "Switch camera",
"unauthenticated_view_body": "Not registered yet? <2>Create an account</2>",
"unauthenticated_view_eula_caption": "By clicking \"Go\", you agree to our <2>End User Licensing Agreement (EULA)</2>",
"unauthenticated_view_login_button": "Login to your account",

View File

@@ -43,6 +43,8 @@ export interface UrlParams {
/**
* Whether upon entering a room, the user should be prompted to launch the
* native mobile app. (Affects only Android and iOS.)
*
* The app prompt must also be enabled in the config for this to take effect.
*/
appPrompt: boolean;
/**

View File

@@ -16,6 +16,7 @@ import {
EndCallIcon,
ShareScreenSolidIcon,
SettingsSolidIcon,
SwitchCameraSolidIcon,
} from "@vector-im/compound-design-tokens/assets/web/icons";
import styles from "./Button.module.css";
@@ -66,6 +67,23 @@ export const VideoButton: FC<VideoButtonProps> = ({ muted, ...props }) => {
);
};
export const SwitchCameraButton: FC<ComponentPropsWithoutRef<"button">> = (
props,
) => {
const { t } = useTranslation();
return (
<Tooltip label={t("switch_camera")}>
<CpdButton
iconOnly
Icon={SwitchCameraSolidIcon}
kind="secondary"
{...props}
/>
</Tooltip>
);
};
interface ShareScreenButtonProps extends ComponentPropsWithoutRef<"button"> {
enabled: boolean;
}

View File

@@ -0,0 +1,133 @@
/*
Copyright 2024 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only
Please see LICENSE in the repository root for full details.
*/
import { Button as CpdButton, Tooltip } from "@vector-im/compound-web";
import {
ComponentPropsWithoutRef,
FC,
ReactNode,
useCallback,
useState,
} from "react";
import { useTranslation } from "react-i18next";
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 { useReactions } from "../useReactions";
import { useMatrixRTCSessionMemberships } from "../useMatrixRTCSessionMemberships";
interface InnerButtonProps extends ComponentPropsWithoutRef<"button"> {
raised: boolean;
}
const InnerButton: FC<InnerButtonProps> = ({ raised, ...props }) => {
const { t } = useTranslation();
return (
<Tooltip label={t("common.raise_hand")}>
<CpdButton
kind={raised ? "primary" : "secondary"}
{...props}
style={{ paddingLeft: 8, paddingRight: 8 }}
>
<p
role="img"
aria-hidden
style={{
width: "30px",
height: "0px",
display: "inline-block",
fontSize: "22px",
}}
>
</p>
</CpdButton>
</Tooltip>
);
};
interface RaisedHandToggleButtonProps {
rtcSession: MatrixRTCSession;
client: MatrixClient;
}
export function RaiseHandToggleButton({
client,
rtcSession,
}: RaisedHandToggleButtonProps): ReactNode {
const { raisedHands, myReactionId } = useReactions();
const [busy, setBusy] = useState(false);
const userId = client.getUserId()!;
const isHandRaised = !!raisedHands[userId];
const memberships = useMatrixRTCSessionMemberships(rtcSession);
const toggleRaisedHand = useCallback(() => {
const raiseHand = async (): Promise<void> => {
if (isHandRaised) {
if (!myReactionId) {
logger.warn(`Hand raised but no reaction event to redact!`);
return;
}
try {
setBusy(true);
await client.redactEvent(rtcSession.room.roomId, myReactionId);
logger.debug("Redacted raise hand event");
} catch (ex) {
logger.error("Failed to redact reaction event", myReactionId, ex);
} finally {
setBusy(false);
}
} else {
const myMembership = memberships.find((m) => m.sender === userId);
if (!myMembership?.eventId) {
logger.error("Cannot find own membership event");
return;
}
const parentEventId = myMembership.eventId;
try {
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);
} catch (ex) {
logger.error("Failed to send reaction event", ex);
} finally {
setBusy(false);
}
}
};
void raiseHand();
}, [
client,
isHandRaised,
memberships,
myReactionId,
rtcSession.room.roomId,
userId,
]);
return (
<InnerButton
disabled={busy}
onClick={toggleRaisedHand}
raised={isHandRaised}
/>
);
}

View File

@@ -88,6 +88,14 @@ export interface ConfigOptions {
*/
enable_video?: boolean;
};
/**
* Whether upon entering a room, the user should be prompted to launch the
* native mobile app. (Affects only Android and iOS.)
*
* Note that this can additionally be disabled by the app's URL parameters.
*/
app_prompt?: boolean;
}
// Overrides members from ConfigOptions that are always provided by the
@@ -103,6 +111,7 @@ export interface ResolvedConfigOptions extends ConfigOptions {
enable_audio: boolean;
enable_video: boolean;
};
app_prompt: boolean;
}
export const DEFAULT_CONFIG: ResolvedConfigOptions = {
@@ -117,4 +126,5 @@ export const DEFAULT_CONFIG: ResolvedConfigOptions = {
enable_audio: true,
enable_video: true,
},
app_prompt: true,
};

View File

@@ -0,0 +1,52 @@
.raisedHandWidget {
display: flex;
background-color: var(--cpd-color-bg-subtle-primary);
border-radius: var(--cpd-radius-pill-effect);
color: var(--cpd-color-icon-secondary);
}
.raisedHandWidget > p {
padding: none;
margin-top: auto;
margin-bottom: auto;
width: 4em;
}
.raisedHandWidgetLarge > p {
padding: var(--cpd-space-2x);
}
.raisedHandLarge {
margin: var(--cpd-space-2x);
padding: var(--cpd-space-2x);
padding-block: var(--cpd-space-2x);
}
.raisedHand {
margin: var(--cpd-space-1x);
color: var(--cpd-color-icon-secondary);
background-color: var(--cpd-color-icon-secondary);
display: flex;
align-items: center;
border-radius: var(--cpd-radius-pill-effect);
user-select: none;
overflow: hidden;
box-shadow: var(--small-drop-shadow);
box-sizing: border-box;
max-inline-size: 100%;
max-width: fit-content;
}
.raisedHand > span {
width: var(--cpd-space-6x);
height: var(--cpd-space-6x);
display: inline-block;
text-align: center;
font-size: 16px;
}
.raisedHandLarge > span {
width: var(--cpd-space-8x);
height: var(--cpd-space-8x);
font-size: 22px;
}

View File

@@ -8,7 +8,6 @@ Please see LICENSE in the repository root for full details.
import { ReactNode, useEffect, useState } from "react";
import "@formatjs/intl-durationformat/polyfill";
import { DurationFormat } from "@formatjs/intl-durationformat";
import { ReactionIndicator } from "./ReactionIndicator";
const durationFormatter = new DurationFormat(undefined, {

View File

@@ -42,6 +42,7 @@ import {
ShareScreenButton,
SettingsButton,
ReactionToggleButton,
SwitchCameraButton,
} from "../button";
import { Header, LeftNav, RightNav, RoomHeaderInfo } from "../Header";
import { useUrlParams } from "../UrlParams";
@@ -84,6 +85,7 @@ import { ReactionsProvider, useReactions } from "../useReactions";
import handSoundOgg from "../sound/raise_hand.ogg?url";
import handSoundMp3 from "../sound/raise_hand.mp3?url";
import { ReactionsAudioRenderer } from "./ReactionAudioRenderer";
import { useSwitchCamera } from "./useSwitchCamera";
const canScreenshare = "getDisplayMedia" in (navigator.mediaDevices ?? {});
@@ -119,7 +121,7 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
const vm = new CallViewModel(
props.rtcSession.room,
livekitRoom,
props.e2eeSystem.kind !== E2eeType.NONE,
props.e2eeSystem,
connStateObservable,
);
setVm(vm);
@@ -128,7 +130,7 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
}, [
props.rtcSession.room,
livekitRoom,
props.e2eeSystem.kind,
props.e2eeSystem,
connStateObservable,
]);
@@ -242,6 +244,7 @@ export const InCallView: FC<InCallViewProps> = ({
const gridMode = useObservableEagerState(vm.gridMode);
const showHeader = useObservableEagerState(vm.showHeader);
const showFooter = useObservableEagerState(vm.showFooter);
const switchCamera = useSwitchCamera(vm.localVideo);
// Ideally we could detect taps by listening for click events and checking
// that the pointerType of the event is "touch", but this isn't yet supported
@@ -285,10 +288,17 @@ export const InCallView: FC<InCallViewProps> = ({
[setSettingsModalOpen],
);
const openProfile = useCallback(() => {
setSettingsTab("profile");
setSettingsModalOpen(true);
}, [setSettingsTab, setSettingsModalOpen]);
const openProfile = useMemo(
() =>
// Profile settings are unavailable in widget mode
widget === null
? (): void => {
setSettingsTab("profile");
setSettingsModalOpen(true);
}
: null,
[setSettingsTab, setSettingsModalOpen],
);
const [headerRef, headerBounds] = useMeasure();
const [footerRef, footerBounds] = useMeasure();
@@ -526,14 +536,14 @@ export const InCallView: FC<InCallViewProps> = ({
buttons.push(
<MicButton
key="1"
key="audio"
muted={!muteStates.audio.enabled}
onClick={toggleMicrophone}
disabled={muteStates.audio.setEnabled === null}
data-testid="incall_mute"
/>,
<VideoButton
key="2"
key="video"
muted={!muteStates.video.enabled}
onClick={toggleCamera}
disabled={muteStates.video.setEnabled === null}
@@ -541,10 +551,14 @@ export const InCallView: FC<InCallViewProps> = ({
/>,
);
if (!reducedControls) {
if (switchCamera !== null)
buttons.push(
<SwitchCameraButton key="switch_camera" onClick={switchCamera} />,
);
if (canScreenshare && !hideScreensharing) {
buttons.push(
<ShareScreenButton
key="3"
key="share_screen"
enabled={isScreenShareEnabled}
onClick={toggleScreensharing}
data-testid="incall_screenshare"
@@ -560,12 +574,12 @@ export const InCallView: FC<InCallViewProps> = ({
/>,
);
}
buttons.push(<SettingsButton key="5" onClick={openSettings} />);
buttons.push(<SettingsButton key="settings" onClick={openSettings} />);
}
buttons.push(
<EndCallButton
key="6"
key="end_call"
onClick={function (): void {
onLeave();
}}

View File

@@ -5,12 +5,17 @@ SPDX-License-Identifier: AGPL-3.0-only
Please see LICENSE in the repository root for full details.
*/
import { FC, useCallback, useState } from "react";
import { FC, useCallback, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { MatrixClient } from "matrix-js-sdk/src/matrix";
import { Button } from "@vector-im/compound-web";
import classNames from "classnames";
import { useHistory } from "react-router-dom";
import { logger } from "matrix-js-sdk/src/logger";
import { usePreviewTracks } from "@livekit/components-react";
import { LocalVideoTrack, Track } from "livekit-client";
import { useObservable } from "observable-hooks";
import { map } from "rxjs";
import inCallStyles from "./InCallView.module.css";
import styles from "./LobbyView.module.css";
@@ -23,12 +28,16 @@ import {
EndCallButton,
MicButton,
SettingsButton,
SwitchCameraButton,
VideoButton,
} from "../button/Button";
import { SettingsModal, defaultSettingsTab } from "../settings/SettingsModal";
import { useMediaQuery } from "../useMediaQuery";
import { E2eeType } from "../e2ee/e2eeType";
import { Link } from "../button/Link";
import { useMediaDevices } from "../livekit/MediaDevicesContext";
import { useInitial } from "../useInitial";
import { useSwitchCamera } from "./useSwitchCamera";
interface Props {
client: MatrixClient;
@@ -89,6 +98,61 @@ export const LobbyView: FC<Props> = ({
</Link>
);
const devices = useMediaDevices();
// Capture the audio options as they were when we first mounted, because
// we're not doing anything with the audio anyway so we don't need to
// re-open the devices when they change (see below).
const initialAudioOptions = useInitial(
() =>
muteStates.audio.enabled && { deviceId: devices.audioInput.selectedId },
);
const localTrackOptions = useMemo(
() => ({
// The only reason we request audio here is to get the audio permission
// request over with at the same time. But changing the audio settings
// shouldn't cause this hook to recreate the track, which is why we
// reference the initial values here.
// We also pass in a clone because livekit mutates the object passed in,
// which would cause the devices to be re-opened on the next render.
audio: Object.assign({}, initialAudioOptions),
video: muteStates.video.enabled && {
deviceId: devices.videoInput.selectedId,
},
}),
[
initialAudioOptions,
devices.videoInput.selectedId,
muteStates.video.enabled,
],
);
const onError = useCallback(
(error: Error) => {
logger.error("Error while creating preview Tracks:", error);
muteStates.audio.setEnabled?.(false);
muteStates.video.setEnabled?.(false);
},
[muteStates.audio, muteStates.video],
);
const tracks = usePreviewTracks(localTrackOptions, onError);
const videoTrack = useMemo(
() =>
(tracks?.find((t) => t.kind === Track.Kind.Video) ??
null) as LocalVideoTrack | null,
[tracks],
);
const switchCamera = useSwitchCamera(
useObservable(
(inputs) => inputs.pipe(map(([video]) => video)),
[videoTrack],
),
);
// TODO: Unify this component with InCallView, so we can get slick joining
// animations and don't have to feel bad about reusing its CSS
return (
@@ -111,7 +175,11 @@ export const LobbyView: FC<Props> = ({
</Header>
)}
<div className={styles.content}>
<VideoPreview matrixInfo={matrixInfo} muteStates={muteStates}>
<VideoPreview
matrixInfo={matrixInfo}
muteStates={muteStates}
videoTrack={videoTrack}
>
<Button
className={classNames(styles.join, {
[styles.wait]: waitingForInvite,
@@ -140,6 +208,7 @@ export const LobbyView: FC<Props> = ({
onClick={onVideoPress}
disabled={muteStates.video.setEnabled === null}
/>
{switchCamera && <SwitchCameraButton onClick={switchCamera} />}
<SettingsButton onClick={openSettings} />
{!confineToRoom && <EndCallButton onClick={onLeaveClick} />}
</div>

View File

@@ -27,6 +27,7 @@ import { E2eeType } from "../e2ee/e2eeType";
import { useProfile } from "../profile/useProfile";
import { useMuteStates } from "./MuteStates";
import { useOptInAnalytics } from "../settings/settings";
import { Config } from "../config/Config";
export const RoomPage: FC = () => {
const {
@@ -185,6 +186,7 @@ export const RoomPage: FC = () => {
{content}
{/* On Android and iOS, show a prompt to launch the mobile app. */}
{appPrompt &&
Config.get().app_prompt &&
(platform === "android" || platform === "ios") &&
roomId && <AppSelectionModal roomId={roomId} />}
</>

View File

@@ -19,10 +19,13 @@ Please see LICENSE in the repository root for full details.
height: 100%;
object-fit: cover;
background-color: black;
transform: scaleX(-1);
background-color: var(--cpd-color-bg-subtle-primary);
}
video.mirror {
transform: scaleX(-1);
}
.avatarContainer {
position: absolute;
top: 0;

View File

@@ -5,18 +5,14 @@ SPDX-License-Identifier: AGPL-3.0-only
Please see LICENSE in the repository root for full details.
*/
import { useEffect, useMemo, useRef, FC, ReactNode, useCallback } from "react";
import { useEffect, useRef, FC, ReactNode } from "react";
import useMeasure from "react-use-measure";
import { usePreviewTracks } from "@livekit/components-react";
import { LocalVideoTrack, Track } from "livekit-client";
import { facingModeFromLocalTrack, LocalVideoTrack } from "livekit-client";
import classNames from "classnames";
import { logger } from "matrix-js-sdk/src/logger";
import { Avatar } from "../Avatar";
import styles from "./VideoPreview.module.css";
import { useMediaDevices } from "../livekit/MediaDevicesContext";
import { MuteStates } from "./MuteStates";
import { useInitial } from "../useInitial";
import { EncryptionSystem } from "../e2ee/sharedKeyManagement";
export type MatrixInfo = {
@@ -33,65 +29,18 @@ export type MatrixInfo = {
interface Props {
matrixInfo: MatrixInfo;
muteStates: MuteStates;
videoTrack: LocalVideoTrack | null;
children: ReactNode;
}
export const VideoPreview: FC<Props> = ({
matrixInfo,
muteStates,
videoTrack,
children,
}) => {
const [previewRef, previewBounds] = useMeasure();
const devices = useMediaDevices();
// Capture the audio options as they were when we first mounted, because
// we're not doing anything with the audio anyway so we don't need to
// re-open the devices when they change (see below).
const initialAudioOptions = useInitial(
() =>
muteStates.audio.enabled && { deviceId: devices.audioInput.selectedId },
);
const localTrackOptions = useMemo(
() => ({
// The only reason we request audio here is to get the audio permission
// request over with at the same time. But changing the audio settings
// shouldn't cause this hook to recreate the track, which is why we
// reference the initial values here.
// We also pass in a clone because livekit mutates the object passed in,
// which would cause the devices to be re-opened on the next render.
audio: Object.assign({}, initialAudioOptions),
video: muteStates.video.enabled && {
deviceId: devices.videoInput.selectedId,
},
}),
[
initialAudioOptions,
devices.videoInput.selectedId,
muteStates.video.enabled,
],
);
const onError = useCallback(
(error: Error) => {
logger.error("Error while creating preview Tracks:", error);
muteStates.audio.setEnabled?.(false);
muteStates.video.setEnabled?.(false);
},
[muteStates.audio, muteStates.video],
);
const tracks = usePreviewTracks(localTrackOptions, onError);
const videoTrack = useMemo(
() =>
tracks?.find((t) => t.kind === Track.Kind.Video) as
| LocalVideoTrack
| undefined,
[tracks],
);
const videoEl = useRef<HTMLVideoElement | null>(null);
useEffect(() => {
@@ -107,6 +56,12 @@ export const VideoPreview: FC<Props> = ({
return (
<div className={classNames(styles.preview)} ref={previewRef}>
<video
className={
videoTrack &&
facingModeFromLocalTrack(videoTrack).facingMode === "user"
? styles.mirror
: undefined
}
ref={videoEl}
muted
playsInline

View File

@@ -0,0 +1,98 @@
/*
Copyright 2024 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only
Please see LICENSE in the repository root for full details.
*/
import {
fromEvent,
map,
merge,
Observable,
of,
startWith,
switchMap,
} from "rxjs";
import {
facingModeFromLocalTrack,
LocalVideoTrack,
TrackEvent,
} from "livekit-client";
import { useObservable, useObservableEagerState } from "observable-hooks";
import { useEffect } from "react";
import { logger } from "matrix-js-sdk/src/logger";
import { useMediaDevices } from "../livekit/MediaDevicesContext";
import { platform } from "../Platform";
import { useLatest } from "../useLatest";
/**
* Determines whether the user should be shown a button to switch their camera,
* producing a callback if so.
*/
export function useSwitchCamera(
video: Observable<LocalVideoTrack | null>,
): (() => void) | null {
const mediaDevices = useMediaDevices();
// Produce an observable like the input 'video' observable, except make it
// emit whenever the track is muted or the device changes
const videoTrack: Observable<LocalVideoTrack | null> = useObservable(
(inputs) =>
inputs.pipe(
switchMap(([video]) => video),
switchMap((video) => {
if (video === null) return of(null);
return merge(
fromEvent(video, TrackEvent.Restarted).pipe(
startWith(null),
map(() => video),
),
fromEvent(video, TrackEvent.Muted).pipe(map(() => null)),
);
}),
),
[video],
);
const switchCamera: Observable<(() => void) | null> = useObservable(
(inputs) =>
platform === "desktop"
? of(null)
: inputs.pipe(
switchMap(([track]) => track),
map((track) => {
if (track === null) return null;
const facingMode = facingModeFromLocalTrack(track).facingMode;
// If the camera isn't front or back-facing, don't provide a switch
// camera shortcut at all
if (facingMode !== "user" && facingMode !== "environment")
return null;
// Restart the track with a camera facing the opposite direction
return (): void =>
void track
.restartTrack({
facingMode: facingMode === "user" ? "environment" : "user",
})
.catch((e) =>
logger.error("Failed to switch camera", facingMode, e),
);
}),
),
[videoTrack],
);
const setVideoInput = useLatest(mediaDevices.videoInput.select);
useEffect(() => {
// Watch for device changes due to switching the camera and feed them back
// into the MediaDeviceContext
const subscription = videoTrack.subscribe((track) => {
const deviceId = track?.mediaStreamTrack.getSettings().deviceId;
if (deviceId !== undefined) setVideoInput.current(deviceId);
});
return (): void => subscription.unsubscribe();
}, [videoTrack, setVideoInput]);
return useObservableEagerState(switchCamera);
}

View File

@@ -29,6 +29,7 @@ import {
ECAddonConnectionState,
ECConnectionState,
} from "../livekit/useECConnectionState";
import { E2eeType } from "../e2ee/e2eeType";
vi.mock("@livekit/components-core");
@@ -158,7 +159,9 @@ function withCallViewModel(
getMember: (userId) => members.get(userId) ?? null,
}),
mockLivekitRoom({ localParticipant }),
true,
{
kind: E2eeType.PER_PARTICIPANT,
},
connectionState,
);

View File

@@ -13,8 +13,10 @@ import {
import {
Room as LivekitRoom,
LocalParticipant,
LocalVideoTrack,
ParticipantEvent,
RemoteParticipant,
Track,
} from "livekit-client";
import {
Room as MatrixRoom,
@@ -58,6 +60,7 @@ import {
import {
LocalUserMediaViewModel,
MediaViewModel,
observeTrackReference,
RemoteUserMediaViewModel,
ScreenShareViewModel,
UserMediaViewModel,
@@ -67,6 +70,7 @@ import { ObservableScope } from "./ObservableScope";
import { duplicateTiles } from "../settings/settings";
import { isFirefox } from "../Platform";
import { setPipEnabled } from "../controls";
import { EncryptionSystem } from "../e2ee/sharedKeyManagement";
// How long we wait after a focus switch before showing the real participant
// list again
@@ -167,20 +171,20 @@ class UserMedia {
public readonly id: string,
member: RoomMember | undefined,
participant: LocalParticipant | RemoteParticipant,
callEncrypted: boolean,
encryptionSystem: EncryptionSystem,
) {
this.vm = participant.isLocal
? new LocalUserMediaViewModel(
id,
member,
participant as LocalParticipant,
callEncrypted,
encryptionSystem,
)
: new RemoteUserMediaViewModel(
id,
member,
participant as RemoteParticipant,
callEncrypted,
encryptionSystem,
);
this.speaker = this.vm.speaking.pipe(
@@ -223,9 +227,14 @@ class ScreenShare {
id: string,
member: RoomMember | undefined,
participant: LocalParticipant | RemoteParticipant,
callEncrypted: boolean,
encryptionSystem: EncryptionSystem,
) {
this.vm = new ScreenShareViewModel(id, member, participant, callEncrypted);
this.vm = new ScreenShareViewModel(
id,
member,
participant,
encryptionSystem,
);
}
public destroy(): void {
@@ -259,6 +268,17 @@ function findMatrixMember(
// TODO: Move wayyyy more business logic from the call and lobby views into here
export class CallViewModel extends ViewModel {
public readonly localVideo: Observable<LocalVideoTrack | null> =
observeTrackReference(
this.livekitRoom.localParticipant,
Track.Source.Camera,
).pipe(
map((trackRef) => {
const track = trackRef.publication?.track;
return track instanceof LocalVideoTrack ? track : null;
}),
);
private readonly rawRemoteParticipants = connectedParticipantsObserver(
this.livekitRoom,
).pipe(this.scope.state());
@@ -349,7 +369,12 @@ export class CallViewModel extends ViewModel {
yield [
userMediaId,
prevItems.get(userMediaId) ??
new UserMedia(userMediaId, member, p, this.encrypted),
new UserMedia(
userMediaId,
member,
p,
this.encryptionSystem,
),
];
if (p.isScreenShareEnabled) {
@@ -357,7 +382,12 @@ export class CallViewModel extends ViewModel {
yield [
screenShareId,
prevItems.get(screenShareId) ??
new ScreenShare(screenShareId, member, p, this.encrypted),
new ScreenShare(
screenShareId,
member,
p,
this.encryptionSystem,
),
];
}
}
@@ -815,7 +845,7 @@ export class CallViewModel extends ViewModel {
// A call is permanently tied to a single Matrix room and LiveKit room
private readonly matrixRoom: MatrixRoom,
private readonly livekitRoom: LivekitRoom,
private readonly encrypted: boolean,
private readonly encryptionSystem: EncryptionSystem,
private readonly connectionState: Observable<ECConnectionState>,
) {
super();

View File

@@ -42,6 +42,8 @@ import { ViewModel } from "./ViewModel";
import { useReactiveState } from "../useReactiveState";
import { alwaysShowSelf } from "../settings/settings";
import { accumulate } from "../utils/observable";
import { EncryptionSystem } from "../e2ee/sharedKeyManagement";
import { E2eeType } from "../e2ee/e2eeType";
// TODO: Move this naming logic into the view model
export function useDisplayName(vm: MediaViewModel): string {
@@ -65,7 +67,7 @@ export function useDisplayName(vm: MediaViewModel): string {
return displayName;
}
function observeTrackReference(
export function observeTrackReference(
participant: Participant,
source: Track.Source,
): Observable<TrackReferenceOrPlaceholder> {
@@ -105,7 +107,7 @@ abstract class BaseMediaViewModel extends ViewModel {
// member object internal
public readonly member: RoomMember | undefined,
protected readonly participant: LocalParticipant | RemoteParticipant,
callEncrypted: boolean,
encryptionSystem: EncryptionSystem,
audioSource: AudioSource,
videoSource: VideoSource,
) {
@@ -119,7 +121,7 @@ abstract class BaseMediaViewModel extends ViewModel {
this.unencryptedWarning = combineLatest(
[audio, this.video],
(a, v) =>
callEncrypted &&
encryptionSystem.kind !== E2eeType.NONE &&
(a.publication?.isEncrypted === false ||
v.publication?.isEncrypted === false),
).pipe(this.scope.state());
@@ -168,13 +170,13 @@ abstract class BaseUserMediaViewModel extends BaseMediaViewModel {
id: string,
member: RoomMember | undefined,
participant: LocalParticipant | RemoteParticipant,
callEncrypted: boolean,
encryptionSystem: EncryptionSystem,
) {
super(
id,
member,
participant,
callEncrypted,
encryptionSystem,
Track.Source.Microphone,
Track.Source.Camera,
);
@@ -225,9 +227,9 @@ export class LocalUserMediaViewModel extends BaseUserMediaViewModel {
id: string,
member: RoomMember | undefined,
participant: LocalParticipant,
callEncrypted: boolean,
encryptionSystem: EncryptionSystem,
) {
super(id, member, participant, callEncrypted);
super(id, member, participant, encryptionSystem);
}
}
@@ -285,9 +287,9 @@ export class RemoteUserMediaViewModel extends BaseUserMediaViewModel {
id: string,
member: RoomMember | undefined,
participant: RemoteParticipant,
callEncrypted: boolean,
encryptionSystem: EncryptionSystem,
) {
super(id, member, participant, callEncrypted);
super(id, member, participant, encryptionSystem);
// Sync the local volume with LiveKit
this.localVolume
@@ -318,13 +320,13 @@ export class ScreenShareViewModel extends BaseMediaViewModel {
id: string,
member: RoomMember | undefined,
participant: LocalParticipant | RemoteParticipant,
callEncrypted: boolean,
encryptionSystem: EncryptionSystem,
) {
super(
id,
member,
participant,
callEncrypted,
encryptionSystem,
Track.Source.ScreenShareAudio,
Track.Source.ScreenShare,
);

View File

@@ -172,7 +172,7 @@ UserMediaTile.displayName = "UserMediaTile";
interface LocalUserMediaTileProps extends TileProps {
vm: LocalUserMediaViewModel;
onOpenProfile: () => void;
onOpenProfile: (() => void) | null;
}
const LocalUserMediaTile = forwardRef<HTMLDivElement, LocalUserMediaTileProps>(
@@ -203,11 +203,13 @@ const LocalUserMediaTile = forwardRef<HTMLDivElement, LocalUserMediaTileProps>(
/>
}
menuEnd={
<MenuItem
Icon={UserProfileIcon}
label={t("common.profile")}
onSelect={onOpenProfile}
/>
onOpenProfile && (
<MenuItem
Icon={UserProfileIcon}
label={t("common.profile")}
onSelect={onOpenProfile}
/>
)
}
{...props}
/>
@@ -280,7 +282,7 @@ RemoteUserMediaTile.displayName = "RemoteUserMediaTile";
interface GridTileProps {
vm: UserMediaViewModel;
onOpenProfile: () => void;
onOpenProfile: (() => void) | null;
targetWidth: number;
targetHeight: number;
className?: string;

View File

@@ -28,6 +28,7 @@ import { useMatrixRTCSessionMemberships } from "./useMatrixRTCSessionMemberships
import { useClientState } from "./ClientContext";
import {
ECallReactionEventContent,
ElementCallReactionEventType,
GenericReaction,
ReactionOption,
ReactionSet,
@@ -195,7 +196,7 @@ export const ReactionsProvider = ({
return;
}
if (event.getType() === "io.element.call.reaction") {
if (event.getType() === ElementCallReactionEventType) {
// TODO: Validate content.
const content: ECallReactionEventContent = event.getContent();

View File

@@ -20,6 +20,7 @@ import {
LocalUserMediaViewModel,
RemoteUserMediaViewModel,
} from "../state/MediaViewModel";
import { E2eeType } from "../e2ee/e2eeType";
export function withFakeTimers(continuation: () => void): void {
vi.useFakeTimers();
@@ -122,7 +123,9 @@ export async function withLocalMedia(
"local",
mockMember(member),
mockLocalParticipant({}),
true,
{
kind: E2eeType.PER_PARTICIPANT,
},
);
try {
await continuation(vm);
@@ -153,7 +156,9 @@ export async function withRemoteMedia(
"remote",
mockMember(member),
mockRemoteParticipant(participant),
true,
{
kind: E2eeType.PER_PARTICIPANT,
},
);
try {
await continuation(vm);

View File

@@ -5676,9 +5676,9 @@ lines-and-columns@^1.1.6:
integrity sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==
livekit-client@^2.5.7:
version "2.5.10"
resolved "https://registry.yarnpkg.com/livekit-client/-/livekit-client-2.5.10.tgz#ce0b70fe9b199760a159ffb99536bc3b6492ab64"
integrity sha512-H7EeIb19LAH8ejlvhh0JWtWkvXDan6Yf3bpFGlDMb54uPmyRgBY+McfgQsFgJCB9WJL0X+GYUoV1Cmnn8iAoIQ==
version "2.6.0"
resolved "https://registry.yarnpkg.com/livekit-client/-/livekit-client-2.6.0.tgz#7506838fb59937150208b8e593701cde19f3fb75"
integrity sha512-hpxNBtyWIFCefoHjHoSjqPCw3m7AfSJVcVZw6rMsqds4u+dSpWLfYkglWP8JuPGUIssyOsZm/+bV3gBWfuOGGQ==
dependencies:
"@livekit/mutex" "1.0.0"
"@livekit/protocol" "1.24.0"