Merge pull request #3358 from element-hq/robin/remove-forward-ref

Remove usages of forwardRef
This commit is contained in:
Robin
2025-06-24 08:32:03 -04:00
committed by GitHub
16 changed files with 712 additions and 725 deletions

View File

@@ -6,12 +6,7 @@ Please see LICENSE in the repository root for full details.
*/ */
import classNames from "classnames"; import classNames from "classnames";
import { import { type Ref, type FC, type HTMLAttributes, type ReactNode } from "react";
type FC,
type HTMLAttributes,
type ReactNode,
forwardRef,
} from "react";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Heading, Text } from "@vector-im/compound-web"; import { Heading, Text } from "@vector-im/compound-web";
@@ -24,23 +19,27 @@ import { EncryptionLock } from "./room/EncryptionLock";
import { useMediaQuery } from "./useMediaQuery"; import { useMediaQuery } from "./useMediaQuery";
interface HeaderProps extends HTMLAttributes<HTMLElement> { interface HeaderProps extends HTMLAttributes<HTMLElement> {
ref?: Ref<HTMLElement>;
children: ReactNode; children: ReactNode;
className?: string; className?: string;
} }
export const Header = forwardRef<HTMLElement, HeaderProps>( export const Header: FC<HeaderProps> = ({
({ children, className, ...rest }, ref) => { ref,
return ( children,
<header className,
ref={ref} ...rest
className={classNames(styles.header, className)} }) => {
{...rest} return (
> <header
{children} ref={ref}
</header> className={classNames(styles.header, className)}
); {...rest}
}, >
); {children}
</header>
);
};
Header.displayName = "Header"; Header.displayName = "Header";

View File

@@ -5,11 +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 { import { type ComponentProps, type FC, type MouseEvent } from "react";
type ComponentPropsWithoutRef,
forwardRef,
type MouseEvent,
} from "react";
import { Link as CpdLink } from "@vector-im/compound-web"; import { Link as CpdLink } from "@vector-im/compound-web";
import { type LinkProps, useHref, useLinkClickHandler } from "react-router-dom"; import { type LinkProps, useHref, useLinkClickHandler } from "react-router-dom";
import classNames from "classnames"; import classNames from "classnames";
@@ -26,31 +22,30 @@ export function useLink(
return [href, onClick]; return [href, onClick];
} }
type Props = Omit< type Props = Omit<ComponentProps<typeof CpdLink>, "href" | "onClick"> & {
ComponentPropsWithoutRef<typeof CpdLink>, to: LinkProps["to"];
"href" | "onClick" state?: unknown;
> & { to: LinkProps["to"]; state?: unknown }; };
/** /**
* A version of Compound's link component that integrates with our router setup. * A version of Compound's link component that integrates with our router setup.
* This is only for app-internal links. * This is only for app-internal links.
*/ */
export const Link = forwardRef<HTMLAnchorElement, Props>(function Link( export const Link: FC<Props> = ({ ref, to, state, ...props }) => {
{ to, state, ...props },
ref,
) {
const [path, onClick] = useLink(to, state); const [path, onClick] = useLink(to, state);
return <CpdLink ref={ref} {...props} href={path} onClick={onClick} />; return <CpdLink ref={ref} {...props} href={path} onClick={onClick} />;
}); };
/** /**
* A link to an external web page, made to fit into blocks of text more subtly * A link to an external web page, made to fit into blocks of text more subtly
* than the normal Compound link component. * than the normal Compound link component.
*/ */
export const ExternalLink = forwardRef< export const ExternalLink: FC<ComponentProps<"a">> = ({
HTMLAnchorElement, ref,
ComponentPropsWithoutRef<"a"> className,
>(function ExternalLink({ className, children, ...props }, ref) { children,
...props
}) => {
return ( return (
<a <a
ref={ref} ref={ref}
@@ -62,4 +57,4 @@ export const ExternalLink = forwardRef<
{children} {children}
</a> </a>
); );
}); };

View File

@@ -5,24 +5,22 @@ 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 { type ComponentPropsWithoutRef, forwardRef } from "react"; import { type ComponentProps, type FC } from "react";
import { Button } from "@vector-im/compound-web"; import { Button } from "@vector-im/compound-web";
import type { LinkProps } from "react-router-dom"; import type { LinkProps } from "react-router-dom";
import { useLink } from "./Link"; import { useLink } from "./Link";
type Props = Omit< type Props = Omit<ComponentProps<typeof Button<"a">>, "as" | "href"> & {
ComponentPropsWithoutRef<typeof Button<"a">>, to: LinkProps["to"];
"as" | "href" state?: unknown;
> & { to: LinkProps["to"]; state?: unknown }; };
/** /**
* A version of Compound's button component that acts as a link and integrates * A version of Compound's button component that acts as a link and integrates
* with our router setup. * with our router setup.
*/ */
export const LinkButton = forwardRef<HTMLAnchorElement, Props>( export const LinkButton: FC<Props> = ({ ref, to, state, ...props }) => {
function LinkButton({ to, state, ...props }, ref) { const [path, onClick] = useLink(to, state);
const [path, onClick] = useLink(to, state); return <Button as="a" ref={ref} {...props} href={path} onClick={onClick} />;
return <Button as="a" ref={ref} {...props} href={path} onClick={onClick} />; };
},
);

View File

@@ -6,28 +6,32 @@ Please see LICENSE in the repository root for full details.
*/ */
import classNames from "classnames"; import classNames from "classnames";
import { type FormEventHandler, forwardRef, type ReactNode } from "react"; import {
type FC,
type Ref,
type FormEventHandler,
type ReactNode,
} from "react";
import styles from "./Form.module.css"; import styles from "./Form.module.css";
interface FormProps { interface FormProps {
ref?: Ref<HTMLFormElement>;
className: string; className: string;
onSubmit: FormEventHandler<HTMLFormElement>; onSubmit: FormEventHandler<HTMLFormElement>;
children: ReactNode[]; children: ReactNode[];
} }
export const Form = forwardRef<HTMLFormElement, FormProps>( export const Form: FC<FormProps> = ({ ref, children, className, onSubmit }) => {
({ children, className, onSubmit }, ref) => { return (
return ( <form
<form onSubmit={onSubmit}
onSubmit={onSubmit} className={classNames(styles.form, className)}
className={classNames(styles.form, className)} ref={ref}
ref={ref} >
> {children}
{children} </form>
</form> );
); };
},
);
Form.displayName = "Form"; Form.displayName = "Form";

