From 1c6ef974576c5a39f4fb6a31595d0cd28c34e09b Mon Sep 17 00:00:00 2001 From: Robin Townsend Date: Wed, 28 Jun 2023 10:59:36 -0400 Subject: [PATCH] Improve typing around layouts and grid components --- src/room/InCallView.tsx | 3 +- src/video-grid/BigGrid.tsx | 3 +- src/video-grid/Layout.ts | 74 ------------- src/video-grid/Layout.tsx | 178 ++++++++++++++++++++++++++++++++ src/video-grid/NewVideoGrid.tsx | 112 +++----------------- src/video-grid/TileWrapper.tsx | 9 +- src/video-grid/VideoGrid.tsx | 4 +- 7 files changed, 207 insertions(+), 176 deletions(-) delete mode 100644 src/video-grid/Layout.ts create mode 100644 src/video-grid/Layout.tsx diff --git a/src/room/InCallView.tsx b/src/room/InCallView.tsx index 4a3fc4a..a8a171b 100644 --- a/src/room/InCallView.tsx +++ b/src/room/InCallView.tsx @@ -73,7 +73,7 @@ import { useJoinRule } from "./useJoinRule"; import { ParticipantInfo } from "./useGroupCall"; import { ItemData, TileContent } from "../video-grid/VideoTile"; import { Config } from "../config/Config"; -import { NewVideoGrid, useLayoutStates } from "../video-grid/NewVideoGrid"; +import { NewVideoGrid } from "../video-grid/NewVideoGrid"; import { OTelGroupCallMembership } from "../otel/OTelGroupCallMembership"; import { SettingsModal } from "../settings/SettingsModal"; import { InviteModal } from "./InviteModal"; @@ -83,6 +83,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 diff --git a/src/video-grid/BigGrid.tsx b/src/video-grid/BigGrid.tsx index 650278d..f08676f 100644 --- a/src/video-grid/BigGrid.tsx +++ b/src/video-grid/BigGrid.tsx @@ -865,7 +865,8 @@ export const BigGrid: Layout = { emptyState: { columns: 4, cells: [] }, updateTiles, updateBounds, - getTiles: (g) => g.cells.filter((c) => c?.origin).map((c) => c!.item), + getTiles: (g) => + g.cells.filter((c) => c?.origin).map((c) => c!.item as T), canDragTile: () => true, dragTile, toggleFocus: cycleTileSize, diff --git a/src/video-grid/Layout.ts b/src/video-grid/Layout.ts deleted file mode 100644 index d4467aa..0000000 --- a/src/video-grid/Layout.ts +++ /dev/null @@ -1,74 +0,0 @@ -/* -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 type { ComponentType } from "react"; -import type { RectReadOnly } from "react-use-measure"; -import type { TileDescriptor } from "./VideoGrid"; - -/** - * A video grid layout system with concrete states of type State. - */ -export interface Layout { - /** - * 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: (s: State, tiles: TileDescriptor[]) => 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: (s: State) => TileDescriptor[]; - /** - * Determines whether a tile is draggable. - */ - readonly canDragTile: (s: State, tile: TileDescriptor) => 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: ( - s: State, - from: TileDescriptor, - to: TileDescriptor, - 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?: (s: State, tile: TileDescriptor) => 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; -} diff --git a/src/video-grid/Layout.tsx b/src/video-grid/Layout.tsx new file mode 100644 index 0000000..2b29594 --- /dev/null +++ b/src/video-grid/Layout.tsx @@ -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 { + /** + * 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: (s: State, tiles: TileDescriptor[]) => 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: (s: State) => TileDescriptor[]; + /** + * Determines whether a tile is draggable. + */ + readonly canDragTile: (s: State, tile: TileDescriptor) => 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: ( + s: State, + from: TileDescriptor, + to: TileDescriptor, + 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?: (s: State, tile: TileDescriptor) => 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(layout: Layout): State | undefined; + set(layout: Layout, state: State): LayoutStatesMap; + delete(layout: Layout): boolean; +} + +/** + * Hook creating a Map to store layout states in. + */ +export const useLayoutStates = (): LayoutStatesMap => { + const layoutStates = useRef>(); + 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 = ( + layout: Layout, + items: TileDescriptor[], + bounds: RectReadOnly, + layoutStates: LayoutStatesMap +) => { + const prevLayout = useRef>(); + const prevState = layoutStates.get(layout); + + const [state, setState] = useReactiveState(() => { + // 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(0); + if (state !== prevState) generation.current++; + + prevLayout.current = layout as Layout; + // 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(state), [layout, state]), + generation: generation.current, + canDragTile: useCallback( + (tile: TileDescriptor) => layout.canDragTile(state, tile), + [layout, state] + ), + dragTile: useCallback( + ( + from: TileDescriptor, + to: TileDescriptor, + 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) => + setState((s) => layout.toggleFocus!(s, tile))), + [layout, setState] + ), + slots: , + }; +}; diff --git a/src/video-grid/NewVideoGrid.tsx b/src/video-grid/NewVideoGrid.tsx index 97d9f2c..b88128d 100644 --- a/src/video-grid/NewVideoGrid.tsx +++ b/src/video-grid/NewVideoGrid.tsx @@ -20,13 +20,12 @@ import React, { CSSProperties, FC, ReactNode, - useCallback, useEffect, useMemo, useRef, useState, } from "react"; -import useMeasure, { RectReadOnly } from "react-use-measure"; +import useMeasure from "react-use-measure"; import { zip } from "lodash"; import styles from "./NewVideoGrid.module.css"; @@ -40,84 +39,7 @@ import { useReactiveState } from "../useReactiveState"; import { useMergedRefs } from "../useMergedRefs"; import { TileWrapper } from "./TileWrapper"; import { BigGrid } from "./BigGrid"; -import { Layout } from "./Layout"; - -export const useLayoutStates = () => { - const layoutStates = useRef, unknown>>(); - if (layoutStates.current === undefined) layoutStates.current = new Map(); - return layoutStates.current; -}; - -const useGrid = ( - layout: Layout, - items: TileDescriptor[], - bounds: RectReadOnly, - layoutStates: Map, unknown> -) => { - const prevLayout = useRef>(layout); - const prevState = layoutStates.get(layout); - - const [state, setState] = useReactiveState(() => { - // If the bounds aren't known yet, don't add anything to the layout - if (bounds.width === 0) { - return layout.emptyState; - } else { - if (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(0); - if (state !== prevState) generation.current++; - - prevLayout.current = layout; - // 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 { - grid: state, - orderedItems: useMemo(() => layout.getTiles(state), [layout, state]), - generation: generation.current, - canDragTile: useCallback( - (tile: TileDescriptor) => layout.canDragTile(state, tile), - [layout, state] - ), - dragTile: useCallback( - ( - from: TileDescriptor, - to: TileDescriptor, - 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) => - setState((s) => layout.toggleFocus!(s, tile))), - [layout, setState] - ), - slots: , - }; -}; +import { useLayout } from "./Layout"; interface Rect { x: number; @@ -126,8 +48,8 @@ interface Rect { height: number; } -interface Tile extends Rect { - item: TileDescriptor; +interface Tile extends Rect { + item: TileDescriptor; } interface DragState { @@ -215,23 +137,23 @@ export function NewVideoGrid({ // TODO: Implement more layouts and select the right one here const layout = BigGrid; const { - grid, + state: grid, orderedItems, generation, canDragTile, dragTile, toggleFocus, slots, - } = useGrid(layout as Layout, items, gridBounds, layoutStates); + } = useLayout(layout, items, gridBounds, layoutStates); - const [tiles] = useReactiveState( + const [tiles] = useReactiveState[]>( (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 (renderedGeneration !== generation) return prevTiles ?? []; - const tileRects = new Map, Rect>( - zip(orderedItems, slotRects) as [TileDescriptor, Rect][] + const tileRects = new Map( + zip(orderedItems, slotRects) as [TileDescriptor, Rect][] ); // In order to not break drag gestures, it's critical that we render tiles // in a stable order (that of 'items') @@ -247,8 +169,8 @@ export function NewVideoGrid({ const [tileTransitions, springRef] = useTransition( tiles, () => ({ - key: ({ item }: Tile) => item.id, - from: ({ x, y, width, height }: Tile) => ({ + key: ({ item }: Tile) => item.id, + from: ({ x, y, width, height }: Tile) => ({ opacity: 0, scale: 0, shadow: 1, @@ -261,7 +183,7 @@ export function NewVideoGrid({ immediate: disableAnimations, }), enter: { opacity: 1, scale: 1, immediate: disableAnimations }, - update: ({ item, x, y, width, height }: Tile) => + update: ({ item, x, y, width, height }: Tile) => item.id === dragState.current?.tileId ? null : { @@ -275,7 +197,7 @@ export function NewVideoGrid({ 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, SpringRef]; + ) as unknown as [TransitionFn, TileSpring>, SpringRef]; // Because we're using react-spring in imperative mode, we're responsible for // firing animations manually whenever the tiles array updates @@ -288,7 +210,7 @@ export function NewVideoGrid({ const tile = tiles.find((t) => t.item.id === tileId)!; springRef.current - .find((c) => (c.item as Tile).item.id === tileId) + .find((c) => (c.item as Tile).item.id === tileId) ?.start( endOfGesture ? { @@ -353,10 +275,10 @@ export function NewVideoGrid({ toggleFocus?.(items.find((i) => i.id === tileId)!); } else { const tileController = springRef.current.find( - (c) => (c.item as Tile).item.id === tileId + (c) => (c.item as Tile).item.id === tileId )!; - if (canDragTile((tileController.item as Tile).item)) { + if (canDragTile((tileController.item as Tile).item)) { if (dragState.current === null) { const tileSpring = tileController.get(); dragState.current = { @@ -422,7 +344,7 @@ export function NewVideoGrid({ data={tile.item.data} {...spring} > - {children as (props: ChildrenProperties) => ReactNode} + {children as (props: ChildrenProperties) => ReactNode} ))} diff --git a/src/video-grid/TileWrapper.tsx b/src/video-grid/TileWrapper.tsx index b9f84b5..09b67aa 100644 --- a/src/video-grid/TileWrapper.tsx +++ b/src/video-grid/TileWrapper.tsx @@ -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 React, { 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 { * A wrapper around a tile in a video grid. This component exists to decouple * child components from the grid. */ -export const TileWrapper: FC> = memo( +export const TileWrapper = memo( ({ id, onDragRef, @@ -97,4 +97,7 @@ export const TileWrapper: FC> = 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 (props: Props) => JSX.Element; diff --git a/src/video-grid/VideoGrid.tsx b/src/video-grid/VideoGrid.tsx index acf3cf5..a9b847b 100644 --- a/src/video-grid/VideoGrid.tsx +++ b/src/video-grid/VideoGrid.tsx @@ -42,7 +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 { Layout as LayoutSystem } from "./Layout"; +import { LayoutStatesMap } from "./Layout"; interface TilePosition { x: number; @@ -818,7 +818,7 @@ export interface VideoGridProps { items: TileDescriptor[]; layout: Layout; disableAnimations: boolean; - layoutStates: Map, unknown>; + layoutStates: LayoutStatesMap; children: (props: ChildrenProperties) => React.ReactNode; }