mirror of
https://github.com/vector-im/element-call.git
synced 2026-07-09 18:29:21 +00:00
Merge remote-tracking branch 'origin/livekit' into hs/emoji-reactions
This commit is contained in:
@@ -9,12 +9,20 @@ import { RemoteTrackPublication } from "livekit-client";
|
||||
import { test, expect } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { axe } from "vitest-axe";
|
||||
import { of } from "rxjs";
|
||||
import { MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc/MatrixRTCSession";
|
||||
|
||||
import { GridTile } from "./GridTile";
|
||||
import { withRemoteMedia } from "../utils/test";
|
||||
import { GridTileViewModel } from "../state/TileViewModel";
|
||||
import { ReactionsProvider } from "../useReactions";
|
||||
|
||||
global.IntersectionObserver = class MockIntersectionObserver {
|
||||
public observe(): void {}
|
||||
public unobserve(): void {}
|
||||
public disconnect(): void {}
|
||||
} as unknown as typeof IntersectionObserver;
|
||||
|
||||
test("GridTile is accessible", async () => {
|
||||
await withRemoteMedia(
|
||||
{
|
||||
@@ -42,11 +50,10 @@ test("GridTile is accessible", async () => {
|
||||
const { container } = render(
|
||||
<ReactionsProvider rtcSession={fakeRtcSession}>
|
||||
<GridTile
|
||||
vm={vm}
|
||||
vm={new GridTileViewModel(of(vm))}
|
||||
onOpenProfile={() => {}}
|
||||
targetWidth={300}
|
||||
targetHeight={200}
|
||||
showVideo
|
||||
showSpeakingIndicators
|
||||
/>
|
||||
</ReactionsProvider>,
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
ReactNode,
|
||||
forwardRef,
|
||||
useCallback,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { animated } from "@react-spring/web";
|
||||
@@ -44,6 +45,8 @@ import {
|
||||
import { Slider } from "../Slider";
|
||||
import { MediaView } from "./MediaView";
|
||||
import { useLatest } from "../useLatest";
|
||||
import { GridTileViewModel } from "../state/TileViewModel";
|
||||
import { useMergedRefs } from "../useMergedRefs";
|
||||
import { useReactions } from "../useReactions";
|
||||
import { ReactionOption } from "../reactions";
|
||||
|
||||
@@ -53,7 +56,6 @@ interface TileProps {
|
||||
targetWidth: number;
|
||||
targetHeight: number;
|
||||
displayName: string;
|
||||
showVideo: boolean;
|
||||
showSpeakingIndicators: boolean;
|
||||
}
|
||||
|
||||
@@ -68,7 +70,6 @@ const UserMediaTile = forwardRef<HTMLDivElement, UserMediaTileProps>(
|
||||
(
|
||||
{
|
||||
vm,
|
||||
showVideo,
|
||||
showSpeakingIndicators,
|
||||
menuStart,
|
||||
menuEnd,
|
||||
@@ -81,6 +82,7 @@ const UserMediaTile = forwardRef<HTMLDivElement, UserMediaTileProps>(
|
||||
const { t } = useTranslation();
|
||||
const video = useObservableEagerState(vm.video);
|
||||
const unencryptedWarning = useObservableEagerState(vm.unencryptedWarning);
|
||||
const encryptionStatus = useObservableEagerState(vm.encryptionStatus);
|
||||
const audioEnabled = useObservableEagerState(vm.audioEnabled);
|
||||
const videoEnabled = useObservableEagerState(vm.videoEnabled);
|
||||
const speaking = useObservableEagerState(vm.speaking);
|
||||
@@ -92,7 +94,7 @@ const UserMediaTile = forwardRef<HTMLDivElement, UserMediaTileProps>(
|
||||
},
|
||||
[vm],
|
||||
);
|
||||
const { raisedHands, reactions } = useReactions();
|
||||
const { raisedHands, lowerHand, reactions } = useReactions();
|
||||
|
||||
const MicIcon = audioEnabled ? MicOnSolidIcon : MicOffSolidIcon;
|
||||
|
||||
@@ -113,6 +115,8 @@ const UserMediaTile = forwardRef<HTMLDivElement, UserMediaTileProps>(
|
||||
const handRaised: Date | undefined = raisedHands[vm.member?.userId ?? ""];
|
||||
const currentReaction: ReactionOption | undefined =
|
||||
reactions[vm.member?.userId ?? ""];
|
||||
const raisedHandOnClick =
|
||||
vm.local && handRaised ? (): void => void lowerHand() : undefined;
|
||||
|
||||
const showSpeaking = showSpeakingIndicators && speaking;
|
||||
|
||||
@@ -122,7 +126,8 @@ const UserMediaTile = forwardRef<HTMLDivElement, UserMediaTileProps>(
|
||||
video={video}
|
||||
member={vm.member}
|
||||
unencryptedWarning={unencryptedWarning}
|
||||
videoEnabled={videoEnabled && showVideo}
|
||||
encryptionStatus={encryptionStatus}
|
||||
videoEnabled={videoEnabled}
|
||||
videoFit={cropVideo ? "cover" : "contain"}
|
||||
className={classNames(className, styles.tile, {
|
||||
[styles.speaking]: showSpeaking,
|
||||
@@ -156,6 +161,7 @@ const UserMediaTile = forwardRef<HTMLDivElement, UserMediaTileProps>(
|
||||
}
|
||||
raisedHandTime={handRaised}
|
||||
currentReaction={currentReaction}
|
||||
raisedHandOnClick={raisedHandOnClick}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
@@ -281,25 +287,27 @@ const RemoteUserMediaTile = forwardRef<
|
||||
RemoteUserMediaTile.displayName = "RemoteUserMediaTile";
|
||||
|
||||
interface GridTileProps {
|
||||
vm: UserMediaViewModel;
|
||||
vm: GridTileViewModel;
|
||||
onOpenProfile: (() => void) | null;
|
||||
targetWidth: number;
|
||||
targetHeight: number;
|
||||
className?: string;
|
||||
style?: ComponentProps<typeof animated.div>["style"];
|
||||
showVideo: boolean;
|
||||
showSpeakingIndicators: boolean;
|
||||
}
|
||||
|
||||
export const GridTile = forwardRef<HTMLDivElement, GridTileProps>(
|
||||
({ vm, onOpenProfile, ...props }, ref) => {
|
||||
const displayName = useDisplayName(vm);
|
||||
({ vm, onOpenProfile, ...props }, theirRef) => {
|
||||
const ourRef = useRef<HTMLDivElement | null>(null);
|
||||
const ref = useMergedRefs(ourRef, theirRef);
|
||||
const media = useObservableEagerState(vm.media);
|
||||
const displayName = useDisplayName(media);
|
||||
|
||||
if (vm instanceof LocalUserMediaViewModel) {
|
||||
if (media instanceof LocalUserMediaViewModel) {
|
||||
return (
|
||||
<LocalUserMediaTile
|
||||
ref={ref}
|
||||
vm={vm}
|
||||
vm={media}
|
||||
onOpenProfile={onOpenProfile}
|
||||
displayName={displayName}
|
||||
{...props}
|
||||
@@ -309,7 +317,7 @@ export const GridTile = forwardRef<HTMLDivElement, GridTileProps>(
|
||||
return (
|
||||
<RemoteUserMediaTile
|
||||
ref={ref}
|
||||
vm={vm}
|
||||
vm={media}
|
||||
displayName={displayName}
|
||||
{...props}
|
||||
/>
|
||||
|
||||
@@ -85,7 +85,7 @@ unconditionally select the container so we can use cqmin units */
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
grid-template-rows: 1fr auto;
|
||||
grid-template-areas: ". ." "nameTag button";
|
||||
grid-template-areas: "status status" "nameTag button";
|
||||
gap: var(--cpd-space-1x);
|
||||
place-items: start;
|
||||
}
|
||||
@@ -106,6 +106,25 @@ unconditionally select the container so we can use cqmin units */
|
||||
max-inline-size: 100%;
|
||||
}
|
||||
|
||||
.status {
|
||||
grid-area: status;
|
||||
justify-self: center;
|
||||
align-self: start;
|
||||
padding: var(--cpd-space-1x);
|
||||
padding-block: var(--cpd-space-1x);
|
||||
color: var(--cpd-color-text-primary);
|
||||
background-color: var(--cpd-color-bg-canvas-default);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border-radius: var(--cpd-radius-pill-effect);
|
||||
user-select: none;
|
||||
overflow: hidden;
|
||||
box-shadow: var(--small-drop-shadow);
|
||||
box-sizing: border-box;
|
||||
max-inline-size: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.nameTag > svg,
|
||||
.nameTag > span {
|
||||
flex-shrink: 0;
|
||||
|
||||
@@ -17,6 +17,7 @@ import { ErrorIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||
|
||||
import styles from "./MediaView.module.css";
|
||||
import { Avatar } from "../Avatar";
|
||||
import { EncryptionStatus } from "../state/MediaViewModel";
|
||||
import { RaisedHandIndicator } from "../reactions/RaisedHandIndicator";
|
||||
import { showHandRaisedTimer, useSetting } from "../settings/settings";
|
||||
import { ReactionOption } from "../reactions";
|
||||
@@ -33,11 +34,13 @@ interface Props extends ComponentProps<typeof animated.div> {
|
||||
member: RoomMember | undefined;
|
||||
videoEnabled: boolean;
|
||||
unencryptedWarning: boolean;
|
||||
encryptionStatus: EncryptionStatus;
|
||||
nameTagLeadingIcon?: ReactNode;
|
||||
displayName: string;
|
||||
primaryButton?: ReactNode;
|
||||
raisedHandTime?: Date;
|
||||
currentReaction?: ReactionOption;
|
||||
raisedHandOnClick?: () => void;
|
||||
}
|
||||
|
||||
export const MediaView = forwardRef<HTMLDivElement, Props>(
|
||||
@@ -56,8 +59,10 @@ export const MediaView = forwardRef<HTMLDivElement, Props>(
|
||||
nameTagLeadingIcon,
|
||||
displayName,
|
||||
primaryButton,
|
||||
encryptionStatus,
|
||||
raisedHandTime,
|
||||
currentReaction,
|
||||
raisedHandOnClick,
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
@@ -110,6 +115,26 @@ export const MediaView = forwardRef<HTMLDivElement, Props>(
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{encryptionStatus !== EncryptionStatus.Okay && (
|
||||
<div className={styles.status}>
|
||||
<Text as="span" size="sm" weight="medium" className={styles.name}>
|
||||
{encryptionStatus === EncryptionStatus.Connecting &&
|
||||
t("e2ee_encryption_status.connecting")}
|
||||
{encryptionStatus === EncryptionStatus.KeyMissing &&
|
||||
t("e2ee_encryption_status.key_missing")}
|
||||
{encryptionStatus === EncryptionStatus.KeyInvalid &&
|
||||
t("e2ee_encryption_status.key_invalid")}
|
||||
{encryptionStatus === EncryptionStatus.PasswordInvalid &&
|
||||
t("e2ee_encryption_status.password_invalid")}
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
<RaisedHandIndicator
|
||||
raisedHandTime={raisedHandTime}
|
||||
miniature={avatarSize < 96}
|
||||
showTimer={handRaiseTimerVisible}
|
||||
onClick={raisedHandOnClick}
|
||||
/>
|
||||
<div className={styles.nameTag}>
|
||||
{nameTagLeadingIcon}
|
||||
<Text as="span" size="sm" weight="medium" className={styles.name}>
|
||||
|
||||
@@ -9,9 +9,11 @@ import { test, expect, vi } from "vitest";
|
||||
import { isInaccessible, render, screen } from "@testing-library/react";
|
||||
import { axe } from "vitest-axe";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { of } from "rxjs";
|
||||
|
||||
import { SpotlightTile } from "./SpotlightTile";
|
||||
import { withLocalMedia, withRemoteMedia } from "../utils/test";
|
||||
import { SpotlightTileViewModel } from "../state/TileViewModel";
|
||||
|
||||
global.IntersectionObserver = class MockIntersectionObserver {
|
||||
public observe(): void {}
|
||||
@@ -36,10 +38,9 @@ test("SpotlightTile is accessible", async () => {
|
||||
const toggleExpanded = vi.fn();
|
||||
const { container } = render(
|
||||
<SpotlightTile
|
||||
vms={[vm1, vm2]}
|
||||
vm={new SpotlightTileViewModel(of([vm1, vm2]), of(false))}
|
||||
targetWidth={300}
|
||||
targetHeight={200}
|
||||
maximised={false}
|
||||
expanded={false}
|
||||
onToggleExpanded={toggleExpanded}
|
||||
showIndicators
|
||||
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
} from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||
import { animated } from "@react-spring/web";
|
||||
import { Observable, map } from "rxjs";
|
||||
import { useObservableEagerState } from "observable-hooks";
|
||||
import { useObservableEagerState, useObservableRef } from "observable-hooks";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import classNames from "classnames";
|
||||
import { TrackReferenceOrPlaceholder } from "@livekit/components-core";
|
||||
@@ -31,6 +31,7 @@ import { RoomMember } from "matrix-js-sdk/src/matrix";
|
||||
import { MediaView } from "./MediaView";
|
||||
import styles from "./SpotlightTile.module.css";
|
||||
import {
|
||||
EncryptionStatus,
|
||||
LocalUserMediaViewModel,
|
||||
MediaViewModel,
|
||||
ScreenShareViewModel,
|
||||
@@ -39,9 +40,9 @@ import {
|
||||
} from "../state/MediaViewModel";
|
||||
import { useInitial } from "../useInitial";
|
||||
import { useMergedRefs } from "../useMergedRefs";
|
||||
import { useObservableRef } from "../state/useObservable";
|
||||
import { useReactiveState } from "../useReactiveState";
|
||||
import { useLatest } from "../useLatest";
|
||||
import { SpotlightTileViewModel } from "../state/TileViewModel";
|
||||
|
||||
interface SpotlightItemBaseProps {
|
||||
className?: string;
|
||||
@@ -51,6 +52,7 @@ interface SpotlightItemBaseProps {
|
||||
video: TrackReferenceOrPlaceholder;
|
||||
member: RoomMember | undefined;
|
||||
unencryptedWarning: boolean;
|
||||
encryptionStatus: EncryptionStatus;
|
||||
displayName: string;
|
||||
"aria-hidden"?: boolean;
|
||||
}
|
||||
@@ -86,14 +88,16 @@ const SpotlightUserMediaItem = forwardRef<
|
||||
const videoEnabled = useObservableEagerState(vm.videoEnabled);
|
||||
const cropVideo = useObservableEagerState(vm.cropVideo);
|
||||
|
||||
const baseProps: SpotlightUserMediaItemBaseProps = {
|
||||
const baseProps: SpotlightUserMediaItemBaseProps &
|
||||
RefAttributes<HTMLDivElement> = {
|
||||
ref,
|
||||
videoEnabled,
|
||||
videoFit: cropVideo ? "cover" : "contain",
|
||||
...props,
|
||||
};
|
||||
|
||||
return vm instanceof LocalUserMediaViewModel ? (
|
||||
<SpotlightLocalUserMediaItem ref={ref} vm={vm} {...baseProps} />
|
||||
<SpotlightLocalUserMediaItem vm={vm} {...baseProps} />
|
||||
) : (
|
||||
<MediaView mirror={false} {...baseProps} />
|
||||
);
|
||||
@@ -130,6 +134,7 @@ const SpotlightItem = forwardRef<HTMLDivElement, SpotlightItemProps>(
|
||||
const displayName = useDisplayName(vm);
|
||||
const video = useObservableEagerState(vm.video);
|
||||
const unencryptedWarning = useObservableEagerState(vm.unencryptedWarning);
|
||||
const encryptionStatus = useObservableEagerState(vm.encryptionStatus);
|
||||
|
||||
// Hook this item up to the intersection observer
|
||||
useEffect(() => {
|
||||
@@ -156,6 +161,7 @@ const SpotlightItem = forwardRef<HTMLDivElement, SpotlightItemProps>(
|
||||
member: vm.member,
|
||||
unencryptedWarning,
|
||||
displayName,
|
||||
encryptionStatus,
|
||||
"aria-hidden": ariaHidden,
|
||||
};
|
||||
|
||||
@@ -175,8 +181,7 @@ const SpotlightItem = forwardRef<HTMLDivElement, SpotlightItemProps>(
|
||||
SpotlightItem.displayName = "SpotlightItem";
|
||||
|
||||
interface Props {
|
||||
vms: MediaViewModel[];
|
||||
maximised: boolean;
|
||||
vm: SpotlightTileViewModel;
|
||||
expanded: boolean;
|
||||
onToggleExpanded: (() => void) | null;
|
||||
targetWidth: number;
|
||||
@@ -189,8 +194,7 @@ interface Props {
|
||||
export const SpotlightTile = forwardRef<HTMLDivElement, Props>(
|
||||
(
|
||||
{
|
||||
vms,
|
||||
maximised,
|
||||
vm,
|
||||
expanded,
|
||||
onToggleExpanded,
|
||||
targetWidth,
|
||||
@@ -202,14 +206,16 @@ export const SpotlightTile = forwardRef<HTMLDivElement, Props>(
|
||||
theirRef,
|
||||
) => {
|
||||
const { t } = useTranslation();
|
||||
const [root, ourRef] = useObservableRef<HTMLDivElement | null>(null);
|
||||
const [ourRef, root] = useObservableRef<HTMLDivElement | null>(null);
|
||||
const ref = useMergedRefs(ourRef, theirRef);
|
||||
const [visibleId, setVisibleId] = useState(vms[0].id);
|
||||
const latestVms = useLatest(vms);
|
||||
const maximised = useObservableEagerState(vm.maximised);
|
||||
const media = useObservableEagerState(vm.media);
|
||||
const [visibleId, setVisibleId] = useState(media[0].id);
|
||||
const latestMedia = useLatest(media);
|
||||
const latestVisibleId = useLatest(visibleId);
|
||||
const visibleIndex = vms.findIndex((vm) => vm.id === visibleId);
|
||||
const visibleIndex = media.findIndex((vm) => vm.id === visibleId);
|
||||
const canGoBack = visibleIndex > 0;
|
||||
const canGoToNext = visibleIndex !== -1 && visibleIndex < vms.length - 1;
|
||||
const canGoToNext = visibleIndex !== -1 && visibleIndex < media.length - 1;
|
||||
|
||||
// To keep track of which item is visible, we need an intersection observer
|
||||
// hooked up to the root element and the items. Because the items will run
|
||||
@@ -234,28 +240,30 @@ export const SpotlightTile = forwardRef<HTMLDivElement, Props>(
|
||||
|
||||
const [scrollToId, setScrollToId] = useReactiveState<string | null>(
|
||||
(prev) =>
|
||||
prev == null || prev === visibleId || vms.every((vm) => vm.id !== prev)
|
||||
prev == null ||
|
||||
prev === visibleId ||
|
||||
media.every((vm) => vm.id !== prev)
|
||||
? null
|
||||
: prev,
|
||||
[visibleId],
|
||||
);
|
||||
|
||||
const onBackClick = useCallback(() => {
|
||||
const vms = latestVms.current;
|
||||
const visibleIndex = vms.findIndex(
|
||||
const media = latestMedia.current;
|
||||
const visibleIndex = media.findIndex(
|
||||
(vm) => vm.id === latestVisibleId.current,
|
||||
);
|
||||
if (visibleIndex > 0) setScrollToId(vms[visibleIndex - 1].id);
|
||||
}, [latestVisibleId, latestVms, setScrollToId]);
|
||||
if (visibleIndex > 0) setScrollToId(media[visibleIndex - 1].id);
|
||||
}, [latestVisibleId, latestMedia, setScrollToId]);
|
||||
|
||||
const onNextClick = useCallback(() => {
|
||||
const vms = latestVms.current;
|
||||
const visibleIndex = vms.findIndex(
|
||||
const media = latestMedia.current;
|
||||
const visibleIndex = media.findIndex(
|
||||
(vm) => vm.id === latestVisibleId.current,
|
||||
);
|
||||
if (visibleIndex !== -1 && visibleIndex !== vms.length - 1)
|
||||
setScrollToId(vms[visibleIndex + 1].id);
|
||||
}, [latestVisibleId, latestVms, setScrollToId]);
|
||||
if (visibleIndex !== -1 && visibleIndex !== media.length - 1)
|
||||
setScrollToId(media[visibleIndex + 1].id);
|
||||
}, [latestVisibleId, latestMedia, setScrollToId]);
|
||||
|
||||
const ToggleExpandIcon = expanded ? CollapseIcon : ExpandIcon;
|
||||
|
||||
@@ -277,7 +285,7 @@ export const SpotlightTile = forwardRef<HTMLDivElement, Props>(
|
||||
</button>
|
||||
)}
|
||||
<div className={styles.contents}>
|
||||
{vms.map((vm) => (
|
||||
{media.map((vm) => (
|
||||
<SpotlightItem
|
||||
key={vm.id}
|
||||
vm={vm}
|
||||
@@ -316,10 +324,10 @@ export const SpotlightTile = forwardRef<HTMLDivElement, Props>(
|
||||
{!expanded && (
|
||||
<div
|
||||
className={classNames(styles.indicators, {
|
||||
[styles.show]: showIndicators && vms.length > 1,
|
||||
[styles.show]: showIndicators && media.length > 1,
|
||||
})}
|
||||
>
|
||||
{vms.map((vm) => (
|
||||
{media.map((vm) => (
|
||||
<div
|
||||
key={vm.id}
|
||||
className={styles.item}
|
||||
|
||||
Reference in New Issue
Block a user