Merge branch 'livekit' into robin/earpiece-overlay

This commit is contained in:
Robin
2025-06-25 15:54:04 -04:00
23 changed files with 873 additions and 771 deletions

View File

@@ -27,5 +27,5 @@ On mobile platforms (iOS, Android), web views do not reliably support selecting
Callbacks for buttons in EC that are handled by the native application
- `showNativeAudioDevicePicker: (() => void) | undefined`. Callback called whenever the user presses the output button in the settings menu.
This button is only shown on iOS. (`userAgent.includes("iPhone")`)
This button is only shown on iOS. (`/iPad|iPhone|iPod|Mac/.test(navigator.userAgent)`)
- `onBackButtonPressed: (() => void) | undefined`. Callback when the webview detects a tab on the header's back button.

View File

@@ -0,0 +1,104 @@
/*
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { expect, test } from "@playwright/test";
test("When creator left, avoid reconnect to the same SFU", async ({
browser,
}) => {
// Use reduce motion to disable animations that are making the tests a bit flaky
const creatorContext = await browser.newContext({ reducedMotion: "reduce" });
const creatorPage = await creatorContext.newPage();
await creatorPage.goto("/");
// ========
// ARRANGE: The first user creates a call as guest, join it, then click the invite button to copy the invite link
// ========
await creatorPage.getByTestId("home_callName").click();
await creatorPage.getByTestId("home_callName").fill("Welcome");
await creatorPage.getByTestId("home_displayName").click();
await creatorPage.getByTestId("home_displayName").fill("Inviter");
await creatorPage.getByTestId("home_go").click();
await expect(creatorPage.locator("video")).toBeVisible();
// join
await creatorPage.getByTestId("lobby_joinCall").click();
// Spotlight mode to make checking the test visually clearer
await creatorPage.getByRole("radio", { name: "Spotlight" }).check();
// Get the invite link
await creatorPage.getByRole("button", { name: "Invite" }).click();
await expect(
creatorPage.getByRole("heading", { name: "Invite to this call" }),
).toBeVisible();
await expect(creatorPage.getByRole("img", { name: "QR Code" })).toBeVisible();
await expect(creatorPage.getByTestId("modal_inviteLink")).toBeVisible();
await expect(creatorPage.getByTestId("modal_inviteLink")).toBeVisible();
await creatorPage.getByTestId("modal_inviteLink").click();
const inviteLink = (await creatorPage.evaluate(
"navigator.clipboard.readText()",
)) as string;
expect(inviteLink).toContain("room/#/");
// ========
// ACT: The other user use the invite link to join the call as a guest
// ========
const guestB = await browser.newContext({
reducedMotion: "reduce",
});
const guestBPage = await guestB.newPage();
await guestBPage.goto(inviteLink);
await guestBPage.getByTestId("joincall_displayName").fill("Invitee");
await expect(guestBPage.getByTestId("joincall_joincall")).toBeVisible();
await guestBPage.getByTestId("joincall_joincall").click();
await guestBPage.getByTestId("lobby_joinCall").click();
await guestBPage.getByRole("radio", { name: "Spotlight" }).check();
// ========
// ACT: add a third user to the call to reproduce the bug
// ========
const guestC = await browser.newContext({
reducedMotion: "reduce",
});
const guestCPage = await guestC.newPage();
let sfuGetCallCount = 0;
await guestCPage.route("**/livekit/jwt/sfu/get", async (route) => {
sfuGetCallCount++;
await route.continue();
});
// Track WebSocket connections
let wsConnectionCount = 0;
await guestCPage.routeWebSocket("**", (ws) => {
// For some reason the interception is not working with the **
if (ws.url().includes("livekit/sfu/rtc")) {
wsConnectionCount++;
}
ws.connectToServer();
});
await guestCPage.goto(inviteLink);
await guestCPage.getByTestId("joincall_displayName").fill("Invitee");
await expect(guestCPage.getByTestId("joincall_joincall")).toBeVisible();
await guestCPage.getByTestId("joincall_joincall").click();
await guestCPage.getByTestId("lobby_joinCall").click();
await guestCPage.getByRole("radio", { name: "Spotlight" }).check();
await guestCPage.waitForTimeout(1000);
// ========
// the creator leaves the call
await creatorPage.getByTestId("incall_leave").click();
await guestCPage.waitForTimeout(2000);
// https://github.com/element-hq/element-call/issues/3344
// The app used to request a new jwt token then to reconnect to the SFU
expect(wsConnectionCount).toBe(1);
expect(sfuGetCallCount).toBe(1);
});

View File