View File

@@ -18,11 +18,10 @@ import {
type ComponentType, type ComponentType,
type Dispatch, type Dispatch,
type FC, type FC,
type LegacyRef,
type ReactNode, type ReactNode,
type Ref,
type SetStateAction, type SetStateAction,
createContext, createContext,
forwardRef,
memo, memo,
use, use,
useEffect, useEffect,
@@ -162,7 +161,7 @@ const windowHeightObservable$ = fromEvent(window, "resize").pipe(
); );
export interface LayoutProps<LayoutModel, TileModel, R extends HTMLElement> { export interface LayoutProps<LayoutModel, TileModel, R extends HTMLElement> {
ref: LegacyRef<R>; ref?: Ref<R>;
model: LayoutModel; model: LayoutModel;
/** /**
* Component creating an invisible "slot" for a tile to go in. * Component creating an invisible "slot" for a tile to go in.
@@ -171,7 +170,7 @@ export interface LayoutProps<LayoutModel, TileModel, R extends HTMLElement> {
} }
export interface TileProps<Model, R extends HTMLElement> { export interface TileProps<Model, R extends HTMLElement> {
ref: LegacyRef<R>; ref?: Ref<R>;
className?: string; className?: string;
style?: ComponentProps<typeof animated.div>["style"]; style?: ComponentProps<typeof animated.div>["style"];
/** /**
@@ -297,14 +296,13 @@ export function Grid<
// render of Grid causes a re-render of Layout, which in turn re-renders Grid // render of Grid causes a re-render of Layout, which in turn re-renders Grid
const LayoutMemo = useMemo( const LayoutMemo = useMemo(
() => () =>
memo( memo(function LayoutMemo({
forwardRef< ref,
LayoutRef, Layout,
LayoutMemoProps<LayoutModel, TileModel, LayoutRef> ...props
>(function LayoutMemo({ Layout, ...props }, ref): ReactNode { }: LayoutMemoProps<LayoutModel, TileModel, LayoutRef>): ReactNode {
return <Layout {...props} ref={ref} />; return <Layout {...props} ref={ref} />;
}), }),
),
[], [],
); );

View File

@@ -5,7 +5,12 @@ 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 { type CSSProperties, forwardRef, useCallback, useMemo } from "react"; import {
type CSSProperties,
type ReactNode,
useCallback,
useMemo,
} from "react";
import { distinctUntilChanged } from "rxjs"; import { distinctUntilChanged } from "rxjs";
import { useObservableEagerState } from "observable-hooks"; import { useObservableEagerState } from "observable-hooks";
@@ -33,7 +38,7 @@ export const makeGridLayout: CallLayout<GridLayoutModel> = ({
// The "fixed" (non-scrolling) part of the layout is where the spotlight tile // The "fixed" (non-scrolling) part of the layout is where the spotlight tile
// lives // lives
fixed: forwardRef(function GridLayoutFixed({ model, Slot }, ref) { fixed: function GridLayoutFixed({ ref, model, Slot }): ReactNode {
useUpdateLayout(); useUpdateLayout();
const alignment = useObservableEagerState( const alignment = useObservableEagerState(
useInitial(() => useInitial(() =>
@@ -68,10 +73,10 @@ export const makeGridLayout: CallLayout<GridLayoutModel> = ({
)} )}
</div> </div>
); );
}), },
// The scrolling part of the layout is where all the grid tiles live // The scrolling part of the layout is where all the grid tiles live
scrolling: forwardRef(function GridLayout({ model, Slot }, ref) { scrolling: function GridLayout({ ref, model, Slot }): ReactNode {
useUpdateLayout(); useUpdateLayout();
useVisibleTiles(model.setVisibleTiles); useVisibleTiles(model.setVisibleTiles);
const { width, height: minHeight } = useObservableEagerState(minBounds$); const { width, height: minHeight } = useObservableEagerState(minBounds$);
@@ -98,5 +103,5 @@ export const makeGridLayout: CallLayout<GridLayoutModel> = ({
))} ))}
</div> </div>
); );
}), },
}); });

View File

@@ -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 { forwardRef, useCallback, useMemo } from "react"; import { type ReactNode, useCallback, useMemo } from "react";
import { useObservableEagerState } from "observable-hooks"; import { useObservableEagerState } from "observable-hooks";
import classNames from "classnames"; import classNames from "classnames";
@@ -24,12 +24,12 @@ export const makeOneOnOneLayout: CallLayout<OneOnOneLayoutModel> = ({
}) => ({ }) => ({
scrollingOnTop: false, scrollingOnTop: false,
fixed: forwardRef(function OneOnOneLayoutFixed(_props, ref) { fixed: function OneOnOneLayoutFixed({ ref }): ReactNode {
useUpdateLayout(); useUpdateLayout();
return <div ref={ref} />; return <div ref={ref} />;
}), },
scrolling: forwardRef(function OneOnOneLayoutScrolling({ model, Slot }, ref) { scrolling: function OneOnOneLayoutScrolling({ ref, model, Slot }): ReactNode {
useUpdateLayout(); useUpdateLayout();
const { width, height } = useObservableEagerState(minBounds$); const { width, height } = useObservableEagerState(minBounds$);
const pipAlignmentValue = useObservableEagerState(pipAlignment$); const pipAlignmentValue = useObservableEagerState(pipAlignment$);
@@ -66,5 +66,5 @@ export const makeOneOnOneLayout: CallLayout<OneOnOneLayoutModel> = ({
</Slot> </Slot>
</div> </div>
); );
}), },
}); });

View File

@@ -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 { forwardRef, useCallback } from "react"; import { type ReactNode, useCallback } from "react";
import { useObservableEagerState } from "observable-hooks"; import { useObservableEagerState } from "observable-hooks";
import { type SpotlightExpandedLayout as SpotlightExpandedLayoutModel } from "../state/CallViewModel"; import { type SpotlightExpandedLayout as SpotlightExpandedLayoutModel } from "../state/CallViewModel";
@@ -22,10 +22,11 @@ export const makeSpotlightExpandedLayout: CallLayout<
> = ({ pipAlignment$ }) => ({ > = ({ pipAlignment$ }) => ({
scrollingOnTop: true, scrollingOnTop: true,
fixed: forwardRef(function SpotlightExpandedLayoutFixed( fixed: function SpotlightExpandedLayoutFixed({
{ model, Slot },
ref, ref,
) { model,
Slot,
}): ReactNode {
useUpdateLayout(); useUpdateLayout();
return ( return (
@@ -37,12 +38,13 @@ export const makeSpotlightExpandedLayout: CallLayout<
/> />
</div> </div>
); );
}), },
scrolling: forwardRef(function SpotlightExpandedLayoutScrolling( scrolling: function SpotlightExpandedLayoutScrolling({
{ model, Slot },
ref, ref,
) { model,
Slot,
}): ReactNode {
useUpdateLayout(); useUpdateLayout();
const pipAlignmentValue = useObservableEagerState(pipAlignment$); const pipAlignmentValue = useObservableEagerState(pipAlignment$);
@@ -69,5 +71,5 @@ export const makeSpotlightExpandedLayout: CallLayout<
)} )}
</div> </div>
); );
}), },
}); });

View File

@@ -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 { forwardRef } from "react"; import { type ReactNode } from "react";
import { useObservableEagerState } from "observable-hooks"; import { useObservableEagerState } from "observable-hooks";
import classNames from "classnames"; import classNames from "classnames";
@@ -24,10 +24,11 @@ export const makeSpotlightLandscapeLayout: CallLayout<
> = ({ minBounds$ }) => ({ > = ({ minBounds$ }) => ({
scrollingOnTop: false, scrollingOnTop: false,
fixed: forwardRef(function SpotlightLandscapeLayoutFixed( fixed: function SpotlightLandscapeLayoutFixed({
{ model, Slot },
ref, ref,
) { model,
Slot,
}): ReactNode {
useUpdateLayout(); useUpdateLayout();
useObservableEagerState(minBounds$); useObservableEagerState(minBounds$);
@@ -43,12 +44,13 @@ export const makeSpotlightLandscapeLayout: CallLayout<
<div className={styles.grid} /> <div className={styles.grid} />
</div> </div>
); );
}), },
scrolling: forwardRef(function SpotlightLandscapeLayoutScrolling( scrolling: function SpotlightLandscapeLayoutScrolling({
{ model, Slot },
ref, ref,
) { model,
Slot,
}): ReactNode {
useUpdateLayout(); useUpdateLayout();
useVisibleTiles(model.setVisibleTiles); useVisibleTiles(model.setVisibleTiles);
useObservableEagerState(minBounds$); useObservableEagerState(minBounds$);
@@ -69,5 +71,5 @@ export const makeSpotlightLandscapeLayout: CallLayout<
</div> </div>
</div> </div>
); );
}), },
}); });

View File

@@ -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 { type CSSProperties, forwardRef } from "react"; import { type ReactNode, type CSSProperties } from "react";
import { useObservableEagerState } from "observable-hooks"; import { useObservableEagerState } from "observable-hooks";
import classNames from "classnames"; import classNames from "classnames";
@@ -30,10 +30,11 @@ export const makeSpotlightPortraitLayout: CallLayout<
> = ({ minBounds$ }) => ({ > = ({ minBounds$ }) => ({
scrollingOnTop: false, scrollingOnTop: false,
fixed: forwardRef(function SpotlightPortraitLayoutFixed( fixed: function SpotlightPortraitLayoutFixed({
{ model, Slot },
ref, ref,
) { model,
Slot,
}): ReactNode {
useUpdateLayout(); useUpdateLayout();
return ( return (
@@ -47,12 +48,13 @@ export const makeSpotlightPortraitLayout: CallLayout<
</div> </div>
</div> </div>
); );
}), },
scrolling: forwardRef(function SpotlightPortraitLayoutScrolling( scrolling: function SpotlightPortraitLayoutScrolling({
{ model, Slot },
ref, ref,
) { model,
Slot,
}): ReactNode {
useUpdateLayout(); useUpdateLayout();
useVisibleTiles(model.setVisibleTiles); useVisibleTiles(model.setVisibleTiles);
const { width } = useObservableEagerState(minBounds$); const { width } = useObservableEagerState(minBounds$);
@@ -90,5 +92,5 @@ export const makeSpotlightPortraitLayout: CallLayout<
</div> </div>
</div> </div>
); );
}), },
}); });

View File

@@ -9,10 +9,10 @@ import {
type ChangeEvent, type ChangeEvent,
type FC, type FC,
type ForwardedRef, type ForwardedRef,
forwardRef,
type ReactNode, type ReactNode,
useId, useId,
type JSX, type JSX,
type Ref,
} from "react"; } from "react";
import classNames from "classnames"; import classNames from "classnames";
@@ -54,6 +54,7 @@ function Field({ children, className }: FieldProps): JSX.Element {
} }
interface InputFieldProps { interface InputFieldProps {
ref?: Ref<HTMLInputElement | HTMLTextAreaElement>;
label?: string; label?: string;
type: string; type: string;
prefix?: string; prefix?: string;
@@ -78,88 +79,81 @@ interface InputFieldProps {
onChange?: (event: ChangeEvent<HTMLInputElement>) => void; onChange?: (event: ChangeEvent<HTMLInputElement>) => void;
} }
export const InputField = forwardRef< export const InputField: FC<InputFieldProps> = ({
HTMLInputElement | HTMLTextAreaElement, ref,
InputFieldProps id,
>( label,
( className,
{ type,
id, checked,
label, prefix,
className, suffix,
type, description,
checked, disabled,
prefix, min,
suffix, ...rest
description, }) => {
disabled, const descriptionId = useId();
min,
...rest
},
ref,
) => {
const descriptionId = useId();
return ( return (
<Field <Field
className={classNames( className={classNames(
type === "checkbox" ? styles.checkboxField : styles.inputField, type === "checkbox" ? styles.checkboxField : styles.inputField,
{ {
[styles.prefix]: !!prefix, [styles.prefix]: !!prefix,
[styles.disabled]: disabled, [styles.disabled]: disabled,
}, },
className, className,
)} )}
> >
{prefix && <span>{prefix}</span>} {prefix && <span>{prefix}</span>}
{type === "textarea" ? ( {type === "textarea" ? (
// eslint-disable-next-line @typescript-eslint/ban-ts-comment // eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore // @ts-ignore
<textarea <textarea
id={id} id={id}
ref={ref as ForwardedRef<HTMLTextAreaElement>} ref={ref as ForwardedRef<HTMLTextAreaElement>}
disabled={disabled} disabled={disabled}
aria-describedby={descriptionId} aria-describedby={descriptionId}
{...rest} {...rest}
/> />
) : ( ) : (
<input <input
id={id} id={id}
ref={ref as ForwardedRef<HTMLInputElement>} ref={ref as ForwardedRef<HTMLInputElement>}
type={type} type={type}
checked={checked} checked={checked}
disabled={disabled} disabled={disabled}
aria-describedby={descriptionId} aria-describedby={descriptionId}
min={min} min={min}
{...rest} {...rest}
/> />
)} )}
<label htmlFor={id}> <label htmlFor={id}>
{type === "checkbox" && ( {type === "checkbox" && (
<div className={styles.checkbox}> <div className={styles.checkbox}>
<CheckIcon /> <CheckIcon />
</div> </div>
)}
{label}
</label>
{suffix && <span>{suffix}</span>}
{description && (
<p
id={descriptionId}
className={
label
? styles.description
: classNames(styles.description, styles.noLabel)
}
>
{description}
</p>
)} )}
</Field> {label}
); </label>
}, {suffix && <span>{suffix}</span>}
); {description && (
<p
id={descriptionId}
className={
label
? styles.description
: classNames(styles.description, styles.noLabel)
}
>
{description}
</p>
)}
</Field>
);
};
InputField.displayName = "InputField"; InputField.displayName = "InputField";

View File

@@ -12,15 +12,14 @@ import { type MatrixClient } from "matrix-js-sdk";
import { import {
type FC, type FC,
type PointerEvent, type PointerEvent,
type PropsWithoutRef,
type TouchEvent, type TouchEvent,
forwardRef,
useCallback, useCallback,
useEffect, useEffect,
useMemo, useMemo,
useRef, useRef,
useState, useState,
type JSX, type JSX,
type ReactNode,
} from "react"; } from "react";
import useMeasure from "react-use-measure"; import useMeasure from "react-use-measure";
import { type MatrixRTCSession } from "matrix-js-sdk/lib/matrixrtc"; import { type MatrixRTCSession } from "matrix-js-sdk/lib/matrixrtc";
@@ -437,13 +436,14 @@ export const InCallView: FC<InCallViewProps> = ({
const Tile = useMemo( const Tile = useMemo(
() => () =>
forwardRef< function Tile({
HTMLDivElement,
PropsWithoutRef<TileProps<TileViewModel, HTMLDivElement>>
>(function Tile(
{ className, style, targetWidth, targetHeight, model },
ref, ref,
) { className,
style,
targetWidth,
targetHeight,
model,
}: TileProps<TileViewModel, HTMLDivElement>): ReactNode {
const spotlightExpanded = useObservableEagerState( const spotlightExpanded = useObservableEagerState(
vm.spotlightExpanded$, vm.spotlightExpanded$,
); );
@@ -481,7 +481,7 @@ export const InCallView: FC<InCallViewProps> = ({
style={style} style={style}
/> />
); );
}), },
[vm, openProfile], [vm, openProfile],
); );

View File

@@ -7,8 +7,9 @@ Please see LICENSE in the repository root for full details.
import { import {
type ComponentProps, type ComponentProps,
type FC,
type ReactNode, type ReactNode,
forwardRef, type Ref,
useCallback, useCallback,
useRef, useRef,
useState, useState,
@@ -50,6 +51,7 @@ import { useMergedRefs } from "../useMergedRefs";
import { useReactionsSender } from "../reactions/useReactionsSender"; import { useReactionsSender } from "../reactions/useReactionsSender";
interface TileProps { interface TileProps {
ref?: Ref<HTMLDivElement>;
className?: string; className?: string;
style?: ComponentProps<typeof animated.div>["style"]; style?: ComponentProps<typeof animated.div>["style"];
targetWidth: number; targetWidth: number;
@@ -66,132 +68,128 @@ interface UserMediaTileProps extends TileProps {
menuEnd?: ReactNode; menuEnd?: ReactNode;
} }
const UserMediaTile = forwardRef<HTMLDivElement, UserMediaTileProps>( const UserMediaTile: FC<UserMediaTileProps> = ({
( ref,
{ vm,
vm, showSpeakingIndicators,
showSpeakingIndicators, locallyMuted,
locallyMuted, menuStart,
menuStart, menuEnd,
menuEnd, className,
className, displayName,
displayName, ...props
...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, [vm],
) => { );
const { toggleRaisedHand } = useReactionsSender(); const handRaised = useObservableState(vm.handRaised$);
const { t } = useTranslation(); const reaction = useObservableState(vm.reaction$);
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$);
const AudioIcon = locallyMuted const AudioIcon = locallyMuted
? VolumeOffSolidIcon ? VolumeOffSolidIcon
: audioEnabled : audioEnabled
? MicOnSolidIcon ? MicOnSolidIcon
: MicOffSolidIcon; : MicOffSolidIcon;
const audioIconLabel = locallyMuted const audioIconLabel = locallyMuted
? t("video_tile.muted_for_me") ? t("video_tile.muted_for_me")
: audioEnabled : audioEnabled
? t("microphone_on") ? t("microphone_on")
: t("microphone_off"); : t("microphone_off");
const [menuOpen, setMenuOpen] = useState(false); const [menuOpen, setMenuOpen] = useState(false);
const menu = ( const menu = (
<> <>
{menuStart} {menuStart}
<ToggleMenuItem <ToggleMenuItem
Icon={ExpandIcon} Icon={ExpandIcon}
label={t("video_tile.change_fit_contain")} label={t("video_tile.change_fit_contain")}
checked={cropVideo} checked={cropVideo}
onSelect={onSelectFitContain} 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}
/> />
); {menuEnd}
</>
);
return ( const raisedHandOnClick = vm.local
<ContextMenu title={displayName} trigger={tile} hasAccessibleAlternative> ? (): void => void toggleRaisedHand()
{menu} : undefined;
</ContextMenu>
); 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"; UserMediaTile.displayName = "UserMediaTile";
@@ -200,48 +198,51 @@ interface LocalUserMediaTileProps extends TileProps {
onOpenProfile: (() => void) | null; onOpenProfile: (() => void) | null;
} }
const LocalUserMediaTile = forwardRef<HTMLDivElement, LocalUserMediaTileProps>( const LocalUserMediaTile: FC<LocalUserMediaTileProps> = ({
({ vm, onOpenProfile, ...props }, ref) => { ref,
const { t } = useTranslation(); vm,
const mirror = useObservableEagerState(vm.mirror$); onOpenProfile,
const alwaysShow = useObservableEagerState(vm.alwaysShow$); ...props
const latestAlwaysShow = useLatest(alwaysShow); }) => {
const onSelectAlwaysShow = useCallback( const { t } = useTranslation();
(e: Event) => { const mirror = useObservableEagerState(vm.mirror$);
e.preventDefault(); const alwaysShow = useObservableEagerState(vm.alwaysShow$);
vm.setAlwaysShow(!latestAlwaysShow.current); const latestAlwaysShow = useLatest(alwaysShow);
}, const onSelectAlwaysShow = useCallback(
[vm, latestAlwaysShow], (e: Event) => {
); e.preventDefault();
vm.setAlwaysShow(!latestAlwaysShow.current);
},
[vm, latestAlwaysShow],
);
return ( return (
<UserMediaTile <UserMediaTile
ref={ref} ref={ref}
vm={vm} vm={vm}
locallyMuted={false} locallyMuted={false}
mirror={mirror} mirror={mirror}
menuStart={ menuStart={
<ToggleMenuItem <ToggleMenuItem
Icon={VisibilityOnIcon} Icon={VisibilityOnIcon}
label={t("video_tile.always_show")} label={t("video_tile.always_show")}
checked={alwaysShow} checked={alwaysShow}
onSelect={onSelectAlwaysShow} onSelect={onSelectAlwaysShow}
/>
}
menuEnd={
onOpenProfile && (
<MenuItem
Icon={UserProfileIcon}
label={t("common.profile")}
onSelect={onOpenProfile}
/> />
} )
menuEnd={ }
onOpenProfile && ( {...props}
<MenuItem />
Icon={UserProfileIcon} );
label={t("common.profile")} };
onSelect={onOpenProfile}
/>
)
}
{...props}
/>
);
},
);
LocalUserMediaTile.displayName = "LocalUserMediaTile"; LocalUserMediaTile.displayName = "LocalUserMediaTile";
@@ -249,10 +250,11 @@ interface RemoteUserMediaTileProps extends TileProps {
vm: RemoteUserMediaViewModel; vm: RemoteUserMediaViewModel;
} }
const RemoteUserMediaTile = forwardRef< const RemoteUserMediaTile: FC<RemoteUserMediaTileProps> = ({
HTMLDivElement, ref,
RemoteUserMediaTileProps vm,
>(({ vm, ...props }, ref) => { ...props
}) => {
const { t } = useTranslation(); const { t } = useTranslation();
const locallyMuted = useObservableEagerState(vm.locallyMuted$); const locallyMuted = useObservableEagerState(vm.locallyMuted$);
const localVolume = useObservableEagerState(vm.localVolume$); const localVolume = useObservableEagerState(vm.localVolume$);
@@ -303,11 +305,12 @@ const RemoteUserMediaTile = forwardRef<
{...props} {...props}
/> />
); );
}); };
RemoteUserMediaTile.displayName = "RemoteUserMediaTile"; RemoteUserMediaTile.displayName = "RemoteUserMediaTile";
interface GridTileProps { interface GridTileProps {
ref?: Ref<HTMLDivElement>;
vm: GridTileViewModel; vm: GridTileViewModel;
onOpenProfile: (() => void) | null; onOpenProfile: (() => void) | null;
targetWidth: number; targetWidth: number;
@@ -317,34 +320,37 @@ interface GridTileProps {
showSpeakingIndicators: boolean; showSpeakingIndicators: boolean;
} }
export const GridTile = forwardRef<HTMLDivElement, GridTileProps>( export const GridTile: FC<GridTileProps> = ({
({ vm, onOpenProfile, ...props }, theirRef) => { ref: theirRef,
const ourRef = useRef<HTMLDivElement | null>(null); vm,
const ref = useMergedRefs(ourRef, theirRef); onOpenProfile,
const media = useObservableEagerState(vm.media$); ...props
const displayName = useObservableEagerState(media.displayname$); }) => {
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) { if (media instanceof LocalUserMediaViewModel) {
return ( return (
<LocalUserMediaTile <LocalUserMediaTile
ref={ref} ref={ref}
vm={media} vm={media}
onOpenProfile={onOpenProfile} onOpenProfile={onOpenProfile}
displayName={displayName} displayName={displayName}
{...props} {...props}
/> />
); );
} else { } else {
return ( return (
<RemoteUserMediaTile <RemoteUserMediaTile
ref={ref} ref={ref}
vm={media} vm={media}
displayName={displayName} displayName={displayName}
{...props} {...props}
/> />
); );
} }
}, };
);
GridTile.displayName = "GridTile"; GridTile.displayName = "GridTile";

View File

@@ -8,7 +8,7 @@ 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 RoomMember } from "matrix-js-sdk"; 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 { useTranslation } from "react-i18next";
import classNames from "classnames"; import classNames from "classnames";
import { VideoTrack } from "@livekit/components-react"; import { VideoTrack } from "@livekit/components-react";
@@ -47,97 +47,94 @@ interface Props extends ComponentProps<typeof animated.div> {
videoStreamStats?: RTCInboundRtpStreamStats | RTCOutboundRtpStreamStats; videoStreamStats?: RTCInboundRtpStreamStats | RTCOutboundRtpStreamStats;
} }
export const MediaView = forwardRef<HTMLDivElement, Props>( export const MediaView: FC<Props> = ({
( ref,
{ className,
className, style,
style, targetWidth,
targetWidth, targetHeight,
targetHeight, video,
video, videoFit,
videoFit, mirror,
mirror, member,
member, videoEnabled,
videoEnabled, unencryptedWarning,
unencryptedWarning, nameTagLeadingIcon,
nameTagLeadingIcon, displayName,
displayName, primaryButton,
primaryButton, encryptionStatus,
encryptionStatus, raisedHandTime,
raisedHandTime, currentReaction,
currentReaction, raisedHandOnClick,
raisedHandOnClick, localParticipant,
localParticipant, audioStreamStats,
audioStreamStats, videoStreamStats,
videoStreamStats, ...props
...props }) => {
}, const { t } = useTranslation();
ref, const [handRaiseTimerVisible] = useSetting(showHandRaisedTimer);
) => {
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 ( return (
<animated.div <animated.div
className={classNames(styles.media, className, { className={classNames(styles.media, className, {
[styles.mirror]: mirror, [styles.mirror]: mirror,
})} })}
style={style} style={style}
ref={ref} ref={ref}
data-testid="videoTile" data-testid="videoTile"
data-video-fit={videoFit} data-video-fit={videoFit}
{...props} {...props}
> >
<div className={styles.bg}> <div className={styles.bg}>
<Avatar <Avatar
id={member?.userId ?? displayName} id={member?.userId ?? displayName}
name={displayName} name={displayName}
size={avatarSize} size={avatarSize}
src={member?.getMxcAvatarUrl()} src={member?.getMxcAvatarUrl()}
className={styles.avatar} className={styles.avatar}
style={{ display: video && videoEnabled ? "none" : "initial" }} 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 </div>
trackRef={video} <div className={styles.fg}>
// There's no reason for this to be focusable <div className={styles.reactions}>
tabIndex={-1} <RaisedHandIndicator
disablePictureInPicture raisedHandTime={raisedHandTime}
style={{ display: video && videoEnabled ? "block" : "none" }} miniature={avatarSize < 96}
data-testid="video" showTimer={handRaiseTimerVisible}
onClick={raisedHandOnClick}
/>
{currentReaction && (
<ReactionIndicator
miniature={avatarSize < 96}
emoji={currentReaction.emoji}
/> />
)} )}
</div> </div>
<div className={styles.fg}> {!video && !localParticipant && (
<div className={styles.reactions}> <div className={styles.status}>
<RaisedHandIndicator {t("video_tile.waiting_for_media")}
raisedHandTime={raisedHandTime}
miniature={avatarSize < 96}
showTimer={handRaiseTimerVisible}
onClick={raisedHandOnClick}
/>
{currentReaction && (
<ReactionIndicator
miniature={avatarSize < 96}
emoji={currentReaction.emoji}
/>
)}
</div> </div>
{!video && !localParticipant && ( )}
<div className={styles.status}> {(audioStreamStats || videoStreamStats) && (
{t("video_tile.waiting_for_media")} <RTCConnectionStats
</div> audio={audioStreamStats}
)} video={videoStreamStats}
{(audioStreamStats || videoStreamStats) && ( />
<RTCConnectionStats )}
audio={audioStreamStats} {/* TODO: Bring this back once encryption status is less broken */}
video={videoStreamStats} {/*encryptionStatus !== EncryptionStatus.Okay && (
/>
)}
{/* TODO: Bring this back once encryption status is less broken */}
{/*encryptionStatus !== EncryptionStatus.Okay && (
<div className={styles.status}> <div className={styles.status}>
<Text as="span" size="sm" weight="medium" className={styles.name}> <Text as="span" size="sm" weight="medium" className={styles.name}>
{encryptionStatus === EncryptionStatus.Connecting && {encryptionStatus === EncryptionStatus.Connecting &&
@@ -151,38 +148,37 @@ export const MediaView = forwardRef<HTMLDivElement, Props>(
</Text> </Text>
</div> </div>
)*/} )*/}
<div className={styles.nameTag}> <div className={styles.nameTag}>
{nameTagLeadingIcon} {nameTagLeadingIcon}
<Text <Text
as="span" as="span"
size="sm" size="sm"
weight="medium" weight="medium"
className={styles.name} className={styles.name}
data-testid="name_tag" data-testid="name_tag"
>
{displayName}
</Text>
{unencryptedWarning && (
<Tooltip
label={t("common.unencrypted")}
placement="bottom"
isTriggerInteractive={false}
> >
{displayName} <ErrorSolidIcon
</Text> width={20}
{unencryptedWarning && ( height={20}
<Tooltip className={styles.errorIcon}
label={t("common.unencrypted")} role="img"
placement="bottom" aria-label={t("common.unencrypted")}
isTriggerInteractive={false} />
> </Tooltip>
<ErrorSolidIcon )}
width={20}
height={20}
className={styles.errorIcon}
role="img"
aria-label={t("common.unencrypted")}
/>
</Tooltip>
)}
</div>
{primaryButton}
</div> </div>
</animated.div> {primaryButton}
); </div>
}, </animated.div>
); );
};
MediaView.displayName = "MediaView"; MediaView.displayName = "MediaView";

