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

This commit is contained in:
Half-Shot
2024-11-07 09:10:42 +00:00
60 changed files with 2437 additions and 1097 deletions

View File

@@ -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>,

View File

@@ -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}
/>

View File

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

View File

@@ -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}>

View File

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

View File

@@ -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}