@@ -6,12 +6,7 @@ Please see LICENSE in the repository root for full details.
*/
import classNames from "classnames";
import {
type FC,
type HTMLAttributes,
type ReactNode,
forwardRef,
} from "react";
import { type Ref, type FC, type HTMLAttributes, type ReactNode } from "react";
import { Link } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { Heading, Text } from "@vector-im/compound-web";
@@ -24,23 +19,27 @@ import { EncryptionLock } from "./room/EncryptionLock";
import { useMediaQuery } from "./useMediaQuery";
interface HeaderProps extends HTMLAttributes<HTMLElement> {
ref?: Ref<HTMLElement>;
children: ReactNode;
className?: string;
}
export const Header = forwardRef<HTMLElement, HeaderProps>(
({ children, className, ...rest }, ref) => {
return (
<header
ref={ref}
className={classNames(styles.header, className)}
{...rest}
>
{children}
</header>
);
},
);
export const Header: FC<HeaderProps> = ({
ref,
children,
className,
...rest
}) => {
return (
<header
ref={ref}
className={classNames(styles.header, className)}
{...rest}
>
{children}
</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.
*/
import {
type ComponentPropsWithoutRef,
forwardRef,
type MouseEvent,
} from "react";
import { type ComponentProps, type FC, type MouseEvent } from "react";
import { Link as CpdLink } from "@vector-im/compound-web";
import { type LinkProps, useHref, useLinkClickHandler } from "react-router-dom";
import classNames from "classnames";
@@ -26,31 +22,30 @@ export function useLink(
return [href, onClick];
}
type Props = Omit<
ComponentPropsWithoutRef<typeof CpdLink>,
"href" | "onClick"
> & { to: LinkProps["to"]; state?: unknown };
type Props = Omit<ComponentProps<typeof CpdLink>, "href" | "onClick"> & {
to: LinkProps["to"];
state?: unknown;
};
/**
* A version of Compound's link component that integrates with our router setup.
* This is only for app-internal links.
*/
export const Link = forwardRef<HTMLAnchorElement, Props>(function Link(
{ to, state, ...props },
ref,
) {
export const Link: FC<Props> = ({ ref, to, state, ...props }) => {
const [path, onClick] = useLink(to, state);
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
* than the normal Compound link component.
*/
export const ExternalLink = forwardRef<
HTMLAnchorElement,
ComponentPropsWithoutRef<"a">
>(function ExternalLink({ className, children, ...props }, ref) {
export const ExternalLink: FC<ComponentProps<"a">> = ({
ref,
className,
children,
...props
}) => {
return (
<a
ref={ref}
@@ -62,4 +57,4 @@ export const ExternalLink = forwardRef<
{children}
</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.
*/
import { type ComponentPropsWithoutRef, forwardRef } from "react";
import { type ComponentProps, type FC } from "react";
import { Button } from "@vector-im/compound-web";
import type { LinkProps } from "react-router-dom";
import { useLink } from "./Link";
type Props = Omit<
ComponentPropsWithoutRef<typeof Button<"a">>,
"as" | "href"
> & { to: LinkProps["to"]; state?: unknown };
type Props = Omit<ComponentProps<typeof Button<"a">>, "as" | "href"> & {
to: LinkProps["to"];
state?: unknown;
};
/**
* A version of Compound's button component that acts as a link and integrates
* with our router setup.
*/
export const LinkButton = forwardRef<HTMLAnchorElement, Props>(
function LinkButton({ to, state, ...props }, ref) {
const [path, onClick] = useLink(to, state);
return <Button as="a" ref={ref} {...props} href={path} onClick={onClick} />;
},
);
export const LinkButton: FC<Props> = ({ ref, to, state, ...props }) => {
const [path, onClick] = useLink(to, state);
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 { type FormEventHandler, forwardRef, type ReactNode } from "react";
import {
type FC,
type Ref,
type FormEventHandler,
type ReactNode,
} from "react";
import styles from "./Form.module.css";
interface FormProps {
ref?: Ref<HTMLFormElement>;
className: string;
onSubmit: FormEventHandler<HTMLFormElement>;
children: ReactNode[];
}
export const Form = forwardRef<HTMLFormElement, FormProps>(
({ children, className, onSubmit }, ref) => {
return (
<form
onSubmit={onSubmit}
className={classNames(styles.form, className)}
ref={ref}
>
{children}
</form>
);
},
);
export const Form: FC<FormProps> = ({ ref, children, className, onSubmit }) => {
return (
<form
onSubmit={onSubmit}
className={classNames(styles.form, className)}
ref={ref}
>
{children}
</form>
);
};
Form.displayName = "Form";

View File

@@ -18,11 +18,10 @@ import {
type ComponentType,
type Dispatch,
type FC,
type LegacyRef,
type ReactNode,
type Ref,
type SetStateAction,
createContext,
forwardRef,
memo,
use,
useEffect,
@@ -162,7 +161,7 @@ const windowHeightObservable$ = fromEvent(window, "resize").pipe(
);
export interface LayoutProps<LayoutModel, TileModel, R extends HTMLElement> {
ref: LegacyRef<R>;
ref?: Ref<R>;
model: LayoutModel;
/**
* 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> {
ref: LegacyRef<R>;
ref?: Ref<R>;
className?: string;
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
const LayoutMemo = useMemo(
() =>
memo(
forwardRef<
LayoutRef,
LayoutMemoProps<LayoutModel, TileModel, LayoutRef>
>(function LayoutMemo({ Layout, ...props }, ref): ReactNode {
return <Layout {...props} ref={ref} />;
}),
),
memo(function LayoutMemo({
ref,
Layout,
...props
}: LayoutMemoProps<LayoutModel, TileModel, LayoutRef>): ReactNode {
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.
*/
import { type CSSProperties, forwardRef, useCallback, useMemo } from "react";
import {
type CSSProperties,
type ReactNode,
useCallback,
useMemo,
} from "react";
import { distinctUntilChanged } from "rxjs";
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
// lives
fixed: forwardRef(function GridLayoutFixed({ model, Slot }, ref) {
fixed: function GridLayoutFixed({ ref, model, Slot }): ReactNode {
useUpdateLayout();
const alignment = useObservableEagerState(
useInitial(() =>
@@ -68,10 +73,10 @@ export const makeGridLayout: CallLayout<GridLayoutModel> = ({
)}
</div>
);
}),
},
// 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();
useVisibleTiles(model.setVisibleTiles);
const { width, height: minHeight } = useObservableEagerState(minBounds$);
@@ -98,5 +103,5 @@ export const makeGridLayout: CallLayout<GridLayoutModel> = ({
))}
</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.
*/
import { forwardRef, useCallback, useMemo } from "react";
import { type ReactNode, useCallback, useMemo } from "react";
import { useObservableEagerState } from "observable-hooks";
import classNames from "classnames";
@@ -24,12 +24,12 @@ export const makeOneOnOneLayout: CallLayout<OneOnOneLayoutModel> = ({
}) => ({
scrollingOnTop: false,
fixed: forwardRef(function OneOnOneLayoutFixed(_props, ref) {
fixed: function OneOnOneLayoutFixed({ ref }): ReactNode {
useUpdateLayout();
return <div ref={ref} />;
}),
},
scrolling: forwardRef(function OneOnOneLayoutScrolling({ model, Slot }, ref) {
scrolling: function OneOnOneLayoutScrolling({ ref, model, Slot }): ReactNode {
useUpdateLayout();
const { width, height } = useObservableEagerState(minBounds$);
const pipAlignmentValue = useObservableEagerState(pipAlignment$);
@@ -66,5 +66,5 @@ export const makeOneOnOneLayout: CallLayout<OneOnOneLayoutModel> = ({
</Slot>
</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.
*/
import { forwardRef, useCallback } from "react";
import { type ReactNode, useCallback } from "react";
import { useObservableEagerState } from "observable-hooks";
import { type SpotlightExpandedLayout as SpotlightExpandedLayoutModel } from "../state/CallViewModel";
@@ -22,10 +22,11 @@ export const makeSpotlightExpandedLayout: CallLayout<
> = ({ pipAlignment$ }) => ({
scrollingOnTop: true,
fixed: forwardRef(function SpotlightExpandedLayoutFixed(
{ model, Slot },
fixed: function SpotlightExpandedLayoutFixed({
ref,
) {
model,
Slot,
}): ReactNode {
useUpdateLayout();
return (
@@ -37,12 +38,13 @@ export const makeSpotlightExpandedLayout: CallLayout<
/>
</div>
);
}),
},
scrolling: forwardRef(function SpotlightExpandedLayoutScrolling(
{ model, Slot },
scrolling: function SpotlightExpandedLayoutScrolling({
ref,
) {
model,
Slot,
}): ReactNode {
useUpdateLayout();
const pipAlignmentValue = useObservableEagerState(pipAlignment$);
@@ -69,5 +71,5 @@ export const makeSpotlightExpandedLayout: CallLayout<
)}
</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.
*/
import { forwardRef } from "react";
import { type ReactNode } from "react";
import { useObservableEagerState } from "observable-hooks";
import classNames from "classnames";
@@ -24,10 +24,11 @@ export const makeSpotlightLandscapeLayout: CallLayout<
> = ({ minBounds$ }) => ({
scrollingOnTop: false,
fixed: forwardRef(function SpotlightLandscapeLayoutFixed(
{ model, Slot },
fixed: function SpotlightLandscapeLayoutFixed({
ref,
) {
model,
Slot,
}): ReactNode {
useUpdateLayout();
useObservableEagerState(minBounds$);
@@ -43,12 +44,13 @@ export const makeSpotlightLandscapeLayout: CallLayout<
<div className={styles.grid} />
</div>
);
}),
},
scrolling: forwardRef(function SpotlightLandscapeLayoutScrolling(
{ model, Slot },
scrolling: function SpotlightLandscapeLayoutScrolling({
ref,
) {
model,
Slot,
}): ReactNode {
useUpdateLayout();
useVisibleTiles(model.setVisibleTiles);
useObservableEagerState(minBounds$);
@@ -69,5 +71,5 @@ export const makeSpotlightLandscapeLayout: 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.
*/
import { type CSSProperties, forwardRef } from "react";
import { type ReactNode, type CSSProperties } from "react";
import { useObservableEagerState } from "observable-hooks";
import classNames from "classnames";
@@ -30,10 +30,11 @@ export const makeSpotlightPortraitLayout: CallLayout<
> = ({ minBounds$ }) => ({
scrollingOnTop: false,
fixed: forwardRef(function SpotlightPortraitLayoutFixed(
{ model, Slot },
fixed: function SpotlightPortraitLayoutFixed({
ref,
) {
model,
Slot,
}): ReactNode {
useUpdateLayout();
return (
@@ -47,12 +48,13 @@ export const makeSpotlightPortraitLayout: CallLayout<
</div>
</div>
);
}),
},
scrolling: forwardRef(function SpotlightPortraitLayoutScrolling(
{ model, Slot },
scrolling: function SpotlightPortraitLayoutScrolling({
ref,
) {
model,
Slot,
}): ReactNode {
useUpdateLayout();
useVisibleTiles(model.setVisibleTiles);
const { width } = useObservableEagerState(minBounds$);
@@ -90,5 +92,5 @@ export const makeSpotlightPortraitLayout: CallLayout<
</div>
</div>
);
}),
},
});

View File

@@ -74,6 +74,13 @@ body {
-webkit-tap-highlight-color: transparent;
}
/* This prohibits the view to scroll for pages smaller than 122px in width
we use this for mobile pip webviews */
.no-scroll-body {
position: fixed;
width: 100%;
}
/* We use this to not render the page at all until we know the theme.*/
.no-theme {
opacity: 0;

View File

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

View File

@@ -133,6 +133,16 @@ export const GroupCallView: FC<Props> = ({
};
}, []);
// This CSS is the only way we could find to not make element call scroll for
// viewport sizes smaller than 122px width. (It is actually this exact number: 122px
// tested on different devices...)
useEffect(() => {
document.body.classList.add("no-scroll-body");
return (): void => {
document.body.classList.remove("no-scroll-body");
};
}, []);
useEffect(() => {
window.rtcSession = rtcSession;
return (): void => {

View File

@@ -12,9 +12,7 @@ import { type MatrixClient } from "matrix-js-sdk";
import {
type FC,
type PointerEvent,
type PropsWithoutRef,
type TouchEvent,
forwardRef,
useCallback,
useEffect,
useMemo,
@@ -521,13 +519,14 @@ export const InCallView: FC<InCallViewProps> = ({
const Tile = useMemo(
() =>
forwardRef<
HTMLDivElement,
PropsWithoutRef<TileProps<TileViewModel, HTMLDivElement>>
>(function Tile(
{ className, style, targetWidth, targetHeight, model },
function Tile({
ref,
) {
className,
style,
targetWidth,
targetHeight,
model,
}: TileProps<TileViewModel, HTMLDivElement>): ReactNode {
const spotlightExpanded = useObservableEagerState(
vm.spotlightExpanded$,
);
@@ -565,7 +564,7 @@ export const InCallView: FC<InCallViewProps> = ({
style={style}
/>
);
}),
},
[vm, openProfile],
);

View File

@@ -9,7 +9,7 @@ import {
type MatrixRTCSession,
MatrixRTCSessionEvent,
} from "matrix-js-sdk/lib/matrixrtc";
import { useCallback, useEffect, useRef } from "react";
import { useCallback, useRef } from "react";
import { deepCompare } from "matrix-js-sdk/lib/utils";
import { logger } from "matrix-js-sdk/lib/logger";
import { type LivekitFocus, isLivekitFocus } from "matrix-js-sdk/lib/matrixrtc";
@@ -24,27 +24,22 @@ import { useTypedEventEmitterState } from "../useEvents";
export function useActiveLivekitFocus(
rtcSession: MatrixRTCSession,
): LivekitFocus | undefined {
const activeFocus = useTypedEventEmitterState(
const prevActiveFocus = useRef<LivekitFocus | undefined>(undefined);
return useTypedEventEmitterState(
rtcSession,
MatrixRTCSessionEvent.MembershipsChanged,
useCallback(() => {
const f = rtcSession.getActiveFocus();
// Only handle foci with type="livekit" for now.
return !!f && isLivekitFocus(f) ? f : undefined;
if (f && isLivekitFocus(f) && !deepCompare(f, prevActiveFocus.current)) {
const oldestMembership = rtcSession.getOldestMembership();
logger.info(
`Got new active focus from membership: ${oldestMembership?.sender}/${oldestMembership?.deviceId}.
Updated focus (focus switch) from ${JSON.stringify(prevActiveFocus.current)} to ${JSON.stringify(f)}`,
);
prevActiveFocus.current = f;
}
return prevActiveFocus.current;
}, [rtcSession]),
);
const prevActiveFocus = useRef(activeFocus);
useEffect(() => {
if (!deepCompare(activeFocus, prevActiveFocus.current)) {
const oldestMembership = rtcSession.getOldestMembership();
logger.warn(
`Got new active focus from membership: ${oldestMembership?.sender}/${oldestMembership?.deviceId}.
Updated focus (focus switch) from ${JSON.stringify(prevActiveFocus.current)} to ${JSON.stringify(activeFocus)}`,
);
prevActiveFocus.current = activeFocus;
}
}, [activeFocus, rtcSession]);
return activeFocus;
}

View File

@@ -32,6 +32,7 @@ import {
availableOutputDevices$ as controlledAvailableOutputDevices$,
} from "../controls";
import { getUrlParams } from "../UrlParams";
import { platform } from "../Platform";
// This hardcoded id is used in EX ios! It can only be changed in coordination with
// the ios swift team.
@@ -90,9 +91,8 @@ export interface MediaDevice<Label, Selected> {
* - Only show the earpiece toggle option if the earpiece is available:
* `availableOutputDevices$.includes((d)=>d.forEarpiece)`
*/
export const iosDeviceMenu$ = navigator.userAgent.includes("iPhone")
? of(true)
: alwaysShowIphoneEarpieceSetting.value$;
export const iosDeviceMenu$ =
platform === "ios" ? of(true) : alwaysShowIphoneEarpieceSetting.value$;
function availableRawDevices$(
kind: MediaDeviceKind,
@@ -273,21 +273,21 @@ class ControlledAudioOutput
this.deviceSelection$.next(id);
}
public readonly selected$ = combineLatest([
this.available$,
merge(
controlledOutputSelection$.pipe(startWith(undefined)),
this.deviceSelection$,
),
]).pipe(
map(([available, selectId]) => {
const id = selectId ?? available.keys().next().value;
return id
? { id, virtualEarpiece: id === EARPIECE_CONFIG_ID }
: undefined;
}),
this.scope.state(),
);
public readonly selected$ = combineLatest(
[
this.available$,
merge(
controlledOutputSelection$.pipe(startWith(undefined)),
this.deviceSelection$,
),
],
(available, preferredId) => {
const id = preferredId ?? available.keys().next().value;
return id === undefined
? undefined
: { id, virtualEarpiece: id === EARPIECE_CONFIG_ID };
},
).pipe(this.scope.state());
public constructor(private readonly scope: ObservableScope) {
this.selected$.subscribe((device) => {

View File

@@ -7,8 +7,9 @@ Please see LICENSE in the repository root for full details.
import {
type ComponentProps,
type FC,
type ReactNode,
forwardRef,
type Ref,
useCallback,
useRef,
useState,
@@ -50,6 +51,7 @@ import { useMergedRefs } from "../useMergedRefs";
import { useReactionsSender } from "../reactions/useReactionsSender";
interface TileProps {
ref?: Ref<HTMLDivElement>;
className?: string;
style?: ComponentProps<typeof animated.div>["style"];
targetWidth: number;
@@ -66,132 +68,128 @@ interface UserMediaTileProps extends TileProps {
menuEnd?: ReactNode;
}
const UserMediaTile = forwardRef<HTMLDivElement, UserMediaTileProps>(
(
{
vm,
showSpeakingIndicators,
locallyMuted,
menuStart,
menuEnd,
className,
displayName,
...props
const UserMediaTile: FC<UserMediaTileProps> = ({
ref,
vm,
showSpeakingIndicators,
locallyMuted,
menuStart,
menuEnd,
className,
displayName,
...props
}) => {
const { toggleRaisedHand } = useReactionsSender();
const { t } = useTranslation();
const video = useObservableEagerState(vm.video$);
const unencryptedWarning = useObservableEagerState(vm.unencryptedWarning$);
const encryptionStatus = useObservableEagerState(vm.encryptionStatus$);
const audioStreamStats = useObservableEagerState<
RTCInboundRtpStreamStats | RTCOutboundRtpStreamStats | undefined
>(vm.audioStreamStats$);
const videoStreamStats = useObservableEagerState<
RTCInboundRtpStreamStats | RTCOutboundRtpStreamStats | undefined
>(vm.videoStreamStats$);
const audioEnabled = useObservableEagerState(vm.audioEnabled$);
const videoEnabled = useObservableEagerState(vm.videoEnabled$);
const speaking = useObservableEagerState(vm.speaking$);
const cropVideo = useObservableEagerState(vm.cropVideo$);
const onSelectFitContain = useCallback(
(e: Event) => {
e.preventDefault();
vm.toggleFitContain();
},
ref,
) => {
const { toggleRaisedHand } = useReactionsSender();
const { t } = useTranslation();
const video = useObservableEagerState(vm.video$);
const unencryptedWarning = useObservableEagerState(vm.unencryptedWarning$);
const encryptionStatus = useObservableEagerState(vm.encryptionStatus$);
const audioStreamStats = useObservableEagerState<
RTCInboundRtpStreamStats | RTCOutboundRtpStreamStats | undefined
>(vm.audioStreamStats$);
const videoStreamStats = useObservableEagerState<
RTCInboundRtpStreamStats | RTCOutboundRtpStreamStats | undefined
>(vm.videoStreamStats$);
const audioEnabled = useObservableEagerState(vm.audioEnabled$);
const videoEnabled = useObservableEagerState(vm.videoEnabled$);
const speaking = useObservableEagerState(vm.speaking$);
const cropVideo = useObservableEagerState(vm.cropVideo$);
const onSelectFitContain = useCallback(
(e: Event) => {
e.preventDefault();
vm.toggleFitContain();
},
[vm],
);
const handRaised = useObservableState(vm.handRaised$);
const reaction = useObservableState(vm.reaction$);
[vm],
);
const handRaised = useObservableState(vm.handRaised$);
const reaction = useObservableState(vm.reaction$);
const AudioIcon = locallyMuted
? VolumeOffSolidIcon
: audioEnabled
? MicOnSolidIcon
: MicOffSolidIcon;
const audioIconLabel = locallyMuted
? t("video_tile.muted_for_me")
: audioEnabled
? t("microphone_on")
: t("microphone_off");
const AudioIcon = locallyMuted
? VolumeOffSolidIcon
: audioEnabled
? MicOnSolidIcon
: MicOffSolidIcon;
const audioIconLabel = locallyMuted
? t("video_tile.muted_for_me")
: audioEnabled
? t("microphone_on")
: t("microphone_off");
const [menuOpen, setMenuOpen] = useState(false);
const menu = (
<>
{menuStart}
<ToggleMenuItem
Icon={ExpandIcon}
label={t("video_tile.change_fit_contain")}
checked={cropVideo}
onSelect={onSelectFitContain}
/>
{menuEnd}
</>
);
const raisedHandOnClick = vm.local
? (): void => void toggleRaisedHand()
: undefined;
const showSpeaking = showSpeakingIndicators && speaking;
const tile = (
<MediaView
ref={ref}
video={video}
member={vm.member}
unencryptedWarning={unencryptedWarning}
encryptionStatus={encryptionStatus}
videoEnabled={videoEnabled}
videoFit={cropVideo ? "cover" : "contain"}
className={classNames(className, styles.tile, {
[styles.speaking]: showSpeaking,
[styles.handRaised]: !showSpeaking && handRaised,
})}
nameTagLeadingIcon={
<AudioIcon
width={20}
height={20}
aria-label={audioIconLabel}
data-muted={locallyMuted || !audioEnabled}
className={styles.muteIcon}
/>
}
displayName={displayName}
primaryButton={
<Menu
open={menuOpen}
onOpenChange={setMenuOpen}
title={displayName}
trigger={
<button aria-label={t("common.options")}>
<OverflowHorizontalIcon aria-hidden width={20} height={20} />
</button>
}
side="left"
align="start"
>
{menu}
</Menu>
}
raisedHandTime={handRaised ?? undefined}
currentReaction={reaction ?? undefined}
raisedHandOnClick={raisedHandOnClick}
localParticipant={vm.local}
audioStreamStats={audioStreamStats}
videoStreamStats={videoStreamStats}
{...props}
const [menuOpen, setMenuOpen] = useState(false);
const menu = (
<>
{menuStart}
<ToggleMenuItem
Icon={ExpandIcon}
label={t("video_tile.change_fit_contain")}
checked={cropVideo}
onSelect={onSelectFitContain}
/>
);
{menuEnd}
</>
);
return (
<ContextMenu title={displayName} trigger={tile} hasAccessibleAlternative>
{menu}
</ContextMenu>
);
},
);
const raisedHandOnClick = vm.local
? (): void => void toggleRaisedHand()
: undefined;
const showSpeaking = showSpeakingIndicators && speaking;
const tile = (
<MediaView
ref={ref}
video={video}
member={vm.member}
unencryptedWarning={unencryptedWarning}
encryptionStatus={encryptionStatus}
videoEnabled={videoEnabled}
videoFit={cropVideo ? "cover" : "contain"}
className={classNames(className, styles.tile, {
[styles.speaking]: showSpeaking,
[styles.handRaised]: !showSpeaking && handRaised,
})}
nameTagLeadingIcon={
<AudioIcon
width={20}
height={20}
aria-label={audioIconLabel}
data-muted={locallyMuted || !audioEnabled}
className={styles.muteIcon}
/>
}
displayName={displayName}
primaryButton={
<Menu
open={menuOpen}
onOpenChange={setMenuOpen}
title={displayName}
trigger={
<button aria-label={t("common.options")}>
<OverflowHorizontalIcon aria-hidden width={20} height={20} />
</button>
}
side="left"
align="start"
>
{menu}
</Menu>
}
raisedHandTime={handRaised ?? undefined}
currentReaction={reaction ?? undefined}
raisedHandOnClick={raisedHandOnClick}
localParticipant={vm.local}
audioStreamStats={audioStreamStats}
videoStreamStats={videoStreamStats}
{...props}
/>
);
return (
<ContextMenu title={displayName} trigger={tile} hasAccessibleAlternative>
{menu}
</ContextMenu>
);
};
UserMediaTile.displayName = "UserMediaTile";
@@ -200,48 +198,51 @@ interface LocalUserMediaTileProps extends TileProps {
onOpenProfile: (() => void) | null;
}
const LocalUserMediaTile = forwardRef<HTMLDivElement, LocalUserMediaTileProps>(
({ vm, onOpenProfile, ...props }, ref) => {
const { t } = useTranslation();
const mirror = useObservableEagerState(vm.mirror$);
const alwaysShow = useObservableEagerState(vm.alwaysShow$);
const latestAlwaysShow = useLatest(alwaysShow);
const onSelectAlwaysShow = useCallback(
(e: Event) => {
e.preventDefault();
vm.setAlwaysShow(!latestAlwaysShow.current);
},
[vm, latestAlwaysShow],
);
const LocalUserMediaTile: FC<LocalUserMediaTileProps> = ({
ref,
vm,
onOpenProfile,
...props
}) => {
const { t } = useTranslation();
const mirror = useObservableEagerState(vm.mirror$);
const alwaysShow = useObservableEagerState(vm.alwaysShow$);
const latestAlwaysShow = useLatest(alwaysShow);
const onSelectAlwaysShow = useCallback(
(e: Event) => {
e.preventDefault();
vm.setAlwaysShow(!latestAlwaysShow.current);
},
[vm, latestAlwaysShow],
);
return (
<UserMediaTile
ref={ref}
vm={vm}
locallyMuted={false}
mirror={mirror}
menuStart={
<ToggleMenuItem
Icon={VisibilityOnIcon}
label={t("video_tile.always_show")}
checked={alwaysShow}
onSelect={onSelectAlwaysShow}
return (
<UserMediaTile
ref={ref}
vm={vm}
locallyMuted={false}
mirror={mirror}
menuStart={
<ToggleMenuItem
Icon={VisibilityOnIcon}
label={t("video_tile.always_show")}
checked={alwaysShow}
onSelect={onSelectAlwaysShow}
/>
}
menuEnd={
onOpenProfile && (
<MenuItem
Icon={UserProfileIcon}
label={t("common.profile")}
onSelect={onOpenProfile}
/>
}
menuEnd={
onOpenProfile && (
<MenuItem
Icon={UserProfileIcon}
label={t("common.profile")}
onSelect={onOpenProfile}
/>
)
}
{...props}
/>
);
},
);
)
}
{...props}
/>
);
};
LocalUserMediaTile.displayName = "LocalUserMediaTile";
@@ -249,10 +250,11 @@ interface RemoteUserMediaTileProps extends TileProps {
vm: RemoteUserMediaViewModel;
}
const RemoteUserMediaTile = forwardRef<
HTMLDivElement,
RemoteUserMediaTileProps
>(({ vm, ...props }, ref) => {
const RemoteUserMediaTile: FC<RemoteUserMediaTileProps> = ({
ref,
vm,
...props
}) => {
const { t } = useTranslation();
const locallyMuted = useObservableEagerState(vm.locallyMuted$);
const localVolume = useObservableEagerState(vm.localVolume$);
@@ -303,11 +305,12 @@ const RemoteUserMediaTile = forwardRef<
{...props}
/>
);
});
};
RemoteUserMediaTile.displayName = "RemoteUserMediaTile";
interface GridTileProps {
ref?: Ref<HTMLDivElement>;
vm: GridTileViewModel;
onOpenProfile: (() => void) | null;
targetWidth: number;
@@ -317,34 +320,37 @@ interface GridTileProps {
showSpeakingIndicators: boolean;
}
export const GridTile = forwardRef<HTMLDivElement, GridTileProps>(
({ vm, onOpenProfile, ...props }, theirRef) => {
const ourRef = useRef<HTMLDivElement | null>(null);
const ref = useMergedRefs(ourRef, theirRef);
const media = useObservableEagerState(vm.media$);
const displayName = useObservableEagerState(media.displayname$);
export const GridTile: FC<GridTileProps> = ({
ref: theirRef,
vm,
onOpenProfile,
...props
}) => {
const ourRef = useRef<HTMLDivElement | null>(null);
const ref = useMergedRefs(ourRef, theirRef);
const media = useObservableEagerState(vm.media$);
const displayName = useObservableEagerState(media.displayname$);
if (media instanceof LocalUserMediaViewModel) {
return (
<LocalUserMediaTile
ref={ref}
vm={media}
onOpenProfile={onOpenProfile}
displayName={displayName}
{...props}
/>
);
} else {
return (
<RemoteUserMediaTile
ref={ref}
vm={media}
displayName={displayName}
{...props}
/>
);
}
},
);
if (media instanceof LocalUserMediaViewModel) {
return (
<LocalUserMediaTile
ref={ref}
vm={media}
onOpenProfile={onOpenProfile}
displayName={displayName}
{...props}
/>
);
} else {
return (
<RemoteUserMediaTile
ref={ref}
vm={media}
displayName={displayName}
{...props}
/>
);
}
};
GridTile.displayName = "GridTile";

View File

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

View File

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

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.
*/
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
* same DOM node.
*/
export const useMergedRefs = <T>(
...refs: (MutableRefObject<T | null> | RefCallback<T | null> | null)[]
...refs: (RefObject<T | null> | RefCallback<T | null> | null | undefined)[]
): RefCallback<T | null> =>
useCallback(
(value) =>
refs.forEach((ref) => {
if (typeof ref === "function") {
ref(value);
} else if (ref !== null) {
} else if (ref) {
ref.current = value;
}
}),

View File

@@ -2702,37 +2702,37 @@ __metadata:
languageName: node
linkType: hard
"@livekit/components-core@npm:0.12.7, @livekit/components-core@npm:^0.12.0":
version: 0.12.7
resolution: "@livekit/components-core@npm:0.12.7"
"@livekit/components-core@npm:0.12.8, @livekit/components-core@npm:^0.12.0":
version: 0.12.8
resolution: "@livekit/components-core@npm:0.12.8"
dependencies:
"@floating-ui/dom": "npm:1.6.13"
loglevel: "npm:1.9.1"
rxjs: "npm:7.8.2"
peerDependencies:
livekit-client: ^2.13.1
livekit-client: ^2.13.3
tslib: ^2.6.2
checksum: 10c0/11024e120f2b1b81688d507dc8441f663730956d4c79f66014a9f19a3723fcdee9517a09239f21d501067cc299bf97659655404048c415314c39bf6af5d8ab9d
checksum: 10c0/68c471b2f1319fbbfff05138b55442cc2ba575bc8506b3e71bb222fdf7026041f88c38e832227bbcc37edd4d3936f997c1b54ce7b27d1971f960794f049c9684
languageName: node
linkType: hard
"@livekit/components-react@npm:^2.0.0":
version: 2.9.10
resolution: "@livekit/components-react@npm:2.9.10"
version: 2.9.12
resolution: "@livekit/components-react@npm:2.9.12"
dependencies:
"@livekit/components-core": "npm:0.12.7"
"@livekit/components-core": "npm:0.12.8"
clsx: "npm:2.1.1"
usehooks-ts: "npm:3.1.1"
peerDependencies:
"@livekit/krisp-noise-filter": ^0.2.12
livekit-client: ^2.13.1
livekit-client: ^2.13.3
react: ">=18"
react-dom: ">=18"
tslib: ^2.6.2
peerDependenciesMeta:
"@livekit/krisp-noise-filter":
optional: true
checksum: 10c0/86fc78d7b2d97540c43592435a65ac38b43fca9923af000aa90d884bd35794e18d202b7d915f44b63087abea5c94c37d6980772ed722226904262b2cddbec107
checksum: 10c0/445099ff7d335f8b9908e814e327d43477ea19fb93c428fadb0bf2aa56e323171af10fcc9493f686ad5060fb75e0d5c66632e55cd648a76fafe146827c53bd87
languageName: node
linkType: hard