mirror of
https://github.com/vector-im/element-call.git
synced 2026-02-20 04:57:03 +00:00
Inform user that their camera is starting in Lobby (#2869)
* Inform user that their camera is starting Instead of just showing a grey box. * Review feedback * Show spinner from design suggestion * useMemo * Lint * Lint * Feedback from review * Use colour that actually exists * Refactor into Avatar superclass * . * Remove size limit behaviour * Add VideoPreview tests
This commit is contained in:
@@ -195,6 +195,7 @@
|
||||
"version": "{{productName}} version: {{version}}",
|
||||
"video_tile": {
|
||||
"always_show": "Always show",
|
||||
"camera_starting": "Video loading...",
|
||||
"change_fit_contain": "Fit to frame",
|
||||
"collapse": "Collapse",
|
||||
"expand": "Expand",
|
||||
|
||||
@@ -33,7 +33,7 @@ export const sizes = new Map([
|
||||
[Size.XL, 90],
|
||||
]);
|
||||
|
||||
interface Props {
|
||||
export interface Props {
|
||||
id: string;
|
||||
name: string;
|
||||
className?: string;
|
||||
|
||||
@@ -25,6 +25,17 @@ video.mirror {
|
||||
transform: scaleX(-1);
|
||||
}
|
||||
|
||||
.preview .cameraStarting {
|
||||
position: absolute;
|
||||
top: var(--cpd-space-10x);
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
color: var(--cpd-color-text-secondary);
|
||||
}
|
||||
|
||||
.avatarContainer {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
|
||||
73
src/room/VideoPreview.test.tsx
Normal file
73
src/room/VideoPreview.test.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { expect, describe, it, vi, beforeAll } from "vitest";
|
||||
import { render } from "@testing-library/react";
|
||||
|
||||
import { type MatrixInfo, VideoPreview } from "./VideoPreview";
|
||||
import { type MuteStates } from "./MuteStates";
|
||||
import { E2eeType } from "../e2ee/e2eeType";
|
||||
|
||||
function mockMuteStates({ audio = true, video = true } = {}): MuteStates {
|
||||
return {
|
||||
audio: { enabled: audio, setEnabled: vi.fn() },
|
||||
video: { enabled: video, setEnabled: vi.fn() },
|
||||
};
|
||||
}
|
||||
|
||||
describe("VideoPreview", () => {
|
||||
const matrixInfo: MatrixInfo = {
|
||||
userId: "@a:example.org",
|
||||
displayName: "Alice",
|
||||
avatarUrl: "",
|
||||
roomId: "",
|
||||
roomName: "",
|
||||
e2eeSystem: { kind: E2eeType.NONE },
|
||||
roomAlias: null,
|
||||
roomAvatar: null,
|
||||
};
|
||||
|
||||
beforeAll(() => {
|
||||
window.ResizeObserver = class ResizeObserver {
|
||||
public observe(): void {
|
||||
// do nothing
|
||||
}
|
||||
public unobserve(): void {
|
||||
// do nothing
|
||||
}
|
||||
public disconnect(): void {
|
||||
// do nothing
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
it("shows avatar with video disabled", () => {
|
||||
const { queryByRole } = render(
|
||||
<VideoPreview
|
||||
matrixInfo={matrixInfo}
|
||||
muteStates={mockMuteStates({ video: false })}
|
||||
videoTrack={null}
|
||||
children={<></>}
|
||||
/>,
|
||||
);
|
||||
expect(queryByRole("img", { name: "@a:example.org" })).toBeVisible();
|
||||
});
|
||||
|
||||
it("shows loading status with video enabled but no track", () => {
|
||||
const { queryByRole } = render(
|
||||
<VideoPreview
|
||||
matrixInfo={matrixInfo}
|
||||
muteStates={mockMuteStates({ video: true })}
|
||||
videoTrack={null}
|
||||
children={<></>}
|
||||
/>,
|
||||
);
|
||||
expect(queryByRole("status")).toHaveTextContent(
|
||||
"video_tile.camera_starting",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -5,12 +5,13 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, type FC, type ReactNode } from "react";
|
||||
import { useEffect, useMemo, useRef, type FC, type ReactNode } from "react";
|
||||
import useMeasure from "react-use-measure";
|
||||
import { facingModeFromLocalTrack, type LocalVideoTrack } from "livekit-client";
|
||||
import classNames from "classnames";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { Avatar } from "../Avatar";
|
||||
import { TileAvatar } from "../tile/TileAvatar";
|
||||
import styles from "./VideoPreview.module.css";
|
||||
import { type MuteStates } from "./MuteStates";
|
||||
import { type EncryptionSystem } from "../e2ee/sharedKeyManagement";
|
||||
@@ -39,6 +40,7 @@ export const VideoPreview: FC<Props> = ({
|
||||
videoTrack,
|
||||
children,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [previewRef, previewBounds] = useMeasure();
|
||||
|
||||
const videoEl = useRef<HTMLVideoElement | null>(null);
|
||||
@@ -53,6 +55,11 @@ export const VideoPreview: FC<Props> = ({
|
||||
};
|
||||
}, [videoTrack]);
|
||||
|
||||
const cameraIsStarting = useMemo(
|
||||
() => muteStates.video.enabled && !videoTrack,
|
||||
[muteStates.video.enabled, videoTrack],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={classNames(styles.preview)} ref={previewRef}>
|
||||
<video
|
||||
@@ -69,15 +76,23 @@ export const VideoPreview: FC<Props> = ({
|
||||
tabIndex={-1}
|
||||
disablePictureInPicture
|
||||
/>
|
||||
{!muteStates.video.enabled && (
|
||||
<div className={styles.avatarContainer}>
|
||||
<Avatar
|
||||
id={matrixInfo.userId}
|
||||
name={matrixInfo.displayName}
|
||||
size={Math.min(previewBounds.width, previewBounds.height) / 2}
|
||||
src={matrixInfo.avatarUrl}
|
||||
/>
|
||||
</div>
|
||||
{(!muteStates.video.enabled || cameraIsStarting) && (
|
||||
<>
|
||||
<div className={styles.avatarContainer}>
|
||||
{cameraIsStarting && (
|
||||
<div className={styles.cameraStarting} role="status">
|
||||
{t("video_tile.camera_starting")}
|
||||
</div>
|
||||
)}
|
||||
<TileAvatar
|
||||
id={matrixInfo.userId}
|
||||
name={matrixInfo.displayName}
|
||||
size={Math.min(previewBounds.width, previewBounds.height) / 2}
|
||||
src={matrixInfo.avatarUrl}
|
||||
loading={cameraIsStarting}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div className={styles.buttonBar}>{children}</div>
|
||||
</div>
|
||||
|
||||
20
src/tile/TileAvatar.module.css
Normal file
20
src/tile/TileAvatar.module.css
Normal file
@@ -0,0 +1,20 @@
|
||||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
.loading {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
opacity: 0.5;
|
||||
/* TODO: make this --cpd-color-fg-primary when available. */
|
||||
color: var(--cpd-color-text-primary);
|
||||
}
|
||||
27
src/tile/TileAvatar.test.tsx
Normal file
27
src/tile/TileAvatar.test.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { expect, describe, it } from "vitest";
|
||||
import { render } from "@testing-library/react";
|
||||
|
||||
import { TileAvatar } from "./TileAvatar";
|
||||
|
||||
describe("TileAvatar", () => {
|
||||
it("should show loading spinner when loading", () => {
|
||||
const { container } = render(
|
||||
<TileAvatar id="@a:example.org" name="Alice" size={96} loading={true} />,
|
||||
);
|
||||
expect(container.querySelector(".loading")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not show loading spinner when not loading", () => {
|
||||
const { container } = render(
|
||||
<TileAvatar id="@a:example.org" name="Alice" size={96} loading={false} />,
|
||||
);
|
||||
expect(container.querySelector(".loading")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
30
src/tile/TileAvatar.tsx
Normal file
30
src/tile/TileAvatar.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { type FC } from "react";
|
||||
import { InlineSpinner } from "@vector-im/compound-web";
|
||||
|
||||
import styles from "./TileAvatar.module.css";
|
||||
import { Avatar, type Props as AvatarProps } from "../Avatar";
|
||||
|
||||
interface Props extends AvatarProps {
|
||||
size: number;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
export const TileAvatar: FC<Props> = ({ size, loading, ...props }) => {
|
||||
return (
|
||||
<div>
|
||||
{loading && (
|
||||
<div className={styles.loading}>
|
||||
<InlineSpinner size={size / 3} />
|
||||
</div>
|
||||
)}
|
||||
<Avatar size={size} {...props} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user