Merge remote-tracking branch 'upstream/livekit' into SimonBrandner/feat/url

This commit is contained in:
Šimon Brandner
2023-07-04 16:26:54 +02:00
91 changed files with 1207 additions and 697 deletions

View File

@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { Suspense, useEffect, useState } from "react";
import { Suspense, useEffect, useState } from "react";
import { BrowserRouter as Router, Switch, Route } from "react-router-dom";
import * as Sentry from "@sentry/react";
import { OverlayProvider } from "@react-aria/overlays";

View File

@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { useMemo, CSSProperties } from "react";
import { useMemo, CSSProperties, HTMLAttributes, FC } from "react";
import classNames from "classnames";
import { MatrixClient } from "matrix-js-sdk/src/client";
@@ -62,7 +62,7 @@ function hashStringToArrIndex(str: string, arrLength: number) {
const resolveAvatarSrc = (client: MatrixClient, src: string, size: number) =>
src?.startsWith("mxc://") ? client && getAvatarUrl(client, src, size) : src;
interface Props extends React.HTMLAttributes<HTMLDivElement> {
interface Props extends HTMLAttributes<HTMLDivElement> {
bgKey?: string;
src?: string;
size?: Size | number;
@@ -71,7 +71,7 @@ interface Props extends React.HTMLAttributes<HTMLDivElement> {
fallback: string;
}
export const Avatar: React.FC<Props> = ({
export const Avatar: FC<Props> = ({
bgKey,
src,
fallback,

View File

@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, {
import {
FC,
useCallback,
useEffect,

View File

@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { HTMLAttributes, useMemo } from "react";
import { HTMLAttributes, useMemo } from "react";
import classNames from "classnames";
import { MatrixClient } from "matrix-js-sdk/src/client";
import { RoomMember } from "matrix-js-sdk/src/models/room-member";

View File

@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { ReactNode, useCallback, useEffect } from "react";
import { ReactNode, useCallback, useEffect } from "react";
import { useLocation } from "react-router-dom";
import classNames from "classnames";
import { Trans, useTranslation } from "react-i18next";

View File

@@ -1,4 +1,3 @@
import React from "react";
import { GridLayoutMenu } from "./room/GridLayoutMenu";
import {
Header,

View File

@@ -15,7 +15,7 @@ limitations under the License.
*/
import classNames from "classnames";
import React, { HTMLAttributes, ReactNode, useCallback } from "react";
import { HTMLAttributes, ReactNode, useCallback } from "react";
import { Link } from "react-router-dom";
import { Room } from "matrix-js-sdk/src/models/room";
import { useTranslation } from "react-i18next";
@@ -24,9 +24,7 @@ import styles from "./Header.module.css";
import { useModalTriggerState } from "./Modal";
import { Button } from "./button";
import { ReactComponent as Logo } from "./icons/Logo.svg";
import { ReactComponent as VideoIcon } from "./icons/Video.svg";
import { Subtitle } from "./typography/Typography";
import { Avatar, Size } from "./Avatar";
import { IncompatibleVersionModal } from "./IncompatibleVersionModal";
interface HeaderProps extends HTMLAttributes<HTMLElement> {
@@ -116,21 +114,11 @@ export function HeaderLogo({ className }: HeaderLogoProps) {
interface RoomHeaderInfo {
roomName: string;
avatarUrl: string | null;
}
export function RoomHeaderInfo({ roomName, avatarUrl }: RoomHeaderInfo) {
export function RoomHeaderInfo({ roomName }: RoomHeaderInfo) {
return (
<>
<div className={styles.roomAvatar}>
<Avatar
size={Size.MD}
src={avatarUrl ?? undefined}
bgKey={roomName}
fallback={roomName.slice(0, 1).toUpperCase()}
/>
<VideoIcon width={16} height={16} />
</div>
<Subtitle data-testid="roomHeader_roomName" fontWeight="semiBold">
{roomName}
</Subtitle>

View File

@@ -15,7 +15,7 @@ limitations under the License.
*/
import { Room } from "matrix-js-sdk/src/models/room";
import React, { useMemo } from "react";
import { FC, useMemo } from "react";
import { Trans, useTranslation } from "react-i18next";
import { Modal, ModalContent } from "./Modal";
@@ -27,7 +27,7 @@ interface Props {
onClose: () => void;
}
export const IncompatibleVersionModal: React.FC<Props> = ({
export const IncompatibleVersionModal: FC<Props> = ({
userIds,
room,
onClose,

View File

@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { useCallback, useRef } from "react";
import { MutableRefObject, PointerEvent, useCallback, useRef } from "react";
import { useListBox, useOption, AriaListBoxOptions } from "@react-aria/listbox";
import { ListState } from "@react-stately/list";
import { Node } from "@react-types/shared";
@@ -26,7 +26,7 @@ interface ListBoxProps<T> extends AriaListBoxOptions<T> {
optionClassName: string;
state: ListState<T>;
className?: string;
listBoxRef?: React.MutableRefObject<HTMLUListElement>;
listBoxRef?: MutableRefObject<HTMLUListElement>;
}
export function ListBox<T>({
@@ -84,7 +84,7 @@ function Option<T>({ item, state, className }: OptionProps<T>) {
delete optionProps.onPointerUp;
optionProps.onClick = useCallback(
(e) => {
origPointerUp(e as unknown as React.PointerEvent<HTMLElement>);
origPointerUp(e as unknown as PointerEvent<HTMLElement>);
},
[origPointerUp]
);

View File

@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { Key, useRef, useState } from "react";
import { Key, useRef, useState } from "react";
import { AriaMenuOptions, useMenu, useMenuItem } from "@react-aria/menu";
import { TreeState, useTreeState } from "@react-stately/tree";
import { mergeProps } from "@react-aria/utils";

View File

@@ -16,7 +16,7 @@ limitations under the License.
/* eslint-disable jsx-a11y/no-autofocus */
import React, { useRef, useMemo, ReactNode } from "react";
import { useRef, useMemo, ReactNode } from "react";
import {
useOverlay,
usePreventScroll,

View File

@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { useCallback, useState } from "react";
import { useCallback, useState } from "react";
import { useTranslation } from "react-i18next";
import {

View File

@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, {
import {
ForwardedRef,
forwardRef,
ReactElement,

View File

@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { useCallback, useMemo } from "react";
import { useCallback, useMemo } from "react";
import { Item } from "@react-stately/collections";
import { useLocation } from "react-router-dom";
import { useTranslation } from "react-i18next";

View File

@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { useCallback, useState } from "react";
import { useCallback, useState } from "react";
import { useHistory, useLocation } from "react-router-dom";
import { useClient } from "./ClientContext";

View File

@@ -1,4 +1,4 @@
import React, { FC } from "react";
import { FC } from "react";
import { Trans } from "react-i18next";
import { Link } from "../typography/Typography";

View File

@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { FC, FormEvent, useCallback, useRef, useState } from "react";
import { FC, FormEvent, useCallback, useRef, useState } from "react";
import { useHistory, useLocation, Link } from "react-router-dom";
import { Trans, useTranslation } from "react-i18next";

View File

@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, {
import {
ChangeEvent,
FC,
FormEvent,

View File

@@ -13,7 +13,7 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { forwardRef, useCallback } from "react";
import { forwardRef, useCallback } from "react";
import { PressEvent } from "@react-types/shared";
import classNames from "classnames";
import { useButton } from "@react-aria/button";

View File

@@ -14,7 +14,6 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React from "react";
import { useTranslation } from "react-i18next";
import useClipboard from "react-use-clipboard";

View File

@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { HTMLAttributes } from "react";
import { HTMLAttributes } from "react";
import { Link } from "react-router-dom";
import classNames from "classnames";
import * as H from "history";

View File

@@ -15,8 +15,6 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React from "react";
import { ReactComponent as AudioMuted } from "../icons/AudioMuted.svg";
import { ReactComponent as AudioLow } from "../icons/AudioLow.svg";
import { ReactComponent as Audio } from "../icons/Audio.svg";

View File

@@ -15,7 +15,7 @@ limitations under the License.
*/
import classNames from "classnames";
import React, { FormEventHandler, forwardRef, ReactNode } from "react";
import { FormEventHandler, forwardRef, ReactNode } from "react";
import styles from "./Form.module.css";

View File

@@ -14,7 +14,6 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React from "react";
import { Link } from "react-router-dom";
import { MatrixClient } from "matrix-js-sdk/src/client";
import { RoomMember } from "matrix-js-sdk/src/models/room-member";

View File

@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { FC } from "react";
import { FC } from "react";
import { Item } from "@react-stately/collections";
import { useTranslation } from "react-i18next";

View File

@@ -14,7 +14,6 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React from "react";
import { useTranslation } from "react-i18next";
import { useClient } from "../ClientContext";

View File

@@ -14,7 +14,6 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React from "react";
import { PressEvent } from "@react-types/shared";
import { useTranslation } from "react-i18next";

View File

@@ -14,12 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, {
useState,
useCallback,
FormEvent,
FormEventHandler,
} from "react";
import { useState, useCallback, FormEvent, FormEventHandler } from "react";
import { useHistory } from "react-router-dom";
import { MatrixClient } from "matrix-js-sdk/src/client";
import { useTranslation } from "react-i18next";

View File

@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { FC, useCallback, useState, FormEventHandler } from "react";
import { FC, useCallback, useState, FormEventHandler } from "react";
import { useHistory } from "react-router-dom";
import { randomString } from "matrix-js-sdk/src/randomstring";
import { Trans, useTranslation } from "react-i18next";

View File

@@ -15,7 +15,7 @@ limitations under the License.
*/
import { useObjectRef } from "@react-aria/utils";
import React, { AllHTMLAttributes, useEffect } from "react";
import { AllHTMLAttributes, ChangeEvent, useEffect } from "react";
import { useCallback } from "react";
import { useState } from "react";
import { forwardRef } from "react";
@@ -51,7 +51,7 @@ export const AvatarInputField = forwardRef<HTMLInputElement, Props>(
const currentInput = fileInputRef.current;
const onChange = (e: Event) => {
const inputEvent = e as unknown as React.ChangeEvent<HTMLInputElement>;
const inputEvent = e as unknown as ChangeEvent<HTMLInputElement>;
if (inputEvent.target.files.length > 0) {
setObjUrl(URL.createObjectURL(inputEvent.target.files[0]));
setRemoved(false);

View File

@@ -14,7 +14,14 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { ChangeEvent, FC, forwardRef, ReactNode, useId } from "react";
import {
ChangeEvent,
FC,
ForwardedRef,
forwardRef,
ReactNode,
useId,
} from "react";
import classNames from "classnames";
import styles from "./Input.module.css";
@@ -114,7 +121,7 @@ export const InputField = forwardRef<
{type === "textarea" ? (
<textarea
id={id}
ref={ref as React.ForwardedRef<HTMLTextAreaElement>}
ref={ref as ForwardedRef<HTMLTextAreaElement>}
disabled={disabled}
aria-describedby={descriptionId}
{...rest}
@@ -122,7 +129,7 @@ export const InputField = forwardRef<
) : (
<input
id={id}
ref={ref as React.ForwardedRef<HTMLInputElement>}
ref={ref as ForwardedRef<HTMLInputElement>}
type={type}
checked={checked}
disabled={disabled}

View File

@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { useRef } from "react";
import { useRef } from "react";
import { AriaSelectOptions, HiddenSelect, useSelect } from "@react-aria/select";
import { useButton } from "@react-aria/button";
import { useSelectState } from "@react-stately/select";

View File

@@ -13,7 +13,7 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { useState } from "react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import styles from "./StarRatingInput.module.css";

View File

@@ -1,6 +1,6 @@
import { Room, RoomOptions } from "livekit-client";
import { useLiveKitRoom, useToken } from "@livekit/components-react";
import React from "react";
import { useMemo } from "react";
import { defaultLiveKitOptions } from "./options";
@@ -26,7 +26,7 @@ export function useLiveKit(
userChoices: UserChoices,
config: LiveKitConfig
): Room | undefined {
const tokenOptions = React.useMemo(
const tokenOptions = useMemo(
() => ({
userInfo: {
name: config.userDisplayName,
@@ -37,7 +37,7 @@ export function useLiveKit(
);
const token = useToken(config.jwtUrl, config.roomName, tokenOptions);
const roomOptions = React.useMemo((): RoomOptions => {
const roomOptions = useMemo((): RoomOptions => {
const options = defaultLiveKitOptions;
options.videoCaptureDefaults = {
...options.videoCaptureDefaults,

View File

@@ -20,7 +20,7 @@ limitations under the License.
// dependency references.
import "matrix-js-sdk/src/browser-index";
import React, { StrictMode } from "react";
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { createBrowserHistory } from "history";

View File

@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { forwardRef, HTMLAttributes } from "react";
import { forwardRef, HTMLAttributes } from "react";
import { DismissButton, useOverlay } from "@react-aria/overlays";
import { FocusScope } from "@react-aria/focus";
import classNames from "classnames";

View File

@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { forwardRef, useRef } from "react";
import { forwardRef, useRef } from "react";
import { useMenuTriggerState } from "@react-stately/menu";
import { useMenuTrigger } from "@react-aria/menu";
import { OverlayContainer, useOverlayPosition } from "@react-aria/overlays";

View File

@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { FormEventHandler, useCallback, useState } from "react";
import { FormEventHandler, useCallback, useState } from "react";
import { MatrixClient } from "matrix-js-sdk/src/client";
import { Trans, useTranslation } from "react-i18next";
import { useHistory } from "react-router-dom";

View File

@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { useCallback } from "react";
import { useCallback } from "react";
import { Item } from "@react-stately/collections";
import { useTranslation } from "react-i18next";

View File

@@ -16,13 +16,16 @@ limitations under the License.
import * as Sentry from "@sentry/react";
import { Resizable } from "re-resizable";
import React, {
import {
useEffect,
useState,
useReducer,
useRef,
createContext,
useContext,
Dispatch,
SetStateAction,
ReactNode,
} from "react";
import ReactJson, { CollapsedFieldProps } from "react-json-view";
import mermaid from "mermaid";
@@ -136,16 +139,13 @@ function lineForEvent(event: SequenceDiagramMatrixEvent): string {
export const InspectorContext =
createContext<
[
InspectorContextState,
React.Dispatch<React.SetStateAction<InspectorContextState>>
]
[InspectorContextState, Dispatch<SetStateAction<InspectorContextState>>]
>(undefined);
export function InspectorContextProvider({
children,
}: {
children: React.ReactNode;
children: ReactNode;
}) {
// We take the tuple of [currentState, setter] and stick
// it straight into the context for other things to call

View File

@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { ReactNode } from "react";
import { ReactNode } from "react";
import { MatrixClient } from "matrix-js-sdk/src/client";
import { GroupCall } from "matrix-js-sdk/src/webrtc/groupCall";
import { useTranslation } from "react-i18next";

View File

@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { useCallback, useEffect, useState } from "react";
import { useCallback, useEffect, useState } from "react";
import { useHistory } from "react-router-dom";
import { GroupCall, GroupCallState } from "matrix-js-sdk/src/webrtc/groupCall";
import { MatrixClient } from "matrix-js-sdk/src/client";
@@ -35,7 +35,6 @@ import { PosthogAnalytics } from "../analytics/PosthogAnalytics";
import { useProfile } from "../profile/useProfile";
import { UserChoices } from "../livekit/useLiveKit";
import { findDeviceByName } from "../media-utils";
import { useRoomAvatar } from "./useRoomAvatar";
declare global {
interface Window {
@@ -82,14 +81,12 @@ export function GroupCallView({
}, [groupCall]);
const { displayName, avatarUrl } = useProfile(client);
const roomAvatarUrl = useRoomAvatar(groupCall.room);
const matrixInfo: MatrixInfo = {
displayName,
avatarUrl,
roomName: groupCall.room.name,
roomIdOrAlias,
roomAvatarUrl,
};
useEffect(() => {

View File

@@ -28,7 +28,7 @@ import { Room, Track } from "livekit-client";
import { MatrixClient } from "matrix-js-sdk/src/client";
import { RoomMember } from "matrix-js-sdk/src/models/room-member";
import { GroupCall } from "matrix-js-sdk/src/webrtc/groupCall";
import React, { Ref, useCallback, useEffect, useMemo, useRef } from "react";
import { Ref, useCallback, useEffect, useMemo, useRef } from "react";
import { useTranslation } from "react-i18next";
import useMeasure from "react-use-measure";
import { OverlayTriggerState } from "@react-stately/overlays";
@@ -51,9 +51,9 @@ import {
VersionMismatchWarning,
} from "../Header";
import {
VideoGrid,
useVideoGridLayout,
TileDescriptor,
VideoGrid,
} from "../video-grid/VideoGrid";
import {
useShowInspector,
@@ -82,6 +82,7 @@ import { VideoTile } from "../video-grid/VideoTile";
import { UserChoices, useLiveKit } from "../livekit/useLiveKit";
import { useMediaDevices } from "../livekit/useMediaDevices";
import { useFullscreen } from "./useFullscreen";
import { useLayoutStates } from "../video-grid/Layout";
const canScreenshare = "getDisplayMedia" in (navigator.mediaDevices ?? {});
// There is currently a bug in Safari our our code with cloning and sending MediaStreams
@@ -252,6 +253,10 @@ export function InCallView({
const prefersReducedMotion = usePrefersReducedMotion();
// This state is lifted out of NewVideoGrid so that layout states can be
// restored after a layout switch or upon exiting fullscreen
const layoutStates = useLayoutStates();
const renderContent = (): JSX.Element => {
if (items.length === 0) {
return (
@@ -281,6 +286,7 @@ export function InCallView({
items={items}
layout={layout}
disableAnimations={prefersReducedMotion || isSafari}
layoutStates={layoutStates}
>
{(props) => (
<VideoTile
@@ -388,10 +394,7 @@ export function InCallView({
{!hideHeader && maximisedParticipant === null && (
<Header>
<LeftNav>
<RoomHeaderInfo
roomName={matrixInfo.roomName}
avatarUrl={matrixInfo.roomAvatarUrl}
/>
<RoomHeaderInfo roomName={matrixInfo.roomName} />
<VersionMismatchWarning
users={unencryptedEventsFromUsers}
room={groupCall.room}

View File

@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { FC } from "react";
import { FC } from "react";
import { useTranslation } from "react-i18next";
import { Modal, ModalContent, ModalProps } from "../Modal";

View File

@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React from "react";
import { useRef, useEffect, useState } from "react";
import { Trans, useTranslation } from "react-i18next";
import styles from "./LobbyView.module.css";
@@ -39,14 +39,14 @@ export function LobbyView(props: Props) {
const { t } = useTranslation();
useLocationNavigation();
const joinCallButtonRef = React.useRef<HTMLButtonElement>();
React.useEffect(() => {
const joinCallButtonRef = useRef<HTMLButtonElement>();
useEffect(() => {
if (joinCallButtonRef.current) {
joinCallButtonRef.current.focus();
}
}, [joinCallButtonRef]);
const [userChoices, setUserChoices] = React.useState<UserChoices | undefined>(
const [userChoices, setUserChoices] = useState<UserChoices | undefined>(
undefined
);
@@ -55,10 +55,7 @@ export function LobbyView(props: Props) {
{!props.hideHeader && (
<Header>
<LeftNav>
<RoomHeaderInfo
roomName={props.matrixInfo.roomName}
avatarUrl={props.matrixInfo.roomAvatarUrl}
/>
<RoomHeaderInfo roomName={props.matrixInfo.roomName} />
</LeftNav>
<RightNav>
<UserMenuContainer />

View File

@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { FC, useEffect } from "react";
import { FC, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { Modal, ModalContent, ModalProps } from "../Modal";

View File

@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { useCallback, useState } from "react";
import { useCallback, useState } from "react";
import { useLocation } from "react-router-dom";
import { Trans, useTranslation } from "react-i18next";

View File

@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { FC, useEffect, useState, useCallback } from "react";
import { FC, useEffect, useState, useCallback } from "react";
import { useTranslation } from "react-i18next";
import type { GroupCall } from "matrix-js-sdk/src/webrtc/groupCall";

View File

@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { useEffect } from "react";
import { useEffect } from "react";
import { useLocation, useHistory } from "react-router-dom";
import { Config } from "../config/Config";

View File

@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { useCallback } from "react";
import { useState, useEffect, useRef, useCallback } from "react";
import useMeasure from "react-use-measure";
import { ResizeObserver } from "@juggle/resize-observer";
import { OverlayTriggerState } from "@react-stately/overlays";
@@ -35,7 +35,6 @@ export type MatrixInfo = {
avatarUrl: string;
roomName: string;
roomIdOrAlias: string;
roomAvatarUrl: string | null;
};
interface Props {
@@ -66,8 +65,8 @@ export function VideoPreview({ matrixInfo, onUserChoicesChanged }: Props) {
const mediaDevices = useMediaDevices();
// Create local media tracks.
const [videoEnabled, setVideoEnabled] = React.useState<boolean>(true);
const [audioEnabled, setAudioEnabled] = React.useState<boolean>(true);
const [videoEnabled, setVideoEnabled] = useState<boolean>(true);
const [audioEnabled, setAudioEnabled] = useState<boolean>(true);
const [videoId, audioId] = [
mediaDevices.videoIn.selectedId,
mediaDevices.audioIn.selectedId,
@@ -86,7 +85,7 @@ export function VideoPreview({ matrixInfo, onUserChoicesChanged }: Props) {
const activeVideoId = video?.selectedDevice?.deviceId;
const activeAudioId = audio?.selectedDevice?.deviceId;
React.useEffect(() => {
useEffect(() => {
const createChoices = (
enabled: boolean,
deviceId?: string
@@ -117,7 +116,7 @@ export function VideoPreview({ matrixInfo, onUserChoicesChanged }: Props) {
mediaDevices.videoIn.setSelected,
mediaDevices.audioIn.setSelected,
];
React.useEffect(() => {
useEffect(() => {
if (activeVideoId && activeVideoId !== "") {
selectVideo(activeVideoId);
}
@@ -126,8 +125,8 @@ export function VideoPreview({ matrixInfo, onUserChoicesChanged }: Props) {
}
}, [selectVideo, selectAudio, activeVideoId, activeAudioId]);
const mediaElement = React.useRef(null);
React.useEffect(() => {
const mediaElement = useRef(null);
useEffect(() => {
if (mediaElement.current) {
video?.localTrack?.attach(mediaElement.current);
}

View File

@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { useCallback } from "react";
import { useCallback } from "react";
import { randomString } from "matrix-js-sdk/src/randomstring";
import { useTranslation } from "react-i18next";

View File

@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { useCallback, useEffect, useRef } from "react";
import { useCallback, useEffect, useRef } from "react";
import { MatrixClient } from "matrix-js-sdk/src/client";
import { useTranslation } from "react-i18next";

View File

@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { useCallback, useState } from "react";
import { ChangeEvent, useCallback, useState } from "react";
import { Item } from "@react-stately/collections";
import { Trans, useTranslation } from "react-i18next";
import { MatrixClient } from "matrix-js-sdk";
@@ -188,7 +188,7 @@ export const SettingsModal = (props: Props) => {
description={t(
"Expose developer settings in the settings window."
)}
onChange={(event: React.ChangeEvent<HTMLInputElement>) =>
onChange={(event: ChangeEvent<HTMLInputElement>) =>
setDeveloperSettingsTab(event.target.checked)
}
/>
@@ -200,7 +200,7 @@ export const SettingsModal = (props: Props) => {
type="checkbox"
checked={optInAnalytics}
description={optInDescription}
onChange={(event: React.ChangeEvent<HTMLInputElement>) =>
onChange={(event: ChangeEvent<HTMLInputElement>) =>
setOptInAnalytics(event.target.checked)
}
/>
@@ -230,7 +230,7 @@ export const SettingsModal = (props: Props) => {
label={t("Show call inspector")}
type="checkbox"
checked={showInspector}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
onChange={(e: ChangeEvent<HTMLInputElement>) =>
setShowInspector(e.target.checked)
}
/>
@@ -242,7 +242,7 @@ export const SettingsModal = (props: Props) => {
label={t("Show connection stats")}
type="checkbox"
checked={showConnectionStats}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
onChange={(e: ChangeEvent<HTMLInputElement>) =>
setShowConnectionStats(e.target.checked)
}
/>

View File

@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React from "react";
import { FC } from "react";
import { TabContainer, TabItem } from "./Tabs";
import { ReactComponent as AudioIcon } from "../icons/Audio.svg";
@@ -30,7 +30,7 @@ export default {
},
};
export const Tabs: React.FC<{}> = () => (
export const Tabs: FC = () => (
<TabContainer>
<TabItem
title={

View File

@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { useRef } from "react";
import { useRef } from "react";
import { useTabList, useTab, useTabPanel } from "@react-aria/tabs";
import { Item } from "@react-stately/collections";
import { useTabListState, TabListState } from "@react-stately/tabs";

View File

@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React from "react";
import { FC } from "react";
import { Headline, Title, Subtitle, Body, Caption, Micro } from "./Typography";
@@ -25,7 +25,7 @@ export default {
},
};
export const Typography: React.FC<{}> = () => (
export const Typography: FC = () => (
<>
<Headline>Headline Semi Bold</Headline>
<Title>Title</Title>

View File

@@ -0,0 +1,29 @@
/*
Copyright 2023 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
.bigGrid {
display: grid;
grid-auto-rows: 163px;
gap: 8px;
}
@media (min-width: 800px) {
.bigGrid {
grid-auto-rows: 183px;
column-gap: 18px;
row-gap: 21px;
}
}

View File

@@ -15,33 +15,47 @@ limitations under the License.
*/
import TinyQueue from "tinyqueue";
import { RectReadOnly } from "react-use-measure";
import { FC, memo, ReactNode } from "react";
import { TileDescriptor } from "./VideoGrid";
import { Slot } from "./NewVideoGrid";
import { Layout } from "./Layout";
import { count, findLastIndex } from "../array-utils";
import styles from "./BigGrid.module.css";
/**
* A 1×1 cell in a grid which belongs to a tile.
*/
export interface Cell {
interface Cell {
/**
* The item displayed on the tile.
*/
item: TileDescriptor<unknown>;
readonly item: TileDescriptor<unknown>;
/**
* Whether this cell is the origin (top left corner) of the tile.
*/
origin: boolean;
readonly origin: boolean;
/**
* The width, in columns, of the tile.
*/
columns: number;
readonly columns: number;
/**
* The height, in rows, of the tile.
*/
rows: number;
readonly rows: number;
}
export interface Grid {
export interface BigGridState {
readonly columns: number;
/**
* The cells of the grid, in left-to-right top-to-bottom order.
* undefined = empty.
*/
readonly cells: (Cell | undefined)[];
}
interface MutableBigGridState {
columns: number;
/**
* The cells of the grid, in left-to-right top-to-bottom order.
@@ -58,7 +72,7 @@ export interface Grid {
* @returns An array in which each cell holds the index of the next cell to move
* to to reach the destination, or null if it is the destination.
*/
export function getPaths(dest: number, g: Grid): (number | null)[] {
export function getPaths(dest: number, g: BigGridState): (number | null)[] {
const destRow = row(dest, g);
const destColumn = column(dest, g);
@@ -106,18 +120,23 @@ export function getPaths(dest: number, g: Grid): (number | null)[] {
return edges as (number | null)[];
}
const findLast1By1Index = (g: Grid): number | null =>
const findLast1By1Index = (g: BigGridState): number | null =>
findLastIndex(g.cells, (c) => c?.rows === 1 && c?.columns === 1);
export function row(index: number, g: Grid): number {
export function row(index: number, g: BigGridState): number {
return Math.floor(index / g.columns);
}
export function column(index: number, g: Grid): number {
export function column(index: number, g: BigGridState): number {
return ((index % g.columns) + g.columns) % g.columns;
}
function inArea(index: number, start: number, end: number, g: Grid): boolean {
function inArea(
index: number,
start: number,
end: number,
g: BigGridState
): boolean {
const indexColumn = column(index, g);
const indexRow = row(index, g);
return (
@@ -131,7 +150,7 @@ function inArea(index: number, start: number, end: number, g: Grid): boolean {
function* cellsInArea(
start: number,
end: number,
g: Grid
g: BigGridState
): Generator<number, void, unknown> {
const startColumn = column(start, g);
const endColumn = column(end, g);
@@ -149,7 +168,7 @@ function* cellsInArea(
export function forEachCellInArea(
start: number,
end: number,
g: Grid,
g: BigGridState,
fn: (c: Cell | undefined, i: number) => void
): void {
for (const i of cellsInArea(start, end, g)) fn(g.cells[i], i);
@@ -158,7 +177,7 @@ export function forEachCellInArea(
function allCellsInArea(
start: number,
end: number,
g: Grid,
g: BigGridState,
fn: (c: Cell | undefined, i: number) => boolean
): boolean {
for (const i of cellsInArea(start, end, g)) {
@@ -172,16 +191,19 @@ const areaEnd = (
start: number,
columns: number,
rows: number,
g: Grid
g: BigGridState
): number => start + columns - 1 + g.columns * (rows - 1);
const cloneGrid = (g: Grid): Grid => ({ ...g, cells: [...g.cells] });
const cloneGrid = (g: BigGridState): BigGridState => ({
...g,
cells: [...g.cells],
});
/**
* Gets the index of the next gap in the grid that should be backfilled by 1×1
* tiles.
*/
function getNextGap(g: Grid): number | null {
function getNextGap(g: BigGridState): number | null {
const last1By1Index = findLast1By1Index(g);
if (last1By1Index === null) return null;
@@ -204,7 +226,7 @@ function getNextGap(g: Grid): number | null {
/**
* Gets the index of the origin of the tile to which the given cell belongs.
*/
function getOrigin(g: Grid, index: number): number {
function getOrigin(g: BigGridState, index: number): number {
const initialColumn = column(index, g);
for (
@@ -229,7 +251,7 @@ function getOrigin(g: Grid, index: number): number {
* along the way.
* Precondition: the destination area must consist of only 1×1 tiles.
*/
function moveTile(g: Grid, from: number, to: number) {
function moveTileUnchecked(g: BigGridState, from: number, to: number) {
const tile = g.cells[from]!;
const fromEnd = areaEnd(from, tile.columns, tile.rows, g);
const toEnd = areaEnd(to, tile.columns, tile.rows, g);
@@ -262,10 +284,15 @@ function moveTile(g: Grid, from: number, to: number) {
/**
* Moves the tile at index "from" over to index "to", if there is space.
*/
export function tryMoveTile(g: Grid, from: number, to: number): Grid {
export function moveTile(
g: BigGridState,
from: number,
to: number
): BigGridState {
const tile = g.cells[from]!;
if (
to !== from && // Skip the operation if nothing would move
to >= 0 &&
to < g.cells.length &&
column(to, g) <= g.columns - tile.columns
@@ -283,7 +310,7 @@ export function tryMoveTile(g: Grid, from: number, to: number): Grid {
if (allCellsInArea(to, toEnd, g, displaceable)) {
// The target space is free; move
const gClone = cloneGrid(g);
moveTile(gClone, from, to);
moveTileUnchecked(gClone, from, to);
return gClone;
}
}
@@ -297,7 +324,7 @@ export function tryMoveTile(g: Grid, from: number, to: number): Grid {
* enlarged tiles around when necessary.
* @returns Whether the tile was actually pushed
*/
function pushTileUp(g: Grid, from: number): boolean {
function pushTileUp(g: BigGridState, from: number): boolean {
const tile = g.cells[from]!;
// TODO: pushing large tiles sideways might be more successful in some
@@ -315,7 +342,7 @@ function pushTileUp(g: Grid, from: number): boolean {
);
if (cellsAboveAreDisplacable) {
moveTile(g, from, from - g.columns);
moveTileUnchecked(g, from, from - g.columns);
return true;
} else {
return false;
@@ -325,8 +352,8 @@ function pushTileUp(g: Grid, from: number): boolean {
/**
* Backfill any gaps in the grid.
*/
export function fillGaps(g: Grid): Grid {
const result = cloneGrid(g);
export function fillGaps(g: BigGridState): BigGridState {
const result = cloneGrid(g) as MutableBigGridState;
// This will hopefully be the size of the grid after we're done here, assuming
// that we can pack the large tiles tightly enough
@@ -403,7 +430,11 @@ export function fillGaps(g: Grid): Grid {
return result;
}
function createRows(g: Grid, count: number, atRow: number): Grid {
function createRows(
g: BigGridState,
count: number,
atRow: number
): BigGridState {
const result = {
columns: g.columns,
cells: new Array(g.cells.length + g.columns * count),
@@ -430,9 +461,12 @@ function createRows(g: Grid, count: number, atRow: number): Grid {
}
/**
* Adds a set of new items into the grid.
* Adds a set of new items into the grid. (May leave gaps.)
*/
export function addItems(items: TileDescriptor<unknown>[], g: Grid): Grid {
export function addItems(
items: TileDescriptor<unknown>[],
g: BigGridState
): BigGridState {
let result = cloneGrid(g);
for (const item of items) {
@@ -444,13 +478,11 @@ export function addItems(items: TileDescriptor<unknown>[], g: Grid): Grid {
};
let placeAt: number;
let hasGaps: boolean;
if (item.placeNear === undefined) {
// This item has no special placement requests, so let's put it
// uneventfully at the end of the grid
placeAt = result.cells.length;
hasGaps = false;
} else {
// This item wants to be placed near another; let's put it on a row
// directly below the related tile
@@ -460,7 +492,6 @@ export function addItems(items: TileDescriptor<unknown>[], g: Grid): Grid {
if (placeNear === -1) {
// Can't find the related tile, so let's give up and place it at the end
placeAt = result.cells.length;
hasGaps = false;
} else {
const placeNearCell = result.cells[placeNear]!;
const placeNearEnd = areaEnd(
@@ -475,7 +506,6 @@ export function addItems(items: TileDescriptor<unknown>[], g: Grid): Grid {
placeNear +
Math.floor(placeNearCell.columns / 2) +
result.columns * placeNearCell.rows;
hasGaps = true;
}
}
@@ -484,21 +514,19 @@ export function addItems(items: TileDescriptor<unknown>[], g: Grid): Grid {
if (item.largeBaseSize) {
// Cycle the tile size once to set up the tile with its larger base size
// This also fills any gaps in the grid, hence no extra call to fillGaps
result = cycleTileSize(item.id, result);
} else if (hasGaps) {
result = fillGaps(result);
result = cycleTileSize(result, item);
}
}
return result;
}
const largeTileDimensions = (g: Grid): [number, number] => [
const largeTileDimensions = (g: BigGridState): [number, number] => [
Math.min(3, Math.max(2, g.columns - 1)),
2,
];
const extraLargeTileDimensions = (g: Grid): [number, number] =>
const extraLargeTileDimensions = (g: BigGridState): [number, number] =>
g.columns > 3 ? [4, 3] : [g.columns, 2];
/**
@@ -507,8 +535,11 @@ const extraLargeTileDimensions = (g: Grid): [number, number] =>
* @param g The grid.
* @returns The updated grid.
*/
export function cycleTileSize(tileId: string, g: Grid): Grid {
const from = g.cells.findIndex((c) => c?.item.id === tileId);
export function cycleTileSize(
g: BigGridState,
tile: TileDescriptor<unknown>
): BigGridState {
const from = g.cells.findIndex((c) => c?.item === tile);
if (from === -1) return g; // Tile removed, no change
const fromCell = g.cells[from]!;
const fromWidth = fromCell.columns;
@@ -629,8 +660,8 @@ export function cycleTileSize(tileId: string, g: Grid): Grid {
/**
* Resizes the grid to a new column width.
*/
export function resize(g: Grid, columns: number): Grid {
const result: Grid = { columns, cells: [] };
export function resize(g: BigGridState, columns: number): BigGridState {
const result: BigGridState = { columns, cells: [] };
const [largeColumns, largeRows] = largeTileDimensions(result);
// Copy each tile from the old grid to the resized one in the same order
@@ -640,6 +671,7 @@ export function resize(g: Grid, columns: number): Grid {
for (const cell of g.cells) {
if (cell?.origin) {
// TODO make aware of extra large tiles
const [nextColumns, nextRows] =
cell.columns > 1 || cell.rows > 1 ? [largeColumns, largeRows] : [1, 1];
@@ -672,7 +704,7 @@ export function resize(g: Grid, columns: number): Grid {
/**
* Promotes speakers to the first page of the grid.
*/
export function promoteSpeakers(g: Grid) {
export function promoteSpeakers(g: BigGridState) {
// This is all a bit of a hack right now, because we don't know if the designs
// will stick with this approach in the long run
// We assume that 4 rows are probably about 1 page
@@ -694,10 +726,149 @@ export function promoteSpeakers(g: Grid) {
toCell === undefined ||
(toCell.columns === 1 && toCell.rows === 1)
) {
moveTile(g, from, to);
moveTileUnchecked(g, from, to);
break;
}
}
}
}
}
/**
* The algorithm for updating a grid with a new set of tiles.
*/
function updateTiles(
g: BigGridState,
tiles: TileDescriptor<unknown>[]
): BigGridState {
// Step 1: Update tiles that still exist, and remove tiles that have left
// the grid
const itemsById = new Map(tiles.map((i) => [i.id, i]));
const grid1: BigGridState = {
...g,
cells: g.cells.map((c) => {
if (c === undefined) return undefined;
const item = itemsById.get(c.item.id);
return item === undefined ? undefined : { ...c, item };
}),
};
// Step 2: Add new tiles
const existingItemIds = new Set(
grid1.cells.filter((c) => c !== undefined).map((c) => c!.item.id)
);
const newItems = tiles.filter((i) => !existingItemIds.has(i.id));
const grid2 = addItems(newItems, grid1);
// Step 3: Promote speakers to the top
promoteSpeakers(grid2);
return fillGaps(grid2);
}
function updateBounds(g: BigGridState, bounds: RectReadOnly): BigGridState {
const columns = Math.max(2, Math.floor(bounds.width * 0.0045));
return columns === g.columns ? g : resize(g, columns);
}
const Slots: FC<{ s: BigGridState }> = memo(({ s: g }) => {
const areas = new Array<(number | null)[]>(
Math.ceil(g.cells.length / g.columns)
);
for (let i = 0; i < areas.length; i++)
areas[i] = new Array<number | null>(g.columns).fill(null);
let slotCount = 0;
for (let i = 0; i < g.cells.length; i++) {
const cell = g.cells[i];
if (cell?.origin) {
const slotEnd = i + cell.columns - 1 + g.columns * (cell.rows - 1);
forEachCellInArea(
i,
slotEnd,
g,
(_c, j) => (areas[row(j, g)][column(j, g)] = slotCount)
);
slotCount++;
}
}
const style = {
gridTemplateAreas: areas
.map(
(row) =>
`'${row
.map((slotId) => (slotId === null ? "." : `s${slotId}`))
.join(" ")}'`
)
.join(" "),
gridTemplateColumns: `repeat(${g.columns}, 1fr)`,
};
const slots = new Array<ReactNode>(slotCount);
for (let i = 0; i < slotCount; i++)
slots[i] = <Slot key={i} style={{ gridArea: `s${i}` }} />;
return (
<div className={styles.bigGrid} style={style}>
{slots}
</div>
);
});
/**
* Given a tile and numbers in the range [0, 1) describing a position within the
* tile, this returns the index of the specific cell in which that position
* lies.
*/
function positionOnTileToCell(
g: BigGridState,
tileOriginIndex: number,
xPositionOnTile: number,
yPositionOnTile: number
): number {
const tileOrigin = g.cells[tileOriginIndex]!;
const columnOnTile = Math.floor(xPositionOnTile * tileOrigin.columns);
const rowOnTile = Math.floor(yPositionOnTile * tileOrigin.rows);
return tileOriginIndex + columnOnTile + g.columns * rowOnTile;
}
function dragTile(
g: BigGridState,
from: TileDescriptor<unknown>,
to: TileDescriptor<unknown>,
xPositionOnFrom: number,
yPositionOnFrom: number,
xPositionOnTo: number,
yPositionOnTo: number
): BigGridState {
const fromOrigin = g.cells.findIndex((c) => c?.item === from);
const toOrigin = g.cells.findIndex((c) => c?.item === to);
const fromCell = positionOnTileToCell(
g,
fromOrigin,
xPositionOnFrom,
yPositionOnFrom
);
const toCell = positionOnTileToCell(
g,
toOrigin,
xPositionOnTo,
yPositionOnTo
);
return moveTile(g, fromOrigin, fromOrigin + toCell - fromCell);
}
export const BigGrid: Layout<BigGridState> = {
emptyState: { columns: 4, cells: [] },
updateTiles,
updateBounds,
getTiles: <T,>(g) =>
g.cells.filter((c) => c?.origin).map((c) => c!.item as T),
canDragTile: () => true,
dragTile,
toggleFocus: cycleTileSize,
Slots,
rememberState: false,
};

178
src/video-grid/Layout.tsx Normal file
View File

@@ -0,0 +1,178 @@
/*
Copyright 2023 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { ComponentType, useCallback, useMemo, useRef } from "react";
import type { RectReadOnly } from "react-use-measure";
import { useReactiveState } from "../useReactiveState";
import type { TileDescriptor } from "./VideoGrid";
/**
* A video grid layout system with concrete states of type State.
*/
// Ideally State would be parameterized by the tile data type, but then that
// makes Layout a higher-kinded type, which isn't achievable in TypeScript
// (unless you invoke some dark type-level computation magic… 😏)
// So we're stuck with these types being a little too strong.
export interface Layout<State> {
/**
* The layout state for zero tiles.
*/
readonly emptyState: State;
/**
* Updates/adds/removes tiles in a way that looks natural in the context of
* the given initial state.
*/
readonly updateTiles: <T>(s: State, tiles: TileDescriptor<T>[]) => State;
/**
* Adapts the layout to a new container size.
*/
readonly updateBounds: (s: State, bounds: RectReadOnly) => State;
/**
* Gets tiles in the order created by the layout.
*/
readonly getTiles: <T>(s: State) => TileDescriptor<T>[];
/**
* Determines whether a tile is draggable.
*/
readonly canDragTile: <T>(s: State, tile: TileDescriptor<T>) => boolean;
/**
* Drags the tile 'from' to the location of the tile 'to' (if possible).
* The position parameters are numbers in the range [0, 1) describing the
* specific positions on 'from' and 'to' that the drag gesture is targeting.
*/
readonly dragTile: <T>(
s: State,
from: TileDescriptor<T>,
to: TileDescriptor<T>,
xPositionOnFrom: number,
yPositionOnFrom: number,
xPositionOnTo: number,
yPositionOnTo: number
) => State;
/**
* Toggles the focus of the given tile (if this layout has the concept of
* focus).
*/
readonly toggleFocus?: <T>(s: State, tile: TileDescriptor<T>) => State;
/**
* A React component generating the slot elements for a given layout state.
*/
readonly Slots: ComponentType<{ s: State }>;
/**
* Whether the state of this layout should be remembered even while a
* different layout is active.
*/
readonly rememberState: boolean;
}
/**
* A version of Map with stronger types that allow us to save layout states in a
* type-safe way.
*/
export interface LayoutStatesMap {
get<State>(layout: Layout<State>): State | undefined;
set<State>(layout: Layout<State>, state: State): LayoutStatesMap;
delete<State>(layout: Layout<State>): boolean;
}
/**
* Hook creating a Map to store layout states in.
*/
export const useLayoutStates = (): LayoutStatesMap => {
const layoutStates = useRef<Map<unknown, unknown>>();
if (layoutStates.current === undefined) layoutStates.current = new Map();
return layoutStates.current as LayoutStatesMap;
};
/**
* Hook which uses the provided layout system to arrange a set of items into a
* concrete layout state, and provides callbacks for user interaction.
*/
export const useLayout = <State, T>(
layout: Layout<State>,
items: TileDescriptor<T>[],
bounds: RectReadOnly,
layoutStates: LayoutStatesMap
) => {
const prevLayout = useRef<Layout<unknown>>();
const prevState = layoutStates.get(layout);
const [state, setState] = useReactiveState<State>(() => {
// If the bounds aren't known yet, don't add anything to the layout
if (bounds.width === 0) {
return layout.emptyState;
} else {
if (
prevLayout.current !== undefined &&
layout !== prevLayout.current &&
!prevLayout.current.rememberState
)
layoutStates.delete(prevLayout.current);
const baseState = layoutStates.get(layout) ?? layout.emptyState;
return layout.updateTiles(layout.updateBounds(baseState, bounds), items);
}
}, [layout, items, bounds]);
const generation = useRef<number>(0);
if (state !== prevState) generation.current++;
prevLayout.current = layout as Layout<unknown>;
// No point in remembering an empty state, plus it would end up clobbering the
// real saved state while restoring a layout
if (state !== layout.emptyState) layoutStates.set(layout, state);
return {
state,
orderedItems: useMemo(() => layout.getTiles<T>(state), [layout, state]),
generation: generation.current,
canDragTile: useCallback(
(tile: TileDescriptor<T>) => layout.canDragTile(state, tile),
[layout, state]
),
dragTile: useCallback(
(
from: TileDescriptor<T>,
to: TileDescriptor<T>,
xPositionOnFrom: number,
yPositionOnFrom: number,
xPositionOnTo: number,
yPositionOnTo: number
) =>
setState((s) =>
layout.dragTile(
s,
from,
to,
xPositionOnFrom,
yPositionOnFrom,
xPositionOnTo,
yPositionOnTo
)
),
[layout, setState]
),
toggleFocus: useMemo(
() =>
layout.toggleFocus &&
((tile: TileDescriptor<T>) =>
setState((s) => layout.toggleFocus!(s, tile))),
[layout, setState]
),
slots: <layout.Slots s={state} />,
};
};

View File

@@ -23,11 +23,8 @@ limitations under the License.
overflow-x: hidden;
}
.slotGrid {
.slots {
position: relative;
display: grid;
grid-auto-rows: 163px;
gap: 8px;
}
.slot {
@@ -38,10 +35,4 @@ limitations under the License.
.grid {
padding: 0 22px var(--footerHeight);
}
.slotGrid {
grid-auto-rows: 183px;
column-gap: 18px;
row-gap: 21px;
}
}

View File

@@ -16,18 +16,17 @@ limitations under the License.
import { SpringRef, TransitionFn, useTransition } from "@react-spring/web";
import { EventTypes, Handler, useScroll } from "@use-gesture/react";
import React, {
Dispatch,
import {
CSSProperties,
FC,
ReactNode,
SetStateAction,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import useMeasure from "react-use-measure";
import { zipWith } from "lodash";
import { zip } from "lodash";
import styles from "./NewVideoGrid.module.css";
import {
@@ -38,99 +37,9 @@ import {
} from "./VideoGrid";
import { useReactiveState } from "../useReactiveState";
import { useMergedRefs } from "../useMergedRefs";
import {
Grid,
Cell,
row,
column,
fillGaps,
forEachCellInArea,
cycleTileSize,
addItems,
tryMoveTile,
resize,
promoteSpeakers,
} from "./model";
import { TileWrapper } from "./TileWrapper";
interface GridState extends Grid {
/**
* The ID of the current state of the grid.
*/
generation: number;
}
const useGridState = (
columns: number | null,
items: TileDescriptor<unknown>[]
): [GridState | null, Dispatch<SetStateAction<Grid>>] => {
const [grid, setGrid_] = useReactiveState<GridState | null>(
(prevGrid = null) => {
if (prevGrid === null) {
// We can't do anything if the column count isn't known yet
if (columns === null) {
return null;
} else {
prevGrid = { generation: 0, columns, cells: [] };
}
}
// Step 1: Update tiles that still exist, and remove tiles that have left
// the grid
const itemsById = new Map(items.map((i) => [i.id, i]));
const grid1: Grid = {
...prevGrid,
cells: prevGrid.cells.map((c) => {
if (c === undefined) return undefined;
const item = itemsById.get(c.item.id);
return item === undefined ? undefined : { ...c, item };
}),
};
// Step 2: Resize the grid if necessary and backfill gaps left behind by
// removed tiles
// Resizing already takes care of backfilling gaps
const grid2 =
columns !== grid1.columns ? resize(grid1, columns!) : fillGaps(grid1);
// Step 3: Add new tiles to the end of the grid
const existingItemIds = new Set(
grid2.cells.filter((c) => c !== undefined).map((c) => c!.item.id)
);
const newItems = items.filter((i) => !existingItemIds.has(i.id));
const grid3 = addItems(newItems, grid2);
// Step 4: Promote speakers to the top
promoteSpeakers(grid3);
return { ...grid3, generation: prevGrid.generation + 1 };
},
[columns, items]
);
const setGrid: Dispatch<SetStateAction<Grid>> = useCallback(
(action) => {
if (typeof action === "function") {
setGrid_((prevGrid) =>
prevGrid === null
? null
: {
...(action as (prev: Grid) => Grid)(prevGrid),
generation: prevGrid.generation + 1,
}
);
} else {
setGrid_((prevGrid) => ({
...action,
generation: prevGrid?.generation ?? 1,
}));
}
},
[setGrid_]
);
return [grid, setGrid];
};
import { BigGrid } from "./BigGrid";
import { useLayout } from "./Layout";
interface Rect {
x: number;
@@ -139,8 +48,8 @@ interface Rect {
height: number;
}
interface Tile extends Rect {
item: TileDescriptor<unknown>;
interface Tile<T> extends Rect {
item: TileDescriptor<T>;
}
interface DragState {
@@ -151,12 +60,21 @@ interface DragState {
cursorY: number;
}
interface SlotProps {
style?: CSSProperties;
}
export const Slot: FC<SlotProps> = ({ style }) => (
<div className={styles.slot} style={style} />
);
/**
* An interactive, animated grid of video tiles.
*/
export function NewVideoGrid<T>({
items,
disableAnimations,
layoutStates,
children,
}: Props<T>) {
// Overview: This component lays out tiles by rendering an invisible template
@@ -169,36 +87,36 @@ export function NewVideoGrid<T>({
// most recently rendered generation of the grid, and watch it with a
// MutationObserver.
const [slotGrid, setSlotGrid] = useState<HTMLDivElement | null>(null);
const [slotGridGeneration, setSlotGridGeneration] = useState(0);
const [slotsRoot, setSlotsRoot] = useState<HTMLDivElement | null>(null);
const [renderedGeneration, setRenderedGeneration] = useState(0);
useEffect(() => {
if (slotGrid !== null) {
setSlotGridGeneration(
parseInt(slotGrid.getAttribute("data-generation")!)
if (slotsRoot !== null) {
setRenderedGeneration(
parseInt(slotsRoot.getAttribute("data-generation")!)
);
const observer = new MutationObserver((mutations) => {
if (mutations.some((m) => m.type === "attributes")) {
setSlotGridGeneration(
parseInt(slotGrid.getAttribute("data-generation")!)
setRenderedGeneration(
parseInt(slotsRoot.getAttribute("data-generation")!)
);
}
});
observer.observe(slotGrid, { attributes: true });
observer.observe(slotsRoot, { attributes: true });
return () => observer.disconnect();
}
}, [slotGrid, setSlotGridGeneration]);
}, [slotsRoot, setRenderedGeneration]);
const [gridRef1, gridBounds] = useMeasure();
const gridRef2 = useRef<HTMLDivElement | null>(null);
const gridRef = useMergedRefs(gridRef1, gridRef2);
const slotRects = useMemo(() => {
if (slotGrid === null) return [];
if (slotsRoot === null) return [];
const slots = slotGrid.getElementsByClassName(styles.slot);
const slots = slotsRoot.getElementsByClassName(styles.slot);
const rects = new Array<Rect>(slots.length);
for (let i = 0; i < slots.length; i++) {
const slot = slots[i] as HTMLElement;
@@ -214,32 +132,34 @@ export function NewVideoGrid<T>({
// The rects may change due to the grid being resized or rerendered, but
// eslint can't statically verify this
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [slotGrid, slotGridGeneration, gridBounds]);
}, [slotsRoot, renderedGeneration, gridBounds]);
const columns = useMemo(
() =>
// The grid bounds might not be known yet
gridBounds.width === 0
? null
: Math.max(2, Math.floor(gridBounds.width * 0.0045)),
[gridBounds]
);
// TODO: Implement more layouts and select the right one here
const layout = BigGrid;
const {
state: grid,
orderedItems,
generation,
canDragTile,
dragTile,
toggleFocus,
slots,
} = useLayout(layout, items, gridBounds, layoutStates);
const [grid, setGrid] = useGridState(columns, items);
const [tiles] = useReactiveState<Tile[]>(
const [tiles] = useReactiveState<Tile<T>[]>(
(prevTiles) => {
// If React hasn't yet rendered the current generation of the grid, skip
// the update, because grid and slotRects will be out of sync
if (slotGridGeneration !== grid?.generation) return prevTiles ?? [];
if (renderedGeneration !== generation) return prevTiles ?? [];
const tileCells = grid.cells.filter((c) => c?.origin) as Cell[];
const tileRects = new Map<TileDescriptor<unknown>, Rect>(
zipWith(tileCells, slotRects, (cell, rect) => [cell.item, rect])
const tileRects = new Map(
zip(orderedItems, slotRects) as [TileDescriptor<T>, Rect][]
);
// In order to not break drag gestures, it's critical that we render tiles
// in a stable order (that of 'items')
return items.map((item) => ({ ...tileRects.get(item)!, item }));
},
[slotRects, grid, slotGridGeneration]
[slotRects, grid, renderedGeneration]
);
// Drag state is stored in a ref rather than component state, because we use
@@ -249,8 +169,8 @@ export function NewVideoGrid<T>({
const [tileTransitions, springRef] = useTransition(
tiles,
() => ({
key: ({ item }: Tile) => item.id,
from: ({ x, y, width, height }: Tile) => ({
key: ({ item }: Tile<T>) => item.id,
from: ({ x, y, width, height }: Tile<T>) => ({
opacity: 0,
scale: 0,
shadow: 1,
@@ -263,7 +183,7 @@ export function NewVideoGrid<T>({
immediate: disableAnimations,
}),
enter: { opacity: 1, scale: 1, immediate: disableAnimations },
update: ({ item, x, y, width, height }: Tile) =>
update: ({ item, x, y, width, height }: Tile<T>) =>
item.id === dragState.current?.tileId
? null
: {
@@ -277,7 +197,7 @@ export function NewVideoGrid<T>({
config: { mass: 0.7, tension: 252, friction: 25 },
})
// react-spring's types are bugged and can't infer the spring type
) as unknown as [TransitionFn<Tile, TileSpring>, SpringRef<TileSpring>];
) as unknown as [TransitionFn<Tile<T>, TileSpring>, SpringRef<TileSpring>];
// Because we're using react-spring in imperative mode, we're responsible for
// firing animations manually whenever the tiles array updates
@@ -288,11 +208,9 @@ export function NewVideoGrid<T>({
const animateDraggedTile = (endOfGesture: boolean) => {
const { tileId, tileX, tileY, cursorX, cursorY } = dragState.current!;
const tile = tiles.find((t) => t.item.id === tileId)!;
const originIndex = grid!.cells.findIndex((c) => c?.item.id === tileId);
const originCell = grid!.cells[originIndex]!;
springRef.current
.find((c) => (c.item as Tile).item.id === tileId)
.find((c) => (c.item as Tile<T>).item.id === tileId)
?.start(
endOfGesture
? {
@@ -320,38 +238,30 @@ export function NewVideoGrid<T>({
}
);
const columns = grid!.columns;
const rows = row(grid!.cells.length - 1, grid!) + 1;
const cursorColumn = Math.floor(
(cursorX / slotGrid!.clientWidth) * columns
);
const cursorRow = Math.floor((cursorY / slotGrid!.clientHeight) * rows);
const cursorColumnOnTile = Math.floor(
((cursorX - tileX) / tile.width) * originCell.columns
);
const cursorRowOnTile = Math.floor(
((cursorY - tileY) / tile.height) * originCell.rows
const overTile = tiles.find(
(t) =>
cursorX >= t.x &&
cursorX < t.x + t.width &&
cursorY >= t.y &&
cursorY < t.y + t.height
);
const dest =
Math.max(
0,
Math.min(
columns - originCell.columns,
cursorColumn - cursorColumnOnTile
)
) +
grid!.columns *
Math.max(
0,
Math.min(rows - originCell.rows, cursorRow - cursorRowOnTile)
);
if (dest !== originIndex) setGrid((g) => tryMoveTile(g, originIndex, dest));
if (overTile !== undefined)
dragTile(
tile.item,
overTile.item,
(cursorX - tileX) / tile.width,
(cursorY - tileY) / tile.height,
(cursorX - overTile.x) / overTile.width,
(cursorY - overTile.y) / overTile.height
);
};
const [lastTappedTileId, setLastTappedTileId] = useState<string | undefined>(
undefined
);
const [lastTapTime, setLastTapTime] = useState<number>(0);
// Callback for useDrag. We could call useDrag here, but the default
// pattern of spreading {...bind()} across the children to bind the gesture
// ends up breaking memoization and ruining this component's performance.
@@ -367,29 +277,40 @@ export function NewVideoGrid<T>({
}: Parameters<Handler<"drag", EventTypes["drag"]>>[0]
) => {
if (tap) {
setGrid((g) => cycleTileSize(tileId, g!));
} else {
const tileSpring = springRef.current
.find((c) => (c.item as Tile).item.id === tileId)!
.get();
const now = Date.now();
if (dragState.current === null) {
dragState.current = {
tileId,
tileX: tileSpring.x,
tileY: tileSpring.y,
cursorX: initialX - gridBounds.x,
cursorY: initialY - gridBounds.y + scrollOffset.current,
};
if (tileId === lastTappedTileId && now - lastTapTime < 500) {
toggleFocus?.(items.find((i) => i.id === tileId)!);
}
dragState.current.tileX += dx;
dragState.current.tileY += dy;
dragState.current.cursorX += dx;
dragState.current.cursorY += dy;
animateDraggedTile(last);
setLastTappedTileId(tileId);
setLastTapTime(now);
} else {
const tileController = springRef.current.find(
(c) => (c.item as Tile<T>).item.id === tileId
)!;
if (last) dragState.current = null;
if (canDragTile((tileController.item as Tile<T>).item)) {
if (dragState.current === null) {
const tileSpring = tileController.get();
dragState.current = {
tileId,
tileX: tileSpring.x,
tileY: tileSpring.y,
cursorX: initialX - gridBounds.x,
cursorY: initialY - gridBounds.y + scrollOffset.current,
};
}
dragState.current.tileX += dx;
dragState.current.tileY += dy;
dragState.current.cursorX += dx;
dragState.current.cursorY += dy;
animateDraggedTile(last);
if (last) dragState.current = null;
}
}
};
@@ -411,52 +332,6 @@ export function NewVideoGrid<T>({
{ target: gridRef2 }
);
const slotGridStyle = useMemo(() => {
if (grid === null) return {};
const areas = new Array<(number | null)[]>(
Math.ceil(grid.cells.length / grid.columns)
);
for (let i = 0; i < areas.length; i++)
areas[i] = new Array<number | null>(grid.columns).fill(null);
let slotId = 0;
for (let i = 0; i < grid.cells.length; i++) {
const cell = grid.cells[i];
if (cell?.origin) {
const slotEnd = i + cell.columns - 1 + grid.columns * (cell.rows - 1);
forEachCellInArea(
i,
slotEnd,
grid,
(_c, j) => (areas[row(j, grid)][column(j, grid)] = slotId)
);
slotId++;
}
}
return {
gridTemplateAreas: areas
.map(
(row) =>
`'${row
.map((slotId) => (slotId === null ? "." : `s${slotId}`))
.join(" ")}'`
)
.join(" "),
gridTemplateColumns: `repeat(${columns}, 1fr)`,
};
}, [grid, columns]);
const slots = useMemo(() => {
const slots = new Array<ReactNode>(items.length);
for (let i = 0; i < items.length; i++)
slots[i] = (
<div className={styles.slot} key={i} style={{ gridArea: `s${i}` }} />
);
return slots;
}, [items.length]);
// Render nothing if the grid has yet to be generated
if (grid === null) {
return <div ref={gridRef} className={styles.grid} />;
@@ -465,10 +340,9 @@ export function NewVideoGrid<T>({
return (
<div ref={gridRef} className={styles.grid}>
<div
style={slotGridStyle}
ref={setSlotGrid}
className={styles.slotGrid}
data-generation={grid.generation}
ref={setSlotsRoot}
className={styles.slots}
data-generation={generation}
>
{slots}
</div>
@@ -482,7 +356,7 @@ export function NewVideoGrid<T>({
data={tile.item.data}
{...spring}
>
{children as (props: ChildrenProperties<unknown>) => ReactNode}
{children as (props: ChildrenProperties<T>) => ReactNode}
</TileWrapper>
))}
</div>

View File

@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { FC, memo, ReactNode, RefObject, useRef } from "react";
import { memo, ReactNode, RefObject, useRef } from "react";
import { EventTypes, Handler, useDrag } from "@use-gesture/react";
import { SpringValue, to } from "@react-spring/web";
@@ -47,7 +47,7 @@ interface Props<T> {
* A wrapper around a tile in a video grid. This component exists to decouple
* child components from the grid.
*/
export const TileWrapper: FC<Props<unknown>> = memo(
export const TileWrapper = memo(
({
id,
onDragRef,
@@ -97,4 +97,7 @@ export const TileWrapper: FC<Props<unknown>> = memo(
</>
);
}
);
// We pretend this component is a simple function rather than a
// NamedExoticComponent, because that's the only way we can fit in a type
// parameter
) as <T>(props: Props<T>) => JSX.Element;

View File

@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, {
import {
ComponentProps,
Key,
ReactNode,
@@ -42,6 +42,7 @@ import { ResizeObserver as JuggleResizeObserver } from "@juggle/resize-observer"
import styles from "./VideoGrid.module.css";
import { Layout } from "../room/GridLayoutMenu";
import { TileWrapper } from "./TileWrapper";
import { LayoutStatesMap } from "./Layout";
interface TilePosition {
x: number;
@@ -817,7 +818,8 @@ export interface VideoGridProps<T> {
items: TileDescriptor<T>[];
layout: Layout;
disableAnimations: boolean;
children: (props: ChildrenProperties<T>) => React.ReactNode;
layoutStates: LayoutStatesMap;
children: (props: ChildrenProperties<T>) => ReactNode;
}
// Represents something that should get a tile on the layout,

View File

@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { useCallback } from "react";
import { ComponentProps, forwardRef, useCallback, useEffect } from "react";
import { animated } from "@react-spring/web";
import classNames from "classnames";
import { useTranslation } from "react-i18next";
@@ -57,12 +57,12 @@ interface Props {
targetWidth: number;
targetHeight: number;
className?: string;
style?: React.ComponentProps<typeof animated.div>["style"];
style?: ComponentProps<typeof animated.div>["style"];
showSpeakingIndicator: boolean;
showConnectionStats: boolean;
}
export const VideoTile = React.forwardRef<HTMLDivElement, Props>(
export const VideoTile = forwardRef<HTMLDivElement, Props>(
(
{
data,
@@ -87,7 +87,7 @@ export const VideoTile = React.forwardRef<HTMLDivElement, Props>(
() => member?.rawDisplayName ?? "[👻]",
[member]
);
React.useEffect(() => {
useEffect(() => {
if (member) {
const updateName = () => {
setDisplayName(member.rawDisplayName);