Remove usages of forwardRef
It has been deprecated in React 19, which allows functional components to receive refs just like any other prop.
This commit is contained in:
@@ -7,8 +7,9 @@ Please see LICENSE in the repository root for full details.
|
||||
|
||||
import {
|
||||
type ComponentProps,
|
||||
type FC,
|
||||
type ReactNode,
|
||||
forwardRef,
|
||||
type Ref,
|
||||
useCallback,
|
||||
useRef,
|
||||
useState,
|
||||
@@ -50,6 +51,7 @@ import { useMergedRefs } from "../useMergedRefs";
|
||||
import { useReactionsSender } from "../reactions/useReactionsSender";
|
||||
|
||||
interface TileProps {
|
||||
ref?: Ref<HTMLDivElement>;
|
||||
className?: string;
|
||||
style?: ComponentProps<typeof animated.div>["style"];
|
||||
targetWidth: number;
|
||||
@@ -66,132 +68,128 @@ interface UserMediaTileProps extends TileProps {
|
||||
menuEnd?: ReactNode;
|
||||
}
|
||||
|
||||
const UserMediaTile = forwardRef<HTMLDivElement, UserMediaTileProps>(
|
||||
(
|
||||
{
|
||||
vm,
|
||||
showSpeakingIndicators,
|
||||
locallyMuted,
|
||||
menuStart,
|
||||
menuEnd,
|
||||
className,
|
||||
displayName,
|
||||
...props
|
||||
const UserMediaTile: FC<UserMediaTileProps> = ({
|
||||
ref,
|
||||
vm,
|
||||
showSpeakingIndicators,
|
||||
locallyMuted,
|
||||
menuStart,
|
||||
menuEnd,
|
||||
className,
|
||||
displayName,
|
||||
...props
|
||||
}) => {
|
||||
const { toggleRaisedHand } = useReactionsSender();
|
||||
const { t } = useTranslation();
|
||||
const video = useObservableEagerState(vm.video$);
|
||||
const unencryptedWarning = useObservableEagerState(vm.unencryptedWarning$);
|
||||
const encryptionStatus = useObservableEagerState(vm.encryptionStatus$);
|
||||
const audioStreamStats = useObservableEagerState<
|
||||
RTCInboundRtpStreamStats | RTCOutboundRtpStreamStats | undefined
|
||||
>(vm.audioStreamStats$);
|
||||
const videoStreamStats = useObservableEagerState<
|
||||
RTCInboundRtpStreamStats | RTCOutboundRtpStreamStats | undefined
|
||||
>(vm.videoStreamStats$);
|
||||
const audioEnabled = useObservableEagerState(vm.audioEnabled$);
|
||||
const videoEnabled = useObservableEagerState(vm.videoEnabled$);
|
||||
const speaking = useObservableEagerState(vm.speaking$);
|
||||
const cropVideo = useObservableEagerState(vm.cropVideo$);
|
||||
const onSelectFitContain = useCallback(
|
||||
(e: Event) => {
|
||||
e.preventDefault();
|
||||
vm.toggleFitContain();
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const { toggleRaisedHand } = useReactionsSender();
|
||||
const { t } = useTranslation();
|
||||
const video = useObservableEagerState(vm.video$);
|
||||
const unencryptedWarning = useObservableEagerState(vm.unencryptedWarning$);
|
||||
const encryptionStatus = useObservableEagerState(vm.encryptionStatus$);
|
||||
const audioStreamStats = useObservableEagerState<
|
||||
RTCInboundRtpStreamStats | RTCOutboundRtpStreamStats | undefined
|
||||
>(vm.audioStreamStats$);
|
||||
const videoStreamStats = useObservableEagerState<
|
||||
RTCInboundRtpStreamStats | RTCOutboundRtpStreamStats | undefined
|
||||
>(vm.videoStreamStats$);
|
||||
const audioEnabled = useObservableEagerState(vm.audioEnabled$);
|
||||
const videoEnabled = useObservableEagerState(vm.videoEnabled$);
|
||||
const speaking = useObservableEagerState(vm.speaking$);
|
||||
const cropVideo = useObservableEagerState(vm.cropVideo$);
|
||||
const onSelectFitContain = useCallback(
|
||||
(e: Event) => {
|
||||
e.preventDefault();
|
||||
vm.toggleFitContain();
|
||||
},
|
||||
[vm],
|
||||
);
|
||||
const handRaised = useObservableState(vm.handRaised$);
|
||||
const reaction = useObservableState(vm.reaction$);
|
||||
[vm],
|
||||
);
|
||||
const handRaised = useObservableState(vm.handRaised$);
|
||||
const reaction = useObservableState(vm.reaction$);
|
||||
|
||||
const AudioIcon = locallyMuted
|
||||
? VolumeOffSolidIcon
|
||||
: audioEnabled
|
||||
? MicOnSolidIcon
|
||||
: MicOffSolidIcon;
|
||||
const audioIconLabel = locallyMuted
|
||||
? t("video_tile.muted_for_me")
|
||||
: audioEnabled
|
||||
? t("microphone_on")
|
||||
: t("microphone_off");
|
||||
const AudioIcon = locallyMuted
|
||||
? VolumeOffSolidIcon
|
||||
: audioEnabled
|
||||
? MicOnSolidIcon
|
||||
: MicOffSolidIcon;
|
||||
const audioIconLabel = locallyMuted
|
||||
? t("video_tile.muted_for_me")
|
||||
: audioEnabled
|
||||
? t("microphone_on")
|
||||
: t("microphone_off");
|
||||
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const menu = (
|
||||
<>
|
||||
{menuStart}
|
||||
<ToggleMenuItem
|
||||
Icon={ExpandIcon}
|
||||
label={t("video_tile.change_fit_contain")}
|
||||
checked={cropVideo}
|
||||
onSelect={onSelectFitContain}
|
||||
/>
|
||||
{menuEnd}
|
||||
</>
|
||||
);
|
||||
|
||||
const raisedHandOnClick = vm.local
|
||||
? (): void => void toggleRaisedHand()
|
||||
: undefined;
|
||||
|
||||
const showSpeaking = showSpeakingIndicators && speaking;
|
||||
|
||||
const tile = (
|
||||
<MediaView
|
||||
ref={ref}
|
||||
video={video}
|
||||
member={vm.member}
|
||||
unencryptedWarning={unencryptedWarning}
|
||||
encryptionStatus={encryptionStatus}
|
||||
videoEnabled={videoEnabled}
|
||||
videoFit={cropVideo ? "cover" : "contain"}
|
||||
className={classNames(className, styles.tile, {
|
||||
[styles.speaking]: showSpeaking,
|
||||
[styles.handRaised]: !showSpeaking && handRaised,
|
||||
})}
|
||||
nameTagLeadingIcon={
|
||||
<AudioIcon
|
||||
width={20}
|
||||
height={20}
|
||||
aria-label={audioIconLabel}
|
||||
data-muted={locallyMuted || !audioEnabled}
|
||||
className={styles.muteIcon}
|
||||
/>
|
||||
}
|
||||
displayName={displayName}
|
||||
primaryButton={
|
||||
<Menu
|
||||
open={menuOpen}
|
||||
onOpenChange={setMenuOpen}
|
||||
title={displayName}
|
||||
trigger={
|
||||
<button aria-label={t("common.options")}>
|
||||
<OverflowHorizontalIcon aria-hidden width={20} height={20} />
|
||||
</button>
|
||||
}
|
||||
side="left"
|
||||
align="start"
|
||||
>
|
||||
{menu}
|
||||
</Menu>
|
||||
}
|
||||
raisedHandTime={handRaised ?? undefined}
|
||||
currentReaction={reaction ?? undefined}
|
||||
raisedHandOnClick={raisedHandOnClick}
|
||||
localParticipant={vm.local}
|
||||
audioStreamStats={audioStreamStats}
|
||||
videoStreamStats={videoStreamStats}
|
||||
{...props}
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const menu = (
|
||||
<>
|
||||
{menuStart}
|
||||
<ToggleMenuItem
|
||||
Icon={ExpandIcon}
|
||||
label={t("video_tile.change_fit_contain")}
|
||||
checked={cropVideo}
|
||||
onSelect={onSelectFitContain}
|
||||
/>
|
||||
);
|
||||
{menuEnd}
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<ContextMenu title={displayName} trigger={tile} hasAccessibleAlternative>
|
||||
{menu}
|
||||
</ContextMenu>
|
||||
);
|
||||
},
|
||||
);
|
||||
const raisedHandOnClick = vm.local
|
||||
? (): void => void toggleRaisedHand()
|
||||
: undefined;
|
||||
|
||||
const showSpeaking = showSpeakingIndicators && speaking;
|
||||
|
||||
const tile = (
|
||||
<MediaView
|
||||
ref={ref}
|
||||
video={video}
|
||||
member={vm.member}
|
||||
unencryptedWarning={unencryptedWarning}
|
||||
encryptionStatus={encryptionStatus}
|
||||
videoEnabled={videoEnabled}
|
||||
videoFit={cropVideo ? "cover" : "contain"}
|
||||
className={classNames(className, styles.tile, {
|
||||
[styles.speaking]: showSpeaking,
|
||||
[styles.handRaised]: !showSpeaking && handRaised,
|
||||
})}
|
||||
nameTagLeadingIcon={
|
||||
<AudioIcon
|
||||
width={20}
|
||||
height={20}
|
||||
aria-label={audioIconLabel}
|
||||
data-muted={locallyMuted || !audioEnabled}
|
||||
className={styles.muteIcon}
|
||||
/>
|
||||
}
|
||||
displayName={displayName}
|
||||
primaryButton={
|
||||
<Menu
|
||||
open={menuOpen}
|
||||
onOpenChange={setMenuOpen}
|
||||
title={displayName}
|
||||
trigger={
|
||||
<button aria-label={t("common.options")}>
|
||||
<OverflowHorizontalIcon aria-hidden width={20} height={20} />
|
||||
</button>
|
||||
}
|
||||
side="left"
|
||||
align="start"
|
||||
>
|
||||
{menu}
|
||||
</Menu>
|
||||
}
|
||||
raisedHandTime={handRaised ?? undefined}
|
||||
currentReaction={reaction ?? undefined}
|
||||
raisedHandOnClick={raisedHandOnClick}
|
||||
localParticipant={vm.local}
|
||||
audioStreamStats={audioStreamStats}
|
||||
videoStreamStats={videoStreamStats}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<ContextMenu title={displayName} trigger={tile} hasAccessibleAlternative>
|
||||
{menu}
|
||||
</ContextMenu>
|
||||
);
|
||||
};
|
||||
|
||||
UserMediaTile.displayName = "UserMediaTile";
|
||||
|
||||
@@ -200,48 +198,51 @@ interface LocalUserMediaTileProps extends TileProps {
|
||||
onOpenProfile: (() => void) | null;
|
||||
}
|
||||
|
||||
const LocalUserMediaTile = forwardRef<HTMLDivElement, LocalUserMediaTileProps>(
|
||||
({ vm, onOpenProfile, ...props }, ref) => {
|
||||
const { t } = useTranslation();
|
||||
const mirror = useObservableEagerState(vm.mirror$);
|
||||
const alwaysShow = useObservableEagerState(vm.alwaysShow$);
|
||||
const latestAlwaysShow = useLatest(alwaysShow);
|
||||
const onSelectAlwaysShow = useCallback(
|
||||
(e: Event) => {
|
||||
e.preventDefault();
|
||||
vm.setAlwaysShow(!latestAlwaysShow.current);
|
||||
},
|
||||
[vm, latestAlwaysShow],
|
||||
);
|
||||
const LocalUserMediaTile: FC<LocalUserMediaTileProps> = ({
|
||||
ref,
|
||||
vm,
|
||||
onOpenProfile,
|
||||
...props
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const mirror = useObservableEagerState(vm.mirror$);
|
||||
const alwaysShow = useObservableEagerState(vm.alwaysShow$);
|
||||
const latestAlwaysShow = useLatest(alwaysShow);
|
||||
const onSelectAlwaysShow = useCallback(
|
||||
(e: Event) => {
|
||||
e.preventDefault();
|
||||
vm.setAlwaysShow(!latestAlwaysShow.current);
|
||||
},
|
||||
[vm, latestAlwaysShow],
|
||||
);
|
||||
|
||||
return (
|
||||
<UserMediaTile
|
||||
ref={ref}
|
||||
vm={vm}
|
||||
locallyMuted={false}
|
||||
mirror={mirror}
|
||||
menuStart={
|
||||
<ToggleMenuItem
|
||||
Icon={VisibilityOnIcon}
|
||||
label={t("video_tile.always_show")}
|
||||
checked={alwaysShow}
|
||||
onSelect={onSelectAlwaysShow}
|
||||
return (
|
||||
<UserMediaTile
|
||||
ref={ref}
|
||||
vm={vm}
|
||||
locallyMuted={false}
|
||||
mirror={mirror}
|
||||
menuStart={
|
||||
<ToggleMenuItem
|
||||
Icon={VisibilityOnIcon}
|
||||
label={t("video_tile.always_show")}
|
||||
checked={alwaysShow}
|
||||
onSelect={onSelectAlwaysShow}
|
||||
/>
|
||||
}
|
||||
menuEnd={
|
||||
onOpenProfile && (
|
||||
<MenuItem
|
||||
Icon={UserProfileIcon}
|
||||
label={t("common.profile")}
|
||||
onSelect={onOpenProfile}
|
||||
/>
|
||||
}
|
||||
menuEnd={
|
||||
onOpenProfile && (
|
||||
<MenuItem
|
||||
Icon={UserProfileIcon}
|
||||
label={t("common.profile")}
|
||||
onSelect={onOpenProfile}
|
||||
/>
|
||||
)
|
||||
}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
)
|
||||
}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
LocalUserMediaTile.displayName = "LocalUserMediaTile";
|
||||
|
||||
@@ -249,10 +250,11 @@ interface RemoteUserMediaTileProps extends TileProps {
|
||||
vm: RemoteUserMediaViewModel;
|
||||
}
|
||||
|
||||
const RemoteUserMediaTile = forwardRef<
|
||||
HTMLDivElement,
|
||||
RemoteUserMediaTileProps
|
||||
>(({ vm, ...props }, ref) => {
|
||||
const RemoteUserMediaTile: FC<RemoteUserMediaTileProps> = ({
|
||||
ref,
|
||||
vm,
|
||||
...props
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const locallyMuted = useObservableEagerState(vm.locallyMuted$);
|
||||
const localVolume = useObservableEagerState(vm.localVolume$);
|
||||
@@ -303,11 +305,12 @@ const RemoteUserMediaTile = forwardRef<
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
RemoteUserMediaTile.displayName = "RemoteUserMediaTile";
|
||||
|
||||
interface GridTileProps {
|
||||
ref?: Ref<HTMLDivElement>;
|
||||
vm: GridTileViewModel;
|
||||
onOpenProfile: (() => void) | null;
|
||||
targetWidth: number;
|
||||
@@ -317,34 +320,37 @@ interface GridTileProps {
|
||||
showSpeakingIndicators: boolean;
|
||||
}
|
||||
|
||||
export const GridTile = forwardRef<HTMLDivElement, GridTileProps>(
|
||||
({ vm, onOpenProfile, ...props }, theirRef) => {
|
||||
const ourRef = useRef<HTMLDivElement | null>(null);
|
||||
const ref = useMergedRefs(ourRef, theirRef);
|
||||
const media = useObservableEagerState(vm.media$);
|
||||
const displayName = useObservableEagerState(media.displayname$);
|
||||
export const GridTile: FC<GridTileProps> = ({
|
||||
ref: theirRef,
|
||||
vm,
|
||||
onOpenProfile,
|
||||
...props
|
||||
}) => {
|
||||
const ourRef = useRef<HTMLDivElement | null>(null);
|
||||
const ref = useMergedRefs(ourRef, theirRef);
|
||||
const media = useObservableEagerState(vm.media$);
|
||||
const displayName = useObservableEagerState(media.displayname$);
|
||||
|
||||
if (media instanceof LocalUserMediaViewModel) {
|
||||
return (
|
||||
<LocalUserMediaTile
|
||||
ref={ref}
|
||||
vm={media}
|
||||
onOpenProfile={onOpenProfile}
|
||||
displayName={displayName}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<RemoteUserMediaTile
|
||||
ref={ref}
|
||||
vm={media}
|
||||
displayName={displayName}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
if (media instanceof LocalUserMediaViewModel) {
|
||||
return (
|
||||
<LocalUserMediaTile
|
||||
ref={ref}
|
||||
vm={media}
|
||||
onOpenProfile={onOpenProfile}
|
||||
displayName={displayName}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<RemoteUserMediaTile
|
||||
ref={ref}
|
||||
vm={media}
|
||||
displayName={displayName}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
GridTile.displayName = "GridTile";
|
||||
|
||||
@@ -8,7 +8,7 @@ Please see LICENSE in the repository root for full details.
|
||||
import { type TrackReferenceOrPlaceholder } from "@livekit/components-core";
|
||||
import { animated } from "@react-spring/web";
|
||||
import { type RoomMember } from "matrix-js-sdk";
|
||||
import { type ComponentProps, type ReactNode, forwardRef } from "react";
|
||||
import { type FC, type ComponentProps, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import classNames from "classnames";
|
||||
import { VideoTrack } from "@livekit/components-react";
|
||||
@@ -47,97 +47,94 @@ interface Props extends ComponentProps<typeof animated.div> {
|
||||
videoStreamStats?: RTCInboundRtpStreamStats | RTCOutboundRtpStreamStats;
|
||||
}
|
||||
|
||||
export const MediaView = forwardRef<HTMLDivElement, Props>(
|
||||
(
|
||||
{
|
||||
className,
|
||||
style,
|
||||
targetWidth,
|
||||
targetHeight,
|
||||
video,
|
||||
videoFit,
|
||||
mirror,
|
||||
member,
|
||||
videoEnabled,
|
||||
unencryptedWarning,
|
||||
nameTagLeadingIcon,
|
||||
displayName,
|
||||
primaryButton,
|
||||
encryptionStatus,
|
||||
raisedHandTime,
|
||||
currentReaction,
|
||||
raisedHandOnClick,
|
||||
localParticipant,
|
||||
audioStreamStats,
|
||||
videoStreamStats,
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const { t } = useTranslation();
|
||||
const [handRaiseTimerVisible] = useSetting(showHandRaisedTimer);
|
||||
export const MediaView: FC<Props> = ({
|
||||
ref,
|
||||
className,
|
||||
style,
|
||||
targetWidth,
|
||||
targetHeight,
|
||||
video,
|
||||
videoFit,
|
||||
mirror,
|
||||
member,
|
||||
videoEnabled,
|
||||
unencryptedWarning,
|
||||
nameTagLeadingIcon,
|
||||
displayName,
|
||||
primaryButton,
|
||||
encryptionStatus,
|
||||
raisedHandTime,
|
||||
currentReaction,
|
||||
raisedHandOnClick,
|
||||
localParticipant,
|
||||
audioStreamStats,
|
||||
videoStreamStats,
|
||||
...props
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [handRaiseTimerVisible] = useSetting(showHandRaisedTimer);
|
||||
|
||||
const avatarSize = Math.round(Math.min(targetWidth, targetHeight) / 2);
|
||||
const avatarSize = Math.round(Math.min(targetWidth, targetHeight) / 2);
|
||||
|
||||
return (
|
||||
<animated.div
|
||||
className={classNames(styles.media, className, {
|
||||
[styles.mirror]: mirror,
|
||||
})}
|
||||
style={style}
|
||||
ref={ref}
|
||||
data-testid="videoTile"
|
||||
data-video-fit={videoFit}
|
||||
{...props}
|
||||
>
|
||||
<div className={styles.bg}>
|
||||
<Avatar
|
||||
id={member?.userId ?? displayName}
|
||||
name={displayName}
|
||||
size={avatarSize}
|
||||
src={member?.getMxcAvatarUrl()}
|
||||
className={styles.avatar}
|
||||
style={{ display: video && videoEnabled ? "none" : "initial" }}
|
||||
return (
|
||||
<animated.div
|
||||
className={classNames(styles.media, className, {
|
||||
[styles.mirror]: mirror,
|
||||
})}
|
||||
style={style}
|
||||
ref={ref}
|
||||
data-testid="videoTile"
|
||||
data-video-fit={videoFit}
|
||||
{...props}
|
||||
>
|
||||
<div className={styles.bg}>
|
||||
<Avatar
|
||||
id={member?.userId ?? displayName}
|
||||
name={displayName}
|
||||
size={avatarSize}
|
||||
src={member?.getMxcAvatarUrl()}
|
||||
className={styles.avatar}
|
||||
style={{ display: video && videoEnabled ? "none" : "initial" }}
|
||||
/>
|
||||
{video?.publication !== undefined && (
|
||||
<VideoTrack
|
||||
trackRef={video}
|
||||
// There's no reason for this to be focusable
|
||||
tabIndex={-1}
|
||||
disablePictureInPicture
|
||||
style={{ display: video && videoEnabled ? "block" : "none" }}
|
||||
data-testid="video"
|
||||
/>
|
||||
{video?.publication !== undefined && (
|
||||
<VideoTrack
|
||||
trackRef={video}
|
||||
// There's no reason for this to be focusable
|
||||
tabIndex={-1}
|
||||
disablePictureInPicture
|
||||
style={{ display: video && videoEnabled ? "block" : "none" }}
|
||||
data-testid="video"
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.fg}>
|
||||
<div className={styles.reactions}>
|
||||
<RaisedHandIndicator
|
||||
raisedHandTime={raisedHandTime}
|
||||
miniature={avatarSize < 96}
|
||||
showTimer={handRaiseTimerVisible}
|
||||
onClick={raisedHandOnClick}
|
||||
/>
|
||||
{currentReaction && (
|
||||
<ReactionIndicator
|
||||
miniature={avatarSize < 96}
|
||||
emoji={currentReaction.emoji}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.fg}>
|
||||
<div className={styles.reactions}>
|
||||
<RaisedHandIndicator
|
||||
raisedHandTime={raisedHandTime}
|
||||
miniature={avatarSize < 96}
|
||||
showTimer={handRaiseTimerVisible}
|
||||
onClick={raisedHandOnClick}
|
||||
/>
|
||||
{currentReaction && (
|
||||
<ReactionIndicator
|
||||
miniature={avatarSize < 96}
|
||||
emoji={currentReaction.emoji}
|
||||
/>
|
||||
)}
|
||||
{!video && !localParticipant && (
|
||||
<div className={styles.status}>
|
||||
{t("video_tile.waiting_for_media")}
|
||||
</div>
|
||||
{!video && !localParticipant && (
|
||||
<div className={styles.status}>
|
||||
{t("video_tile.waiting_for_media")}
|
||||
</div>
|
||||
)}
|
||||
{(audioStreamStats || videoStreamStats) && (
|
||||
<RTCConnectionStats
|
||||
audio={audioStreamStats}
|
||||
video={videoStreamStats}
|
||||
/>
|
||||
)}
|
||||
{/* TODO: Bring this back once encryption status is less broken */}
|
||||
{/*encryptionStatus !== EncryptionStatus.Okay && (
|
||||
)}
|
||||
{(audioStreamStats || videoStreamStats) && (
|
||||
<RTCConnectionStats
|
||||
audio={audioStreamStats}
|
||||
video={videoStreamStats}
|
||||
/>
|
||||
)}
|
||||
{/* TODO: Bring this back once encryption status is less broken */}
|
||||
{/*encryptionStatus !== EncryptionStatus.Okay && (
|
||||
<div className={styles.status}>
|
||||
<Text as="span" size="sm" weight="medium" className={styles.name}>
|
||||
{encryptionStatus === EncryptionStatus.Connecting &&
|
||||
@@ -151,38 +148,37 @@ export const MediaView = forwardRef<HTMLDivElement, Props>(
|
||||
</Text>
|
||||
</div>
|
||||
)*/}
|
||||
<div className={styles.nameTag}>
|
||||
{nameTagLeadingIcon}
|
||||
<Text
|
||||
as="span"
|
||||
size="sm"
|
||||
weight="medium"
|
||||
className={styles.name}
|
||||
data-testid="name_tag"
|
||||
<div className={styles.nameTag}>
|
||||
{nameTagLeadingIcon}
|
||||
<Text
|
||||
as="span"
|
||||
size="sm"
|
||||
weight="medium"
|
||||
className={styles.name}
|
||||
data-testid="name_tag"
|
||||
>
|
||||
{displayName}
|
||||
</Text>
|
||||
{unencryptedWarning && (
|
||||
<Tooltip
|
||||
label={t("common.unencrypted")}
|
||||
placement="bottom"
|
||||
isTriggerInteractive={false}
|
||||
>
|
||||
{displayName}
|
||||
</Text>
|
||||
{unencryptedWarning && (
|
||||
<Tooltip
|
||||
label={t("common.unencrypted")}
|
||||
placement="bottom"
|
||||
isTriggerInteractive={false}
|
||||
>
|
||||
<ErrorSolidIcon
|
||||
width={20}
|
||||
height={20}
|
||||
className={styles.errorIcon}
|
||||
role="img"
|
||||
aria-label={t("common.unencrypted")}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
{primaryButton}
|
||||
<ErrorSolidIcon
|
||||
width={20}
|
||||
height={20}
|
||||
className={styles.errorIcon}
|
||||
role="img"
|
||||
aria-label={t("common.unencrypted")}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</animated.div>
|
||||
);
|
||||
},
|
||||
);
|
||||
{primaryButton}
|
||||
</div>
|
||||
</animated.div>
|
||||
);
|
||||
};
|
||||
|
||||
MediaView.displayName = "MediaView";
|
||||
|
||||
@@ -7,8 +7,9 @@ Please see LICENSE in the repository root for full details.
|
||||
|
||||
import {
|
||||
type ComponentProps,
|
||||
type FC,
|
||||
type Ref,
|
||||
type RefAttributes,
|
||||
forwardRef,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
@@ -44,6 +45,7 @@ import { useLatest } from "../useLatest";
|
||||
import { type SpotlightTileViewModel } from "../state/TileViewModel";
|
||||
|
||||
interface SpotlightItemBaseProps {
|
||||
ref?: Ref<HTMLDivElement>;
|
||||
className?: string;
|
||||
"data-id": string;
|
||||
targetWidth: number;
|
||||
@@ -67,13 +69,13 @@ interface SpotlightLocalUserMediaItemProps
|
||||
vm: LocalUserMediaViewModel;
|
||||
}
|
||||
|
||||
const SpotlightLocalUserMediaItem = forwardRef<
|
||||
HTMLDivElement,
|
||||
SpotlightLocalUserMediaItemProps
|
||||
>(({ vm, ...props }, ref) => {
|
||||
const SpotlightLocalUserMediaItem: FC<SpotlightLocalUserMediaItemProps> = ({
|
||||
vm,
|
||||
...props
|
||||
}) => {
|
||||
const mirror = useObservableEagerState(vm.mirror$);
|
||||
return <MediaView ref={ref} mirror={mirror} {...props} />;
|
||||
});
|
||||
return <MediaView mirror={mirror} {...props} />;
|
||||
};
|
||||
|
||||
SpotlightLocalUserMediaItem.displayName = "SpotlightLocalUserMediaItem";
|
||||
|
||||
@@ -81,16 +83,15 @@ interface SpotlightUserMediaItemProps extends SpotlightItemBaseProps {
|
||||
vm: UserMediaViewModel;
|
||||
}
|
||||
|
||||
const SpotlightUserMediaItem = forwardRef<
|
||||
HTMLDivElement,
|
||||
SpotlightUserMediaItemProps
|
||||
>(({ vm, ...props }, ref) => {
|
||||
const SpotlightUserMediaItem: FC<SpotlightUserMediaItemProps> = ({
|
||||
vm,
|
||||
...props
|
||||
}) => {
|
||||
const videoEnabled = useObservableEagerState(vm.videoEnabled$);
|
||||
const cropVideo = useObservableEagerState(vm.cropVideo$);
|
||||
|
||||
const baseProps: SpotlightUserMediaItemBaseProps &
|
||||
RefAttributes<HTMLDivElement> = {
|
||||
ref,
|
||||
videoEnabled,
|
||||
videoFit: cropVideo ? "cover" : "contain",
|
||||
...props,
|
||||
@@ -101,11 +102,12 @@ const SpotlightUserMediaItem = forwardRef<
|
||||
) : (
|
||||
<MediaView mirror={false} {...baseProps} />
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
SpotlightUserMediaItem.displayName = "SpotlightUserMediaItem";
|
||||
|
||||
interface SpotlightItemProps {
|
||||
ref?: Ref<HTMLDivElement>;
|
||||
vm: MediaViewModel;
|
||||
targetWidth: number;
|
||||
targetHeight: number;
|
||||
@@ -117,71 +119,63 @@ interface SpotlightItemProps {
|
||||
"aria-hidden"?: boolean;
|
||||
}
|
||||
|
||||
const SpotlightItem = forwardRef<HTMLDivElement, SpotlightItemProps>(
|
||||
(
|
||||
{
|
||||
vm,
|
||||
targetWidth,
|
||||
targetHeight,
|
||||
intersectionObserver$,
|
||||
snap,
|
||||
"aria-hidden": ariaHidden,
|
||||
},
|
||||
theirRef,
|
||||
) => {
|
||||
const ourRef = useRef<HTMLDivElement | null>(null);
|
||||
const ref = useMergedRefs(ourRef, theirRef);
|
||||
const displayName = useObservableEagerState(vm.displayname$);
|
||||
const video = useObservableEagerState(vm.video$);
|
||||
const unencryptedWarning = useObservableEagerState(vm.unencryptedWarning$);
|
||||
const encryptionStatus = useObservableEagerState(vm.encryptionStatus$);
|
||||
const SpotlightItem: FC<SpotlightItemProps> = ({
|
||||
ref: theirRef,
|
||||
vm,
|
||||
targetWidth,
|
||||
targetHeight,
|
||||
intersectionObserver$,
|
||||
snap,
|
||||
"aria-hidden": ariaHidden,
|
||||
}) => {
|
||||
const ourRef = useRef<HTMLDivElement | null>(null);
|
||||
const ref = useMergedRefs(ourRef, theirRef);
|
||||
const displayName = useObservableEagerState(vm.displayname$);
|
||||
const video = useObservableEagerState(vm.video$);
|
||||
const unencryptedWarning = useObservableEagerState(vm.unencryptedWarning$);
|
||||
const encryptionStatus = useObservableEagerState(vm.encryptionStatus$);
|
||||
|
||||
// Hook this item up to the intersection observer
|
||||
useEffect(() => {
|
||||
const element = ourRef.current!;
|
||||
let prevIo: IntersectionObserver | null = null;
|
||||
const subscription = intersectionObserver$.subscribe((io) => {
|
||||
prevIo?.unobserve(element);
|
||||
io.observe(element);
|
||||
prevIo = io;
|
||||
});
|
||||
return (): void => {
|
||||
subscription.unsubscribe();
|
||||
prevIo?.unobserve(element);
|
||||
};
|
||||
}, [intersectionObserver$]);
|
||||
|
||||
const baseProps: SpotlightItemBaseProps & RefAttributes<HTMLDivElement> = {
|
||||
ref,
|
||||
"data-id": vm.id,
|
||||
className: classNames(styles.item, { [styles.snap]: snap }),
|
||||
targetWidth,
|
||||
targetHeight,
|
||||
video,
|
||||
member: vm.member,
|
||||
unencryptedWarning,
|
||||
displayName,
|
||||
encryptionStatus,
|
||||
"aria-hidden": ariaHidden,
|
||||
localParticipant: vm.local,
|
||||
// Hook this item up to the intersection observer
|
||||
useEffect(() => {
|
||||
const element = ourRef.current!;
|
||||
let prevIo: IntersectionObserver | null = null;
|
||||
const subscription = intersectionObserver$.subscribe((io) => {
|
||||
prevIo?.unobserve(element);
|
||||
io.observe(element);
|
||||
prevIo = io;
|
||||
});
|
||||
return (): void => {
|
||||
subscription.unsubscribe();
|
||||
prevIo?.unobserve(element);
|
||||
};
|
||||
}, [intersectionObserver$]);
|
||||
|
||||
return vm instanceof ScreenShareViewModel ? (
|
||||
<MediaView
|
||||
videoEnabled
|
||||
videoFit="contain"
|
||||
mirror={false}
|
||||
{...baseProps}
|
||||
/>
|
||||
) : (
|
||||
<SpotlightUserMediaItem vm={vm} {...baseProps} />
|
||||
);
|
||||
},
|
||||
);
|
||||
const baseProps: SpotlightItemBaseProps & RefAttributes<HTMLDivElement> = {
|
||||
ref,
|
||||
"data-id": vm.id,
|
||||
className: classNames(styles.item, { [styles.snap]: snap }),
|
||||
targetWidth,
|
||||
targetHeight,
|
||||
video,
|
||||
member: vm.member,
|
||||
unencryptedWarning,
|
||||
displayName,
|
||||
encryptionStatus,
|
||||
"aria-hidden": ariaHidden,
|
||||
localParticipant: vm.local,
|
||||
};
|
||||
|
||||
return vm instanceof ScreenShareViewModel ? (
|
||||
<MediaView videoEnabled videoFit="contain" mirror={false} {...baseProps} />
|
||||
) : (
|
||||
<SpotlightUserMediaItem vm={vm} {...baseProps} />
|
||||
);
|
||||
};
|
||||
|
||||
SpotlightItem.displayName = "SpotlightItem";
|
||||
|
||||
interface Props {
|
||||
ref?: Ref<HTMLDivElement>;
|
||||
vm: SpotlightTileViewModel;
|
||||
expanded: boolean;
|
||||
onToggleExpanded: (() => void) | null;
|
||||
@@ -192,156 +186,148 @@ interface Props {
|
||||
style?: ComponentProps<typeof animated.div>["style"];
|
||||
}
|
||||
|
||||
export const SpotlightTile = forwardRef<HTMLDivElement, Props>(
|
||||
(
|
||||
{
|
||||
vm,
|
||||
expanded,
|
||||
onToggleExpanded,
|
||||
targetWidth,
|
||||
targetHeight,
|
||||
showIndicators,
|
||||
className,
|
||||
style,
|
||||
},
|
||||
theirRef,
|
||||
) => {
|
||||
const { t } = useTranslation();
|
||||
const [ourRef, root$] = useObservableRef<HTMLDivElement | null>(null);
|
||||
const ref = useMergedRefs(ourRef, theirRef);
|
||||
const maximised = useObservableEagerState(vm.maximised$);
|
||||
const media = useObservableEagerState(vm.media$);
|
||||
const [visibleId, setVisibleId] = useState<string | undefined>(
|
||||
media[0]?.id,
|
||||
);
|
||||
const latestMedia = useLatest(media);
|
||||
const latestVisibleId = useLatest(visibleId);
|
||||
const visibleIndex = media.findIndex((vm) => vm.id === visibleId);
|
||||
const canGoBack = visibleIndex > 0;
|
||||
const canGoToNext = visibleIndex !== -1 && visibleIndex < media.length - 1;
|
||||
export const SpotlightTile: FC<Props> = ({
|
||||
ref: theirRef,
|
||||
vm,
|
||||
expanded,
|
||||
onToggleExpanded,
|
||||
targetWidth,
|
||||
targetHeight,
|
||||
showIndicators,
|
||||
className,
|
||||
style,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [ourRef, root$] = useObservableRef<HTMLDivElement | null>(null);
|
||||
const ref = useMergedRefs(ourRef, theirRef);
|
||||
const maximised = useObservableEagerState(vm.maximised$);
|
||||
const media = useObservableEagerState(vm.media$);
|
||||
const [visibleId, setVisibleId] = useState<string | undefined>(media[0]?.id);
|
||||
const latestMedia = useLatest(media);
|
||||
const latestVisibleId = useLatest(visibleId);
|
||||
const visibleIndex = media.findIndex((vm) => vm.id === visibleId);
|
||||
const canGoBack = visibleIndex > 0;
|
||||
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
|
||||
// their effects before their parent does, we need to do this dance with an
|
||||
// Observable to actually give them the intersection observer.
|
||||
const intersectionObserver$ = useInitial<Observable<IntersectionObserver>>(
|
||||
() =>
|
||||
root$.pipe(
|
||||
map(
|
||||
(r) =>
|
||||
new IntersectionObserver(
|
||||
(entries) => {
|
||||
const visible = entries.find((e) => e.isIntersecting);
|
||||
if (visible !== undefined)
|
||||
setVisibleId(visible.target.getAttribute("data-id")!);
|
||||
},
|
||||
{ root: r, threshold: 0.5 },
|
||||
),
|
||||
),
|
||||
// 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
|
||||
// their effects before their parent does, we need to do this dance with an
|
||||
// Observable to actually give them the intersection observer.
|
||||
const intersectionObserver$ = useInitial<Observable<IntersectionObserver>>(
|
||||
() =>
|
||||
root$.pipe(
|
||||
map(
|
||||
(r) =>
|
||||
new IntersectionObserver(
|
||||
(entries) => {
|
||||
const visible = entries.find((e) => e.isIntersecting);
|
||||
if (visible !== undefined)
|
||||
setVisibleId(visible.target.getAttribute("data-id")!);
|
||||
},
|
||||
{ root: r, threshold: 0.5 },
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
const [scrollToId, setScrollToId] = useReactiveState<string | null>(
|
||||
(prev) =>
|
||||
prev == null || prev === visibleId || media.every((vm) => vm.id !== prev)
|
||||
? null
|
||||
: prev,
|
||||
[visibleId],
|
||||
);
|
||||
|
||||
const onBackClick = useCallback(() => {
|
||||
const media = latestMedia.current;
|
||||
const visibleIndex = media.findIndex(
|
||||
(vm) => vm.id === latestVisibleId.current,
|
||||
);
|
||||
if (visibleIndex > 0) setScrollToId(media[visibleIndex - 1].id);
|
||||
}, [latestVisibleId, latestMedia, setScrollToId]);
|
||||
|
||||
const [scrollToId, setScrollToId] = useReactiveState<string | null>(
|
||||
(prev) =>
|
||||
prev == null ||
|
||||
prev === visibleId ||
|
||||
media.every((vm) => vm.id !== prev)
|
||||
? null
|
||||
: prev,
|
||||
[visibleId],
|
||||
const onNextClick = useCallback(() => {
|
||||
const media = latestMedia.current;
|
||||
const visibleIndex = media.findIndex(
|
||||
(vm) => vm.id === latestVisibleId.current,
|
||||
);
|
||||
if (visibleIndex !== -1 && visibleIndex !== media.length - 1)
|
||||
setScrollToId(media[visibleIndex + 1].id);
|
||||
}, [latestVisibleId, latestMedia, setScrollToId]);
|
||||
|
||||
const onBackClick = useCallback(() => {
|
||||
const media = latestMedia.current;
|
||||
const visibleIndex = media.findIndex(
|
||||
(vm) => vm.id === latestVisibleId.current,
|
||||
);
|
||||
if (visibleIndex > 0) setScrollToId(media[visibleIndex - 1].id);
|
||||
}, [latestVisibleId, latestMedia, setScrollToId]);
|
||||
const ToggleExpandIcon = expanded ? CollapseIcon : ExpandIcon;
|
||||
|
||||
const onNextClick = useCallback(() => {
|
||||
const media = latestMedia.current;
|
||||
const visibleIndex = media.findIndex(
|
||||
(vm) => vm.id === latestVisibleId.current,
|
||||
);
|
||||
if (visibleIndex !== -1 && visibleIndex !== media.length - 1)
|
||||
setScrollToId(media[visibleIndex + 1].id);
|
||||
}, [latestVisibleId, latestMedia, setScrollToId]);
|
||||
|
||||
const ToggleExpandIcon = expanded ? CollapseIcon : ExpandIcon;
|
||||
|
||||
return (
|
||||
<animated.div
|
||||
ref={ref}
|
||||
className={classNames(className, styles.tile, {
|
||||
[styles.maximised]: maximised,
|
||||
})}
|
||||
style={style}
|
||||
>
|
||||
{canGoBack && (
|
||||
<button
|
||||
className={classNames(styles.advance, styles.back)}
|
||||
aria-label={t("common.back")}
|
||||
onClick={onBackClick}
|
||||
>
|
||||
<ChevronLeftIcon aria-hidden width={24} height={24} />
|
||||
</button>
|
||||
)}
|
||||
<div className={styles.contents}>
|
||||
return (
|
||||
<animated.div
|
||||
ref={ref}
|
||||
className={classNames(className, styles.tile, {
|
||||
[styles.maximised]: maximised,
|
||||
})}
|
||||
style={style}
|
||||
>
|
||||
{canGoBack && (
|
||||
<button
|
||||
className={classNames(styles.advance, styles.back)}
|
||||
aria-label={t("common.back")}
|
||||
onClick={onBackClick}
|
||||
>
|
||||
<ChevronLeftIcon aria-hidden width={24} height={24} />
|
||||
</button>
|
||||
)}
|
||||
<div className={styles.contents}>
|
||||
{media.map((vm) => (
|
||||
<SpotlightItem
|
||||
key={vm.id}
|
||||
vm={vm}
|
||||
targetWidth={targetWidth}
|
||||
targetHeight={targetHeight}
|
||||
intersectionObserver$={intersectionObserver$}
|
||||
// This is how we get the container to scroll to the right media
|
||||
// when the previous/next buttons are clicked: we temporarily
|
||||
// remove all scroll snap points except for just the one media
|
||||
// that we want to bring into view
|
||||
snap={scrollToId === null || scrollToId === vm.id}
|
||||
aria-hidden={(scrollToId ?? visibleId) !== vm.id}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{onToggleExpanded && (
|
||||
<button
|
||||
className={classNames(styles.expand)}
|
||||
aria-label={
|
||||
expanded ? t("video_tile.collapse") : t("video_tile.expand")
|
||||
}
|
||||
onClick={onToggleExpanded}
|
||||
>
|
||||
<ToggleExpandIcon aria-hidden width={20} height={20} />
|
||||
</button>
|
||||
)}
|
||||
{canGoToNext && (
|
||||
<button
|
||||
className={classNames(styles.advance, styles.next)}
|
||||
aria-label={t("common.next")}
|
||||
onClick={onNextClick}
|
||||
>
|
||||
<ChevronRightIcon aria-hidden width={24} height={24} />
|
||||
</button>
|
||||
)}
|
||||
{!expanded && (
|
||||
<div
|
||||
className={classNames(styles.indicators, {
|
||||
[styles.show]: showIndicators && media.length > 1,
|
||||
})}
|
||||
>
|
||||
{media.map((vm) => (
|
||||
<SpotlightItem
|
||||
<div
|
||||
key={vm.id}
|
||||
vm={vm}
|
||||
targetWidth={targetWidth}
|
||||
targetHeight={targetHeight}
|
||||
intersectionObserver$={intersectionObserver$}
|
||||
// This is how we get the container to scroll to the right media
|
||||
// when the previous/next buttons are clicked: we temporarily
|
||||
// remove all scroll snap points except for just the one media
|
||||
// that we want to bring into view
|
||||
snap={scrollToId === null || scrollToId === vm.id}
|
||||
aria-hidden={(scrollToId ?? visibleId) !== vm.id}
|
||||
className={styles.item}
|
||||
data-visible={vm.id === visibleId}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{onToggleExpanded && (
|
||||
<button
|
||||
className={classNames(styles.expand)}
|
||||
aria-label={
|
||||
expanded ? t("video_tile.collapse") : t("video_tile.expand")
|
||||
}
|
||||
onClick={onToggleExpanded}
|
||||
>
|
||||
<ToggleExpandIcon aria-hidden width={20} height={20} />
|
||||
</button>
|
||||
)}
|
||||
{canGoToNext && (
|
||||
<button
|
||||
className={classNames(styles.advance, styles.next)}
|
||||
aria-label={t("common.next")}
|
||||
onClick={onNextClick}
|
||||
>
|
||||
<ChevronRightIcon aria-hidden width={24} height={24} />
|
||||
</button>
|
||||
)}
|
||||
{!expanded && (
|
||||
<div
|
||||
className={classNames(styles.indicators, {
|
||||
[styles.show]: showIndicators && media.length > 1,
|
||||
})}
|
||||
>
|
||||
{media.map((vm) => (
|
||||
<div
|
||||
key={vm.id}
|
||||
className={styles.item}
|
||||
data-visible={vm.id === visibleId}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</animated.div>
|
||||
);
|
||||
},
|
||||
);
|
||||
)}
|
||||
</animated.div>
|
||||
);
|
||||
};
|
||||
|
||||
SpotlightTile.displayName = "SpotlightTile";
|
||||
|
||||
Reference in New Issue
Block a user