Merge branch 'livekit' into valere/default_route
This commit is contained in:
@@ -44,7 +44,7 @@ jobs:
|
|||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Connect to Tailscale
|
- name: Connect to Tailscale
|
||||||
uses: tailscale/github-action@53acf823325fe9ca47f4cdaa951f90b4b0de5bb9 # v4
|
uses: tailscale/github-action@306e68a486fd2350f2bfc3b19fcd143891a4a2d8 # v4
|
||||||
if: github.event_name != 'pull_request'
|
if: github.event_name != 'pull_request'
|
||||||
with:
|
with:
|
||||||
oauth-client-id: ${{ secrets.TS_OAUTH_CLIENT_ID }}
|
oauth-client-id: ${{ secrets.TS_OAUTH_CLIENT_ID }}
|
||||||
|
|||||||
2
.github/workflows/zizmor.yml
vendored
2
.github/workflows/zizmor.yml
vendored
@@ -20,4 +20,4 @@ jobs:
|
|||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Run zizmor 🌈
|
- name: Run zizmor 🌈
|
||||||
uses: zizmorcore/zizmor-action@0dce2577a4760a2749d8cfb7a84b7d5585ebcb7d # v0.5.0
|
uses: zizmorcore/zizmor-action@71321a20a9ded102f6e9ce5718a2fcec2c4f70d8 # v0.5.2
|
||||||
|
|||||||
@@ -249,6 +249,8 @@
|
|||||||
"version": "{{productName}} version: {{version}}",
|
"version": "{{productName}} version: {{version}}",
|
||||||
"video_tile": {
|
"video_tile": {
|
||||||
"always_show": "Always show",
|
"always_show": "Always show",
|
||||||
|
"call_ended": "Call ended",
|
||||||
|
"calling": "Calling…",
|
||||||
"camera_starting": "Video loading...",
|
"camera_starting": "Video loading...",
|
||||||
"collapse": "Collapse",
|
"collapse": "Collapse",
|
||||||
"expand": "Expand",
|
"expand": "Expand",
|
||||||
|
|||||||
@@ -140,7 +140,10 @@
|
|||||||
"@livekit/components-core/rxjs": "^7.8.1",
|
"@livekit/components-core/rxjs": "^7.8.1",
|
||||||
"@livekit/track-processors/@mediapipe/tasks-vision": "^0.10.18",
|
"@livekit/track-processors/@mediapipe/tasks-vision": "^0.10.18",
|
||||||
"minimatch": "^10.2.3",
|
"minimatch": "^10.2.3",
|
||||||
"tar": "^7.5.11"
|
"tar": "^7.5.11",
|
||||||
|
"glob": "^10.5.0",
|
||||||
|
"qs": "^6.14.1",
|
||||||
|
"js-yaml": "^4.1.1"
|
||||||
},
|
},
|
||||||
"packageManager": "yarn@4.7.0"
|
"packageManager": "yarn@4.7.0"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,9 +34,12 @@ widgetTest(
|
|||||||
.locator('iframe[title="Element Call"]')
|
.locator('iframe[title="Element Call"]')
|
||||||
.contentFrame();
|
.contentFrame();
|
||||||
|
|
||||||
// We should show a ringing overlay, let's check for that
|
// We should show a ringing tile, let's check for that
|
||||||
await expect(
|
await expect(
|
||||||
brooksFrame.getByText(`Waiting for ${whistler.displayName} to join…`),
|
brooksFrame
|
||||||
|
.getByTestId("videoTile")
|
||||||
|
.filter({ has: brooksFrame.getByText(whistler.displayName) })
|
||||||
|
.filter({ has: brooksFrame.getByText("Calling…") }),
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
|
|
||||||
await expect(whistler.page.getByText("Incoming voice call")).toBeVisible();
|
await expect(whistler.page.getByText("Incoming voice call")).toBeVisible();
|
||||||
@@ -125,9 +128,12 @@ widgetTest(
|
|||||||
.locator('iframe[title="Element Call"]')
|
.locator('iframe[title="Element Call"]')
|
||||||
.contentFrame();
|
.contentFrame();
|
||||||
|
|
||||||
// We should show a ringing overlay, let's check for that
|
// We should show a ringing tile, let's check for that
|
||||||
await expect(
|
await expect(
|
||||||
brooksFrame.getByText(`Waiting for ${whistler.displayName} to join…`),
|
brooksFrame
|
||||||
|
.getByTestId("videoTile")
|
||||||
|
.filter({ has: brooksFrame.getByText(whistler.displayName) })
|
||||||
|
.filter({ has: brooksFrame.getByText("Calling…") }),
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
|
|
||||||
await expect(whistler.page.getByText("Incoming video call")).toBeVisible();
|
await expect(whistler.page.getByText("Incoming video call")).toBeVisible();
|
||||||
@@ -216,9 +222,12 @@ widgetTest(
|
|||||||
.locator('iframe[title="Element Call"]')
|
.locator('iframe[title="Element Call"]')
|
||||||
.contentFrame();
|
.contentFrame();
|
||||||
|
|
||||||
// We should show a ringing overlay, let's check for that
|
// We should show a ringing tile, let's check for that
|
||||||
await expect(
|
await expect(
|
||||||
brooksFrame.getByText(`Waiting for ${whistler.displayName} to join…`),
|
brooksFrame
|
||||||
|
.getByTestId("videoTile")
|
||||||
|
.filter({ has: brooksFrame.getByText(whistler.displayName) })
|
||||||
|
.filter({ has: brooksFrame.getByText("Calling…") }),
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
|
|
||||||
await expect(whistler.page.getByText("Incoming video call")).toBeVisible();
|
await expect(whistler.page.getByText("Incoming video call")).toBeVisible();
|
||||||
|
|||||||
@@ -22,16 +22,16 @@ import {
|
|||||||
import styles from "./Button.module.css";
|
import styles from "./Button.module.css";
|
||||||
|
|
||||||
interface MicButtonProps extends ComponentPropsWithoutRef<"button"> {
|
interface MicButtonProps extends ComponentPropsWithoutRef<"button"> {
|
||||||
muted: boolean;
|
enabled: boolean;
|
||||||
size?: "sm" | "lg";
|
size?: "sm" | "lg";
|
||||||
}
|
}
|
||||||
|
|
||||||
export const MicButton: FC<MicButtonProps> = ({ muted, ...props }) => {
|
export const MicButton: FC<MicButtonProps> = ({ enabled, ...props }) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const Icon = muted ? MicOffSolidIcon : MicOnSolidIcon;
|
const Icon = enabled ? MicOnSolidIcon : MicOffSolidIcon;
|
||||||
const label = muted
|
const label = enabled
|
||||||
? t("unmute_microphone_button_label")
|
? t("mute_microphone_button_label")
|
||||||
: t("mute_microphone_button_label");
|
: t("unmute_microphone_button_label");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tooltip label={label}>
|
<Tooltip label={label}>
|
||||||
@@ -39,7 +39,7 @@ export const MicButton: FC<MicButtonProps> = ({ muted, ...props }) => {
|
|||||||
iconOnly
|
iconOnly
|
||||||
aria-label={label}
|
aria-label={label}
|
||||||
Icon={Icon}
|
Icon={Icon}
|
||||||
kind={muted ? "primary" : "secondary"}
|
kind={enabled ? "primary" : "secondary"}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
@@ -47,16 +47,16 @@ export const MicButton: FC<MicButtonProps> = ({ muted, ...props }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
interface VideoButtonProps extends ComponentPropsWithoutRef<"button"> {
|
interface VideoButtonProps extends ComponentPropsWithoutRef<"button"> {
|
||||||
muted: boolean;
|
enabled: boolean;
|
||||||
size?: "sm" | "lg";
|
size?: "sm" | "lg";
|
||||||
}
|
}
|
||||||
|
|
||||||
export const VideoButton: FC<VideoButtonProps> = ({ muted, ...props }) => {
|
export const VideoButton: FC<VideoButtonProps> = ({ enabled, ...props }) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const Icon = muted ? VideoCallOffSolidIcon : VideoCallSolidIcon;
|
const Icon = enabled ? VideoCallSolidIcon : VideoCallOffSolidIcon;
|
||||||
const label = muted
|
const label = enabled
|
||||||
? t("start_video_button_label")
|
? t("stop_video_button_label")
|
||||||
: t("stop_video_button_label");
|
: t("start_video_button_label");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tooltip label={label}>
|
<Tooltip label={label}>
|
||||||
@@ -64,7 +64,7 @@ export const VideoButton: FC<VideoButtonProps> = ({ muted, ...props }) => {
|
|||||||
iconOnly
|
iconOnly
|
||||||
aria-label={label}
|
aria-label={label}
|
||||||
Icon={Icon}
|
Icon={Icon}
|
||||||
kind={muted ? "primary" : "secondary"}
|
kind={enabled ? "primary" : "secondary"}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|||||||
@@ -51,15 +51,15 @@ export const makeOneOnOneLayout: CallLayout<OneOnOneLayoutModel> = ({
|
|||||||
return (
|
return (
|
||||||
<div ref={ref} className={styles.layer}>
|
<div ref={ref} className={styles.layer}>
|
||||||
<Slot
|
<Slot
|
||||||
id={model.remote.id}
|
id={model.spotlight.id}
|
||||||
model={model.remote}
|
model={model.spotlight}
|
||||||
className={styles.container}
|
className={styles.container}
|
||||||
style={{ width: tileWidth, height: tileHeight }}
|
style={{ width: tileWidth, height: tileHeight }}
|
||||||
>
|
>
|
||||||
<Slot
|
<Slot
|
||||||
className={classNames(styles.slot, styles.local)}
|
className={classNames(styles.slot, styles.local)}
|
||||||
id={model.local.id}
|
id={model.pip.id}
|
||||||
model={model.local}
|
model={model.pip}
|
||||||
onDrag={onDragLocalTile}
|
onDrag={onDragLocalTile}
|
||||||
data-block-alignment={pipAlignmentValue.block}
|
data-block-alignment={pipAlignmentValue.block}
|
||||||
data-inline-alignment={pipAlignmentValue.inline}
|
data-inline-alignment={pipAlignmentValue.inline}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
|||||||
Please see LICENSE in the repository root for full details.
|
Please see LICENSE in the repository root for full details.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { IconButton, Text, Tooltip } from "@vector-im/compound-web";
|
import { IconButton, Tooltip } from "@vector-im/compound-web";
|
||||||
import { type MatrixClient, type Room as MatrixRoom } from "matrix-js-sdk";
|
import { type MatrixClient, type Room as MatrixRoom } from "matrix-js-sdk";
|
||||||
import {
|
import {
|
||||||
type FC,
|
type FC,
|
||||||
@@ -98,8 +98,6 @@ import { useAppBarHidden, useAppBarSecondaryButton } from "../AppBar.tsx";
|
|||||||
import { useBehavior } from "../useBehavior.ts";
|
import { useBehavior } from "../useBehavior.ts";
|
||||||
import { Toast } from "../Toast.tsx";
|
import { Toast } from "../Toast.tsx";
|
||||||
import overlayStyles from "../Overlay.module.css";
|
import overlayStyles from "../Overlay.module.css";
|
||||||
import { Avatar, Size as AvatarSize } from "../Avatar";
|
|
||||||
import waitingStyles from "./WaitingForJoin.module.css";
|
|
||||||
import { prefetchSounds } from "../soundUtils";
|
import { prefetchSounds } from "../soundUtils";
|
||||||
import { useAudioContext } from "../useAudioContext";
|
import { useAudioContext } from "../useAudioContext";
|
||||||
import ringtoneMp3 from "../sound/ringtone.mp3?url";
|
import ringtoneMp3 from "../sound/ringtone.mp3?url";
|
||||||
@@ -107,6 +105,7 @@ import ringtoneOgg from "../sound/ringtone.ogg?url";
|
|||||||
import { useTrackProcessorObservable$ } from "../livekit/TrackProcessorContext.tsx";
|
import { useTrackProcessorObservable$ } from "../livekit/TrackProcessorContext.tsx";
|
||||||
import { type Layout } from "../state/layout-types.ts";
|
import { type Layout } from "../state/layout-types.ts";
|
||||||
import { ObservableScope } from "../state/ObservableScope.ts";
|
import { ObservableScope } from "../state/ObservableScope.ts";
|
||||||
|
import { useLatest } from "../useLatest.ts";
|
||||||
|
|
||||||
const logger = rootLogger.getChild("[InCallView]");
|
const logger = rootLogger.getChild("[InCallView]");
|
||||||
|
|
||||||
@@ -224,8 +223,6 @@ export const InCallView: FC<InCallViewProps> = ({
|
|||||||
const { showControls } = useUrlParams();
|
const { showControls } = useUrlParams();
|
||||||
|
|
||||||
const muteAllAudio = useBehavior(muteAllAudio$);
|
const muteAllAudio = useBehavior(muteAllAudio$);
|
||||||
// Call pickup state and display names are needed for waiting overlay/sounds
|
|
||||||
const callPickupState = useBehavior(vm.callPickupState$);
|
|
||||||
|
|
||||||
// Preload a waiting and decline sounds
|
// Preload a waiting and decline sounds
|
||||||
const pickupPhaseSoundCache = useInitial(async () => {
|
const pickupPhaseSoundCache = useInitial(async () => {
|
||||||
@@ -239,6 +236,7 @@ export const InCallView: FC<InCallViewProps> = ({
|
|||||||
latencyHint: "interactive",
|
latencyHint: "interactive",
|
||||||
muted: muteAllAudio,
|
muted: muteAllAudio,
|
||||||
});
|
});
|
||||||
|
const latestPickupPhaseAudio = useLatest(pickupPhaseAudio);
|
||||||
|
|
||||||
const audioEnabled = useBehavior(muteStates.audio.enabled$);
|
const audioEnabled = useBehavior(muteStates.audio.enabled$);
|
||||||
const videoEnabled = useBehavior(muteStates.video.enabled$);
|
const videoEnabled = useBehavior(muteStates.video.enabled$);
|
||||||
@@ -257,6 +255,7 @@ export const InCallView: FC<InCallViewProps> = ({
|
|||||||
() => void toggleRaisedHand(),
|
() => void toggleRaisedHand(),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const ringing = useBehavior(vm.ringing$);
|
||||||
const audioParticipants = useBehavior(vm.livekitRoomItems$);
|
const audioParticipants = useBehavior(vm.livekitRoomItems$);
|
||||||
const participantCount = useBehavior(vm.participantCount$);
|
const participantCount = useBehavior(vm.participantCount$);
|
||||||
const reconnecting = useBehavior(vm.reconnecting$);
|
const reconnecting = useBehavior(vm.reconnecting$);
|
||||||
@@ -271,7 +270,6 @@ export const InCallView: FC<InCallViewProps> = ({
|
|||||||
const audioOutputSwitcher = useBehavior(vm.audioOutputSwitcher$);
|
const audioOutputSwitcher = useBehavior(vm.audioOutputSwitcher$);
|
||||||
const sharingScreen = useBehavior(vm.sharingScreen$);
|
const sharingScreen = useBehavior(vm.sharingScreen$);
|
||||||
|
|
||||||
const ringOverlay = useBehavior(vm.ringOverlay$);
|
|
||||||
const fatalCallError = useBehavior(vm.fatalError$);
|
const fatalCallError = useBehavior(vm.fatalError$);
|
||||||
// Stop the rendering and throw for the error boundary
|
// Stop the rendering and throw for the error boundary
|
||||||
if (fatalCallError) {
|
if (fatalCallError) {
|
||||||
@@ -279,58 +277,21 @@ export const InCallView: FC<InCallViewProps> = ({
|
|||||||
throw fatalCallError;
|
throw fatalCallError;
|
||||||
}
|
}
|
||||||
|
|
||||||
// We need to set the proper timings on the animation based upon the sound length.
|
// While ringing, loop the ringtone
|
||||||
const ringDuration = pickupPhaseAudio?.soundDuration["waiting"] ?? 1;
|
|
||||||
useEffect((): (() => void) => {
|
|
||||||
// The CSS animation includes the delay, so we must double the length of the sound.
|
|
||||||
window.document.body.style.setProperty(
|
|
||||||
"--call-ring-duration-s",
|
|
||||||
`${ringDuration * 2}s`,
|
|
||||||
);
|
|
||||||
window.document.body.style.setProperty(
|
|
||||||
"--call-ring-delay-s",
|
|
||||||
`${ringDuration}s`,
|
|
||||||
);
|
|
||||||
// Remove properties when we unload.
|
|
||||||
return () => {
|
|
||||||
window.document.body.style.removeProperty("--call-ring-duration-s");
|
|
||||||
window.document.body.style.removeProperty("--call-ring-delay-s");
|
|
||||||
};
|
|
||||||
}, [pickupPhaseAudio?.soundDuration, ringDuration]);
|
|
||||||
|
|
||||||
// When waiting for pickup, loop a waiting sound
|
|
||||||
useEffect((): void | (() => void) => {
|
useEffect((): void | (() => void) => {
|
||||||
if (callPickupState !== "ringing" || !pickupPhaseAudio) return;
|
const audio = latestPickupPhaseAudio.current;
|
||||||
const endSound = pickupPhaseAudio.playSoundLooping("waiting", ringDuration);
|
if (ringing && audio) {
|
||||||
return () => {
|
const endSound = audio.playSoundLooping(
|
||||||
void endSound().catch((e) => {
|
"waiting",
|
||||||
logger.error("Failed to stop ringing sound", e);
|
audio.soundDuration["waiting"] ?? 1,
|
||||||
});
|
);
|
||||||
};
|
return () => {
|
||||||
}, [callPickupState, pickupPhaseAudio, ringDuration]);
|
void endSound().catch((e) => {
|
||||||
|
logger.error("Failed to stop ringing sound", e);
|
||||||
// Waiting UI overlay
|
});
|
||||||
const waitingOverlay: JSX.Element | null = useMemo(() => {
|
};
|
||||||
return ringOverlay ? (
|
}
|
||||||
<div className={classNames(overlayStyles.bg, waitingStyles.overlay)}>
|
}, [ringing, latestPickupPhaseAudio]);
|
||||||
<div
|
|
||||||
className={classNames(overlayStyles.content, waitingStyles.content)}
|
|
||||||
>
|
|
||||||
<div className={waitingStyles.pulse}>
|
|
||||||
<Avatar
|
|
||||||
id={ringOverlay.idForAvatar}
|
|
||||||
name={ringOverlay.name}
|
|
||||||
src={ringOverlay.avatarMxc}
|
|
||||||
size={AvatarSize.XL}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<Text size="md" className={waitingStyles.text}>
|
|
||||||
{ringOverlay.text}
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : null;
|
|
||||||
}, [ringOverlay]);
|
|
||||||
|
|
||||||
const onViewClick = useCallback(
|
const onViewClick = useCallback(
|
||||||
(e: ReactMouseEvent) => {
|
(e: ReactMouseEvent) => {
|
||||||
@@ -645,7 +606,7 @@ export const InCallView: FC<InCallViewProps> = ({
|
|||||||
<MicButton
|
<MicButton
|
||||||
size={buttonSize}
|
size={buttonSize}
|
||||||
key="audio"
|
key="audio"
|
||||||
muted={!audioEnabled}
|
enabled={audioEnabled}
|
||||||
onClick={toggleAudio ?? undefined}
|
onClick={toggleAudio ?? undefined}
|
||||||
disabled={toggleAudio === null}
|
disabled={toggleAudio === null}
|
||||||
data-testid="incall_mute"
|
data-testid="incall_mute"
|
||||||
@@ -653,7 +614,7 @@ export const InCallView: FC<InCallViewProps> = ({
|
|||||||
<VideoButton
|
<VideoButton
|
||||||
size={buttonSize}
|
size={buttonSize}
|
||||||
key="video"
|
key="video"
|
||||||
muted={!videoEnabled}
|
enabled={videoEnabled}
|
||||||
onClick={toggleVideo ?? undefined}
|
onClick={toggleVideo ?? undefined}
|
||||||
disabled={toggleVideo === null}
|
disabled={toggleVideo === null}
|
||||||
data-testid="incall_videomute"
|
data-testid="incall_videomute"
|
||||||
@@ -764,7 +725,6 @@ export const InCallView: FC<InCallViewProps> = ({
|
|||||||
{reconnectingToast}
|
{reconnectingToast}
|
||||||
{earpieceOverlay}
|
{earpieceOverlay}
|
||||||
<ReactionsOverlay vm={vm} />
|
<ReactionsOverlay vm={vm} />
|
||||||
{waitingOverlay}
|
|
||||||
{footer}
|
{footer}
|
||||||
{layout.type !== "pip" && (
|
{layout.type !== "pip" && (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -230,12 +230,12 @@ export const LobbyView: FC<Props> = ({
|
|||||||
{recentsButtonInFooter && recentsButton}
|
{recentsButtonInFooter && recentsButton}
|
||||||
<div className={inCallStyles.buttons}>
|
<div className={inCallStyles.buttons}>
|
||||||
<MicButton
|
<MicButton
|
||||||
muted={!audioEnabled}
|
enabled={audioEnabled}
|
||||||
onClick={toggleAudio ?? undefined}
|
onClick={toggleAudio ?? undefined}
|
||||||
disabled={toggleAudio === null}
|
disabled={toggleAudio === null}
|
||||||
/>
|
/>
|
||||||
<VideoButton
|
<VideoButton
|
||||||
muted={!videoEnabled}
|
enabled={videoEnabled}
|
||||||
onClick={toggleVideo ?? undefined}
|
onClick={toggleVideo ?? undefined}
|
||||||
disabled={toggleVideo === null}
|
disabled={toggleVideo === null}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,61 +0,0 @@
|
|||||||
.overlay {
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.content {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
gap: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pulse {
|
|
||||||
position: relative;
|
|
||||||
height: 90px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pulse::before {
|
|
||||||
content: "";
|
|
||||||
position: absolute;
|
|
||||||
inset: -12px;
|
|
||||||
border-radius: 9999px;
|
|
||||||
border: 12px solid rgba(255, 255, 255, 0.6);
|
|
||||||
animation: pulse var(--call-ring-duration-s) ease-out infinite;
|
|
||||||
animation-delay: 1s;
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.text {
|
|
||||||
color: var(--cpd-color-text-on-solid-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes pulse {
|
|
||||||
0% {
|
|
||||||
transform: scale(0.95);
|
|
||||||
opacity: 0.7;
|
|
||||||
transform: scale(0);
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
35% {
|
|
||||||
transform: scale(1.15);
|
|
||||||
opacity: 0.15;
|
|
||||||
}
|
|
||||||
50% {
|
|
||||||
transform: scale(1.2);
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
50.01% {
|
|
||||||
transform: scale(0);
|
|
||||||
}
|
|
||||||
85% {
|
|
||||||
transform: scale(0);
|
|
||||||
}
|
|
||||||
100% {
|
|
||||||
transform: scale(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -289,7 +289,7 @@ exports[`InCallView > rendering > renders 1`] = `
|
|||||||
aria-label="Unmute microphone"
|
aria-label="Unmute microphone"
|
||||||
aria-labelledby="_r_8_"
|
aria-labelledby="_r_8_"
|
||||||
class="_button_13vu4_8 _has-icon_13vu4_60 _icon-only_13vu4_53"
|
class="_button_13vu4_8 _has-icon_13vu4_60 _icon-only_13vu4_53"
|
||||||
data-kind="primary"
|
data-kind="secondary"
|
||||||
data-size="lg"
|
data-size="lg"
|
||||||
data-testid="incall_mute"
|
data-testid="incall_mute"
|
||||||
role="button"
|
role="button"
|
||||||
@@ -313,7 +313,7 @@ exports[`InCallView > rendering > renders 1`] = `
|
|||||||
aria-label="Start video"
|
aria-label="Start video"
|
||||||
aria-labelledby="_r_d_"
|
aria-labelledby="_r_d_"
|
||||||
class="_button_13vu4_8 _has-icon_13vu4_60 _icon-only_13vu4_53"
|
class="_button_13vu4_8 _has-icon_13vu4_60 _icon-only_13vu4_53"
|
||||||
data-kind="primary"
|
data-kind="secondary"
|
||||||
data-size="lg"
|
data-size="lg"
|
||||||
data-testid="incall_videomute"
|
data-testid="incall_videomute"
|
||||||
role="button"
|
role="button"
|
||||||
|
|||||||
@@ -89,7 +89,6 @@ export interface Props {
|
|||||||
* `callPickupState$` The current call pickup state of the call.
|
* `callPickupState$` The current call pickup state of the call.
|
||||||
* - "unknown": The client has not yet sent the notification event. We don't know if it will because it first needs to send its own membership.
|
* - "unknown": The client has not yet sent the notification event. We don't know if it will because it first needs to send its own membership.
|
||||||
* Then we can conclude if we were the first one to join or not.
|
* Then we can conclude if we were the first one to join or not.
|
||||||
* This may also be set if we are disconnected.
|
|
||||||
* - "ringing": The call is ringing on other devices in this room (This client should give audiovisual feedback that this is happening).
|
* - "ringing": The call is ringing on other devices in this room (This client should give audiovisual feedback that this is happening).
|
||||||
* - "timeout": No-one picked up in the defined time this call should be ringing on others devices.
|
* - "timeout": No-one picked up in the defined time this call should be ringing on others devices.
|
||||||
* The call failed. If desired this can be used as a trigger to exit the call.
|
* The call failed. If desired this can be used as a trigger to exit the call.
|
||||||
@@ -131,15 +130,9 @@ export function createCallNotificationLifecycle$({
|
|||||||
) as Behavior<Epoch<boolean>>;
|
) as Behavior<Epoch<boolean>>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whenever the RTC session tells us that it intends to ring the remote
|
* The state of the current ringing attempt, if the RTC session is indeed
|
||||||
* participant's devices, this emits an Observable tracking the current state of
|
* ringing the remote participant's devices. Otherwise `null`.
|
||||||
* that ringing process.
|
|
||||||
*/
|
*/
|
||||||
// This is a behavior since we need to store the latest state for when we subscribe to this after `didSendCallNotification$`
|
|
||||||
// has already emitted but we still need the latest observable with a timeout timer that only gets created on after receiving `notificationEvent`.
|
|
||||||
// A behavior will emit the latest observable with the running timer to new subscribers.
|
|
||||||
// see also: callPickupState$ and in particular the line: `return this.ring$.pipe(mergeAll());` here we otherwise might get an EMPTY observable if
|
|
||||||
// `ring$` would not be a behavior.
|
|
||||||
const remoteRingState$: Behavior<"ringing" | "timeout" | "decline" | null> =
|
const remoteRingState$: Behavior<"ringing" | "timeout" | "decline" | null> =
|
||||||
scope.behavior(
|
scope.behavior(
|
||||||
sentCallNotification$.pipe(
|
sentCallNotification$.pipe(
|
||||||
|
|||||||
@@ -46,9 +46,11 @@ import {
|
|||||||
} from "../../utils/test.ts";
|
} from "../../utils/test.ts";
|
||||||
import { E2eeType } from "../../e2ee/e2eeType.ts";
|
import { E2eeType } from "../../e2ee/e2eeType.ts";
|
||||||
import {
|
import {
|
||||||
|
alice,
|
||||||
aliceId,
|
aliceId,
|
||||||
aliceParticipant,
|
aliceParticipant,
|
||||||
aliceRtcMember,
|
aliceRtcMember,
|
||||||
|
aliceUserId,
|
||||||
bobId,
|
bobId,
|
||||||
bobRtcMember,
|
bobRtcMember,
|
||||||
local,
|
local,
|
||||||
@@ -140,8 +142,8 @@ export interface SpotlightExpandedLayoutSummary {
|
|||||||
|
|
||||||
export interface OneOnOneLayoutSummary {
|
export interface OneOnOneLayoutSummary {
|
||||||
type: "one-on-one";
|
type: "one-on-one";
|
||||||
local: string;
|
spotlight: string;
|
||||||
remote: string;
|
pip: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PipLayoutSummary {
|
export interface PipLayoutSummary {
|
||||||
@@ -194,11 +196,11 @@ function summarizeLayout$(l$: Observable<Layout>): Observable<LayoutSummary> {
|
|||||||
);
|
);
|
||||||
case "one-on-one":
|
case "one-on-one":
|
||||||
return combineLatest(
|
return combineLatest(
|
||||||
[l.local.media$, l.remote.media$],
|
[l.spotlight.media$, l.pip.media$],
|
||||||
(local, remote) => ({
|
(spotlight, pip) => ({
|
||||||
type: l.type,
|
type: l.type,
|
||||||
local: local.id,
|
spotlight: spotlight.id,
|
||||||
remote: remote.id,
|
pip: pip.id,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
case "pip":
|
case "pip":
|
||||||
@@ -537,8 +539,8 @@ describe.each([
|
|||||||
b: {
|
b: {
|
||||||
// In a larger window, expect the normal one-on-one layout
|
// In a larger window, expect the normal one-on-one layout
|
||||||
type: "one-on-one",
|
type: "one-on-one",
|
||||||
local: `${localId}:0`,
|
pip: `${localId}:0`,
|
||||||
remote: `${aliceId}:0`,
|
spotlight: `${aliceId}:0`,
|
||||||
},
|
},
|
||||||
c: {
|
c: {
|
||||||
// In a PiP-sized window, we of course expect a PiP layout
|
// In a PiP-sized window, we of course expect a PiP layout
|
||||||
@@ -840,8 +842,8 @@ describe.each([
|
|||||||
},
|
},
|
||||||
b: {
|
b: {
|
||||||
type: "one-on-one",
|
type: "one-on-one",
|
||||||
local: `${localId}:0`,
|
pip: `${localId}:0`,
|
||||||
remote: `${aliceId}:0`,
|
spotlight: `${aliceId}:0`,
|
||||||
},
|
},
|
||||||
c: {
|
c: {
|
||||||
type: "grid",
|
type: "grid",
|
||||||
@@ -883,8 +885,8 @@ describe.each([
|
|||||||
},
|
},
|
||||||
b: {
|
b: {
|
||||||
type: "one-on-one",
|
type: "one-on-one",
|
||||||
local: `${localId}:0`,
|
pip: `${localId}:0`,
|
||||||
remote: `${aliceId}:0`,
|
spotlight: `${aliceId}:0`,
|
||||||
},
|
},
|
||||||
c: {
|
c: {
|
||||||
type: "grid",
|
type: "grid",
|
||||||
@@ -893,8 +895,8 @@ describe.each([
|
|||||||
},
|
},
|
||||||
d: {
|
d: {
|
||||||
type: "one-on-one",
|
type: "one-on-one",
|
||||||
local: `${localId}:0`,
|
pip: `${localId}:0`,
|
||||||
remote: `${daveId}:0`,
|
spotlight: `${daveId}:0`,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -1087,83 +1089,81 @@ describe.each([
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("waitForCallPickup$", () => {
|
test("recipient has placeholder tile while ringing or timed out", () => {
|
||||||
it.skip("regression test: does stop ringing in case livekitConnectionState$ emits after didSendCallNotification$ has already emitted", () => {
|
withTestScheduler(({ schedule, expectObservable }) => {
|
||||||
withTestScheduler(({ schedule, expectObservable, behavior }) => {
|
withCallViewModel(
|
||||||
withCallViewModel(
|
{
|
||||||
{
|
roomMembers: [alice, local], // Simulate a DM
|
||||||
livekitConnectionState$: behavior("d 9ms c", {
|
},
|
||||||
d: ConnectionState.Disconnected,
|
(vm, rtcSession) => {
|
||||||
c: ConnectionState.Connected,
|
// Fire a ringing notification
|
||||||
}),
|
schedule("n", {
|
||||||
},
|
n: () => {
|
||||||
(vm, rtcSession) => {
|
rtcSession.emit(
|
||||||
// Fire a call notification IMMEDIATELY (its important for this test, that this happens before the livekitConnectionState$ emits)
|
MatrixRTCSessionEvent.DidSendCallNotification,
|
||||||
schedule("n", {
|
mockRingEvent("$notif1", 30),
|
||||||
n: () => {
|
);
|
||||||
rtcSession.emit(
|
},
|
||||||
MatrixRTCSessionEvent.DidSendCallNotification,
|
});
|
||||||
mockRingEvent("$notif1", 30),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
expectObservable(vm.callPickupState$).toBe("a 9ms b 19ms c", {
|
// Should ring for 30ms and then time out
|
||||||
a: "unknown",
|
expectObservable(vm.ringing$).toBe("(ny) 26ms n", yesNo);
|
||||||
b: "ringing",
|
// Layout should show placeholder media for the participant we're
|
||||||
c: "timeout",
|
// ringing the entire time (even once timed out)
|
||||||
});
|
expectObservable(summarizeLayout$(vm.layout$)).toBe("a", {
|
||||||
},
|
a: {
|
||||||
{
|
type: "one-on-one",
|
||||||
waitForCallPickup: true,
|
spotlight: `${localId}:0`,
|
||||||
encryptionSystem: { kind: E2eeType.PER_PARTICIPANT },
|
pip: `ringing:${aliceUserId}`,
|
||||||
},
|
},
|
||||||
);
|
});
|
||||||
});
|
},
|
||||||
|
{ waitForCallPickup: true },
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it.skip("ringing -> unknown if we get disconnected", () => {
|
test("recipient's placeholder tile is replaced by their real tile once they answer", () => {
|
||||||
withTestScheduler(({ behavior, schedule, expectObservable }) => {
|
withTestScheduler(({ behavior, schedule, expectObservable }) => {
|
||||||
const connectionState$ = new BehaviorSubject(ConnectionState.Connected);
|
withCallViewModel(
|
||||||
// Someone joins at 20ms (both LiveKit participant and MatrixRTC member)
|
{
|
||||||
withCallViewModel(
|
// Alice answers after 20ms
|
||||||
{
|
rtcMembers$: behavior("a 20ms b", {
|
||||||
remoteParticipants$: behavior("a 19ms b", {
|
a: [localRtcMember],
|
||||||
a: [],
|
b: [localRtcMember, aliceRtcMember],
|
||||||
b: [aliceParticipant],
|
}),
|
||||||
}),
|
roomMembers: [alice, local], // Simulate a DM
|
||||||
rtcMembers$: behavior("a 19ms b", {
|
},
|
||||||
a: [localRtcMember],
|
(vm, rtcSession) => {
|
||||||
b: [localRtcMember, aliceRtcMember],
|
// Fire a ringing notification
|
||||||
}),
|
schedule("n", {
|
||||||
livekitConnectionState$: connectionState$,
|
n: () => {
|
||||||
},
|
rtcSession.emit(
|
||||||
(vm, rtcSession) => {
|
MatrixRTCSessionEvent.DidSendCallNotification,
|
||||||
// Notify at 5ms so we enter ringing, then get disconnected 5ms later
|
mockRingEvent("$notif1", 30),
|
||||||
schedule(" 5ms r 5ms d", {
|
);
|
||||||
r: () => {
|
},
|
||||||
rtcSession.emit(
|
});
|
||||||
MatrixRTCSessionEvent.DidSendCallNotification,
|
|
||||||
mockRingEvent("$notif2", 100),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
d: () => {
|
|
||||||
connectionState$.next(ConnectionState.Disconnected);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
expectObservable(vm.callPickupState$).toBe("a 4ms b 5ms c", {
|
// Should ring until Alice joins
|
||||||
a: "unknown",
|
expectObservable(vm.ringing$).toBe("(ny) 17ms n", yesNo);
|
||||||
b: "ringing",
|
// Layout should show placeholder media for the participant we're
|
||||||
c: "unknown",
|
// ringing the entire time
|
||||||
});
|
expectObservable(summarizeLayout$(vm.layout$)).toBe("a 20ms b", {
|
||||||
},
|
a: {
|
||||||
{
|
type: "one-on-one",
|
||||||
waitForCallPickup: true,
|
spotlight: `${localId}:0`,
|
||||||
encryptionSystem: { kind: E2eeType.PER_PARTICIPANT },
|
pip: `ringing:${aliceUserId}`,
|
||||||
},
|
},
|
||||||
);
|
b: {
|
||||||
});
|
type: "one-on-one",
|
||||||
|
spotlight: `${aliceId}:0`,
|
||||||
|
pip: `${localId}:0`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
{ waitForCallPickup: true },
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -128,7 +128,6 @@ import {
|
|||||||
createSentCallNotification$,
|
createSentCallNotification$,
|
||||||
} from "./CallNotificationLifecycle.ts";
|
} from "./CallNotificationLifecycle.ts";
|
||||||
import {
|
import {
|
||||||
createDMMember$,
|
|
||||||
createMatrixMemberMetadata$,
|
createMatrixMemberMetadata$,
|
||||||
createRoomMembers$,
|
createRoomMembers$,
|
||||||
} from "./remoteMembers/MatrixMemberMetadata.ts";
|
} from "./remoteMembers/MatrixMemberMetadata.ts";
|
||||||
@@ -137,12 +136,17 @@ import { type Connection } from "./remoteMembers/Connection.ts";
|
|||||||
import { createLayoutModeSwitch } from "./LayoutSwitch.ts";
|
import { createLayoutModeSwitch } from "./LayoutSwitch.ts";
|
||||||
import {
|
import {
|
||||||
createWrappedUserMedia,
|
createWrappedUserMedia,
|
||||||
type MediaItem,
|
|
||||||
type WrappedUserMediaViewModel,
|
type WrappedUserMediaViewModel,
|
||||||
} from "../media/MediaItem.ts";
|
} from "../media/WrappedUserMediaViewModel.ts";
|
||||||
import { type ScreenShareViewModel } from "../media/ScreenShareViewModel.ts";
|
import { type ScreenShareViewModel } from "../media/ScreenShareViewModel.ts";
|
||||||
import { type UserMediaViewModel } from "../media/UserMediaViewModel.ts";
|
import { type UserMediaViewModel } from "../media/UserMediaViewModel.ts";
|
||||||
import { type MediaViewModel } from "../media/MediaViewModel.ts";
|
import { type MediaViewModel } from "../media/MediaViewModel.ts";
|
||||||
|
import { type LocalUserMediaViewModel } from "../media/LocalUserMediaViewModel.ts";
|
||||||
|
import { type RemoteUserMediaViewModel } from "../media/RemoteUserMediaViewModel.ts";
|
||||||
|
import {
|
||||||
|
createRingingMedia,
|
||||||
|
type RingingMediaViewModel,
|
||||||
|
} from "../media/RingingMediaViewModel.ts";
|
||||||
|
|
||||||
const logger = rootLogger.getChild("[CallViewModel]");
|
const logger = rootLogger.getChild("[CallViewModel]");
|
||||||
//TODO
|
//TODO
|
||||||
@@ -210,11 +214,10 @@ export type LivekitRoomItem = {
|
|||||||
export interface CallViewModel {
|
export interface CallViewModel {
|
||||||
// lifecycle
|
// lifecycle
|
||||||
autoLeave$: Observable<AutoLeaveReason>;
|
autoLeave$: Observable<AutoLeaveReason>;
|
||||||
// TODO if we are in "unknown" state we need a loading rendering (or empty screen)
|
/**
|
||||||
// Otherwise it looks like we already connected and only than the ringing starts which is weird.
|
* Whether we are ringing a call recipient.
|
||||||
callPickupState$: Behavior<
|
*/
|
||||||
"unknown" | "ringing" | "timeout" | "decline" | "success" | null
|
ringing$: Behavior<boolean>;
|
||||||
>;
|
|
||||||
/** Observable that emits when the user should leave the call (hangup pressed, widget action, error).
|
/** Observable that emits when the user should leave the call (hangup pressed, widget action, error).
|
||||||
* THIS DOES NOT LEAVE THE CALL YET. The only way to leave the call (send the hangup event) is
|
* THIS DOES NOT LEAVE THE CALL YET. The only way to leave the call (send the hangup event) is
|
||||||
* - by ending the scope
|
* - by ending the scope
|
||||||
@@ -289,13 +292,6 @@ export interface CallViewModel {
|
|||||||
/** List of reactions. Keys are: membership.membershipId (currently predefined as: `${membershipEvent.userId}:${membershipEvent.deviceId}`)*/
|
/** List of reactions. Keys are: membership.membershipId (currently predefined as: `${membershipEvent.userId}:${membershipEvent.deviceId}`)*/
|
||||||
reactions$: Behavior<Record<string, ReactionOption>>;
|
reactions$: Behavior<Record<string, ReactionOption>>;
|
||||||
|
|
||||||
ringOverlay$: Behavior<null | {
|
|
||||||
name: string;
|
|
||||||
/** roomId or userId for the avatar generation. */
|
|
||||||
idForAvatar: string;
|
|
||||||
text: string;
|
|
||||||
avatarMxc?: string;
|
|
||||||
}>;
|
|
||||||
// sounds and events
|
// sounds and events
|
||||||
joinSoundEffect$: Observable<void>;
|
joinSoundEffect$: Observable<void>;
|
||||||
leaveSoundEffect$: Observable<void>;
|
leaveSoundEffect$: Observable<void>;
|
||||||
@@ -611,40 +607,6 @@ export function createCallViewModel$(
|
|||||||
matrixRoomMembers$,
|
matrixRoomMembers$,
|
||||||
);
|
);
|
||||||
|
|
||||||
const dmMember$ = createDMMember$(scope, matrixRoomMembers$, matrixRoom);
|
|
||||||
const noUserToCallInRoom$ = scope.behavior(
|
|
||||||
matrixRoomMembers$.pipe(
|
|
||||||
map(
|
|
||||||
(roomMembersMap) =>
|
|
||||||
roomMembersMap.size === 1 && roomMembersMap.get(userId) !== undefined,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
const ringOverlay$ = scope.behavior(
|
|
||||||
combineLatest([noUserToCallInRoom$, dmMember$, callPickupState$]).pipe(
|
|
||||||
map(([noUserToCallInRoom, dmMember, callPickupState]) => {
|
|
||||||
// No overlay if not in ringing state
|
|
||||||
if (callPickupState !== "ringing" || noUserToCallInRoom) return null;
|
|
||||||
|
|
||||||
const name = dmMember ? dmMember.rawDisplayName : matrixRoom.name;
|
|
||||||
const id = dmMember ? dmMember.userId : matrixRoom.roomId;
|
|
||||||
const text = dmMember
|
|
||||||
? `Waiting for ${name} to join…`
|
|
||||||
: "Waiting for other participants…";
|
|
||||||
const avatarMxc = dmMember
|
|
||||||
? (dmMember.getMxcAvatarUrl?.() ?? undefined)
|
|
||||||
: (matrixRoom.getMxcAvatarUrl() ?? undefined);
|
|
||||||
return {
|
|
||||||
name: name ?? id,
|
|
||||||
idForAvatar: id,
|
|
||||||
text,
|
|
||||||
avatarMxc,
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
const allConnections$ = scope.behavior(
|
const allConnections$ = scope.behavior(
|
||||||
connectionManager.connectionManagerData$.pipe(map((d) => d.value)),
|
connectionManager.connectionManagerData$.pipe(map((d) => d.value)),
|
||||||
);
|
);
|
||||||
@@ -720,7 +682,7 @@ export function createCallViewModel$(
|
|||||||
matrixLivekitMembers$,
|
matrixLivekitMembers$,
|
||||||
duplicateTiles.value$,
|
duplicateTiles.value$,
|
||||||
]).pipe(
|
]).pipe(
|
||||||
// Generate a collection of MediaItems from the list of expected (whether
|
// Generate a collection of user media from the list of expected (whether
|
||||||
// present or missing) LiveKit participants.
|
// present or missing) LiveKit participants.
|
||||||
generateItems(
|
generateItems(
|
||||||
"CallViewModel userMedia$",
|
"CallViewModel userMedia$",
|
||||||
@@ -793,32 +755,67 @@ export function createCallViewModel$(
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const ringingMedia$ = scope.behavior<RingingMediaViewModel[]>(
|
||||||
|
combineLatest([userMedia$, matrixRoomMembers$, callPickupState$]).pipe(
|
||||||
|
generateItems(
|
||||||
|
"CallViewModel ringingMedia$",
|
||||||
|
function* ([userMedia, roomMembers, callPickupState]) {
|
||||||
|
if (
|
||||||
|
callPickupState === "ringing" ||
|
||||||
|
callPickupState === "timeout" ||
|
||||||
|
callPickupState === "decline"
|
||||||
|
) {
|
||||||
|
for (const member of roomMembers.values()) {
|
||||||
|
if (!userMedia.some((vm) => vm.userId === member.userId))
|
||||||
|
yield {
|
||||||
|
keys: [member.userId],
|
||||||
|
data: callPickupState,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
(scope, pickupState$, userId) =>
|
||||||
|
createRingingMedia({
|
||||||
|
id: `ringing:${userId}`,
|
||||||
|
userId,
|
||||||
|
displayName$: scope.behavior(
|
||||||
|
matrixRoomMembers$.pipe(
|
||||||
|
map((members) => members.get(userId)?.rawDisplayName || userId),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
mxcAvatarUrl$:
|
||||||
|
matrixMemberMetadataStore.createAvatarUrlBehavior$(userId),
|
||||||
|
pickupState$,
|
||||||
|
muteStates,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
distinctUntilChanged(shallowEquals),
|
||||||
|
tap((ringingMedia) => {
|
||||||
|
if (ringingMedia.length > 1)
|
||||||
|
// Warn that UI may do something unexpected in this case
|
||||||
|
logger.warn(
|
||||||
|
`Ringing more than one participant is not supported (ringing ${ringingMedia.map((vm) => vm.userId).join(", ")})`,
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* List of all media items (user media and screen share media) that we want
|
* All screen share media that we want to display.
|
||||||
* tiles for.
|
|
||||||
*/
|
*/
|
||||||
const mediaItems$ = scope.behavior<MediaItem[]>(
|
const screenShares$ = scope.behavior<ScreenShareViewModel[]>(
|
||||||
userMedia$.pipe(
|
userMedia$.pipe(
|
||||||
switchMap((userMedia) =>
|
switchMap((userMedia) =>
|
||||||
userMedia.length === 0
|
userMedia.length === 0
|
||||||
? of([])
|
? of([])
|
||||||
: combineLatest(
|
: combineLatest(
|
||||||
userMedia.map((m) => m.screenShares$),
|
userMedia.map((m) => m.screenShares$),
|
||||||
(...screenShares) => [...userMedia, ...screenShares.flat(1)],
|
(...screenShares) => screenShares.flat(1),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
/**
|
|
||||||
* List of MediaItems that we want to display, that are of type ScreenShare
|
|
||||||
*/
|
|
||||||
const screenShares$ = scope.behavior<ScreenShareViewModel[]>(
|
|
||||||
mediaItems$.pipe(
|
|
||||||
map((mediaItems) => mediaItems.filter((m) => m.type === "screen share")),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
const joinSoundEffect$ = userMedia$.pipe(
|
const joinSoundEffect$ = userMedia$.pipe(
|
||||||
pairwise(),
|
pairwise(),
|
||||||
filter(
|
filter(
|
||||||
@@ -931,40 +928,20 @@ export function createCallViewModel$(
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
const spotlight$ = scope.behavior<MediaViewModel[]>(
|
/**
|
||||||
screenShares$.pipe(
|
* Local user media suitable for displaying in a PiP (undefined if not found
|
||||||
switchMap((screenShares) => {
|
* or if user prefers to not see themselves).
|
||||||
if (screenShares.length > 0) return of(screenShares);
|
*/
|
||||||
|
const localUserMediaForPip$ = scope.behavior<
|
||||||
return spotlightSpeaker$.pipe(
|
LocalUserMediaViewModel | undefined
|
||||||
map((speaker) => (speaker ? [speaker] : [])),
|
>(
|
||||||
|
userMedia$.pipe(
|
||||||
|
switchMap((userMedia) => {
|
||||||
|
const localUserMedia = userMedia.find(
|
||||||
|
(m): m is WrappedUserMediaViewModel & LocalUserMediaViewModel =>
|
||||||
|
m.type === "user" && m.local,
|
||||||
);
|
);
|
||||||
}),
|
if (!localUserMedia) return of(undefined);
|
||||||
distinctUntilChanged<MediaViewModel[]>(shallowEquals),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
const pip$ = scope.behavior<UserMediaViewModel | undefined>(
|
|
||||||
combineLatest([
|
|
||||||
// TODO This also needs epoch logic to dedupe the screenshares and mediaItems emits
|
|
||||||
screenShares$,
|
|
||||||
spotlightSpeaker$,
|
|
||||||
mediaItems$,
|
|
||||||
]).pipe(
|
|
||||||
switchMap(([screenShares, spotlight, mediaItems]) => {
|
|
||||||
if (screenShares.length > 0) {
|
|
||||||
return spotlightSpeaker$;
|
|
||||||
}
|
|
||||||
if (!spotlight || spotlight.local) {
|
|
||||||
return of(undefined);
|
|
||||||
}
|
|
||||||
|
|
||||||
const localUserMedia = mediaItems.find(
|
|
||||||
(m) => m.type === "user" && m.local,
|
|
||||||
);
|
|
||||||
if (!localUserMedia) {
|
|
||||||
return of(undefined);
|
|
||||||
}
|
|
||||||
return localUserMedia.alwaysShow$.pipe(
|
return localUserMedia.alwaysShow$.pipe(
|
||||||
map((alwaysShow) => (alwaysShow ? localUserMedia : undefined)),
|
map((alwaysShow) => (alwaysShow ? localUserMedia : undefined)),
|
||||||
);
|
);
|
||||||
@@ -972,6 +949,39 @@ export function createCallViewModel$(
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const spotlightAndPip$ = scope.behavior<{
|
||||||
|
spotlight: MediaViewModel[];
|
||||||
|
pip$: Behavior<UserMediaViewModel | undefined>;
|
||||||
|
}>(
|
||||||
|
ringingMedia$.pipe(
|
||||||
|
switchMap((ringingMedia) => {
|
||||||
|
if (ringingMedia.length > 0)
|
||||||
|
return of({ spotlight: ringingMedia, pip$: localUserMediaForPip$ });
|
||||||
|
|
||||||
|
return screenShares$.pipe(
|
||||||
|
switchMap((screenShares) => {
|
||||||
|
if (screenShares.length > 0)
|
||||||
|
return of({ spotlight: screenShares, pip$: spotlightSpeaker$ });
|
||||||
|
|
||||||
|
return spotlightSpeaker$.pipe(
|
||||||
|
map((speaker) => ({
|
||||||
|
spotlight: speaker ? [speaker] : [],
|
||||||
|
pip$: localUserMediaForPip$,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const spotlight$ = scope.behavior<MediaViewModel[]>(
|
||||||
|
spotlightAndPip$.pipe(
|
||||||
|
map(({ spotlight }) => spotlight),
|
||||||
|
distinctUntilChanged<MediaViewModel[]>(shallowEquals),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
const hasRemoteScreenShares$ = scope.behavior<boolean>(
|
const hasRemoteScreenShares$ = scope.behavior<boolean>(
|
||||||
spotlight$.pipe(
|
spotlight$.pipe(
|
||||||
map((spotlight) =>
|
map((spotlight) =>
|
||||||
@@ -1054,24 +1064,61 @@ export function createCallViewModel$(
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
const spotlightExpandedLayoutMedia$: Observable<SpotlightExpandedLayoutMedia> =
|
const spotlightExpandedLayoutMedia$: Observable<SpotlightExpandedLayoutMedia> =
|
||||||
combineLatest([spotlight$, pip$], (spotlight, pip) => ({
|
spotlightAndPip$.pipe(
|
||||||
type: "spotlight-expanded",
|
switchMap(({ spotlight, pip$ }) =>
|
||||||
spotlight,
|
pip$.pipe(
|
||||||
pip: pip ?? undefined,
|
map((pip) => ({
|
||||||
}));
|
type: "spotlight-expanded" as const,
|
||||||
|
spotlight,
|
||||||
|
pip: pip ?? undefined,
|
||||||
|
})),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
const oneOnOneLayoutMedia$: Observable<OneOnOneLayoutMedia | null> =
|
const oneOnOneLayoutMedia$: Observable<OneOnOneLayoutMedia | null> =
|
||||||
mediaItems$.pipe(
|
userMedia$.pipe(
|
||||||
map((mediaItems) => {
|
switchMap((userMedia) => {
|
||||||
if (mediaItems.length !== 2) return null;
|
if (userMedia.length <= 2) {
|
||||||
const local = mediaItems.find((vm) => vm.type === "user" && vm.local);
|
const local = userMedia.find(
|
||||||
const remote = mediaItems.find((vm) => vm.type === "user" && !vm.local);
|
(vm): vm is WrappedUserMediaViewModel & LocalUserMediaViewModel =>
|
||||||
// There might not be a remote tile if there are screen shares, or if
|
vm.type === "user" && vm.local,
|
||||||
// only the local user is in the call and they're using the duplicate
|
);
|
||||||
// tiles option
|
|
||||||
if (!remote || !local) return null;
|
|
||||||
|
|
||||||
return { type: "one-on-one", local, remote };
|
if (local !== undefined) {
|
||||||
|
const remote = userMedia.find(
|
||||||
|
(
|
||||||
|
vm,
|
||||||
|
): vm is WrappedUserMediaViewModel & RemoteUserMediaViewModel =>
|
||||||
|
vm.type === "user" && !vm.local,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (remote !== undefined)
|
||||||
|
return of({
|
||||||
|
type: "one-on-one" as const,
|
||||||
|
spotlight: remote,
|
||||||
|
pip: local,
|
||||||
|
});
|
||||||
|
|
||||||
|
// If there's no other user media in the call (could still happen in
|
||||||
|
// this branch due to the duplicate tiles option), we could possibly
|
||||||
|
// show ringing media instead
|
||||||
|
if (userMedia.length === 1)
|
||||||
|
return ringingMedia$.pipe(
|
||||||
|
map((ringingMedia) => {
|
||||||
|
return ringingMedia.length === 1
|
||||||
|
? {
|
||||||
|
type: "one-on-one" as const,
|
||||||
|
spotlight: local,
|
||||||
|
pip: ringingMedia[0],
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return of(null);
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -1482,8 +1529,9 @@ export function createCallViewModel$(
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
autoLeave$: autoLeave$,
|
autoLeave$: autoLeave$,
|
||||||
callPickupState$: callPickupState$,
|
ringing$: scope.behavior(
|
||||||
ringOverlay$: ringOverlay$,
|
callPickupState$.pipe(map((state) => state === "ringing")),
|
||||||
|
),
|
||||||
leave$: leave$,
|
leave$: leave$,
|
||||||
hangup: (): void => userHangup$.next(),
|
hangup: (): void => userHangup$.next(),
|
||||||
join: localMembership.requestJoinAndPublish,
|
join: localMembership.requestJoinAndPublish,
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import {
|
|||||||
import { SyncState } from "matrix-js-sdk/lib/sync";
|
import { SyncState } from "matrix-js-sdk/lib/sync";
|
||||||
import { BehaviorSubject, type Observable, map, of } from "rxjs";
|
import { BehaviorSubject, type Observable, map, of } from "rxjs";
|
||||||
import { onTestFinished, vi } from "vitest";
|
import { onTestFinished, vi } from "vitest";
|
||||||
import { ClientEvent, type MatrixClient } from "matrix-js-sdk";
|
import { ClientEvent, type RoomMember, type MatrixClient } from "matrix-js-sdk";
|
||||||
import EventEmitter from "events";
|
import EventEmitter from "events";
|
||||||
import * as ComponentsCore from "@livekit/components-core";
|
import * as ComponentsCore from "@livekit/components-core";
|
||||||
|
|
||||||
@@ -63,15 +63,10 @@ const carol = local;
|
|||||||
|
|
||||||
const dave = mockMatrixRoomMember(daveRTLRtcMember, { rawDisplayName: "Dave" });
|
const dave = mockMatrixRoomMember(daveRTLRtcMember, { rawDisplayName: "Dave" });
|
||||||
|
|
||||||
const roomMembers = new Map(
|
|
||||||
[alice, aliceDoppelganger, bob, bobZeroWidthSpace, carol, dave, daveRTL].map(
|
|
||||||
(p) => [p.userId, p],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
export interface CallViewModelInputs {
|
export interface CallViewModelInputs {
|
||||||
remoteParticipants$: Behavior<RemoteParticipant[]>;
|
remoteParticipants$: Behavior<RemoteParticipant[]>;
|
||||||
rtcMembers$: Behavior<Partial<CallMembership>[]>;
|
rtcMembers$: Behavior<Partial<CallMembership>[]>;
|
||||||
|
roomMembers: RoomMember[];
|
||||||
livekitConnectionState$: Behavior<ConnectionState>;
|
livekitConnectionState$: Behavior<ConnectionState>;
|
||||||
speaking: Map<Participant, Observable<boolean>>;
|
speaking: Map<Participant, Observable<boolean>>;
|
||||||
mediaDevices: MediaDevices;
|
mediaDevices: MediaDevices;
|
||||||
@@ -86,6 +81,15 @@ export function withCallViewModel(mode: MatrixRTCMode) {
|
|||||||
{
|
{
|
||||||
remoteParticipants$ = constant([]),
|
remoteParticipants$ = constant([]),
|
||||||
rtcMembers$ = constant([localRtcMember]),
|
rtcMembers$ = constant([localRtcMember]),
|
||||||
|
roomMembers = [
|
||||||
|
alice,
|
||||||
|
aliceDoppelganger,
|
||||||
|
bob,
|
||||||
|
bobZeroWidthSpace,
|
||||||
|
carol,
|
||||||
|
dave,
|
||||||
|
daveRTL,
|
||||||
|
],
|
||||||
livekitConnectionState$: connectionState$ = constant(
|
livekitConnectionState$: connectionState$ = constant(
|
||||||
ConnectionState.Connected,
|
ConnectionState.Connected,
|
||||||
),
|
),
|
||||||
@@ -128,8 +132,8 @@ export function withCallViewModel(mode: MatrixRTCMode) {
|
|||||||
return syncState;
|
return syncState;
|
||||||
}
|
}
|
||||||
})() as Partial<MatrixClient> as MatrixClient,
|
})() as Partial<MatrixClient> as MatrixClient,
|
||||||
getMembers: () => Array.from(roomMembers.values()),
|
getMembers: () => roomMembers,
|
||||||
getMembersWithMembership: () => Array.from(roomMembers.values()),
|
getMembersWithMembership: () => roomMembers,
|
||||||
});
|
});
|
||||||
const rtcSession = new MockRTCSession(room, []).withMemberships(
|
const rtcSession = new MockRTCSession(room, []).withMemberships(
|
||||||
rtcMembers$,
|
rtcMembers$,
|
||||||
|
|||||||
@@ -54,31 +54,6 @@ export function createRoomMembers$(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* creates the member that this DM is with in case it is a DM (two members) otherwise null
|
|
||||||
*/
|
|
||||||
export function createDMMember$(
|
|
||||||
scope: ObservableScope,
|
|
||||||
roomMembers$: Behavior<RoomMemberMap>,
|
|
||||||
matrixRoom: MatrixRoom,
|
|
||||||
): Behavior<Pick<
|
|
||||||
RoomMember,
|
|
||||||
"userId" | "getMxcAvatarUrl" | "rawDisplayName"
|
|
||||||
> | null> {
|
|
||||||
// We cannot use the normal direct check from matrix since we do not have access to the account data.
|
|
||||||
// use primitive member count === 2 check instead.
|
|
||||||
return scope.behavior(
|
|
||||||
roomMembers$.pipe(
|
|
||||||
map((membersMap) => {
|
|
||||||
// primitive appraoch do to no access to account data.
|
|
||||||
const isDM = membersMap.size === 2;
|
|
||||||
if (!isDM) return null;
|
|
||||||
return matrixRoom.getMember(matrixRoom.guessDMUserId());
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Displayname for each member of the call. This will disambiguate
|
* Displayname for each member of the call. This will disambiguate
|
||||||
* any displayname that clashes with another member. Only members
|
* any displayname that clashes with another member. Only members
|
||||||
|
|||||||
@@ -16,14 +16,14 @@ export function oneOnOneLayout(
|
|||||||
prevTiles: TileStore,
|
prevTiles: TileStore,
|
||||||
): [OneOnOneLayout, TileStore] {
|
): [OneOnOneLayout, TileStore] {
|
||||||
const update = prevTiles.from(2);
|
const update = prevTiles.from(2);
|
||||||
update.registerGridTile(media.local);
|
update.registerGridTile(media.pip);
|
||||||
update.registerGridTile(media.remote);
|
update.registerGridTile(media.spotlight);
|
||||||
const tiles = update.build();
|
const tiles = update.build();
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
type: media.type,
|
type: media.type,
|
||||||
local: tiles.gridTilesByMedia.get(media.local)!,
|
spotlight: tiles.gridTilesByMedia.get(media.spotlight)!,
|
||||||
remote: tiles.gridTilesByMedia.get(media.remote)!,
|
pip: tiles.gridTilesByMedia.get(media.pip)!,
|
||||||
},
|
},
|
||||||
tiles,
|
tiles,
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { fillGaps } from "../utils/iter";
|
|||||||
import { debugTileLayout } from "../settings/settings";
|
import { debugTileLayout } from "../settings/settings";
|
||||||
import { type MediaViewModel } from "./media/MediaViewModel";
|
import { type MediaViewModel } from "./media/MediaViewModel";
|
||||||
import { type UserMediaViewModel } from "./media/UserMediaViewModel";
|
import { type UserMediaViewModel } from "./media/UserMediaViewModel";
|
||||||
|
import { type RingingMediaViewModel } from "./media/RingingMediaViewModel";
|
||||||
|
|
||||||
function debugEntries(entries: GridTileData[]): string[] {
|
function debugEntries(entries: GridTileData[]): string[] {
|
||||||
return entries.map((e) => e.media.displayName$.value);
|
return entries.map((e) => e.media.displayName$.value);
|
||||||
@@ -48,8 +49,10 @@ class SpotlightTileData {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class GridTileData {
|
class GridTileData {
|
||||||
private readonly media$: BehaviorSubject<UserMediaViewModel>;
|
private readonly media$: BehaviorSubject<
|
||||||
public get media(): UserMediaViewModel {
|
UserMediaViewModel | RingingMediaViewModel
|
||||||
|
>;
|
||||||
|
public get media(): UserMediaViewModel | RingingMediaViewModel {
|
||||||
return this.media$.value;
|
return this.media$.value;
|
||||||
}
|
}
|
||||||
public set media(value: UserMediaViewModel) {
|
public set media(value: UserMediaViewModel) {
|
||||||
@@ -58,7 +61,7 @@ class GridTileData {
|
|||||||
|
|
||||||
public readonly vm: GridTileViewModel;
|
public readonly vm: GridTileViewModel;
|
||||||
|
|
||||||
public constructor(media: UserMediaViewModel) {
|
public constructor(media: UserMediaViewModel | RingingMediaViewModel) {
|
||||||
this.media$ = new BehaviorSubject(media);
|
this.media$ = new BehaviorSubject(media);
|
||||||
this.vm = new GridTileViewModel(this.media$);
|
this.vm = new GridTileViewModel(this.media$);
|
||||||
}
|
}
|
||||||
@@ -178,7 +181,9 @@ export class TileStoreBuilder {
|
|||||||
* Sets up a grid tile for the given media. If this is never called for some
|
* Sets up a grid tile for the given media. If this is never called for some
|
||||||
* media, then that media will have no grid tile.
|
* media, then that media will have no grid tile.
|
||||||
*/
|
*/
|
||||||
public registerGridTile(media: UserMediaViewModel): void {
|
public registerGridTile(
|
||||||
|
media: UserMediaViewModel | RingingMediaViewModel,
|
||||||
|
): void {
|
||||||
if (DEBUG_ENABLED)
|
if (DEBUG_ENABLED)
|
||||||
logger.debug(
|
logger.debug(
|
||||||
`[TileStore, ${this.generation}] register grid tile: ${media.displayName$.value}`,
|
`[TileStore, ${this.generation}] register grid tile: ${media.displayName$.value}`,
|
||||||
@@ -187,7 +192,11 @@ export class TileStoreBuilder {
|
|||||||
if (this.spotlight !== null) {
|
if (this.spotlight !== null) {
|
||||||
// We actually *don't* want spotlight speakers to appear in both the
|
// We actually *don't* want spotlight speakers to appear in both the
|
||||||
// spotlight and the grid, so they're filtered out here
|
// spotlight and the grid, so they're filtered out here
|
||||||
if (!media.local && this.spotlight.media.includes(media)) return;
|
if (
|
||||||
|
!(media.type === "user" && media.local) &&
|
||||||
|
this.spotlight.media.includes(media)
|
||||||
|
)
|
||||||
|
return;
|
||||||
// When the spotlight speaker changes, we would see one grid tile appear
|
// When the spotlight speaker changes, we would see one grid tile appear
|
||||||
// and another grid tile disappear. This would be an undesirable layout
|
// and another grid tile disappear. This would be an undesirable layout
|
||||||
// shift, so instead what we do is take the speaker's grid tile and swap
|
// shift, so instead what we do is take the speaker's grid tile and swap
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ Please see LICENSE in the repository root for full details.
|
|||||||
|
|
||||||
import { type Behavior } from "./Behavior";
|
import { type Behavior } from "./Behavior";
|
||||||
import { type MediaViewModel } from "./media/MediaViewModel";
|
import { type MediaViewModel } from "./media/MediaViewModel";
|
||||||
|
import { type RingingMediaViewModel } from "./media/RingingMediaViewModel";
|
||||||
import { type UserMediaViewModel } from "./media/UserMediaViewModel";
|
import { type UserMediaViewModel } from "./media/UserMediaViewModel";
|
||||||
|
|
||||||
let nextId = 0;
|
let nextId = 0;
|
||||||
@@ -17,7 +18,11 @@ function createId(): string {
|
|||||||
export class GridTileViewModel {
|
export class GridTileViewModel {
|
||||||
public readonly id = createId();
|
public readonly id = createId();
|
||||||
|
|
||||||
public constructor(public readonly media$: Behavior<UserMediaViewModel>) {}
|
public constructor(
|
||||||
|
public readonly media$: Behavior<
|
||||||
|
UserMediaViewModel | RingingMediaViewModel
|
||||||
|
>,
|
||||||
|
) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class SpotlightTileViewModel {
|
export class SpotlightTileViewModel {
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ Please see LICENSE in the repository root for full details.
|
|||||||
|
|
||||||
import { type LocalUserMediaViewModel } from "./media/LocalUserMediaViewModel.ts";
|
import { type LocalUserMediaViewModel } from "./media/LocalUserMediaViewModel.ts";
|
||||||
import { type MediaViewModel } from "./media/MediaViewModel.ts";
|
import { type MediaViewModel } from "./media/MediaViewModel.ts";
|
||||||
import { type RemoteUserMediaViewModel } from "./media/RemoteUserMediaViewModel.ts";
|
import { type RingingMediaViewModel } from "./media/RingingMediaViewModel.ts";
|
||||||
import { type UserMediaViewModel } from "./media/UserMediaViewModel.ts";
|
import { type UserMediaViewModel } from "./media/UserMediaViewModel.ts";
|
||||||
import {
|
import {
|
||||||
type GridTileViewModel,
|
type GridTileViewModel,
|
||||||
@@ -40,8 +40,8 @@ export interface SpotlightExpandedLayoutMedia {
|
|||||||
|
|
||||||
export interface OneOnOneLayoutMedia {
|
export interface OneOnOneLayoutMedia {
|
||||||
type: "one-on-one";
|
type: "one-on-one";
|
||||||
local: LocalUserMediaViewModel;
|
spotlight: UserMediaViewModel;
|
||||||
remote: RemoteUserMediaViewModel;
|
pip: LocalUserMediaViewModel | RingingMediaViewModel;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PipLayoutMedia {
|
export interface PipLayoutMedia {
|
||||||
@@ -86,8 +86,8 @@ export interface SpotlightExpandedLayout {
|
|||||||
|
|
||||||
export interface OneOnOneLayout {
|
export interface OneOnOneLayout {
|
||||||
type: "one-on-one";
|
type: "one-on-one";
|
||||||
local: GridTileViewModel;
|
spotlight: GridTileViewModel;
|
||||||
remote: GridTileViewModel;
|
pip: GridTileViewModel;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PipLayout {
|
export interface PipLayout {
|
||||||
|
|||||||
@@ -7,13 +7,17 @@ Please see LICENSE in the repository root for full details.
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { type Behavior } from "../Behavior";
|
import { type Behavior } from "../Behavior";
|
||||||
|
import { type RingingMediaViewModel } from "./RingingMediaViewModel";
|
||||||
import { type ScreenShareViewModel } from "./ScreenShareViewModel";
|
import { type ScreenShareViewModel } from "./ScreenShareViewModel";
|
||||||
import { type UserMediaViewModel } from "./UserMediaViewModel";
|
import { type UserMediaViewModel } from "./UserMediaViewModel";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A participant's media.
|
* A participant's media.
|
||||||
*/
|
*/
|
||||||
export type MediaViewModel = UserMediaViewModel | ScreenShareViewModel;
|
export type MediaViewModel =
|
||||||
|
| UserMediaViewModel
|
||||||
|
| ScreenShareViewModel
|
||||||
|
| RingingMediaViewModel;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Properties which are common to all MediaViewModels.
|
* Properties which are common to all MediaViewModels.
|
||||||
|
|||||||
@@ -38,6 +38,8 @@ import { type ObservableScope } from "../ObservableScope";
|
|||||||
import { observeTrackReference$ } from "../observeTrackReference";
|
import { observeTrackReference$ } from "../observeTrackReference";
|
||||||
import { E2eeType } from "../../e2ee/e2eeType";
|
import { E2eeType } from "../../e2ee/e2eeType";
|
||||||
import { observeInboundRtpStreamStats$ } from "./observeRtpStreamStats";
|
import { observeInboundRtpStreamStats$ } from "./observeRtpStreamStats";
|
||||||
|
import { type UserMediaViewModel } from "./UserMediaViewModel";
|
||||||
|
import { type ScreenShareViewModel } from "./ScreenShareViewModel";
|
||||||
|
|
||||||
// TODO: Encryption status is kinda broken and thus unused right now. Remove?
|
// TODO: Encryption status is kinda broken and thus unused right now. Remove?
|
||||||
export enum EncryptionStatus {
|
export enum EncryptionStatus {
|
||||||
@@ -49,9 +51,9 @@ export enum EncryptionStatus {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Media belonging to an active member of the RTC session.
|
* Properties common to all MemberMediaViewModels.
|
||||||
*/
|
*/
|
||||||
export interface MemberMediaViewModel extends BaseMediaViewModel {
|
export interface BaseMemberMediaViewModel extends BaseMediaViewModel {
|
||||||
/**
|
/**
|
||||||
* The LiveKit video track for this media.
|
* The LiveKit video track for this media.
|
||||||
*/
|
*/
|
||||||
@@ -88,7 +90,7 @@ export function createMemberMedia(
|
|||||||
encryptionSystem,
|
encryptionSystem,
|
||||||
...inputs
|
...inputs
|
||||||
}: MemberMediaInputs,
|
}: MemberMediaInputs,
|
||||||
): MemberMediaViewModel {
|
): BaseMemberMediaViewModel {
|
||||||
const trackBehavior$ = (
|
const trackBehavior$ = (
|
||||||
source: Track.Source,
|
source: Track.Source,
|
||||||
): Behavior<TrackReference | undefined> =>
|
): Behavior<TrackReference | undefined> =>
|
||||||
@@ -270,3 +272,8 @@ function observeRemoteTrackReceivingOkay$(
|
|||||||
startWith(undefined),
|
startWith(undefined),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Media belonging to an active member of the call.
|
||||||
|
*/
|
||||||
|
export type MemberMediaViewModel = UserMediaViewModel | ScreenShareViewModel;
|
||||||
|
|||||||
51
src/state/media/RingingMediaViewModel.ts
Normal file
51
src/state/media/RingingMediaViewModel.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2026 Element Creations Ltd.
|
||||||
|
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
Please see LICENSE in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { type Behavior } from "../Behavior";
|
||||||
|
import { type MuteStates } from "../MuteStates";
|
||||||
|
import {
|
||||||
|
type BaseMediaInputs,
|
||||||
|
type BaseMediaViewModel,
|
||||||
|
createBaseMedia,
|
||||||
|
} from "./MediaViewModel";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Media representing a user who is not yet part of the call — one that we are
|
||||||
|
* *ringing*.
|
||||||
|
*/
|
||||||
|
export interface RingingMediaViewModel extends BaseMediaViewModel {
|
||||||
|
type: "ringing";
|
||||||
|
pickupState$: Behavior<"ringing" | "timeout" | "decline">;
|
||||||
|
/**
|
||||||
|
* Whether this media would be expected to have video, were it not simply a
|
||||||
|
* placeholder.
|
||||||
|
*/
|
||||||
|
videoEnabled$: Behavior<boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RingingMediaInputs extends BaseMediaInputs {
|
||||||
|
pickupState$: Behavior<"ringing" | "timeout" | "decline">;
|
||||||
|
/**
|
||||||
|
* The local user's own mute states.
|
||||||
|
*/
|
||||||
|
muteStates: MuteStates;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createRingingMedia({
|
||||||
|
pickupState$,
|
||||||
|
muteStates,
|
||||||
|
...inputs
|
||||||
|
}: RingingMediaInputs): RingingMediaViewModel {
|
||||||
|
return {
|
||||||
|
...createBaseMedia(inputs),
|
||||||
|
type: "ringing",
|
||||||
|
pickupState$,
|
||||||
|
// If our own video is enabled, then this is a video call and we would
|
||||||
|
// expect remote media to have video as well
|
||||||
|
videoEnabled$: muteStates.video.enabled$,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -13,7 +13,7 @@ import { type LocalScreenShareViewModel } from "./LocalScreenShareViewModel";
|
|||||||
import {
|
import {
|
||||||
createMemberMedia,
|
createMemberMedia,
|
||||||
type MemberMediaInputs,
|
type MemberMediaInputs,
|
||||||
type MemberMediaViewModel,
|
type BaseMemberMediaViewModel,
|
||||||
} from "./MemberMediaViewModel";
|
} from "./MemberMediaViewModel";
|
||||||
import { type RemoteScreenShareViewModel } from "./RemoteScreenShareViewModel";
|
import { type RemoteScreenShareViewModel } from "./RemoteScreenShareViewModel";
|
||||||
|
|
||||||
@@ -27,7 +27,7 @@ export type ScreenShareViewModel =
|
|||||||
/**
|
/**
|
||||||
* Properties which are common to all ScreenShareViewModels.
|
* Properties which are common to all ScreenShareViewModels.
|
||||||
*/
|
*/
|
||||||
export interface BaseScreenShareViewModel extends MemberMediaViewModel {
|
export interface BaseScreenShareViewModel extends BaseMemberMediaViewModel {
|
||||||
type: "screen share";
|
type: "screen share";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ import { type LocalUserMediaViewModel } from "./LocalUserMediaViewModel";
|
|||||||
import {
|
import {
|
||||||
createMemberMedia,
|
createMemberMedia,
|
||||||
type MemberMediaInputs,
|
type MemberMediaInputs,
|
||||||
type MemberMediaViewModel,
|
type BaseMemberMediaViewModel,
|
||||||
} from "./MemberMediaViewModel";
|
} from "./MemberMediaViewModel";
|
||||||
import { type RemoteUserMediaViewModel } from "./RemoteUserMediaViewModel";
|
import { type RemoteUserMediaViewModel } from "./RemoteUserMediaViewModel";
|
||||||
import { type ObservableScope } from "../ObservableScope";
|
import { type ObservableScope } from "../ObservableScope";
|
||||||
@@ -42,7 +42,7 @@ export type UserMediaViewModel =
|
|||||||
| LocalUserMediaViewModel
|
| LocalUserMediaViewModel
|
||||||
| RemoteUserMediaViewModel;
|
| RemoteUserMediaViewModel;
|
||||||
|
|
||||||
export interface BaseUserMediaViewModel extends MemberMediaViewModel {
|
export interface BaseUserMediaViewModel extends BaseMemberMediaViewModel {
|
||||||
type: "user";
|
type: "user";
|
||||||
speaking$: Behavior<boolean>;
|
speaking$: Behavior<boolean>;
|
||||||
audioEnabled$: Behavior<boolean>;
|
audioEnabled$: Behavior<boolean>;
|
||||||
|
|||||||
@@ -194,5 +194,3 @@ export function createWrappedUserMedia(
|
|||||||
),
|
),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export type MediaItem = WrappedUserMediaViewModel | ScreenShareViewModel;
|
|
||||||
@@ -7,9 +7,10 @@ Please see LICENSE in the repository root for full details.
|
|||||||
|
|
||||||
import { type RemoteTrackPublication } from "livekit-client";
|
import { type RemoteTrackPublication } from "livekit-client";
|
||||||
import { test, expect } from "vitest";
|
import { test, expect } from "vitest";
|
||||||
import { render, screen } from "@testing-library/react";
|
import { act, render, screen } from "@testing-library/react";
|
||||||
import { axe } from "vitest-axe";
|
import { axe } from "vitest-axe";
|
||||||
import { type MatrixRTCSession } from "matrix-js-sdk/lib/matrixrtc";
|
import { type MatrixRTCSession } from "matrix-js-sdk/lib/matrixrtc";
|
||||||
|
import { BehaviorSubject } from "rxjs";
|
||||||
|
|
||||||
import { GridTile } from "./GridTile";
|
import { GridTile } from "./GridTile";
|
||||||
import {
|
import {
|
||||||
@@ -21,6 +22,11 @@ import { GridTileViewModel } from "../state/TileViewModel";
|
|||||||
import { ReactionsSenderProvider } from "../reactions/useReactionsSender";
|
import { ReactionsSenderProvider } from "../reactions/useReactionsSender";
|
||||||
import type { CallViewModel } from "../state/CallViewModel/CallViewModel";
|
import type { CallViewModel } from "../state/CallViewModel/CallViewModel";
|
||||||
import { constant } from "../state/Behavior";
|
import { constant } from "../state/Behavior";
|
||||||
|
import {
|
||||||
|
createRingingMedia,
|
||||||
|
type RingingMediaViewModel,
|
||||||
|
} from "../state/media/RingingMediaViewModel";
|
||||||
|
import { type MuteStates } from "../state/MuteStates";
|
||||||
|
|
||||||
global.IntersectionObserver = class MockIntersectionObserver {
|
global.IntersectionObserver = class MockIntersectionObserver {
|
||||||
public observe(): void {}
|
public observe(): void {}
|
||||||
@@ -28,6 +34,27 @@ global.IntersectionObserver = class MockIntersectionObserver {
|
|||||||
public disconnect(): void {}
|
public disconnect(): void {}
|
||||||
} as unknown as typeof IntersectionObserver;
|
} as unknown as typeof IntersectionObserver;
|
||||||
|
|
||||||
|
const fakeRtcSession = {
|
||||||
|
on: () => {},
|
||||||
|
off: () => {},
|
||||||
|
room: {
|
||||||
|
on: () => {},
|
||||||
|
off: () => {},
|
||||||
|
client: {
|
||||||
|
getUserId: () => null,
|
||||||
|
getDeviceId: () => null,
|
||||||
|
on: () => {},
|
||||||
|
off: () => {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
memberships: [],
|
||||||
|
} as unknown as MatrixRTCSession;
|
||||||
|
|
||||||
|
const callVm = {
|
||||||
|
reactions$: constant({}),
|
||||||
|
handsRaised$: constant({}),
|
||||||
|
} as Partial<CallViewModel> as CallViewModel;
|
||||||
|
|
||||||
test("GridTile is accessible", async () => {
|
test("GridTile is accessible", async () => {
|
||||||
const vm = mockRemoteMedia(
|
const vm = mockRemoteMedia(
|
||||||
mockRtcMembership("@alice:example.org", "AAAA"),
|
mockRtcMembership("@alice:example.org", "AAAA"),
|
||||||
@@ -42,34 +69,15 @@ test("GridTile is accessible", async () => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
const fakeRtcSession = {
|
|
||||||
on: () => {},
|
|
||||||
off: () => {},
|
|
||||||
room: {
|
|
||||||
on: () => {},
|
|
||||||
off: () => {},
|
|
||||||
client: {
|
|
||||||
getUserId: () => null,
|
|
||||||
getDeviceId: () => null,
|
|
||||||
on: () => {},
|
|
||||||
off: () => {},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
memberships: [],
|
|
||||||
} as unknown as MatrixRTCSession;
|
|
||||||
const cVm = {
|
|
||||||
reactions$: constant({}),
|
|
||||||
handsRaised$: constant({}),
|
|
||||||
} as Partial<CallViewModel> as CallViewModel;
|
|
||||||
const { container } = render(
|
const { container } = render(
|
||||||
<ReactionsSenderProvider vm={cVm} rtcSession={fakeRtcSession}>
|
<ReactionsSenderProvider vm={callVm} rtcSession={fakeRtcSession}>
|
||||||
<GridTile
|
<GridTile
|
||||||
vm={new GridTileViewModel(constant(vm))}
|
vm={new GridTileViewModel(constant(vm))}
|
||||||
onOpenProfile={() => {}}
|
onOpenProfile={() => {}}
|
||||||
targetWidth={300}
|
targetWidth={300}
|
||||||
targetHeight={200}
|
targetHeight={200}
|
||||||
showSpeakingIndicators
|
showSpeakingIndicators
|
||||||
focusable={true}
|
focusable
|
||||||
/>
|
/>
|
||||||
</ReactionsSenderProvider>,
|
</ReactionsSenderProvider>,
|
||||||
);
|
);
|
||||||
@@ -77,3 +85,40 @@ test("GridTile is accessible", async () => {
|
|||||||
// Name should be visible
|
// Name should be visible
|
||||||
screen.getByText("Alice");
|
screen.getByText("Alice");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("GridTile displays ringing media", async () => {
|
||||||
|
const pickupState$ = new BehaviorSubject<
|
||||||
|
RingingMediaViewModel["pickupState$"]["value"]
|
||||||
|
>("ringing");
|
||||||
|
const vm = createRingingMedia({
|
||||||
|
pickupState$,
|
||||||
|
muteStates: {
|
||||||
|
video: { enabled$: constant(false) },
|
||||||
|
} as unknown as MuteStates,
|
||||||
|
id: "test",
|
||||||
|
userId: "@alice:example.org",
|
||||||
|
displayName$: constant("Alice"),
|
||||||
|
mxcAvatarUrl$: constant(undefined),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { container } = render(
|
||||||
|
<ReactionsSenderProvider vm={callVm} rtcSession={fakeRtcSession}>
|
||||||
|
<GridTile
|
||||||
|
vm={new GridTileViewModel(constant(vm))}
|
||||||
|
onOpenProfile={() => {}}
|
||||||
|
targetWidth={300}
|
||||||
|
targetHeight={200}
|
||||||
|
showSpeakingIndicators
|
||||||
|
focusable
|
||||||
|
/>
|
||||||
|
</ReactionsSenderProvider>,
|
||||||
|
);
|
||||||
|
expect(await axe(container)).toHaveNoViolations();
|
||||||
|
// Name and status should be visible
|
||||||
|
screen.getByText("Alice");
|
||||||
|
screen.getByText("Calling…");
|
||||||
|
|
||||||
|
// Alice declines the call
|
||||||
|
act(() => pickupState$.next("decline"));
|
||||||
|
screen.getByText("Call ended");
|
||||||
|
});
|
||||||
|
|||||||
@@ -29,6 +29,9 @@ import {
|
|||||||
UserProfileIcon,
|
UserProfileIcon,
|
||||||
VolumeOffSolidIcon,
|
VolumeOffSolidIcon,
|
||||||
SwitchCameraSolidIcon,
|
SwitchCameraSolidIcon,
|
||||||
|
VideoCallSolidIcon,
|
||||||
|
VoiceCallSolidIcon,
|
||||||
|
EndCallIcon,
|
||||||
} from "@vector-im/compound-design-tokens/assets/web/icons";
|
} from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||||
import {
|
import {
|
||||||
ContextMenu,
|
ContextMenu,
|
||||||
@@ -49,6 +52,7 @@ import { useBehavior } from "../useBehavior";
|
|||||||
import { type LocalUserMediaViewModel } from "../state/media/LocalUserMediaViewModel";
|
import { type LocalUserMediaViewModel } from "../state/media/LocalUserMediaViewModel";
|
||||||
import { type RemoteUserMediaViewModel } from "../state/media/RemoteUserMediaViewModel";
|
import { type RemoteUserMediaViewModel } from "../state/media/RemoteUserMediaViewModel";
|
||||||
import { type UserMediaViewModel } from "../state/media/UserMediaViewModel";
|
import { type UserMediaViewModel } from "../state/media/UserMediaViewModel";
|
||||||
|
import { type RingingMediaViewModel } from "../state/media/RingingMediaViewModel";
|
||||||
|
|
||||||
interface TileProps {
|
interface TileProps {
|
||||||
ref?: Ref<HTMLDivElement>;
|
ref?: Ref<HTMLDivElement>;
|
||||||
@@ -56,21 +60,56 @@ interface TileProps {
|
|||||||
style?: ComponentProps<typeof animated.div>["style"];
|
style?: ComponentProps<typeof animated.div>["style"];
|
||||||
targetWidth: number;
|
targetWidth: number;
|
||||||
targetHeight: number;
|
targetHeight: number;
|
||||||
focusUrl: string | undefined;
|
|
||||||
displayName: string;
|
displayName: string;
|
||||||
mxcAvatarUrl: string | undefined;
|
mxcAvatarUrl: string | undefined;
|
||||||
showSpeakingIndicators: boolean;
|
|
||||||
focusable: boolean;
|
focusable: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface RingingMediaTileProps extends TileProps {
|
||||||
|
vm: RingingMediaViewModel;
|
||||||
|
}
|
||||||
|
|
||||||
|
const RingingMediaTile: FC<RingingMediaTileProps> = ({
|
||||||
|
vm,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const pickupState = useBehavior(vm.pickupState$);
|
||||||
|
const videoEnabled = useBehavior(vm.videoEnabled$);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MediaView
|
||||||
|
className={classNames(className, styles.tile)}
|
||||||
|
video={undefined}
|
||||||
|
userId={vm.userId}
|
||||||
|
unencryptedWarning={false}
|
||||||
|
status={
|
||||||
|
pickupState === "ringing"
|
||||||
|
? {
|
||||||
|
text: t("video_tile.calling"),
|
||||||
|
Icon: videoEnabled ? VideoCallSolidIcon : VoiceCallSolidIcon,
|
||||||
|
}
|
||||||
|
: { text: t("video_tile.call_ended"), Icon: EndCallIcon }
|
||||||
|
}
|
||||||
|
videoEnabled={videoEnabled}
|
||||||
|
videoFit="cover"
|
||||||
|
mirror={false}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
interface UserMediaTileProps extends TileProps {
|
interface UserMediaTileProps extends TileProps {
|
||||||
vm: UserMediaViewModel;
|
vm: UserMediaViewModel;
|
||||||
|
showSpeakingIndicators: boolean;
|
||||||
mirror: boolean;
|
mirror: boolean;
|
||||||
playbackMuted: boolean;
|
playbackMuted: boolean;
|
||||||
waitingForMedia?: boolean;
|
waitingForMedia?: boolean;
|
||||||
primaryButton?: ReactNode;
|
primaryButton?: ReactNode;
|
||||||
menuStart?: ReactNode;
|
menuStart?: ReactNode;
|
||||||
menuEnd?: ReactNode;
|
menuEnd?: ReactNode;
|
||||||
|
focusUrl: string | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
const UserMediaTile: FC<UserMediaTileProps> = ({
|
const UserMediaTile: FC<UserMediaTileProps> = ({
|
||||||
@@ -95,7 +134,6 @@ const UserMediaTile: FC<UserMediaTileProps> = ({
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const video = useBehavior(vm.video$);
|
const video = useBehavior(vm.video$);
|
||||||
const unencryptedWarning = useBehavior(vm.unencryptedWarning$);
|
const unencryptedWarning = useBehavior(vm.unencryptedWarning$);
|
||||||
const encryptionStatus = useBehavior(vm.encryptionStatus$);
|
|
||||||
const audioStreamStats = useObservableEagerState<
|
const audioStreamStats = useObservableEagerState<
|
||||||
RTCInboundRtpStreamStats | RTCOutboundRtpStreamStats | undefined
|
RTCInboundRtpStreamStats | RTCOutboundRtpStreamStats | undefined
|
||||||
>(vm.audioStreamStats$);
|
>(vm.audioStreamStats$);
|
||||||
@@ -153,7 +191,6 @@ const UserMediaTile: FC<UserMediaTileProps> = ({
|
|||||||
video={video}
|
video={video}
|
||||||
userId={vm.userId}
|
userId={vm.userId}
|
||||||
unencryptedWarning={unencryptedWarning}
|
unencryptedWarning={unencryptedWarning}
|
||||||
encryptionStatus={encryptionStatus}
|
|
||||||
videoEnabled={videoEnabled}
|
videoEnabled={videoEnabled}
|
||||||
videoFit={videoFit}
|
videoFit={videoFit}
|
||||||
className={classNames(className, styles.tile, {
|
className={classNames(className, styles.tile, {
|
||||||
@@ -218,6 +255,7 @@ UserMediaTile.displayName = "UserMediaTile";
|
|||||||
|
|
||||||
interface LocalUserMediaTileProps extends TileProps {
|
interface LocalUserMediaTileProps extends TileProps {
|
||||||
vm: LocalUserMediaViewModel;
|
vm: LocalUserMediaViewModel;
|
||||||
|
showSpeakingIndicators: boolean;
|
||||||
onOpenProfile: (() => void) | null;
|
onOpenProfile: (() => void) | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -232,6 +270,7 @@ const LocalUserMediaTile: FC<LocalUserMediaTileProps> = ({
|
|||||||
const mirror = useBehavior(vm.mirror$);
|
const mirror = useBehavior(vm.mirror$);
|
||||||
const alwaysShow = useBehavior(vm.alwaysShow$);
|
const alwaysShow = useBehavior(vm.alwaysShow$);
|
||||||
const switchCamera = useBehavior(vm.switchCamera$);
|
const switchCamera = useBehavior(vm.switchCamera$);
|
||||||
|
const focusUrl = useBehavior(vm.focusUrl$);
|
||||||
|
|
||||||
const latestAlwaysShow = useLatest(alwaysShow);
|
const latestAlwaysShow = useLatest(alwaysShow);
|
||||||
const onSelectAlwaysShow = useCallback(
|
const onSelectAlwaysShow = useCallback(
|
||||||
@@ -278,6 +317,7 @@ const LocalUserMediaTile: FC<LocalUserMediaTileProps> = ({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
focusable={focusable}
|
focusable={focusable}
|
||||||
|
focusUrl={focusUrl}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -287,6 +327,7 @@ LocalUserMediaTile.displayName = "LocalUserMediaTile";
|
|||||||
|
|
||||||
interface RemoteUserMediaTileProps extends TileProps {
|
interface RemoteUserMediaTileProps extends TileProps {
|
||||||
vm: RemoteUserMediaViewModel;
|
vm: RemoteUserMediaViewModel;
|
||||||
|
showSpeakingIndicators: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const RemoteUserMediaTile: FC<RemoteUserMediaTileProps> = ({
|
const RemoteUserMediaTile: FC<RemoteUserMediaTileProps> = ({
|
||||||
@@ -298,6 +339,8 @@ const RemoteUserMediaTile: FC<RemoteUserMediaTileProps> = ({
|
|||||||
const waitingForMedia = useBehavior(vm.waitingForMedia$);
|
const waitingForMedia = useBehavior(vm.waitingForMedia$);
|
||||||
const playbackMuted = useBehavior(vm.playbackMuted$);
|
const playbackMuted = useBehavior(vm.playbackMuted$);
|
||||||
const playbackVolume = useBehavior(vm.playbackVolume$);
|
const playbackVolume = useBehavior(vm.playbackVolume$);
|
||||||
|
const focusUrl = useBehavior(vm.focusUrl$);
|
||||||
|
|
||||||
const onSelectMute = useCallback(
|
const onSelectMute = useCallback(
|
||||||
(e: Event) => {
|
(e: Event) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -338,6 +381,7 @@ const RemoteUserMediaTile: FC<RemoteUserMediaTileProps> = ({
|
|||||||
</MenuItem>
|
</MenuItem>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
|
focusUrl={focusUrl}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -360,23 +404,33 @@ interface GridTileProps {
|
|||||||
export const GridTile: FC<GridTileProps> = ({
|
export const GridTile: FC<GridTileProps> = ({
|
||||||
ref: theirRef,
|
ref: theirRef,
|
||||||
vm,
|
vm,
|
||||||
|
showSpeakingIndicators,
|
||||||
onOpenProfile,
|
onOpenProfile,
|
||||||
...props
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
const ourRef = useRef<HTMLDivElement | null>(null);
|
const ourRef = useRef<HTMLDivElement | null>(null);
|
||||||
const ref = useMergedRefs(ourRef, theirRef);
|
const ref = useMergedRefs(ourRef, theirRef);
|
||||||
const media = useBehavior(vm.media$);
|
const media = useBehavior(vm.media$);
|
||||||
const focusUrl = useBehavior(media.focusUrl$);
|
|
||||||
const displayName = useBehavior(media.displayName$);
|
const displayName = useBehavior(media.displayName$);
|
||||||
const mxcAvatarUrl = useBehavior(media.mxcAvatarUrl$);
|
const mxcAvatarUrl = useBehavior(media.mxcAvatarUrl$);
|
||||||
|
|
||||||
if (media.local) {
|
if (media.type === "ringing") {
|
||||||
|
return (
|
||||||
|
<RingingMediaTile
|
||||||
|
ref={ref}
|
||||||
|
vm={media}
|
||||||
|
{...props}
|
||||||
|
displayName={displayName}
|
||||||
|
mxcAvatarUrl={mxcAvatarUrl}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
} else if (media.local) {
|
||||||
return (
|
return (
|
||||||
<LocalUserMediaTile
|
<LocalUserMediaTile
|
||||||
ref={ref}
|
ref={ref}
|
||||||
vm={media}
|
vm={media}
|
||||||
|
showSpeakingIndicators={showSpeakingIndicators}
|
||||||
onOpenProfile={onOpenProfile}
|
onOpenProfile={onOpenProfile}
|
||||||
focusUrl={focusUrl}
|
|
||||||
displayName={displayName}
|
displayName={displayName}
|
||||||
mxcAvatarUrl={mxcAvatarUrl}
|
mxcAvatarUrl={mxcAvatarUrl}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -387,7 +441,7 @@ export const GridTile: FC<GridTileProps> = ({
|
|||||||
<RemoteUserMediaTile
|
<RemoteUserMediaTile
|
||||||
ref={ref}
|
ref={ref}
|
||||||
vm={media}
|
vm={media}
|
||||||
focusUrl={focusUrl}
|
showSpeakingIndicators={showSpeakingIndicators}
|
||||||
displayName={displayName}
|
displayName={displayName}
|
||||||
mxcAvatarUrl={mxcAvatarUrl}
|
mxcAvatarUrl={mxcAvatarUrl}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -52,6 +52,11 @@ Please see LICENSE in the repository root for full details.
|
|||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.translucent {
|
||||||
|
opacity: 50%;
|
||||||
|
mix-blend-mode: multiply;
|
||||||
|
}
|
||||||
|
|
||||||
/* CSS makes us put a condition here, even though all we want to do is
|
/* CSS makes us put a condition here, even though all we want to do is
|
||||||
unconditionally select the container so we can use cqmin units */
|
unconditionally select the container so we can use cqmin units */
|
||||||
@container mediaView (width > 0) {
|
@container mediaView (width > 0) {
|
||||||
@@ -71,14 +76,15 @@ unconditionally select the container so we can use cqmin units */
|
|||||||
|
|
||||||
.fg {
|
.fg {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: var(
|
--fg-inset: var(
|
||||||
--media-view-fg-inset,
|
--media-view-fg-inset,
|
||||||
calc(var(--media-view-border-radius) - var(--cpd-space-3x))
|
calc(var(--media-view-border-radius) - var(--cpd-space-3x))
|
||||||
);
|
);
|
||||||
|
inset: var(--fg-inset);
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 30px 1fr 30px;
|
grid-template-columns: 30px 1fr 30px;
|
||||||
grid-template-rows: 1fr auto;
|
grid-template-rows: 1fr auto;
|
||||||
grid-template-areas: "reactions status ." "nameTag nameTag button";
|
grid-template-areas: "status status reactions" "nameTag nameTag button";
|
||||||
gap: var(--cpd-space-1x);
|
gap: var(--cpd-space-1x);
|
||||||
place-items: start;
|
place-items: start;
|
||||||
}
|
}
|
||||||
@@ -102,21 +108,19 @@ unconditionally select the container so we can use cqmin units */
|
|||||||
|
|
||||||
.status {
|
.status {
|
||||||
grid-area: status;
|
grid-area: status;
|
||||||
justify-self: center;
|
|
||||||
align-self: start;
|
|
||||||
padding: var(--cpd-space-2x);
|
|
||||||
padding-block: var(--cpd-space-2x);
|
|
||||||
color: var(--cpd-color-text-primary);
|
color: var(--cpd-color-text-primary);
|
||||||
background-color: var(--cpd-color-bg-canvas-default);
|
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-wrap: none;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
border-radius: var(--cpd-radius-pill-effect);
|
gap: 3px;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
box-shadow: var(--small-drop-shadow);
|
margin-block-start: calc(var(--cpd-space-3x) - var(--fg-inset));
|
||||||
box-sizing: border-box;
|
margin-inline-start: calc(var(--cpd-space-4x) - var(--fg-inset));
|
||||||
max-inline-size: 100%;
|
}
|
||||||
text-align: center;
|
|
||||||
|
.status svg {
|
||||||
|
color: var(--cpd-color-icon-tertiary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.reactions {
|
.reactions {
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ import { TrackInfo } from "@livekit/protocol";
|
|||||||
import { type ComponentProps } from "react";
|
import { type ComponentProps } from "react";
|
||||||
|
|
||||||
import { MediaView } from "./MediaView";
|
import { MediaView } from "./MediaView";
|
||||||
import { EncryptionStatus } from "../state/media/MemberMediaViewModel";
|
|
||||||
import { mockLocalParticipant } from "../utils/test";
|
import { mockLocalParticipant } from "../utils/test";
|
||||||
|
|
||||||
describe("MediaView", () => {
|
describe("MediaView", () => {
|
||||||
@@ -41,7 +40,6 @@ describe("MediaView", () => {
|
|||||||
videoFit: "contain",
|
videoFit: "contain",
|
||||||
targetWidth: 300,
|
targetWidth: 300,
|
||||||
targetHeight: 200,
|
targetHeight: 200,
|
||||||
encryptionStatus: EncryptionStatus.Connecting,
|
|
||||||
mirror: false,
|
mirror: false,
|
||||||
unencryptedWarning: false,
|
unencryptedWarning: false,
|
||||||
video: trackReference,
|
video: trackReference,
|
||||||
|
|||||||
@@ -7,7 +7,13 @@ Please see LICENSE in the repository root for full details.
|
|||||||
|
|
||||||
import { type TrackReferenceOrPlaceholder } from "@livekit/components-core";
|
import { type TrackReferenceOrPlaceholder } from "@livekit/components-core";
|
||||||
import { animated } from "@react-spring/web";
|
import { animated } from "@react-spring/web";
|
||||||
import { type FC, type ComponentProps, type ReactNode } from "react";
|
import {
|
||||||
|
type FC,
|
||||||
|
type ComponentProps,
|
||||||
|
type ReactNode,
|
||||||
|
type ComponentType,
|
||||||
|
type SVGAttributes,
|
||||||
|
} from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
import { VideoTrack } from "@livekit/components-react";
|
import { VideoTrack } from "@livekit/components-react";
|
||||||
@@ -16,7 +22,6 @@ import { ErrorSolidIcon } from "@vector-im/compound-design-tokens/assets/web/ico
|
|||||||
|
|
||||||
import styles from "./MediaView.module.css";
|
import styles from "./MediaView.module.css";
|
||||||
import { Avatar } from "../Avatar";
|
import { Avatar } from "../Avatar";
|
||||||
import { type EncryptionStatus } from "../state/media/MemberMediaViewModel";
|
|
||||||
import { RaisedHandIndicator } from "../reactions/RaisedHandIndicator";
|
import { RaisedHandIndicator } from "../reactions/RaisedHandIndicator";
|
||||||
import {
|
import {
|
||||||
showConnectionStats as showConnectionStatsSetting,
|
showConnectionStats as showConnectionStatsSetting,
|
||||||
@@ -38,7 +43,7 @@ interface Props extends ComponentProps<typeof animated.div> {
|
|||||||
userId: string;
|
userId: string;
|
||||||
videoEnabled: boolean;
|
videoEnabled: boolean;
|
||||||
unencryptedWarning: boolean;
|
unencryptedWarning: boolean;
|
||||||
encryptionStatus: EncryptionStatus;
|
status?: { text: string; Icon: ComponentType<SVGAttributes<SVGElement>> };
|
||||||
nameTagLeadingIcon?: ReactNode;
|
nameTagLeadingIcon?: ReactNode;
|
||||||
displayName: string;
|
displayName: string;
|
||||||
mxcAvatarUrl: string | undefined;
|
mxcAvatarUrl: string | undefined;
|
||||||
@@ -72,7 +77,7 @@ export const MediaView: FC<Props> = ({
|
|||||||
mxcAvatarUrl,
|
mxcAvatarUrl,
|
||||||
focusable,
|
focusable,
|
||||||
primaryButton,
|
primaryButton,
|
||||||
encryptionStatus,
|
status,
|
||||||
raisedHandTime,
|
raisedHandTime,
|
||||||
currentReaction,
|
currentReaction,
|
||||||
raisedHandOnClick,
|
raisedHandOnClick,
|
||||||
@@ -106,7 +111,11 @@ export const MediaView: FC<Props> = ({
|
|||||||
name={displayName}
|
name={displayName}
|
||||||
size={avatarSize}
|
size={avatarSize}
|
||||||
src={mxcAvatarUrl}
|
src={mxcAvatarUrl}
|
||||||
className={styles.avatar}
|
className={classNames(styles.avatar, {
|
||||||
|
// When the avatar is overlaid with a status, make it translucent
|
||||||
|
// for readability
|
||||||
|
[styles.translucent]: status,
|
||||||
|
})}
|
||||||
style={{ display: video && videoEnabled ? "none" : "initial" }}
|
style={{ display: video && videoEnabled ? "none" : "initial" }}
|
||||||
/>
|
/>
|
||||||
{video?.publication !== undefined && (
|
{video?.publication !== undefined && (
|
||||||
@@ -152,6 +161,14 @@ export const MediaView: FC<Props> = ({
|
|||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
{status && (
|
||||||
|
<div className={styles.status}>
|
||||||
|
<status.Icon width={16} height={16} aria-hidden />
|
||||||
|
<Text as="span" size="sm" weight="medium">
|
||||||
|
{status.text}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{/* TODO: Bring this back once encryption status is less broken */}
|
{/* TODO: Bring this back once encryption status is less broken */}
|
||||||
{/*encryptionStatus !== EncryptionStatus.Okay && (
|
{/*encryptionStatus !== EncryptionStatus.Okay && (
|
||||||
<div className={styles.status}>
|
<div className={styles.status}>
|
||||||
|
|||||||
@@ -6,10 +6,11 @@ Please see LICENSE in the repository root for full details.
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { test, expect, vi } from "vitest";
|
import { test, expect, vi } from "vitest";
|
||||||
import { isInaccessible, render, screen } from "@testing-library/react";
|
import { act, isInaccessible, render, screen } from "@testing-library/react";
|
||||||
import { axe } from "vitest-axe";
|
import { axe } from "vitest-axe";
|
||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
import { TooltipProvider } from "@vector-im/compound-web";
|
import { TooltipProvider } from "@vector-im/compound-web";
|
||||||
|
import { BehaviorSubject } from "rxjs";
|
||||||
|
|
||||||
import { SpotlightTile } from "./SpotlightTile";
|
import { SpotlightTile } from "./SpotlightTile";
|
||||||
import {
|
import {
|
||||||
@@ -23,6 +24,11 @@ import {
|
|||||||
} from "../utils/test";
|
} from "../utils/test";
|
||||||
import { SpotlightTileViewModel } from "../state/TileViewModel";
|
import { SpotlightTileViewModel } from "../state/TileViewModel";
|
||||||
import { constant } from "../state/Behavior";
|
import { constant } from "../state/Behavior";
|
||||||
|
import {
|
||||||
|
createRingingMedia,
|
||||||
|
type RingingMediaViewModel,
|
||||||
|
} from "../state/media/RingingMediaViewModel";
|
||||||
|
import { type MuteStates } from "../state/MuteStates";
|
||||||
|
|
||||||
global.IntersectionObserver = class MockIntersectionObserver {
|
global.IntersectionObserver = class MockIntersectionObserver {
|
||||||
public observe(): void {}
|
public observe(): void {}
|
||||||
@@ -140,3 +146,41 @@ test("Screen share volume UI is hidden when screen share has no audio", async ()
|
|||||||
screen.queryByRole("button", { name: /volume/i }),
|
screen.queryByRole("button", { name: /volume/i }),
|
||||||
).not.toBeInTheDocument();
|
).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("SpotlightTile displays ringing media", async () => {
|
||||||
|
const pickupState$ = new BehaviorSubject<
|
||||||
|
RingingMediaViewModel["pickupState$"]["value"]
|
||||||
|
>("ringing");
|
||||||
|
const vm = createRingingMedia({
|
||||||
|
pickupState$,
|
||||||
|
muteStates: {
|
||||||
|
video: { enabled$: constant(false) },
|
||||||
|
} as unknown as MuteStates,
|
||||||
|
id: "test",
|
||||||
|
userId: "@alice:example.org",
|
||||||
|
displayName$: constant("Alice"),
|
||||||
|
mxcAvatarUrl$: constant(undefined),
|
||||||
|
});
|
||||||
|
|
||||||
|
const toggleExpanded = vi.fn();
|
||||||
|
const { container } = render(
|
||||||
|
<SpotlightTile
|
||||||
|
vm={new SpotlightTileViewModel(constant([vm]), constant(false))}
|
||||||
|
targetWidth={300}
|
||||||
|
targetHeight={200}
|
||||||
|
expanded={false}
|
||||||
|
onToggleExpanded={toggleExpanded}
|
||||||
|
showIndicators
|
||||||
|
focusable={true}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(await axe(container)).toHaveNoViolations();
|
||||||
|
// Alice should be in the spotlight with the right status
|
||||||
|
screen.getByText("Alice");
|
||||||
|
screen.getByText("Calling…");
|
||||||
|
|
||||||
|
// Now we time out ringing to Alice
|
||||||
|
act(() => pickupState$.next("timeout"));
|
||||||
|
screen.getByText("Call ended");
|
||||||
|
});
|
||||||
|
|||||||
@@ -24,6 +24,9 @@ import {
|
|||||||
VolumeOnIcon,
|
VolumeOnIcon,
|
||||||
VolumeOffSolidIcon,
|
VolumeOffSolidIcon,
|
||||||
VolumeOnSolidIcon,
|
VolumeOnSolidIcon,
|
||||||
|
VideoCallSolidIcon,
|
||||||
|
VoiceCallSolidIcon,
|
||||||
|
EndCallIcon,
|
||||||
} from "@vector-im/compound-design-tokens/assets/web/icons";
|
} from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||||
import { animated } from "@react-spring/web";
|
import { animated } from "@react-spring/web";
|
||||||
import { type Observable, map } from "rxjs";
|
import { type Observable, map } from "rxjs";
|
||||||
@@ -43,7 +46,7 @@ import { useReactiveState } from "../useReactiveState";
|
|||||||
import { useLatest } from "../useLatest";
|
import { useLatest } from "../useLatest";
|
||||||
import { type SpotlightTileViewModel } from "../state/TileViewModel";
|
import { type SpotlightTileViewModel } from "../state/TileViewModel";
|
||||||
import { useBehavior } from "../useBehavior";
|
import { useBehavior } from "../useBehavior";
|
||||||
import { type EncryptionStatus } from "../state/media/MemberMediaViewModel";
|
import { type MemberMediaViewModel } from "../state/media/MemberMediaViewModel";
|
||||||
import { type LocalUserMediaViewModel } from "../state/media/LocalUserMediaViewModel";
|
import { type LocalUserMediaViewModel } from "../state/media/LocalUserMediaViewModel";
|
||||||
import { type RemoteUserMediaViewModel } from "../state/media/RemoteUserMediaViewModel";
|
import { type RemoteUserMediaViewModel } from "../state/media/RemoteUserMediaViewModel";
|
||||||
import { type UserMediaViewModel } from "../state/media/UserMediaViewModel";
|
import { type UserMediaViewModel } from "../state/media/UserMediaViewModel";
|
||||||
@@ -52,6 +55,7 @@ import { type RemoteScreenShareViewModel } from "../state/media/RemoteScreenShar
|
|||||||
import { type MediaViewModel } from "../state/media/MediaViewModel";
|
import { type MediaViewModel } from "../state/media/MediaViewModel";
|
||||||
import { Slider } from "../Slider";
|
import { Slider } from "../Slider";
|
||||||
import { platform } from "../Platform";
|
import { platform } from "../Platform";
|
||||||
|
import { type RingingMediaViewModel } from "../state/media/RingingMediaViewModel";
|
||||||
|
|
||||||
interface SpotlightItemBaseProps {
|
interface SpotlightItemBaseProps {
|
||||||
ref?: Ref<HTMLDivElement>;
|
ref?: Ref<HTMLDivElement>;
|
||||||
@@ -59,18 +63,20 @@ interface SpotlightItemBaseProps {
|
|||||||
"data-id": string;
|
"data-id": string;
|
||||||
targetWidth: number;
|
targetWidth: number;
|
||||||
targetHeight: number;
|
targetHeight: number;
|
||||||
video: TrackReferenceOrPlaceholder | undefined;
|
|
||||||
userId: string;
|
userId: string;
|
||||||
unencryptedWarning: boolean;
|
|
||||||
encryptionStatus: EncryptionStatus;
|
|
||||||
focusUrl: string | undefined;
|
|
||||||
displayName: string;
|
displayName: string;
|
||||||
mxcAvatarUrl: string | undefined;
|
mxcAvatarUrl: string | undefined;
|
||||||
focusable: boolean;
|
focusable: boolean;
|
||||||
"aria-hidden"?: boolean;
|
"aria-hidden"?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SpotlightUserMediaItemBaseProps extends SpotlightItemBaseProps {
|
interface SpotlightMemberMediaItemBaseProps extends SpotlightItemBaseProps {
|
||||||
|
video: TrackReferenceOrPlaceholder | undefined;
|
||||||
|
unencryptedWarning: boolean;
|
||||||
|
focusUrl: string | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SpotlightUserMediaItemBaseProps extends SpotlightMemberMediaItemBaseProps {
|
||||||
videoFit: "contain" | "cover";
|
videoFit: "contain" | "cover";
|
||||||
videoEnabled: boolean;
|
videoEnabled: boolean;
|
||||||
}
|
}
|
||||||
@@ -103,21 +109,32 @@ const SpotlightRemoteUserMediaItem: FC<SpotlightRemoteUserMediaItemProps> = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
interface SpotlightUserMediaItemProps extends SpotlightItemBaseProps {
|
interface SpotlightUserMediaItemProps extends SpotlightMemberMediaItemBaseProps {
|
||||||
vm: UserMediaViewModel;
|
vm: UserMediaViewModel;
|
||||||
}
|
}
|
||||||
|
|
||||||
const SpotlightUserMediaItem: FC<SpotlightUserMediaItemProps> = ({
|
const SpotlightUserMediaItem: FC<SpotlightUserMediaItemProps> = ({
|
||||||
vm,
|
vm,
|
||||||
|
targetWidth,
|
||||||
|
targetHeight,
|
||||||
...props
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
const videoFit = useBehavior(vm.videoFit$);
|
const videoFit = useBehavior(vm.videoFit$);
|
||||||
const videoEnabled = useBehavior(vm.videoEnabled$);
|
const videoEnabled = useBehavior(vm.videoEnabled$);
|
||||||
|
|
||||||
|
// Whenever target bounds change, inform the viewModel
|
||||||
|
useEffect(() => {
|
||||||
|
if (targetWidth > 0 && targetHeight > 0) {
|
||||||
|
vm.setTargetDimensions(targetWidth, targetHeight);
|
||||||
|
}
|
||||||
|
}, [targetWidth, targetHeight, vm]);
|
||||||
|
|
||||||
const baseProps: SpotlightUserMediaItemBaseProps &
|
const baseProps: SpotlightUserMediaItemBaseProps &
|
||||||
RefAttributes<HTMLDivElement> = {
|
RefAttributes<HTMLDivElement> = {
|
||||||
videoFit,
|
videoFit,
|
||||||
videoEnabled,
|
videoEnabled,
|
||||||
|
targetWidth,
|
||||||
|
targetHeight,
|
||||||
...props,
|
...props,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -130,7 +147,7 @@ const SpotlightUserMediaItem: FC<SpotlightUserMediaItemProps> = ({
|
|||||||
|
|
||||||
SpotlightUserMediaItem.displayName = "SpotlightUserMediaItem";
|
SpotlightUserMediaItem.displayName = "SpotlightUserMediaItem";
|
||||||
|
|
||||||
interface SpotlightScreenShareItemProps extends SpotlightItemBaseProps {
|
interface SpotlightScreenShareItemProps extends SpotlightMemberMediaItemBaseProps {
|
||||||
vm: ScreenShareViewModel;
|
vm: ScreenShareViewModel;
|
||||||
videoEnabled: boolean;
|
videoEnabled: boolean;
|
||||||
}
|
}
|
||||||
@@ -142,7 +159,7 @@ const SpotlightScreenShareItem: FC<SpotlightScreenShareItemProps> = ({
|
|||||||
return <MediaView videoFit="contain" mirror={false} {...props} />;
|
return <MediaView videoFit="contain" mirror={false} {...props} />;
|
||||||
};
|
};
|
||||||
|
|
||||||
interface SpotlightRemoteScreenShareItemProps extends SpotlightItemBaseProps {
|
interface SpotlightRemoteScreenShareItemProps extends SpotlightMemberMediaItemBaseProps {
|
||||||
vm: RemoteScreenShareViewModel;
|
vm: RemoteScreenShareViewModel;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -155,6 +172,67 @@ const SpotlightRemoteScreenShareItem: FC<
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
interface SpotlightMemberMediaItemProps extends SpotlightItemBaseProps {
|
||||||
|
vm: MemberMediaViewModel;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SpotlightMemberMediaItem: FC<SpotlightMemberMediaItemProps> = ({
|
||||||
|
vm,
|
||||||
|
...props
|
||||||
|
}) => {
|
||||||
|
const video = useBehavior(vm.video$);
|
||||||
|
const unencryptedWarning = useBehavior(vm.unencryptedWarning$);
|
||||||
|
const focusUrl = useBehavior(vm.focusUrl$);
|
||||||
|
|
||||||
|
const baseProps: SpotlightMemberMediaItemBaseProps &
|
||||||
|
RefAttributes<HTMLDivElement> = {
|
||||||
|
video: video ?? undefined,
|
||||||
|
unencryptedWarning,
|
||||||
|
focusUrl,
|
||||||
|
...props,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (vm.type === "user")
|
||||||
|
return <SpotlightUserMediaItem vm={vm} {...baseProps} />;
|
||||||
|
return vm.local ? (
|
||||||
|
<SpotlightScreenShareItem vm={vm} videoEnabled {...baseProps} />
|
||||||
|
) : (
|
||||||
|
<SpotlightRemoteScreenShareItem vm={vm} {...baseProps} />
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface SpotlightRingingMediaItemProps extends SpotlightItemBaseProps {
|
||||||
|
vm: RingingMediaViewModel;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SpotlightRingingMediaItem: FC<SpotlightRingingMediaItemProps> = ({
|
||||||
|
vm,
|
||||||
|
...props
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const pickupState = useBehavior(vm.pickupState$);
|
||||||
|
const videoEnabled = useBehavior(vm.videoEnabled$);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MediaView
|
||||||
|
video={undefined}
|
||||||
|
unencryptedWarning={false}
|
||||||
|
status={
|
||||||
|
pickupState === "ringing"
|
||||||
|
? {
|
||||||
|
text: t("video_tile.calling"),
|
||||||
|
Icon: videoEnabled ? VideoCallSolidIcon : VoiceCallSolidIcon,
|
||||||
|
}
|
||||||
|
: { text: t("video_tile.call_ended"), Icon: EndCallIcon }
|
||||||
|
}
|
||||||
|
videoEnabled={false}
|
||||||
|
videoFit="cover"
|
||||||
|
mirror={false}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
interface SpotlightItemProps {
|
interface SpotlightItemProps {
|
||||||
ref?: Ref<HTMLDivElement>;
|
ref?: Ref<HTMLDivElement>;
|
||||||
vm: MediaViewModel;
|
vm: MediaViewModel;
|
||||||
@@ -187,22 +265,9 @@ const SpotlightItem: FC<SpotlightItemProps> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const ourRef = useRef<HTMLDivElement | null>(null);
|
const ourRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
// Whenever target bounds change, inform the viewModel
|
|
||||||
useEffect(() => {
|
|
||||||
if (targetWidth > 0 && targetHeight > 0) {
|
|
||||||
if (vm.type != "screen share") {
|
|
||||||
vm.setTargetDimensions(targetWidth, targetHeight);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [targetWidth, targetHeight, vm]);
|
|
||||||
|
|
||||||
const ref = useMergedRefs(ourRef, theirRef);
|
const ref = useMergedRefs(ourRef, theirRef);
|
||||||
const focusUrl = useBehavior(vm.focusUrl$);
|
|
||||||
const displayName = useBehavior(vm.displayName$);
|
const displayName = useBehavior(vm.displayName$);
|
||||||
const mxcAvatarUrl = useBehavior(vm.mxcAvatarUrl$);
|
const mxcAvatarUrl = useBehavior(vm.mxcAvatarUrl$);
|
||||||
const video = useBehavior(vm.video$);
|
|
||||||
const unencryptedWarning = useBehavior(vm.unencryptedWarning$);
|
|
||||||
const encryptionStatus = useBehavior(vm.encryptionStatus$);
|
|
||||||
|
|
||||||
// Hook this item up to the intersection observer
|
// Hook this item up to the intersection observer
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -225,23 +290,17 @@ const SpotlightItem: FC<SpotlightItemProps> = ({
|
|||||||
className: classNames(styles.item, { [styles.snap]: snap }),
|
className: classNames(styles.item, { [styles.snap]: snap }),
|
||||||
targetWidth,
|
targetWidth,
|
||||||
targetHeight,
|
targetHeight,
|
||||||
video: video ?? undefined,
|
|
||||||
userId: vm.userId,
|
userId: vm.userId,
|
||||||
unencryptedWarning,
|
|
||||||
focusUrl,
|
|
||||||
displayName,
|
displayName,
|
||||||
mxcAvatarUrl,
|
mxcAvatarUrl,
|
||||||
focusable,
|
focusable,
|
||||||
encryptionStatus,
|
|
||||||
"aria-hidden": ariaHidden,
|
"aria-hidden": ariaHidden,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (vm.type === "user")
|
return vm.type === "ringing" ? (
|
||||||
return <SpotlightUserMediaItem vm={vm} {...baseProps} />;
|
<SpotlightRingingMediaItem vm={vm} {...baseProps} />
|
||||||
return vm.local ? (
|
|
||||||
<SpotlightScreenShareItem vm={vm} videoEnabled {...baseProps} />
|
|
||||||
) : (
|
) : (
|
||||||
<SpotlightRemoteScreenShareItem vm={vm} {...baseProps} />
|
<SpotlightMemberMediaItem vm={vm} {...baseProps} />
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
164
yarn.lock
164
yarn.lock
@@ -2901,20 +2901,13 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"@eslint-community/regexpp@npm:^4.12.2":
|
"@eslint-community/regexpp@npm:^4.12.2, @eslint-community/regexpp@npm:^4.6.1":
|
||||||
version: 4.12.2
|
version: 4.12.2
|
||||||
resolution: "@eslint-community/regexpp@npm:4.12.2"
|
resolution: "@eslint-community/regexpp@npm:4.12.2"
|
||||||
checksum: 10c0/fddcbc66851b308478d04e302a4d771d6917a0b3740dc351513c0da9ca2eab8a1adf99f5e0aa7ab8b13fa0df005c81adeee7e63a92f3effd7d367a163b721c2d
|
checksum: 10c0/fddcbc66851b308478d04e302a4d771d6917a0b3740dc351513c0da9ca2eab8a1adf99f5e0aa7ab8b13fa0df005c81adeee7e63a92f3effd7d367a163b721c2d
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"@eslint-community/regexpp@npm:^4.6.1":
|
|
||||||
version: 4.11.1
|
|
||||||
resolution: "@eslint-community/regexpp@npm:4.11.1"
|
|
||||||
checksum: 10c0/fbcc1cb65ef5ed5b92faa8dc542e035269065e7ebcc0b39c81a4fe98ad35cfff20b3c8df048641de15a7757e07d69f85e2579c1a5055f993413ba18c055654f8
|
|
||||||
languageName: node
|
|
||||||
linkType: hard
|
|
||||||
|
|
||||||
"@eslint/eslintrc@npm:^2.1.4":
|
"@eslint/eslintrc@npm:^2.1.4":
|
||||||
version: 2.1.4
|
version: 2.1.4
|
||||||
resolution: "@eslint/eslintrc@npm:2.1.4"
|
resolution: "@eslint/eslintrc@npm:2.1.4"
|
||||||
@@ -6218,9 +6211,9 @@ __metadata:
|
|||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"@ungap/structured-clone@npm:^1.2.0":
|
"@ungap/structured-clone@npm:^1.2.0":
|
||||||
version: 1.2.0
|
version: 1.3.0
|
||||||
resolution: "@ungap/structured-clone@npm:1.2.0"
|
resolution: "@ungap/structured-clone@npm:1.3.0"
|
||||||
checksum: 10c0/8209c937cb39119f44eb63cf90c0b73e7c754209a6411c707be08e50e29ee81356dca1a848a405c8bdeebfe2f5e4f831ad310ae1689eeef65e7445c090c6657d
|
checksum: 10c0/0fc3097c2540ada1fc340ee56d58d96b5b536a2a0dab6e3ec17d4bfc8c4c86db345f61a375a8185f9da96f01c69678f836a2b57eeaa9e4b8eeafd26428e57b0a
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
@@ -6437,7 +6430,7 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"acorn@npm:^8.16.0":
|
"acorn@npm:^8.16.0, acorn@npm:^8.9.0":
|
||||||
version: 8.16.0
|
version: 8.16.0
|
||||||
resolution: "acorn@npm:8.16.0"
|
resolution: "acorn@npm:8.16.0"
|
||||||
bin:
|
bin:
|
||||||
@@ -6446,15 +6439,6 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"acorn@npm:^8.9.0":
|
|
||||||
version: 8.12.1
|
|
||||||
resolution: "acorn@npm:8.12.1"
|
|
||||||
bin:
|
|
||||||
acorn: bin/acorn
|
|
||||||
checksum: 10c0/51fb26cd678f914e13287e886da2d7021f8c2bc0ccc95e03d3e0447ee278dd3b40b9c57dc222acd5881adcf26f3edc40901a4953403232129e3876793cd17386
|
|
||||||
languageName: node
|
|
||||||
linkType: hard
|
|
||||||
|
|
||||||
"agent-base@npm:6":
|
"agent-base@npm:6":
|
||||||
version: 6.0.2
|
version: 6.0.2
|
||||||
resolution: "agent-base@npm:6.0.2"
|
resolution: "agent-base@npm:6.0.2"
|
||||||
@@ -6481,14 +6465,14 @@ __metadata:
|
|||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"ajv@npm:^6.12.4":
|
"ajv@npm:^6.12.4":
|
||||||
version: 6.12.6
|
version: 6.14.0
|
||||||
resolution: "ajv@npm:6.12.6"
|
resolution: "ajv@npm:6.14.0"
|
||||||
dependencies:
|
dependencies:
|
||||||
fast-deep-equal: "npm:^3.1.1"
|
fast-deep-equal: "npm:^3.1.1"
|
||||||
fast-json-stable-stringify: "npm:^2.0.0"
|
fast-json-stable-stringify: "npm:^2.0.0"
|
||||||
json-schema-traverse: "npm:^0.4.1"
|
json-schema-traverse: "npm:^0.4.1"
|
||||||
uri-js: "npm:^4.2.2"
|
uri-js: "npm:^4.2.2"
|
||||||
checksum: 10c0/41e23642cbe545889245b9d2a45854ebba51cda6c778ebced9649420d9205f2efb39cb43dbc41e358409223b1ea43303ae4839db682c848b891e4811da1a5a71
|
checksum: 10c0/a2bc39b0555dc9802c899f86990eb8eed6e366cddbf65be43d5aa7e4f3c4e1a199d5460fd7ca4fb3d864000dbbc049253b72faa83b3b30e641ca52cb29a68c22
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
@@ -7749,18 +7733,7 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"cross-spawn@npm:^7.0.2":
|
"cross-spawn@npm:^7.0.2, cross-spawn@npm:^7.0.6":
|
||||||
version: 7.0.3
|
|
||||||
resolution: "cross-spawn@npm:7.0.3"
|
|
||||||
dependencies:
|
|
||||||
path-key: "npm:^3.1.0"
|
|
||||||
shebang-command: "npm:^2.0.0"
|
|
||||||
which: "npm:^2.0.1"
|
|
||||||
checksum: 10c0/5738c312387081c98d69c98e105b6327b069197f864a60593245d64c8089c8a0a744e16349281210d56835bb9274130d825a78b2ad6853ca13cfbeffc0c31750
|
|
||||||
languageName: node
|
|
||||||
linkType: hard
|
|
||||||
|
|
||||||
"cross-spawn@npm:^7.0.6":
|
|
||||||
version: 7.0.6
|
version: 7.0.6
|
||||||
resolution: "cross-spawn@npm:7.0.6"
|
resolution: "cross-spawn@npm:7.0.6"
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -9308,16 +9281,7 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"esquery@npm:^1.4.2, esquery@npm:^1.6.0":
|
"esquery@npm:^1.4.2, esquery@npm:^1.7.0":
|
||||||
version: 1.6.0
|
|
||||||
resolution: "esquery@npm:1.6.0"
|
|
||||||
dependencies:
|
|
||||||
estraverse: "npm:^5.1.0"
|
|
||||||
checksum: 10c0/cb9065ec605f9da7a76ca6dadb0619dfb611e37a81e318732977d90fab50a256b95fee2d925fba7c2f3f0523aa16f91587246693bc09bc34d5a59575fe6e93d2
|
|
||||||
languageName: node
|
|
||||||
linkType: hard
|
|
||||||
|
|
||||||
"esquery@npm:^1.7.0":
|
|
||||||
version: 1.7.0
|
version: 1.7.0
|
||||||
resolution: "esquery@npm:1.7.0"
|
resolution: "esquery@npm:1.7.0"
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -9326,6 +9290,15 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
|
"esquery@npm:^1.6.0":
|
||||||
|
version: 1.6.0
|
||||||
|
resolution: "esquery@npm:1.6.0"
|
||||||
|
dependencies:
|
||||||
|
estraverse: "npm:^5.1.0"
|
||||||
|
checksum: 10c0/cb9065ec605f9da7a76ca6dadb0619dfb611e37a81e318732977d90fab50a256b95fee2d925fba7c2f3f0523aa16f91587246693bc09bc34d5a59575fe6e93d2
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
"esrecurse@npm:^4.3.0":
|
"esrecurse@npm:^4.3.0":
|
||||||
version: 4.3.0
|
version: 4.3.0
|
||||||
resolution: "esrecurse@npm:4.3.0"
|
resolution: "esrecurse@npm:4.3.0"
|
||||||
@@ -9700,13 +9673,6 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"fs.realpath@npm:^1.0.0":
|
|
||||||
version: 1.0.0
|
|
||||||
resolution: "fs.realpath@npm:1.0.0"
|
|
||||||
checksum: 10c0/444cf1291d997165dfd4c0d58b69f0e4782bfd9149fd72faa4fe299e68e0e93d6db941660b37dd29153bf7186672ececa3b50b7e7249477b03fdf850f287c948
|
|
||||||
languageName: node
|
|
||||||
linkType: hard
|
|
||||||
|
|
||||||
"fsevents@npm:2.3.2":
|
"fsevents@npm:2.3.2":
|
||||||
version: 2.3.2
|
version: 2.3.2
|
||||||
resolution: "fsevents@npm:2.3.2"
|
resolution: "fsevents@npm:2.3.2"
|
||||||
@@ -9899,9 +9865,9 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"glob@npm:^10.2.2, glob@npm:^10.3.10, glob@npm:^10.3.7":
|
"glob@npm:^10.5.0":
|
||||||
version: 10.4.5
|
version: 10.5.0
|
||||||
resolution: "glob@npm:10.4.5"
|
resolution: "glob@npm:10.5.0"
|
||||||
dependencies:
|
dependencies:
|
||||||
foreground-child: "npm:^3.1.0"
|
foreground-child: "npm:^3.1.0"
|
||||||
jackspeak: "npm:^3.1.2"
|
jackspeak: "npm:^3.1.2"
|
||||||
@@ -9911,33 +9877,7 @@ __metadata:
|
|||||||
path-scurry: "npm:^1.11.1"
|
path-scurry: "npm:^1.11.1"
|
||||||
bin:
|
bin:
|
||||||
glob: dist/esm/bin.mjs
|
glob: dist/esm/bin.mjs
|
||||||
checksum: 10c0/19a9759ea77b8e3ca0a43c2f07ecddc2ad46216b786bb8f993c445aee80d345925a21e5280c7b7c6c59e860a0154b84e4b2b60321fea92cd3c56b4a7489f160e
|
checksum: 10c0/100705eddbde6323e7b35e1d1ac28bcb58322095bd8e63a7d0bef1a2cdafe0d0f7922a981b2b48369a4f8c1b077be5c171804534c3509dfe950dde15fbe6d828
|
||||||
languageName: node
|
|
||||||
linkType: hard
|
|
||||||
|
|
||||||
"glob@npm:^7.1.3, glob@npm:~7.2.0":
|
|
||||||
version: 7.2.3
|
|
||||||
resolution: "glob@npm:7.2.3"
|
|
||||||
dependencies:
|
|
||||||
fs.realpath: "npm:^1.0.0"
|
|
||||||
inflight: "npm:^1.0.4"
|
|
||||||
inherits: "npm:2"
|
|
||||||
minimatch: "npm:^3.1.1"
|
|
||||||
once: "npm:^1.3.0"
|
|
||||||
path-is-absolute: "npm:^1.0.0"
|
|
||||||
checksum: 10c0/65676153e2b0c9095100fe7f25a778bf45608eeb32c6048cf307f579649bcc30353277b3b898a3792602c65764e5baa4f643714dfbdfd64ea271d210c7a425fe
|
|
||||||
languageName: node
|
|
||||||
linkType: hard
|
|
||||||
|
|
||||||
"glob@npm:^9.3.2":
|
|
||||||
version: 9.3.5
|
|
||||||
resolution: "glob@npm:9.3.5"
|
|
||||||
dependencies:
|
|
||||||
fs.realpath: "npm:^1.0.0"
|
|
||||||
minimatch: "npm:^8.0.2"
|
|
||||||
minipass: "npm:^4.2.4"
|
|
||||||
path-scurry: "npm:^1.6.1"
|
|
||||||
checksum: 10c0/2f6c2b9ee019ee21dc258ae97a88719614591e4c979cb4580b1b9df6f0f778a3cb38b4bdaf18dfa584637ea10f89a3c5f2533a5e449cf8741514ad18b0951f2e
|
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
@@ -10384,7 +10324,17 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"import-fresh@npm:^3.2.1, import-fresh@npm:^3.3.0":
|
"import-fresh@npm:^3.2.1":
|
||||||
|
version: 3.3.1
|
||||||
|
resolution: "import-fresh@npm:3.3.1"
|
||||||
|
dependencies:
|
||||||
|
parent-module: "npm:^1.0.0"
|
||||||
|
resolve-from: "npm:^4.0.0"
|
||||||
|
checksum: 10c0/bf8cc494872fef783249709385ae883b447e3eb09db0ebd15dcead7d9afe7224dad7bd7591c6b73b0b19b3c0f9640eb8ee884f01cfaf2887ab995b0b36a0cbec
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
|
"import-fresh@npm:^3.3.0":
|
||||||
version: 3.3.0
|
version: 3.3.0
|
||||||
resolution: "import-fresh@npm:3.3.0"
|
resolution: "import-fresh@npm:3.3.0"
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -10408,17 +10358,7 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"inflight@npm:^1.0.4":
|
"inherits@npm:^2.0.1, inherits@npm:^2.0.3, inherits@npm:^2.0.4, inherits@npm:~2.0.3, inherits@npm:~2.0.4":
|
||||||
version: 1.0.6
|
|
||||||
resolution: "inflight@npm:1.0.6"
|
|
||||||
dependencies:
|
|
||||||
once: "npm:^1.3.0"
|
|
||||||
wrappy: "npm:1"
|
|
||||||
checksum: 10c0/7faca22584600a9dc5b9fca2cd5feb7135ac8c935449837b315676b4c90aa4f391ec4f42240178244b5a34e8bede1948627fda392ca3191522fc46b34e985ab2
|
|
||||||
languageName: node
|
|
||||||
linkType: hard
|
|
||||||
|
|
||||||
"inherits@npm:2, inherits@npm:^2.0.1, inherits@npm:^2.0.3, inherits@npm:^2.0.4, inherits@npm:~2.0.3, inherits@npm:~2.0.4":
|
|
||||||
version: 2.0.4
|
version: 2.0.4
|
||||||
resolution: "inherits@npm:2.0.4"
|
resolution: "inherits@npm:2.0.4"
|
||||||
checksum: 10c0/4e531f648b29039fb7426fb94075e6545faa1eb9fe83c29f0b6d9e7263aceb4289d2d4557db0d428188eeb449cc7c5e77b0a0b2c4e248ff2a65933a0dee49ef2
|
checksum: 10c0/4e531f648b29039fb7426fb94075e6545faa1eb9fe83c29f0b6d9e7263aceb4289d2d4557db0d428188eeb449cc7c5e77b0a0b2c4e248ff2a65933a0dee49ef2
|
||||||
@@ -10948,14 +10888,14 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"js-yaml@npm:^4.1.0":
|
"js-yaml@npm:^4.1.1":
|
||||||
version: 4.1.0
|
version: 4.1.1
|
||||||
resolution: "js-yaml@npm:4.1.0"
|
resolution: "js-yaml@npm:4.1.1"
|
||||||
dependencies:
|
dependencies:
|
||||||
argparse: "npm:^2.0.1"
|
argparse: "npm:^2.0.1"
|
||||||
bin:
|
bin:
|
||||||
js-yaml: bin/js-yaml.js
|
js-yaml: bin/js-yaml.js
|
||||||
checksum: 10c0/184a24b4eaacfce40ad9074c64fd42ac83cf74d8c8cd137718d456ced75051229e5061b8633c3366b8aada17945a7a356b337828c19da92b51ae62126575018f
|
checksum: 10c0/561c7d7088c40a9bb53cc75becbfb1df6ae49b34b5e6e5a81744b14ae8667ec564ad2527709d1a6e7d5e5fa6d483aa0f373a50ad98d42fde368ec4a190d4fae7
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
@@ -11606,13 +11546,6 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"minipass@npm:^4.2.4":
|
|
||||||
version: 4.2.8
|
|
||||||
resolution: "minipass@npm:4.2.8"
|
|
||||||
checksum: 10c0/4ea76b030d97079f4429d6e8a8affd90baf1b6a1898977c8ccce4701c5a2ba2792e033abc6709373f25c2c4d4d95440d9d5e9464b46b7b76ca44d2ce26d939ce
|
|
||||||
languageName: node
|
|
||||||
linkType: hard
|
|
||||||
|
|
||||||
"minipass@npm:^5.0.0 || ^6.0.2 || ^7.0.0, minipass@npm:^7.0.2, minipass@npm:^7.0.3, minipass@npm:^7.0.4, minipass@npm:^7.1.2":
|
"minipass@npm:^5.0.0 || ^6.0.2 || ^7.0.0, minipass@npm:^7.0.2, minipass@npm:^7.0.3, minipass@npm:^7.0.4, minipass@npm:^7.1.2":
|
||||||
version: 7.1.2
|
version: 7.1.2
|
||||||
resolution: "minipass@npm:7.1.2"
|
resolution: "minipass@npm:7.1.2"
|
||||||
@@ -11990,7 +11923,7 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"once@npm:^1.3.0, once@npm:^1.4.0":
|
"once@npm:^1.4.0":
|
||||||
version: 1.4.0
|
version: 1.4.0
|
||||||
resolution: "once@npm:1.4.0"
|
resolution: "once@npm:1.4.0"
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -12292,13 +12225,6 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"path-is-absolute@npm:^1.0.0":
|
|
||||||
version: 1.0.1
|
|
||||||
resolution: "path-is-absolute@npm:1.0.1"
|
|
||||||
checksum: 10c0/127da03c82172a2a50099cddbf02510c1791fc2cc5f7713ddb613a56838db1e8168b121a920079d052e0936c23005562059756d653b7c544c53185efe53be078
|
|
||||||
languageName: node
|
|
||||||
linkType: hard
|
|
||||||
|
|
||||||
"path-key@npm:^3.1.0":
|
"path-key@npm:^3.1.0":
|
||||||
version: 3.1.1
|
version: 3.1.1
|
||||||
resolution: "path-key@npm:3.1.1"
|
resolution: "path-key@npm:3.1.1"
|
||||||
@@ -12320,7 +12246,7 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"path-scurry@npm:^1.11.1, path-scurry@npm:^1.6.1":
|
"path-scurry@npm:^1.11.1":
|
||||||
version: 1.11.1
|
version: 1.11.1
|
||||||
resolution: "path-scurry@npm:1.11.1"
|
resolution: "path-scurry@npm:1.11.1"
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -13005,12 +12931,12 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"qs@npm:^6.12.3":
|
"qs@npm:^6.14.1":
|
||||||
version: 6.14.0
|
version: 6.15.0
|
||||||
resolution: "qs@npm:6.14.0"
|
resolution: "qs@npm:6.15.0"
|
||||||
dependencies:
|
dependencies:
|
||||||
side-channel: "npm:^1.1.0"
|
side-channel: "npm:^1.1.0"
|
||||||
checksum: 10c0/8ea5d91bf34f440598ee389d4a7d95820e3b837d3fd9f433871f7924801becaa0cd3b3b4628d49a7784d06a8aea9bc4554d2b6d8d584e2d221dc06238a42909c
|
checksum: 10c0/ff341078a78a991d8a48b4524d52949211447b4b1ad907f489cac0770cbc346a28e47304455c0320e5fb000f8762d64b03331e3b71865f663bf351bcba8cdb4b
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user