View File

@@ -7,8 +7,9 @@ Please see LICENSE in the repository root for full details.
import { import {
type ComponentProps, type ComponentProps,
type FC,
type Ref,
type RefAttributes, type RefAttributes,
forwardRef,
useCallback, useCallback,
useEffect, useEffect,
useRef, useRef,
@@ -44,6 +45,7 @@ import { useLatest } from "../useLatest";
import { type SpotlightTileViewModel } from "../state/TileViewModel"; import { type SpotlightTileViewModel } from "../state/TileViewModel";
interface SpotlightItemBaseProps { interface SpotlightItemBaseProps {
ref?: Ref<HTMLDivElement>;
className?: string; className?: string;
"data-id": string; "data-id": string;
targetWidth: number; targetWidth: number;
@@ -67,13 +69,13 @@ interface SpotlightLocalUserMediaItemProps
vm: LocalUserMediaViewModel; vm: LocalUserMediaViewModel;
} }
const SpotlightLocalUserMediaItem = forwardRef< const SpotlightLocalUserMediaItem: FC<SpotlightLocalUserMediaItemProps> = ({
HTMLDivElement, vm,
SpotlightLocalUserMediaItemProps ...props
>(({ vm, ...props }, ref) => { }) => {
const mirror = useObservableEagerState(vm.mirror$); const mirror = useObservableEagerState(vm.mirror$);
return <MediaView ref={ref} mirror={mirror} {...props} />; return <MediaView mirror={mirror} {...props} />;
}); };
SpotlightLocalUserMediaItem.displayName = "SpotlightLocalUserMediaItem"; SpotlightLocalUserMediaItem.displayName = "SpotlightLocalUserMediaItem";
@@ -81,16 +83,15 @@ interface SpotlightUserMediaItemProps extends SpotlightItemBaseProps {
vm: UserMediaViewModel; vm: UserMediaViewModel;
} }
const SpotlightUserMediaItem = forwardRef< const SpotlightUserMediaItem: FC<SpotlightUserMediaItemProps> = ({
HTMLDivElement, vm,
SpotlightUserMediaItemProps ...props
>(({ vm, ...props }, ref) => { }) => {
const videoEnabled = useObservableEagerState(vm.videoEnabled$); const videoEnabled = useObservableEagerState(vm.videoEnabled$);
const cropVideo = useObservableEagerState(vm.cropVideo$); const cropVideo = useObservableEagerState(vm.cropVideo$);
const baseProps: SpotlightUserMediaItemBaseProps & const baseProps: SpotlightUserMediaItemBaseProps &
RefAttributes<HTMLDivElement> = { RefAttributes<HTMLDivElement> = {
ref,
videoEnabled, videoEnabled,
videoFit: cropVideo ? "cover" : "contain", videoFit: cropVideo ? "cover" : "contain",
...props, ...props,
@@ -101,11 +102,12 @@ const SpotlightUserMediaItem = forwardRef<
) : ( ) : (
<MediaView mirror={false} {...baseProps} /> <MediaView mirror={false} {...baseProps} />
); );
}); };
SpotlightUserMediaItem.displayName = "SpotlightUserMediaItem"; SpotlightUserMediaItem.displayName = "SpotlightUserMediaItem";
interface SpotlightItemProps { interface SpotlightItemProps {
ref?: Ref<HTMLDivElement>;
vm: MediaViewModel; vm: MediaViewModel;
targetWidth: number; targetWidth: number;
targetHeight: number; targetHeight: number;
@@ -117,71 +119,63 @@ interface SpotlightItemProps {
"aria-hidden"?: boolean; "aria-hidden"?: boolean;
} }
const SpotlightItem = forwardRef<HTMLDivElement, SpotlightItemProps>( const SpotlightItem: FC<SpotlightItemProps> = ({
( ref: theirRef,
{ vm,
vm, targetWidth,
targetWidth, targetHeight,
targetHeight, intersectionObserver$,
intersectionObserver$, snap,
snap, "aria-hidden": ariaHidden,
"aria-hidden": ariaHidden, }) => {
}, const ourRef = useRef<HTMLDivElement | null>(null);
theirRef, const ref = useMergedRefs(ourRef, theirRef);
) => { const displayName = useObservableEagerState(vm.displayname$);
const ourRef = useRef<HTMLDivElement | null>(null); const video = useObservableEagerState(vm.video$);
const ref = useMergedRefs(ourRef, theirRef); const unencryptedWarning = useObservableEagerState(vm.unencryptedWarning$);
const displayName = useObservableEagerState(vm.displayname$); const encryptionStatus = useObservableEagerState(vm.encryptionStatus$);
const video = useObservableEagerState(vm.video$);
const unencryptedWarning = useObservableEagerState(vm.unencryptedWarning$);
const encryptionStatus = useObservableEagerState(vm.encryptionStatus$);
// Hook this item up to the intersection observer // Hook this item up to the intersection observer
useEffect(() => { useEffect(() => {
const element = ourRef.current!; const element = ourRef.current!;
let prevIo: IntersectionObserver | null = null; let prevIo: IntersectionObserver | null = null;
const subscription = intersectionObserver$.subscribe((io) => { const subscription = intersectionObserver$.subscribe((io) => {
prevIo?.unobserve(element); prevIo?.unobserve(element);
io.observe(element); io.observe(element);
prevIo = io; prevIo = io;
}); });
return (): void => { return (): void => {
subscription.unsubscribe(); subscription.unsubscribe();
prevIo?.unobserve(element); 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,
}; };
}, [intersectionObserver$]);
return vm instanceof ScreenShareViewModel ? ( const baseProps: SpotlightItemBaseProps & RefAttributes<HTMLDivElement> = {
<MediaView ref,
videoEnabled "data-id": vm.id,
videoFit="contain" className: classNames(styles.item, { [styles.snap]: snap }),
mirror={false} targetWidth,
{...baseProps} targetHeight,
/> video,
) : ( member: vm.member,
<SpotlightUserMediaItem vm={vm} {...baseProps} /> 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"; SpotlightItem.displayName = "SpotlightItem";
interface Props { interface Props {
ref?: Ref<HTMLDivElement>;
vm: SpotlightTileViewModel; vm: SpotlightTileViewModel;
expanded: boolean; expanded: boolean;
onToggleExpanded: (() => void) | null; onToggleExpanded: (() => void) | null;
@@ -192,156 +186,148 @@ interface Props {
style?: ComponentProps<typeof animated.div>["style"]; style?: ComponentProps<typeof animated.div>["style"];
} }
export const SpotlightTile = forwardRef<HTMLDivElement, Props>( export const SpotlightTile: FC<Props> = ({
( ref: theirRef,
{ vm,
vm, expanded,
expanded, onToggleExpanded,
onToggleExpanded, targetWidth,
targetWidth, targetHeight,
targetHeight, showIndicators,
showIndicators, className,
className, style,
style, }) => {
}, const { t } = useTranslation();
theirRef, const [ourRef, root$] = useObservableRef<HTMLDivElement | null>(null);
) => { const ref = useMergedRefs(ourRef, theirRef);
const { t } = useTranslation(); const maximised = useObservableEagerState(vm.maximised$);
const [ourRef, root$] = useObservableRef<HTMLDivElement | null>(null); const media = useObservableEagerState(vm.media$);
const ref = useMergedRefs(ourRef, theirRef); const [visibleId, setVisibleId] = useState<string | undefined>(media[0]?.id);
const maximised = useObservableEagerState(vm.maximised$); const latestMedia = useLatest(media);
const media = useObservableEagerState(vm.media$); const latestVisibleId = useLatest(visibleId);
const [visibleId, setVisibleId] = useState<string | undefined>( const visibleIndex = media.findIndex((vm) => vm.id === visibleId);
media[0]?.id, const canGoBack = visibleIndex > 0;
); const canGoToNext = visibleIndex !== -1 && visibleIndex < media.length - 1;
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 // 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 // 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 // their effects before their parent does, we need to do this dance with an
// Observable to actually give them the intersection observer. // Observable to actually give them the intersection observer.
const intersectionObserver$ = useInitial<Observable<IntersectionObserver>>( const intersectionObserver$ = useInitial<Observable<IntersectionObserver>>(
() => () =>
root$.pipe( root$.pipe(
map( map(
(r) => (r) =>
new IntersectionObserver( new IntersectionObserver(
(entries) => { (entries) => {
const visible = entries.find((e) => e.isIntersecting); const visible = entries.find((e) => e.isIntersecting);
if (visible !== undefined) if (visible !== undefined)
setVisibleId(visible.target.getAttribute("data-id")!); setVisibleId(visible.target.getAttribute("data-id")!);
}, },
{ root: r, threshold: 0.5 }, { 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>( const onNextClick = useCallback(() => {
(prev) => const media = latestMedia.current;
prev == null || const visibleIndex = media.findIndex(
prev === visibleId || (vm) => vm.id === latestVisibleId.current,
media.every((vm) => vm.id !== prev)
? null
: prev,
[visibleId],
); );
if (visibleIndex !== -1 && visibleIndex !== media.length - 1)
setScrollToId(media[visibleIndex + 1].id);
}, [latestVisibleId, latestMedia, setScrollToId]);
const onBackClick = useCallback(() => { const ToggleExpandIcon = expanded ? CollapseIcon : ExpandIcon;
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 onNextClick = useCallback(() => { return (
const media = latestMedia.current; <animated.div
const visibleIndex = media.findIndex( ref={ref}
(vm) => vm.id === latestVisibleId.current, className={classNames(className, styles.tile, {
); [styles.maximised]: maximised,
if (visibleIndex !== -1 && visibleIndex !== media.length - 1) })}
setScrollToId(media[visibleIndex + 1].id); style={style}
}, [latestVisibleId, latestMedia, setScrollToId]); >
{canGoBack && (
const ToggleExpandIcon = expanded ? CollapseIcon : ExpandIcon; <button
className={classNames(styles.advance, styles.back)}
return ( aria-label={t("common.back")}
<animated.div onClick={onBackClick}
ref={ref} >
className={classNames(className, styles.tile, { <ChevronLeftIcon aria-hidden width={24} height={24} />
[styles.maximised]: maximised, </button>
})} )}
style={style} <div className={styles.contents}>
> {media.map((vm) => (
{canGoBack && ( <SpotlightItem
<button key={vm.id}
className={classNames(styles.advance, styles.back)} vm={vm}
aria-label={t("common.back")} targetWidth={targetWidth}
onClick={onBackClick} targetHeight={targetHeight}
> intersectionObserver$={intersectionObserver$}
<ChevronLeftIcon aria-hidden width={24} height={24} /> // This is how we get the container to scroll to the right media
</button> // when the previous/next buttons are clicked: we temporarily
)} // remove all scroll snap points except for just the one media
<div className={styles.contents}> // 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) => ( {media.map((vm) => (
<SpotlightItem <div
key={vm.id} key={vm.id}
vm={vm} className={styles.item}
targetWidth={targetWidth} data-visible={vm.id === visibleId}
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> </div>
{onToggleExpanded && ( )}
<button </animated.div>
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>
);
},
);
SpotlightTile.displayName = "SpotlightTile"; SpotlightTile.displayName = "SpotlightTile";

View File

@@ -5,21 +5,21 @@ 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 { type MutableRefObject, type RefCallback, useCallback } from "react"; import { type RefCallback, type RefObject, useCallback } from "react";
/** /**
* Combines multiple refs into one, useful for attaching multiple refs to the * Combines multiple refs into one, useful for attaching multiple refs to the
* same DOM node. * same DOM node.
*/ */
export const useMergedRefs = <T>( export const useMergedRefs = <T>(
...refs: (MutableRefObject<T | null> | RefCallback<T | null> | null)[] ...refs: (RefObject<T | null> | RefCallback<T | null> | null | undefined)[]
): RefCallback<T | null> => ): RefCallback<T | null> =>
useCallback( useCallback(
(value) => (value) =>
refs.forEach((ref) => { refs.forEach((ref) => {
if (typeof ref === "function") { if (typeof ref === "function") {
ref(value); ref(value);
} else if (ref !== null) { } else if (ref) {
ref.current = value; ref.current = value;
} }
}), }),