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

@@ -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);