Merge pull request #3358 from element-hq/robin/remove-forward-ref
Remove usages of forwardRef
This commit is contained in:
@@ -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";
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
});
|
};
|
||||||
|
|||||||
@@ -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} />;
|
};
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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} />;
|
||||||
}),
|
}),
|
||||||
),
|
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}),
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}),
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}),
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}),
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}),
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|
||||||
|
|||||||
@@ -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],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
|||||||
Reference in New Issue
Block a user