diff --git a/public/locales/en-GB/app.json b/public/locales/en-GB/app.json index 7fd742cb8..7a804a5dd 100644 --- a/public/locales/en-GB/app.json +++ b/public/locales/en-GB/app.json @@ -41,6 +41,7 @@ "analytics": "Analytics", "audio": "Audio", "avatar": "Avatar", + "back": "Back", "camera": "Camera", "copied": "Copied!", "display_name": "Display name", @@ -49,6 +50,7 @@ "home": "Home", "loading": "Loading…", "microphone": "Microphone", + "next": "Next", "options": "Options", "password": "Password", "profile": "Profile", diff --git a/src/Header.module.css b/src/Header.module.css index 1c9a72253..81c377196 100644 --- a/src/Header.module.css +++ b/src/Header.module.css @@ -22,6 +22,7 @@ limitations under the License. user-select: none; flex-shrink: 0; padding-inline: var(--inline-content-inset); + padding-block-end: var(--cpd-space-4x); } .nav { diff --git a/src/Header.tsx b/src/Header.tsx index 1bf8a4a72..ffb4731ea 100644 --- a/src/Header.tsx +++ b/src/Header.tsx @@ -15,7 +15,7 @@ limitations under the License. */ import classNames from "classnames"; -import { FC, HTMLAttributes, ReactNode } from "react"; +import { FC, HTMLAttributes, ReactNode, forwardRef } from "react"; import { Link } from "react-router-dom"; import { useTranslation } from "react-i18next"; import { Heading, Text } from "@vector-im/compound-web"; @@ -32,13 +32,21 @@ interface HeaderProps extends HTMLAttributes { className?: string; } -export const Header: FC = ({ children, className, ...rest }) => { - return ( -
- {children} -
- ); -}; +export const Header = forwardRef( + ({ children, className, ...rest }, ref) => { + return ( +
+ {children} +
+ ); + }, +); + +Header.displayName = "Header"; interface LeftNavProps extends HTMLAttributes { children: ReactNode; diff --git a/src/video-grid/NewVideoGrid.module.css b/src/grid/Grid.css similarity index 100% rename from src/video-grid/NewVideoGrid.module.css rename to src/grid/Grid.css diff --git a/src/video-grid/BigGrid.module.css b/src/grid/Grid.module.css similarity index 71% rename from src/video-grid/BigGrid.module.css rename to src/grid/Grid.module.css index 2201295dc..33e593be2 100644 --- a/src/video-grid/BigGrid.module.css +++ b/src/grid/Grid.module.css @@ -1,5 +1,5 @@ /* -Copyright 2023 New Vector Ltd +Copyright 2023-2024 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. @@ -14,15 +14,10 @@ See the License for the specific language governing permissions and limitations under the License. */ -.bigGrid { - display: grid; - grid-auto-rows: 130px; - gap: var(--cpd-space-2x); +.grid { + contain: layout style; } -@media (min-width: 800px) { - .bigGrid { - grid-auto-rows: 135px; - gap: var(--cpd-space-5x); - } +.slot { + contain: strict; } diff --git a/src/grid/Grid.tsx b/src/grid/Grid.tsx new file mode 100644 index 000000000..094d5b4b2 --- /dev/null +++ b/src/grid/Grid.tsx @@ -0,0 +1,458 @@ +/* +Copyright 2023-2024 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 { + SpringRef, + TransitionFn, + animated, + useTransition, +} from "@react-spring/web"; +import { EventTypes, Handler, useScroll } from "@use-gesture/react"; +import { + CSSProperties, + ComponentProps, + ComponentType, + FC, + ReactNode, + Ref, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import useMeasure from "react-use-measure"; +import classNames from "classnames"; + +import styles from "./Grid.module.css"; +import { useMergedRefs } from "../useMergedRefs"; +import { TileWrapper } from "./TileWrapper"; +import { usePrefersReducedMotion } from "../usePrefersReducedMotion"; + +interface Rect { + x: number; + y: number; + width: number; + height: number; +} + +interface Tile extends Rect { + id: string; + model: Model; +} + +interface TileSpring { + opacity: number; + scale: number; + zIndex: number; + x: number; + y: number; + width: number; + height: number; +} + +interface DragState { + tileId: string; + tileX: number; + tileY: number; + cursorX: number; + cursorY: number; +} + +interface SlotProps extends ComponentProps<"div"> { + tile: string; + style?: CSSProperties; + className?: string; +} + +/** + * An invisible "slot" for a tile to go in. + */ +export const Slot: FC = ({ tile, style, className, ...props }) => ( +
+); + +export interface LayoutProps { + ref: Ref; + model: Model; +} + +export interface TileProps { + ref: Ref; + className?: string; + style?: ComponentProps["style"]; + /** + * The width this tile will have once its animations have settled. + */ + targetWidth: number; + /** + * The height this tile will have once its animations have settled. + */ + targetHeight: number; + model: Model; +} + +interface Drag { + /** + * The X coordinate of the dragged tile in grid space. + */ + x: number; + /** + * The Y coordinate of the dragged tile in grid space. + */ + y: number; + /** + * The X coordinate of the dragged tile, as a scalar of the grid width. + */ + xRatio: number; + /** + * The Y coordinate of the dragged tile, as a scalar of the grid height. + */ + yRatio: number; +} + +type DragCallback = (drag: Drag) => void; + +export interface LayoutSystem { + /** + * Defines the ID and model of each tile present in the layout. + */ + tiles: (model: LayoutModel) => Map; + /** + * A component which creates an invisible layout grid of "slots" for tiles to + * go in. The root element must have a data-generation attribute which + * increments whenever the layout may have changed. + */ + Layout: ComponentType>; + /** + * Gets a drag callback for the tile with the given ID. If this is not + * provided or it returns null, the tile is not draggable. + */ + onDrag?: (model: LayoutModel, tile: string) => DragCallback | null; +} + +interface Props< + LayoutModel, + TileModel, + LayoutRef extends HTMLElement, + TileRef extends HTMLElement, +> { + /** + * Data with which to populate the layout. + */ + model: LayoutModel; + /** + * The system by which to arrange the layout and respond to interactions. + */ + system: LayoutSystem; + /** + * The component used to render each tile in the layout. + */ + Tile: ComponentType>; + className?: string; + style?: CSSProperties; +} + +/** + * A grid of animated tiles. + */ +export function Grid< + LayoutModel, + TileModel, + LayoutRef extends HTMLElement, + TileRef extends HTMLElement, +>({ + model, + system: { tiles: getTileModels, Layout, onDrag }, + Tile, + className, + style, +}: Props): ReactNode { + // Overview: This component places tiles by rendering an invisible layout grid + // of "slots" for tiles to go in. Once rendered, it uses the DOM API to get + // the dimensions of each slot, feeding these numbers back into react-spring + // to let the actual tiles move freely atop the layout. + + // To tell us when the layout has changed, the layout system increments its + // data-generation attribute, which we watch with a MutationObserver. + + const [gridRef1, gridBounds] = useMeasure(); + const [gridRoot, gridRef2] = useState(null); + const gridRef = useMergedRefs(gridRef1, gridRef2); + + const [layoutRoot, setLayoutRoot] = useState(null); + const [generation, setGeneration] = useState(null); + const prefersReducedMotion = usePrefersReducedMotion(); + + const layoutRef = useCallback( + (e: HTMLElement | null) => { + setLayoutRoot(e); + if (e !== null) + setGeneration(parseInt(e.getAttribute("data-generation")!)); + }, + [setLayoutRoot, setGeneration], + ); + + useEffect(() => { + if (layoutRoot !== null) { + const observer = new MutationObserver((mutations) => { + if (mutations.some((m) => m.type === "attributes")) { + setGeneration(parseInt(layoutRoot.getAttribute("data-generation")!)); + } + }); + + observer.observe(layoutRoot, { attributes: true }); + return () => observer.disconnect(); + } + }, [layoutRoot, setGeneration]); + + const slotRects = useMemo(() => { + const rects = new Map(); + + if (layoutRoot !== null) { + const slots = layoutRoot.getElementsByClassName( + styles.slot, + ) as HTMLCollectionOf; + for (const slot of slots) + rects.set(slot.getAttribute("data-tile")!, { + x: slot.offsetLeft, + y: slot.offsetTop, + width: slot.offsetWidth, + height: slot.offsetHeight, + }); + } + + return rects; + // 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 + }, [layoutRoot, generation]); + + const tileModels = useMemo( + () => getTileModels(model), + [getTileModels, model], + ); + + // Combine the tile models and slots together to create placed tiles + const tiles = useMemo[]>(() => { + const items: Tile[] = []; + for (const [id, model] of tileModels) { + const rect = slotRects.get(id); + if (rect !== undefined) items.push({ id, model, ...rect }); + } + return items; + }, [slotRects, tileModels]); + + const dragCallbacks = useMemo( + () => + new Map( + (function* (): Iterable<[string, DragCallback | null]> { + if (onDrag !== undefined) + for (const id of tileModels.keys()) yield [id, onDrag(model, id)]; + })(), + ), + [onDrag, tileModels, model], + ); + + // Drag state is stored in a ref rather than component state, because we use + // react-spring's imperative API during gestures to improve responsiveness + const dragState = useRef(null); + + const [tileTransitions, springRef] = useTransition( + tiles, + () => ({ + key: ({ id }: Tile) => id, + from: ({ x, y, width, height }: Tile) => ({ + opacity: 0, + scale: 0, + zIndex: 1, + x, + y, + width, + height, + immediate: prefersReducedMotion, + }), + enter: { opacity: 1, scale: 1, immediate: prefersReducedMotion }, + update: ({ id, x, y, width, height }: Tile) => + id === dragState.current?.tileId + ? null + : { + x, + y, + width, + height, + immediate: prefersReducedMotion, + }, + leave: { opacity: 0, scale: 0, immediate: prefersReducedMotion }, + 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, TileSpring>, + SpringRef, + ]; + + // Because we're using react-spring in imperative mode, we're responsible for + // firing animations manually whenever the tiles array updates + useEffect(() => { + springRef.start(); + }, [tiles, springRef]); + + const animateDraggedTile = ( + endOfGesture: boolean, + callback: DragCallback, + ): void => { + const { tileId, tileX, tileY } = dragState.current!; + const tile = tiles.find((t) => t.id === tileId)!; + + springRef.current + .find((c) => (c.item as Tile).id === tileId) + ?.start( + endOfGesture + ? { + scale: 1, + zIndex: 1, + x: tile.x, + y: tile.y, + width: tile.width, + height: tile.height, + immediate: + prefersReducedMotion || ((key): boolean => key === "zIndex"), + // Allow the tile's position to settle before pushing its + // z-index back down + delay: (key) => (key === "zIndex" ? 500 : 0), + } + : { + scale: 1.1, + zIndex: 2, + x: tileX, + y: tileY, + immediate: + prefersReducedMotion || + ((key): boolean => + key === "zIndex" || key === "x" || key === "y"), + }, + ); + + if (endOfGesture) + callback({ + x: tileX, + y: tileY, + xRatio: tileX / (gridBounds.width - tile.width), + yRatio: tileY / (gridBounds.height - tile.height), + }); + }; + + // 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. + // Instead, we pass this callback to each tile via a ref, to let them bind the + // gesture using the much more sensible ref-based method. + const onTileDrag = ( + tileId: string, + + { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + tap, + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + initial: [initialX, initialY], + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + delta: [dx, dy], + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + last, + }: Parameters>[0], + ): void => { + if (!tap) { + const tileController = springRef.current.find( + (c) => (c.item as Tile).id === tileId, + )!; + const callback = dragCallbacks.get(tileController.item.id); + + if (callback != null) { + 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, callback); + + if (last) dragState.current = null; + } + } + }; + + const onTileDragRef = useRef(onTileDrag); + onTileDragRef.current = onTileDrag; + + const scrollOffset = useRef(0); + + useScroll( + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + ({ xy: [, y], delta: [, dy] }) => { + scrollOffset.current = y; + + if (dragState.current !== null) { + dragState.current.tileY += dy; + dragState.current.cursorY += dy; + animateDraggedTile(false, onDrag!(model, dragState.current.tileId)!); + } + }, + { target: gridRoot ?? undefined }, + ); + + return ( +
+ + {tileTransitions((spring, { id, model, width, height }) => ( + + ))} +
+ ); +} diff --git a/src/grid/GridLayout.module.css b/src/grid/GridLayout.module.css new file mode 100644 index 000000000..ef234b337 --- /dev/null +++ b/src/grid/GridLayout.module.css @@ -0,0 +1,54 @@ +/* +Copyright 2024 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. +*/ + +.scrolling { + box-sizing: border-box; + block-size: 100%; + display: flex; + flex-wrap: wrap; + justify-content: center; + align-content: center; + gap: var(--gap); + box-sizing: border-box; +} + +.scrolling > .slot { + width: var(--width); + height: var(--height); +} + +.fixed > .slot { + position: absolute; + inline-size: 404px; + block-size: 233px; + inset: -12px; +} + +.fixed > .slot[data-block-alignment="start"] { + inset-block-end: unset; +} + +.fixed > .slot[data-block-alignment="end"] { + inset-block-start: unset; +} + +.fixed > .slot[data-inline-alignment="start"] { + inset-inline-end: unset; +} + +.fixed > .slot[data-inline-alignment="end"] { + inset-inline-start: unset; +} diff --git a/src/grid/GridLayout.tsx b/src/grid/GridLayout.tsx new file mode 100644 index 000000000..8df7753bd --- /dev/null +++ b/src/grid/GridLayout.tsx @@ -0,0 +1,189 @@ +/* +Copyright 2024 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 { CSSProperties, useMemo } from "react"; +import { StateObservable, state, useStateObservable } from "@react-rxjs/core"; +import { BehaviorSubject, distinctUntilChanged } from "rxjs"; + +import { GridLayout as GridLayoutModel } from "../state/CallViewModel"; +import { MediaViewModel } from "../state/MediaViewModel"; +import { LayoutSystem, Slot } from "./Grid"; +import styles from "./GridLayout.module.css"; +import { useReactiveState } from "../useReactiveState"; +import { subscribe } from "../state/subscribe"; +import { Alignment } from "../room/InCallView"; +import { useInitial } from "../useInitial"; + +export interface Bounds { + width: number; + height: number; +} + +interface GridCSSProperties extends CSSProperties { + "--gap": string; + "--width": string; + "--height": string; +} + +interface GridLayoutSystems { + scrolling: LayoutSystem; + fixed: LayoutSystem; +} + +const slotMinHeight = 130; +const slotMaxAspectRatio = 17 / 9; +const slotMinAspectRatio = 4 / 3; + +export const gridLayoutSystems = ( + minBounds: StateObservable, + floatingAlignment: BehaviorSubject, +): GridLayoutSystems => ({ + // The "fixed" (non-scrolling) part of the layout is where the spotlight tile + // lives + fixed: { + tiles: (model) => + new Map( + model.spotlight === undefined ? [] : [["spotlight", model.spotlight]], + ), + Layout: subscribe(function GridLayoutFixed({ model }, ref) { + const { width, height } = useStateObservable(minBounds); + const alignment = useStateObservable( + useInitial>(() => + state( + floatingAlignment.pipe( + distinctUntilChanged( + (a1, a2) => a1.block === a2.block && a1.inline === a2.inline, + ), + ), + ), + ), + ); + const [generation] = useReactiveState( + (prev) => (prev === undefined ? 0 : prev + 1), + [model.spotlight === undefined, width, height, alignment], + ); + + return ( +
+ {model.spotlight && ( + + )} +
+ ); + }), + onDrag: + () => + ({ xRatio, yRatio }) => + floatingAlignment.next({ + block: yRatio < 0.5 ? "start" : "end", + inline: xRatio < 0.5 ? "start" : "end", + }), + }, + + // The scrolling part of the layout is where all the grid tiles live + scrolling: { + tiles: (model) => new Map(model.grid.map((tile) => [tile.id, tile])), + Layout: subscribe(function GridLayout({ model }, ref) { + const { width, height: minHeight } = useStateObservable(minBounds); + + // The goal here is to determine the grid size and padding that maximizes + // use of screen space for n tiles without making those tiles too small or + // too cropped (having an extreme aspect ratio) + const [gap, slotWidth, slotHeight] = useMemo(() => { + const gap = width < 800 ? 16 : 20; + const slotMinWidth = width < 500 ? 150 : 180; + + let columns = Math.min( + // Don't create more columns than we have items for + model.grid.length, + // The ideal number of columns is given by a packing of equally-sized + // squares into a grid. + // width / column = height / row. + // columns * rows = number of squares. + // ∴ columns = sqrt(width / height * number of squares). + // Except we actually want 16:9-ish slots rather than squares, so we + // divide the width-to-height ratio by the target aspect ratio. + Math.ceil( + Math.sqrt( + (width / minHeight / slotMaxAspectRatio) * model.grid.length, + ), + ), + ); + let rows = Math.ceil(model.grid.length / columns); + + let slotWidth = (width - (columns - 1) * gap) / columns; + let slotHeight = (minHeight - (rows - 1) * gap) / rows; + + // Impose a minimum width and height on the slots + if (slotWidth < slotMinWidth) { + // In this case we want the slot width to determine the number of columns, + // not the other way around. If we take the above equation for the slot + // width (w = (W - (c - 1) * g) / c) and solve for c, we get + // c = (W + g) / (w + g). + columns = Math.floor((width + gap) / (slotMinWidth + gap)); + rows = Math.ceil(model.grid.length / columns); + slotWidth = (width - (columns - 1) * gap) / columns; + slotHeight = (minHeight - (rows - 1) * gap) / rows; + } + if (slotHeight < slotMinHeight) slotHeight = slotMinHeight; + // Impose a minimum and maximum aspect ratio on the slots + const slotAspectRatio = slotWidth / slotHeight; + if (slotAspectRatio > slotMaxAspectRatio) + slotWidth = slotHeight * slotMaxAspectRatio; + else if (slotAspectRatio < slotMinAspectRatio) + slotHeight = slotWidth / slotMinAspectRatio; + // TODO: We might now be hitting the minimum height or width limit again + + return [gap, slotWidth, slotHeight]; + }, [width, minHeight, model.grid.length]); + + const [generation] = useReactiveState( + (prev) => (prev === undefined ? 0 : prev + 1), + [model.grid, width, minHeight], + ); + + return ( +
+ {model.grid.map((tile) => ( + + ))} +
+ ); + }), + }, +}); diff --git a/src/video-grid/VideoGrid.module.css b/src/grid/LegacyGrid.module.css similarity index 97% rename from src/video-grid/VideoGrid.module.css rename to src/grid/LegacyGrid.module.css index df6e4fa77..6e59e66ec 100644 --- a/src/video-grid/VideoGrid.module.css +++ b/src/grid/LegacyGrid.module.css @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -.videoGrid { +.grid { position: relative; overflow: hidden; flex: 1; diff --git a/src/video-grid/VideoGrid.tsx b/src/grid/LegacyGrid.tsx similarity index 98% rename from src/video-grid/VideoGrid.tsx rename to src/grid/LegacyGrid.tsx index 202539baa..84a86c173 100644 --- a/src/video-grid/VideoGrid.tsx +++ b/src/grid/LegacyGrid.tsx @@ -16,6 +16,7 @@ limitations under the License. import { ComponentProps, + ComponentType, MutableRefObject, ReactNode, Ref, @@ -40,11 +41,11 @@ import useMeasure from "react-use-measure"; import { ResizeObserver as JuggleResizeObserver } from "@juggle/resize-observer"; import { logger } from "matrix-js-sdk/src/logger"; -import styles from "./VideoGrid.module.css"; +import styles from "./LegacyGrid.module.css"; import { Layout } from "../room/LayoutToggle"; import { TileWrapper } from "./TileWrapper"; -import { LayoutStatesMap } from "./Layout"; import { TileDescriptor } from "../state/CallViewModel"; +import { TileProps } from "./Grid"; interface TilePosition { x: number; @@ -79,7 +80,7 @@ export interface TileSpring { type LayoutDirection = "vertical" | "horizontal"; -export function useVideoGridLayout(hasScreenshareFeeds: boolean): { +export function useLegacyGridLayout(hasScreenshareFeeds: boolean): { layout: Layout; setLayout: (layout: Layout) => void; } { @@ -831,20 +832,19 @@ export interface ChildrenProperties { data: T; } -export interface VideoGridProps { +export interface LegacyGridProps { items: TileDescriptor[]; layout: Layout; disableAnimations: boolean; - layoutStates: LayoutStatesMap; - children: (props: ChildrenProperties) => ReactNode; + Tile: ComponentType>; } -export function VideoGrid({ +export function LegacyGrid({ items, layout, disableAnimations, - children, -}: VideoGridProps): ReactNode { + Tile, +}: LegacyGridProps): ReactNode { // Place the PiP in the bottom right corner by default const [pipXRatio, setPipXRatio] = useState(1); const [pipYRatio, setPipYRatio] = useState(1); @@ -1371,7 +1371,7 @@ export function VideoGrid({ ); return ( -
+
{springs.map((spring, i) => { const tile = tiles[i]; const tilePosition = tilePositions[tile.order]; @@ -1380,20 +1380,19 @@ export function VideoGrid({ - {children as (props: ChildrenProperties) => ReactNode} - + /> ); })}
); } -VideoGrid.defaultProps = { +LegacyGrid.defaultProps = { layout: "grid", }; diff --git a/src/grid/TileWrapper.module.css b/src/grid/TileWrapper.module.css new file mode 100644 index 000000000..ed3acda30 --- /dev/null +++ b/src/grid/TileWrapper.module.css @@ -0,0 +1,23 @@ +/* +Copyright 2024 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. +*/ + +.tile.draggable { + cursor: grab; +} + +.tile.draggable:active { + cursor: grabbing; +} diff --git a/src/video-grid/TileWrapper.tsx b/src/grid/TileWrapper.tsx similarity index 59% rename from src/video-grid/TileWrapper.tsx rename to src/grid/TileWrapper.tsx index 5c771e6c9..ded2be28a 100644 --- a/src/video-grid/TileWrapper.tsx +++ b/src/grid/TileWrapper.tsx @@ -14,83 +14,76 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { memo, ReactNode, RefObject, useRef } from "react"; +import { ComponentType, memo, RefObject, useRef } from "react"; import { EventTypes, Handler, useDrag } from "@use-gesture/react"; -import { SpringValue, to } from "@react-spring/web"; +import { SpringValue } from "@react-spring/web"; +import classNames from "classnames"; -import { ChildrenProperties } from "./VideoGrid"; +import { TileProps } from "./Grid"; +import styles from "./TileWrapper.module.css"; -interface Props { +interface Props { id: string; - onDragRef: RefObject< + onDrag: RefObject< ( tileId: string, state: Parameters>[0], ) => void - >; + > | null; targetWidth: number; targetHeight: number; - data: T; + model: M; + Tile: ComponentType>; opacity: SpringValue; scale: SpringValue; - shadow: SpringValue; - shadowSpread: SpringValue; zIndex: SpringValue; x: SpringValue; y: SpringValue; width: SpringValue; height: SpringValue; - children: (props: ChildrenProperties) => ReactNode; } const TileWrapper_ = memo( - ({ + ({ id, - onDragRef, + onDrag, targetWidth, targetHeight, - data, + model, + Tile, opacity, scale, - shadow, - shadowSpread, zIndex, x, y, width, height, - children, - }: Props) => { - const ref = useRef(null); + }: Props) => { + const ref = useRef(null); - useDrag((state) => onDragRef?.current!(id, state), { + useDrag((state) => onDrag?.current!(id, state), { target: ref, filterTaps: true, preventScroll: true, }); return ( - <> - {children({ - ref, - style: { - opacity, - scale, - zIndex, - x, - y, - width, - height, - boxShadow: to( - [shadow, shadowSpread], - (s, ss) => `rgba(0, 0, 0, 0.5) 0px ${s}px ${2 * s}px ${ss}px`, - ), - }, - targetWidth, - targetHeight, - data, - })} - + ); }, ); @@ -104,4 +97,6 @@ TileWrapper_.displayName = "TileWrapper"; // 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 -export const TileWrapper = TileWrapper_ as (props: Props) => JSX.Element; +export const TileWrapper = TileWrapper_ as ( + props: Props, +) => JSX.Element; diff --git a/src/room/InCallView.module.css b/src/room/InCallView.module.css index f413bfc98..8a2994d92 100644 --- a/src/room/InCallView.module.css +++ b/src/room/InCallView.module.css @@ -1,5 +1,5 @@ /* -Copyright 2021 New Vector Ltd +Copyright 2021-2024 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. @@ -19,6 +19,7 @@ limitations under the License. flex-direction: column; height: 100%; width: 100%; + overflow-y: auto; } .controlsOverlay { @@ -46,16 +47,28 @@ limitations under the License. margin-bottom: 0; } +.header { + position: sticky; + inset-block-start: 0; + z-index: 1; + background: linear-gradient( + 0deg, + rgba(0, 0, 0, 0) 0%, + var(--cpd-color-bg-canvas-default) 100% + ); +} + .footer { position: sticky; inset-block-end: 0; + z-index: 1; display: grid; grid-template-columns: 1fr auto 1fr; grid-template-areas: "logo buttons layout"; align-items: center; gap: var(--cpd-space-3x); padding-block: var(--cpd-space-4x); - padding-inline: var(--inline-content-inset); + margin-inline: var(--inline-content-inset); background: linear-gradient( 180deg, rgba(0, 0, 0, 0) 0%, @@ -109,3 +122,23 @@ limitations under the License. .footerHidden { display: none; } + +.fixedGrid { + position: absolute; + inline-size: calc(100% - 2 * var(--inline-content-inset)); + align-self: center; + /* Disable pointer events so the overlay doesn't block interaction with + elements behind it */ + pointer-events: none; +} + +.fixedGrid > :not(:first-child) { + pointer-events: initial; +} + +.scrollingGrid { + position: relative; + flex-grow: 1; + inline-size: calc(100% - 2 * var(--inline-content-inset)); + align-self: center; +} diff --git a/src/room/InCallView.tsx b/src/room/InCallView.tsx index 56cdba49a..2c226858f 100644 --- a/src/room/InCallView.tsx +++ b/src/room/InCallView.tsx @@ -14,7 +14,6 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { ResizeObserver } from "@juggle/resize-observer"; import { RoomAudioRenderer, RoomContext, @@ -26,19 +25,19 @@ import { ConnectionState, Room, Track } from "livekit-client"; import { MatrixClient } from "matrix-js-sdk/src/client"; import { FC, - ReactNode, - Ref, + forwardRef, useCallback, useEffect, useMemo, useRef, useState, } from "react"; -import { useTranslation } from "react-i18next"; import useMeasure from "react-use-measure"; import { MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc/MatrixRTCSession"; import classNames from "classnames"; -import { useStateObservable } from "@react-rxjs/core"; +import { state, useStateObservable } from "@react-rxjs/core"; +import { BehaviorSubject } from "rxjs"; +import { useTranslation } from "react-i18next"; import LogoMark from "../icons/LogoMark.svg?react"; import LogoType from "../icons/LogoType.svg?react"; @@ -51,21 +50,19 @@ import { SettingsButton, } from "../button"; import { Header, LeftNav, RightNav, RoomHeaderInfo } from "../Header"; -import { useVideoGridLayout, VideoGrid } from "../video-grid/VideoGrid"; +import { LegacyGrid, useLegacyGridLayout } from "../grid/LegacyGrid"; import { useUrlParams } from "../UrlParams"; import { useCallViewKeyboardShortcuts } from "../useCallViewKeyboardShortcuts"; import { usePrefersReducedMotion } from "../usePrefersReducedMotion"; import { ElementWidgetActions, widget } from "../widget"; import styles from "./InCallView.module.css"; -import { VideoTile } from "../video-grid/VideoTile"; -import { NewVideoGrid } from "../video-grid/NewVideoGrid"; +import { GridTile } from "../tile/GridTile"; import { OTelGroupCallMembership } from "../otel/OTelGroupCallMembership"; import { SettingsModal, defaultSettingsTab } from "../settings/SettingsModal"; import { useRageshakeRequestModal } from "../settings/submit-rageshake"; import { RageshakeRequestModal } from "./RageshakeRequestModal"; import { useLiveKit } from "../livekit/useLiveKit"; import { useFullscreen } from "./useFullscreen"; -import { useLayoutStates } from "../video-grid/Layout"; import { useWakeLock } from "../useWakeLock"; import { useMergedRefs } from "../useMergedRefs"; import { MuteStates } from "./MuteStates"; @@ -74,13 +71,33 @@ import { InviteButton } from "../button/InviteButton"; import { LayoutToggle } from "./LayoutToggle"; import { ECConnectionState } from "../livekit/useECConnectionState"; import { useOpenIDSFU } from "../livekit/openIDSFU"; -import { useCallViewModel } from "../state/CallViewModel"; +import { + GridMode, + TileDescriptor, + useCallViewModel, +} from "../state/CallViewModel"; import { subscribe } from "../state/subscribe"; +import { Grid, TileProps } from "../grid/Grid"; +import { MediaViewModel } from "../state/MediaViewModel"; +import { gridLayoutSystems } from "../grid/GridLayout"; +import { useObservable } from "../state/useObservable"; +import { useInitial } from "../useInitial"; +import { SpotlightTile } from "../tile/SpotlightTile"; import { EncryptionSystem } from "../e2ee/sharedKeyManagement"; import { E2eeType } from "../e2ee/e2eeType"; const canScreenshare = "getDisplayMedia" in (navigator.mediaDevices ?? {}); -const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent); + +export interface Alignment { + inline: "start" | "end"; + block: "start" | "end"; +} + +const defaultAlignment: Alignment = { inline: "end", block: "end" }; + +const dummySpotlightItem = { + id: "spotlight", +} as TileDescriptor; export interface ActiveCallProps extends Omit { @@ -153,7 +170,7 @@ export const InCallView: FC = subscribe( }, [connState, onLeave]); const containerRef1 = useRef(null); - const [containerRef2, bounds] = useMeasure({ polyfill: ResizeObserver }); + const [containerRef2, bounds] = useMeasure(); const boundsValid = bounds.height > 0; // Merge the refs so they can attach to the same element const containerRef = useMergedRefs(containerRef1, containerRef2); @@ -164,9 +181,8 @@ export const InCallView: FC = subscribe( room: livekitRoom, }, ); - const { layout, setLayout } = useVideoGridLayout( - screenSharingTracks.length > 0, - ); + const { layout: legacyLayout, setLayout: setLegacyLayout } = + useLegacyGridLayout(screenSharingTracks.length > 0); const { hideScreensharing, showControls } = useUrlParams(); @@ -194,23 +210,23 @@ export const InCallView: FC = subscribe( useEffect(() => { widget?.api.transport.send( - layout === "grid" + legacyLayout === "grid" ? ElementWidgetActions.TileLayout : ElementWidgetActions.SpotlightLayout, {}, ); - }, [layout]); + }, [legacyLayout]); useEffect(() => { if (widget) { const onTileLayout = (ev: CustomEvent): void => { - setLayout("grid"); + setLegacyLayout("grid"); widget!.api.transport.reply(ev.detail, {}); }; const onSpotlightLayout = ( ev: CustomEvent, ): void => { - setLayout("spotlight"); + setLegacyLayout("spotlight"); widget!.api.transport.reply(ev.detail, {}); }; @@ -231,7 +247,7 @@ export const InCallView: FC = subscribe( ); }; } - }, [setLayout]); + }, [setLegacyLayout]); const mobile = boundsValid && bounds.width <= 660; const reducedControls = boundsValid && bounds.width <= 340; @@ -244,8 +260,21 @@ export const InCallView: FC = subscribe( connState, ); const items = useStateObservable(vm.tiles); + const layout = useStateObservable(vm.layout); + const hasSpotlight = layout.spotlight !== undefined; + // Hack: We insert a dummy "spotlight" tile into the tiles we pass to + // useFullscreen so that we can control the fullscreen state of the + // spotlight tile in the new layouts with this same hook. + const fullscreenItems = useMemo( + () => [...items, ...(hasSpotlight ? [dummySpotlightItem] : [])], + [items, hasSpotlight], + ); const { fullscreenItem, toggleFullscreen, exitFullscreen } = - useFullscreen(items); + useFullscreen(fullscreenItems); + const toggleSpotlightFullscreen = useCallback( + () => toggleFullscreen("spotlight"), + [toggleFullscreen], + ); // The maximised participant: either the participant that the user has // manually put in fullscreen, or the focused (active) participant if the @@ -259,14 +288,109 @@ export const InCallView: FC = subscribe( [fullscreenItem, noControls, items], ); - const Grid = - items.length > 12 && layout === "grid" ? NewVideoGrid : VideoGrid; - 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 [settingsModalOpen, setSettingsModalOpen] = useState(false); + const [settingsTab, setSettingsTab] = useState(defaultSettingsTab); + + const openSettings = useCallback( + () => setSettingsModalOpen(true), + [setSettingsModalOpen], + ); + const closeSettings = useCallback( + () => setSettingsModalOpen(false), + [setSettingsModalOpen], + ); + + const openProfile = useCallback(() => { + setSettingsTab("profile"); + setSettingsModalOpen(true); + }, [setSettingsTab, setSettingsModalOpen]); + + const [headerRef, headerBounds] = useMeasure(); + const [footerRef, footerBounds] = useMeasure(); + const gridBounds = useMemo( + () => ({ + width: footerBounds.width, + height: bounds.height - headerBounds.height - footerBounds.height, + }), + [ + footerBounds.width, + bounds.height, + headerBounds.height, + footerBounds.height, + ], + ); + const gridBoundsObservable = useObservable(gridBounds); + const floatingAlignment = useInitial( + () => new BehaviorSubject(defaultAlignment), + ); + const { fixed, scrolling } = useInitial(() => + gridLayoutSystems(state(gridBoundsObservable), floatingAlignment), + ); + + const setGridMode = useCallback( + (mode: GridMode) => { + setLegacyLayout(mode); + vm.setGridMode(mode); + }, + [setLegacyLayout, vm], + ); + + const showSpeakingIndicators = + layout.type === "spotlight" || + (layout.type === "grid" && layout.grid.length > 2); + + const SpotlightTileView = useMemo( + () => + forwardRef>( + function SpotlightTileView( + { className, style, targetWidth, targetHeight, model }, + ref, + ) { + return ( + + ); + }, + ), + [toggleSpotlightFullscreen], + ); + const GridTileView = useMemo( + () => + forwardRef>( + function GridTileView( + { className, style, targetWidth, targetHeight, model }, + ref, + ) { + return ( + + ); + }, + ), + [toggleFullscreen, openProfile, showSpeakingIndicators], + ); const renderContent = (): JSX.Element => { if (items.length === 0) { @@ -276,15 +400,29 @@ export const InCallView: FC = subscribe(
); } - if (maximisedParticipant) { + + if (maximisedParticipant !== null) { + const fullscreen = maximisedParticipant === fullscreenItem; + if (maximisedParticipant.id === "spotlight") { + return ( + + ); + } return ( - = subscribe( ); } - return ( - - {({ data: vm, ...props }): ReactNode => ( - 2} - onOpenProfile={openProfile} - {...props} - ref={props.ref as Ref} + // The only new layout we've implemented so far is grid layout for non-1:1 + // calls. All other layouts use the legacy grid system for now. + if ( + legacyLayout === "grid" && + layout.type === "grid" && + !(layout.grid.length === 2 && layout.spotlight === undefined) + ) { + return ( + <> + - )} - - ); + + + ); + } else { + return ( + + ); + } }; const rageshakeRequestModalProps = useRageshakeRequestModal( rtcSession.room.roomId, ); - const [settingsModalOpen, setSettingsModalOpen] = useState(false); - const [settingsTab, setSettingsTab] = useState(defaultSettingsTab); - - const openSettings = useCallback( - () => setSettingsModalOpen(true), - [setSettingsModalOpen], - ); - const closeSettings = useCallback( - () => setSettingsModalOpen(false), - [setSettingsModalOpen], - ); - - const openProfile = useCallback(() => { - setSettingsTab("profile"); - setSettingsModalOpen(true); - }, [setSettingsTab, setSettingsModalOpen]); - const toggleScreensharing = useCallback(async () => { exitFullscreen(); await localParticipant.setScreenShareEnabled(!isScreenShareEnabled, { @@ -395,6 +529,7 @@ export const InCallView: FC = subscribe( ); footer = (
= subscribe( {!mobile && !hideHeader && showControls && ( )}
@@ -428,7 +563,7 @@ export const InCallView: FC = subscribe( return (
{!hideHeader && maximisedParticipant === null && ( -
+
= subscribe(
)} -
- - {renderContent()} - {footer} -
+ + {renderContent()} + {footer} {!noControls && ( )} diff --git a/src/room/VideoPreview.tsx b/src/room/VideoPreview.tsx index 49342c428..252e9b4ad 100644 --- a/src/room/VideoPreview.tsx +++ b/src/room/VideoPreview.tsx @@ -18,11 +18,7 @@ import { useEffect, useMemo, useRef, FC, ReactNode, useCallback } from "react"; import useMeasure from "react-use-measure"; import { ResizeObserver } from "@juggle/resize-observer"; import { usePreviewTracks } from "@livekit/components-react"; -import { - CreateLocalTracksOptions, - LocalVideoTrack, - Track, -} from "livekit-client"; +import { LocalVideoTrack, Track } from "livekit-client"; import classNames from "classnames"; import { logger } from "matrix-js-sdk/src/logger"; import { Glass } from "@vector-im/compound-web"; @@ -32,6 +28,7 @@ import styles from "./VideoPreview.module.css"; import { useMediaDevices } from "../livekit/MediaDevicesContext"; import { MuteStates } from "./MuteStates"; import { useMediaQuery } from "../useMediaQuery"; +import { useInitial } from "../useInitial"; import { EncryptionSystem } from "../e2ee/sharedKeyManagement"; export type MatrixInfo = { @@ -63,10 +60,10 @@ export const VideoPreview: FC = ({ // Capture the audio options as they were when we first mounted, because // we're not doing anything with the audio anyway so we don't need to // re-open the devices when they change (see below). - const initialAudioOptions = useRef(); - initialAudioOptions.current ??= muteStates.audio.enabled && { - deviceId: devices.audioInput.selectedId, - }; + const initialAudioOptions = useInitial( + () => + muteStates.audio.enabled && { deviceId: devices.audioInput.selectedId }, + ); const localTrackOptions = useMemo( () => ({ @@ -76,12 +73,16 @@ export const VideoPreview: FC = ({ // reference the initial values here. // We also pass in a clone because livekit mutates the object passed in, // which would cause the devices to be re-opened on the next render. - audio: Object.assign({}, initialAudioOptions.current), + audio: Object.assign({}, initialAudioOptions), video: muteStates.video.enabled && { deviceId: devices.videoInput.selectedId, }, }), - [devices.videoInput.selectedId, muteStates.video.enabled], + [ + initialAudioOptions, + devices.videoInput.selectedId, + muteStates.video.enabled, + ], ); const onError = useCallback( diff --git a/src/state/CallViewModel.ts b/src/state/CallViewModel.ts index 4ad2f0242..e6d55b72c 100644 --- a/src/state/CallViewModel.ts +++ b/src/state/CallViewModel.ts @@ -154,11 +154,11 @@ class UserMedia { this.vm = new UserMediaViewModel(id, member, participant, callEncrypted); this.speaker = this.vm.speaking.pipeState( - // Require 1 s of continuous speaking to become a speaker, and 10 s of + // Require 1 s of continuous speaking to become a speaker, and 60 s of // continuous silence to stop being considered a speaker audit((s) => merge( - timer(s ? 1000 : 10000), + timer(s ? 1000 : 60000), // If the speaking flag resets to its original value during this time, // end the silencing window to stick with that original value this.vm.speaking.pipe(filter((s1) => s1 !== s)), diff --git a/src/state/MediaViewModel.ts b/src/state/MediaViewModel.ts index db11017e1..f1e772da4 100644 --- a/src/state/MediaViewModel.ts +++ b/src/state/MediaViewModel.ts @@ -32,7 +32,7 @@ import { TrackEvent, facingModeFromLocalTrack, } from "livekit-client"; -import { RoomMember } from "matrix-js-sdk/src/matrix"; +import { RoomMember, RoomMemberEvent } from "matrix-js-sdk/src/matrix"; import { BehaviorSubject, combineLatest, @@ -44,8 +44,49 @@ import { startWith, switchMap, } from "rxjs"; +import { useTranslation } from "react-i18next"; +import { useEffect } from "react"; import { ViewModel } from "./ViewModel"; +import { useReactiveState } from "../useReactiveState"; + +export interface NameData { + /** + * The display name of the participant. + */ + displayName: string; + /** + * The text to be shown on the participant's name tag. + */ + nameTag: string; +} + +// TODO: Move this naming logic into the view model +export function useNameData(vm: MediaViewModel): NameData { + const { t } = useTranslation(); + + const [displayName, setDisplayName] = useReactiveState( + () => vm.member?.rawDisplayName ?? "[👻]", + [vm.member], + ); + useEffect(() => { + if (vm.member) { + const updateName = (): void => { + setDisplayName(vm.member!.rawDisplayName); + }; + + vm.member!.on(RoomMemberEvent.Name, updateName); + return (): void => { + vm.member!.removeListener(RoomMemberEvent.Name, updateName); + }; + } + }, [vm.member, setDisplayName]); + const nameTag = vm.local + ? t("video_tile.sfu_participant_local") + : displayName; + + return { displayName, nameTag }; +} function observeTrackReference( participant: Participant, @@ -78,8 +119,9 @@ abstract class BaseMediaViewModel extends ViewModel { public readonly unencryptedWarning: StateObservable; public constructor( - // TODO: This is only needed for full screen toggling and can be removed as - // soon as that code is moved into the view models + /** + * An opaque identifier for this media. + */ public readonly id: string, /** * The Matrix room member to which this media belongs. diff --git a/src/state/useObservable.ts b/src/state/useObservable.ts index 92210e345..037c3bd59 100644 --- a/src/state/useObservable.ts +++ b/src/state/useObservable.ts @@ -14,9 +14,11 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { useRef } from "react"; +import { Ref, useCallback, useRef } from "react"; import { BehaviorSubject, Observable } from "rxjs"; +import { useInitial } from "../useInitial"; + /** * React hook that creates an Observable from a changing value. The Observable * replays its current value upon subscription and emits whenever the value @@ -28,3 +30,14 @@ export function useObservable(value: T): Observable { if (value !== subject.current.value) subject.current.next(value); return subject.current; } + +/** + * React hook that creates a ref and an Observable that emits any values + * stored in the ref. The Observable replays the value currently stored in the + * ref upon subscription. + */ +export function useObservableRef(initialValue: T): [Observable, Ref] { + const subject = useInitial(() => new BehaviorSubject(initialValue)); + const ref = useCallback((value: T) => subject.next(value), [subject]); + return [subject, ref]; +} diff --git a/src/tile/GridTile.module.css b/src/tile/GridTile.module.css new file mode 100644 index 000000000..923c76338 --- /dev/null +++ b/src/tile/GridTile.module.css @@ -0,0 +1,81 @@ +/* +Copyright 2022-2024 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. +*/ + +.tile { + position: absolute; + top: 0; + --media-view-border-radius: var(--cpd-space-4x); + transition: outline-color ease 0.15s; + outline: var(--cpd-border-width-2) solid rgb(0 0 0 / 0); +} + +/* Use a pseudo-element to create the expressive speaking border, since CSS +borders don't support gradients */ +.tile[data-maximised="false"]::before { + content: ""; + position: absolute; + z-index: -1; /* Put it below the outline */ + opacity: 0; /* Hidden unless speaking */ + transition: opacity ease 0.15s; + inset: calc(-1 * var(--cpd-border-width-4)); + border-radius: var(--cpd-space-5x); + background: linear-gradient( + 119deg, + rgba(13, 92, 189, 0.7) 0%, + rgba(13, 189, 168, 0.7) 100% + ), + linear-gradient( + 180deg, + rgba(13, 92, 189, 0.9) 0%, + rgba(13, 189, 168, 0.9) 100% + ); + background-blend-mode: overlay, normal; +} + +.tile[data-maximised="false"].speaking { + /* !important because speaking border should take priority over hover */ + outline: var(--cpd-border-width-1) solid var(--cpd-color-bg-canvas-default) !important; +} + +.tile[data-maximised="false"].speaking::before { + opacity: 1; +} + +@media (hover: hover) { + .tile[data-maximised="false"]:hover { + outline: var(--cpd-border-width-2) solid + var(--cpd-color-border-interactive-hovered); + } +} + +.tile[data-maximised="true"] { + position: relative; + flex-grow: 1; + --media-view-border-radius: 0; + --media-view-fg-inset: 10px; +} + +.muteIcon[data-muted="true"] { + color: var(--cpd-color-icon-secondary); +} + +.muteIcon[data-muted="false"] { + color: var(--cpd-color-icon-primary); +} + +.volumeSlider { + width: 100%; +} diff --git a/src/video-grid/VideoTile.tsx b/src/tile/GridTile.tsx similarity index 66% rename from src/video-grid/VideoTile.tsx rename to src/tile/GridTile.tsx index d4a7442ec..d88b189fe 100644 --- a/src/video-grid/VideoTile.tsx +++ b/src/tile/GridTile.tsx @@ -1,5 +1,5 @@ /* -Copyright 2022-2023 New Vector Ltd +Copyright 2022-2024 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. @@ -14,29 +14,12 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { - ComponentProps, - ForwardedRef, - ReactNode, - forwardRef, - useCallback, - useEffect, - useState, -} from "react"; +import { ComponentProps, forwardRef, useCallback, useState } from "react"; import { animated } from "@react-spring/web"; import classNames from "classnames"; import { useTranslation } from "react-i18next"; -import { - TrackReferenceOrPlaceholder, - VideoTrack, -} from "@livekit/components-react"; -import { - RoomMember, - RoomMemberEvent, -} from "matrix-js-sdk/src/models/room-member"; import MicOnSolidIcon from "@vector-im/compound-design-tokens/icons/mic-on-solid.svg?react"; import MicOffSolidIcon from "@vector-im/compound-design-tokens/icons/mic-off-solid.svg?react"; -import ErrorIcon from "@vector-im/compound-design-tokens/icons/error.svg?react"; import MicOffIcon from "@vector-im/compound-design-tokens/icons/mic-off.svg?react"; import OverflowHorizontalIcon from "@vector-im/compound-design-tokens/icons/overflow-horizontal.svg?react"; import VolumeOnIcon from "@vector-im/compound-design-tokens/icons/volume-on.svg?react"; @@ -45,8 +28,6 @@ import UserProfileIcon from "@vector-im/compound-design-tokens/icons/user-profil import ExpandIcon from "@vector-im/compound-design-tokens/icons/expand.svg?react"; import CollapseIcon from "@vector-im/compound-design-tokens/icons/collapse.svg?react"; import { - Text, - Tooltip, ContextMenu, MenuItem, ToggleMenuItem, @@ -54,120 +35,16 @@ import { } from "@vector-im/compound-web"; import { useStateObservable } from "@react-rxjs/core"; -import { Avatar } from "../Avatar"; -import styles from "./VideoTile.module.css"; -import { useReactiveState } from "../useReactiveState"; +import styles from "./GridTile.module.css"; import { ScreenShareViewModel, MediaViewModel, UserMediaViewModel, + useNameData, } from "../state/MediaViewModel"; import { subscribe } from "../state/subscribe"; -import { useMergedRefs } from "../useMergedRefs"; import { Slider } from "../Slider"; - -interface TileProps { - tileRef?: ForwardedRef; - className?: string; - style?: ComponentProps["style"]; - targetWidth: number; - targetHeight: number; - video: TrackReferenceOrPlaceholder; - member: RoomMember | undefined; - videoEnabled: boolean; - maximised: boolean; - unencryptedWarning: boolean; - nameTagLeadingIcon?: ReactNode; - nameTag: string; - displayName: string; - primaryButton: ReactNode; - secondaryButton?: ReactNode; - [k: string]: unknown; -} - -const Tile = forwardRef( - ( - { - tileRef = null, - className, - style, - targetWidth, - targetHeight, - video, - member, - videoEnabled, - maximised, - unencryptedWarning, - nameTagLeadingIcon, - nameTag, - displayName, - primaryButton, - secondaryButton, - ...props - }, - ref, - ) => { - const { t } = useTranslation(); - const mergedRef = useMergedRefs(tileRef, ref); - - return ( - -
- - {video.publication !== undefined && ( - - )} -
-
-
- {nameTagLeadingIcon} - - {nameTag} - - {unencryptedWarning && ( - - - - )} -
- {primaryButton} - {secondaryButton} -
-
- ); - }, -); - -Tile.displayName = "Tile"; +import { MediaView } from "./MediaView"; interface UserMediaTileProps { vm: UserMediaViewModel; @@ -175,8 +52,6 @@ interface UserMediaTileProps { style?: ComponentProps["style"]; targetWidth: number; targetHeight: number; - nameTag: string; - displayName: string; maximised: boolean; onOpenProfile: () => void; showSpeakingIndicator: boolean; @@ -190,8 +65,6 @@ const UserMediaTile = subscribe( style, targetWidth, targetHeight, - nameTag, - displayName, maximised, onOpenProfile, showSpeakingIndicator, @@ -199,6 +72,7 @@ const UserMediaTile = subscribe( ref, ) => { const { t } = useTranslation(); + const { displayName, nameTag } = useNameData(vm); const video = useStateObservable(vm.video); const audioEnabled = useStateObservable(vm.audioEnabled); const videoEnabled = useStateObservable(vm.videoEnabled); @@ -273,20 +147,20 @@ const UserMediaTile = subscribe( ); const tile = ( - ["style"]; targetWidth: number; targetHeight: number; - nameTag: string; - displayName: string; maximised: boolean; fullscreen: boolean; onToggleFullscreen: (itemId: string) => void; @@ -349,8 +221,6 @@ const ScreenShareTile = subscribe( style, targetWidth, targetHeight, - nameTag, - displayName, maximised, fullscreen, onToggleFullscreen, @@ -358,6 +228,7 @@ const ScreenShareTile = subscribe( ref, ) => { const { t } = useTranslation(); + const { displayName, nameTag } = useNameData(vm); const video = useStateObservable(vm.video); const unencryptedWarning = useStateObservable(vm.unencryptedWarning); const onClickFullScreen = useCallback( @@ -368,16 +239,20 @@ const ScreenShareTile = subscribe( const FullScreenIcon = fullscreen ? CollapseIcon : ExpandIcon; return ( - ( +export const GridTile = forwardRef( ( { vm, @@ -431,30 +306,6 @@ export const VideoTile = forwardRef( }, ref, ) => { - const { t } = useTranslation(); - - // Handle display name changes. - // TODO: Move this into the view model - const [displayName, setDisplayName] = useReactiveState( - () => vm.member?.rawDisplayName ?? "[👻]", - [vm.member], - ); - useEffect(() => { - if (vm.member) { - const updateName = (): void => { - setDisplayName(vm.member!.rawDisplayName); - }; - - vm.member!.on(RoomMemberEvent.Name, updateName); - return (): void => { - vm.member!.removeListener(RoomMemberEvent.Name, updateName); - }; - } - }, [vm.member, setDisplayName]); - const nameTag = vm.local - ? t("video_tile.sfu_participant_local") - : displayName; - if (vm instanceof UserMediaViewModel) { return ( ( vm={vm} targetWidth={targetWidth} targetHeight={targetHeight} - nameTag={nameTag} - displayName={displayName} maximised={maximised} onOpenProfile={onOpenProfile} showSpeakingIndicator={showSpeakingIndicator} @@ -480,8 +329,6 @@ export const VideoTile = forwardRef( vm={vm} targetWidth={targetWidth} targetHeight={targetHeight} - nameTag={nameTag} - displayName={displayName} maximised={maximised} fullscreen={fullscreen} onToggleFullscreen={onToggleFullscreen} @@ -491,4 +338,4 @@ export const VideoTile = forwardRef( }, ); -VideoTile.displayName = "VideoTile"; +GridTile.displayName = "GridTile"; diff --git a/src/video-grid/VideoTile.module.css b/src/tile/MediaView.module.css similarity index 66% rename from src/video-grid/VideoTile.module.css rename to src/tile/MediaView.module.css index b4da6e5e6..65cf9fc77 100644 --- a/src/video-grid/VideoTile.module.css +++ b/src/tile/MediaView.module.css @@ -1,5 +1,5 @@ /* -Copyright 2022-2023 New Vector Ltd +Copyright 2022-2024 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. @@ -14,63 +14,13 @@ See the License for the specific language governing permissions and limitations under the License. */ -.videoTile { - position: absolute; - top: 0; - container-name: videoTile; +.media { + container-name: mediaView; container-type: size; - border-radius: var(--cpd-space-4x); - transition: outline-color ease 0.15s; - outline: var(--cpd-border-width-2) solid rgb(0 0 0 / 0); -} - -/* Use a pseudo-element to create the expressive speaking border, since CSS -borders don't support gradients */ -.videoTile::before { - content: ""; - position: absolute; - z-index: -1; /* Put it below the outline */ - opacity: 0; /* Hidden unless speaking */ - transition: opacity ease 0.15s; - inset: calc(-1 * var(--cpd-border-width-4)); - border-radius: var(--cpd-space-5x); - background: linear-gradient( - 119deg, - rgba(13, 92, 189, 0.7) 0%, - rgba(13, 189, 168, 0.7) 100% - ), - linear-gradient( - 180deg, - rgba(13, 92, 189, 0.9) 0%, - rgba(13, 189, 168, 0.9) 100% - ); - background-blend-mode: overlay, normal; -} - -.videoTile.speaking { - /* !important because speaking border should take priority over hover */ - outline: var(--cpd-border-width-1) solid var(--cpd-color-bg-canvas-default) !important; -} - -.videoTile.speaking::before { - opacity: 1; + border-radius: var(--media-view-border-radius); } -@media (hover: hover) { - .videoTile:hover { - outline: var(--cpd-border-width-2) solid - var(--cpd-color-border-interactive-hovered); - } -} - -.videoTile.maximised { - position: relative; - border-radius: 0; - inline-size: 100%; - block-size: 100%; -} - -.videoTile video { +.media video { inline-size: 100%; block-size: 100%; object-fit: contain; @@ -81,19 +31,19 @@ borders don't support gradients */ transform: translate(0); } -.videoTile.mirror video { +.media.mirror video { transform: scaleX(-1); } -.videoTile.screenshare video { - object-fit: contain; +.media[data-video-fit="cover"] video { + object-fit: cover; } -.videoTile.cropVideo video { - object-fit: cover; +.media[data-video-fit="contain"] video { + object-fit: contain; } -.videoTile.videoMuted video { +.media.videoMuted video { display: none; } @@ -114,13 +64,13 @@ borders don't support gradients */ pointer-events: none; } -.videoTile.videoMuted .avatar { +.media.videoMuted .avatar { display: initial; } /* CSS makes us put a condition here, even though all we want to do is unconditionally select the container so we can use cqmin units */ -@container videoTile (width > 0) { +@container mediaView (width > 0) { .avatar { /* Half of the smallest dimension of the tile */ inline-size: 50cqmin; @@ -137,7 +87,10 @@ unconditionally select the container so we can use cqmin units */ .fg { position: absolute; - inset: var(--cpd-space-1x); + inset: var( + --media-view-fg-inset, + calc(var(--media-view-border-radius) - var(--cpd-space-3x)) + ); display: grid; grid-template-columns: 1fr auto; grid-template-rows: 1fr auto; @@ -167,14 +120,6 @@ unconditionally select the container so we can use cqmin units */ flex-shrink: 0; } -.muteIcon[data-muted="true"] { - color: var(--cpd-color-icon-secondary); -} - -.muteIcon[data-muted="false"] { - color: var(--cpd-color-icon-primary); -} - .nameTag > .name { text-overflow: ellipsis; overflow: hidden; @@ -200,8 +145,7 @@ unconditionally select the container so we can use cqmin units */ transition: opacity ease 0.15s; } -.fg > button:focus-visible, -.fg > :focus-visible ~ button, +.fg:has(:focus-visible) > button, .fg > button[data-enabled="true"], .fg > button[data-state="open"] { opacity: 1; @@ -237,7 +181,3 @@ unconditionally select the container so we can use cqmin units */ .fg > button:nth-of-type(2) { grid-area: button2; } - -.volumeSlider { - width: 100%; -} diff --git a/src/tile/MediaView.tsx b/src/tile/MediaView.tsx new file mode 100644 index 000000000..69c3591ea --- /dev/null +++ b/src/tile/MediaView.tsx @@ -0,0 +1,130 @@ +/* +Copyright 2024 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 { TrackReferenceOrPlaceholder } from "@livekit/components-core"; +import { animated } from "@react-spring/web"; +import { RoomMember } from "matrix-js-sdk/src/matrix"; +import { ComponentProps, ReactNode, forwardRef } from "react"; +import { useTranslation } from "react-i18next"; +import classNames from "classnames"; +import { VideoTrack } from "@livekit/components-react"; +import { Text, Tooltip } from "@vector-im/compound-web"; +import ErrorIcon from "@vector-im/compound-design-tokens/icons/error.svg?react"; + +import styles from "./MediaView.module.css"; +import { Avatar } from "../Avatar"; + +interface Props extends ComponentProps { + className?: string; + style?: ComponentProps["style"]; + targetWidth: number; + targetHeight: number; + video: TrackReferenceOrPlaceholder; + videoFit: "cover" | "contain"; + mirror: boolean; + member: RoomMember | undefined; + videoEnabled: boolean; + unencryptedWarning: boolean; + nameTagLeadingIcon?: ReactNode; + nameTag: string; + displayName: string; + primaryButton?: ReactNode; + secondaryButton?: ReactNode; +} + +export const MediaView = forwardRef( + ( + { + className, + style, + targetWidth, + targetHeight, + video, + videoFit, + mirror, + member, + videoEnabled, + unencryptedWarning, + nameTagLeadingIcon, + nameTag, + displayName, + primaryButton, + secondaryButton, + ...props + }, + ref, + ) => { + const { t } = useTranslation(); + + return ( + +
+ + {video.publication !== undefined && ( + + )} +
+
+
+ {nameTagLeadingIcon} + + {nameTag} + + {unencryptedWarning && ( + + + + )} +
+ {primaryButton} + {secondaryButton} +
+
+ ); + }, +); + +MediaView.displayName = "MediaView"; diff --git a/src/tile/SpotlightTile.module.css b/src/tile/SpotlightTile.module.css new file mode 100644 index 000000000..9d772c1d7 --- /dev/null +++ b/src/tile/SpotlightTile.module.css @@ -0,0 +1,153 @@ +/* +Copyright 2024 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. +*/ + +.tile { + position: absolute; + top: 0; + --border-width: var(--cpd-space-3x); +} + +.tile.maximised { + position: relative; + flex-grow: 1; + --border-width: 0px; +} + +.border { + box-sizing: border-box; + block-size: 100%; + inline-size: 100%; +} + +.tile.maximised .border { + display: contents; +} + +.contents { + display: flex; + border-radius: var(--cpd-space-6x); + contain: strict; + overflow: auto; + scrollbar-width: none; + scroll-snap-type: inline mandatory; + scroll-snap-stop: always; + /* It would be nice to use smooth scrolling here, but Firefox has a bug where + it will not re-snap if the snapping point changes while it's smoothly + animating to another snapping point. + scroll-behavior: smooth; */ +} + +.tile.maximised .contents { + border-radius: 0; +} + +.item { + height: 100%; + flex-basis: 100%; + flex-shrink: 0; + --media-view-fg-inset: 10px; +} + +.item.snap { + scroll-snap-align: start; +} + +.advance { + appearance: none; + cursor: pointer; + opacity: 0; + padding: calc(var(--cpd-space-3x) - var(--cpd-border-width-1)); + border: var(--cpd-border-width-1) solid + var(--cpd-color-border-interactive-secondary); + border-radius: var(--cpd-radius-pill-effect); + background: var(--cpd-color-alpha-gray-1400); + box-shadow: var(--small-drop-shadow); + transition-duration: 0.1s; + transition-property: opacity, background-color, border-color; + position: absolute; + z-index: 1; + /* Center the button vertically on the tile */ + top: 50%; + transform: translateY(-50%); +} + +.advance > svg { + display: block; + color: var(--cpd-color-icon-on-solid-primary); +} + +@media (hover) { + .advance:hover { + border-color: var(--cpd-color-bg-action-primary-hovered); + background: var(--cpd-color-bg-action-primary-hovered); + } +} + +.advance:active { + border-color: var(--cpd-color-bg-action-primary-pressed); + background: var(--cpd-color-bg-action-primary-pressed); +} + +.back { + inset-inline-start: var(--cpd-space-1x); +} + +.next { + inset-inline-end: var(--cpd-space-1x); +} + +.fullScreen { + appearance: none; + cursor: pointer; + opacity: 0; + padding: var(--cpd-space-2x); + border: none; + border-radius: var(--cpd-radius-pill-effect); + background: var(--cpd-color-alpha-gray-1400); + box-shadow: var(--small-drop-shadow); + transition-duration: 0.1s; + transition-property: opacity, background-color; + position: absolute; + z-index: 1; + --inset: calc(var(--border-width) + 6px); + inset-block-end: var(--inset); + inset-inline-end: var(--inset); +} + +.fullScreen > svg { + display: block; + color: var(--cpd-color-icon-on-solid-primary); +} + +@media (hover) { + .fullScreen:hover { + background: var(--cpd-color-bg-action-primary-hovered); + } +} + +.fullScreen:active { + background: var(--cpd-color-bg-action-primary-pressed); +} + +@media (hover) { + .tile:hover > button { + opacity: 1; + } +} + +.tile:has(:focus-visible) > button { + opacity: 1; +} diff --git a/src/tile/SpotlightTile.tsx b/src/tile/SpotlightTile.tsx new file mode 100644 index 000000000..6abf0cdd8 --- /dev/null +++ b/src/tile/SpotlightTile.tsx @@ -0,0 +1,263 @@ +/* +Copyright 2024 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 { + ComponentProps, + forwardRef, + useCallback, + useEffect, + useRef, + useState, +} from "react"; +import { Glass } from "@vector-im/compound-web"; +import ExpandIcon from "@vector-im/compound-design-tokens/icons/expand.svg?react"; +import CollapseIcon from "@vector-im/compound-design-tokens/icons/collapse.svg?react"; +import ChevronLeftIcon from "@vector-im/compound-design-tokens/icons/chevron-left.svg?react"; +import ChevronRightIcon from "@vector-im/compound-design-tokens/icons/chevron-right.svg?react"; +import { animated } from "@react-spring/web"; +import { state, useStateObservable } from "@react-rxjs/core"; +import { Observable, map, of } from "rxjs"; +import { useTranslation } from "react-i18next"; +import classNames from "classnames"; + +import { MediaView } from "./MediaView"; +import styles from "./SpotlightTile.module.css"; +import { subscribe } from "../state/subscribe"; +import { + MediaViewModel, + UserMediaViewModel, + useNameData, +} from "../state/MediaViewModel"; +import { useInitial } from "../useInitial"; +import { useMergedRefs } from "../useMergedRefs"; +import { useObservableRef } from "../state/useObservable"; +import { useReactiveState } from "../useReactiveState"; +import { useLatest } from "../useLatest"; + +// Screen share video is always enabled +const screenShareVideoEnabled = state(of(true)); +// Never mirror screen share video +const screenShareMirror = state(of(false)); +// Never crop screen share video +const screenShareCropVideo = state(of(false)); + +interface SpotlightItemProps { + vm: MediaViewModel; + targetWidth: number; + targetHeight: number; + intersectionObserver: Observable; + /** + * Whether this item should act as a scroll snapping point. + */ + snap: boolean; +} + +const SpotlightItem = subscribe( + ({ vm, targetWidth, targetHeight, intersectionObserver, snap }, theirRef) => { + const ourRef = useRef(null); + const ref = useMergedRefs(ourRef, theirRef); + const { displayName, nameTag } = useNameData(vm); + const video = useStateObservable(vm.video); + const videoEnabled = useStateObservable( + vm instanceof UserMediaViewModel + ? vm.videoEnabled + : screenShareVideoEnabled, + ); + const mirror = useStateObservable( + vm instanceof UserMediaViewModel ? vm.mirror : screenShareMirror, + ); + const cropVideo = useStateObservable( + vm instanceof UserMediaViewModel ? vm.cropVideo : screenShareCropVideo, + ); + const unencryptedWarning = useStateObservable(vm.unencryptedWarning); + + // 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 ( + + ); + }, +); + +interface Props { + vms: MediaViewModel[]; + maximised: boolean; + fullscreen: boolean; + onToggleFullscreen: () => void; + targetWidth: number; + targetHeight: number; + className?: string; + style?: ComponentProps["style"]; +} + +export const SpotlightTile = forwardRef( + ( + { + vms, + maximised, + fullscreen, + onToggleFullscreen, + targetWidth, + targetHeight, + className, + style, + }, + theirRef, + ) => { + const { t } = useTranslation(); + const [root, ourRef] = useObservableRef(null); + const ref = useMergedRefs(ourRef, theirRef); + const [visibleId, setVisibleId] = useState(vms[0].id); + const latestVms = useLatest(vms); + const latestVisibleId = useLatest(visibleId); + const canGoBack = visibleId !== vms[0].id; + const canGoToNext = visibleId !== vms[vms.length - 1].id; + + // 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>( + () => + 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( + (prev) => + prev == null || prev === visibleId || vms.every((vm) => vm.id !== prev) + ? null + : prev, + [visibleId], + ); + + const onBackClick = useCallback(() => { + const vms = latestVms.current; + const visibleIndex = vms.findIndex( + (vm) => vm.id === latestVisibleId.current, + ); + if (visibleIndex > 0) setScrollToId(vms[visibleIndex - 1].id); + }, [latestVisibleId, latestVms, setScrollToId]); + + const onNextClick = useCallback(() => { + const vms = latestVms.current; + const visibleIndex = vms.findIndex( + (vm) => vm.id === latestVisibleId.current, + ); + if (visibleIndex !== -1 && visibleIndex !== vms.length - 1) + setScrollToId(vms[visibleIndex + 1].id); + }, [latestVisibleId, latestVms, setScrollToId]); + + const FullScreenIcon = fullscreen ? CollapseIcon : ExpandIcon; + + // We need a wrapper element because Glass doesn't provide an animated.div + return ( + + {canGoBack && ( + + )} + + {/* Similarly we need a wrapper element here because Glass expects a + single child */} +
+ {vms.map((vm) => ( + + ))} +
+
+ + {canGoToNext && ( + + )} +
+ ); + }, +); + +SpotlightTile.displayName = "SpotlightTile"; diff --git a/src/useInitial.ts b/src/useInitial.ts new file mode 100644 index 000000000..3b794dd3d --- /dev/null +++ b/src/useInitial.ts @@ -0,0 +1,26 @@ +/* +Copyright 2024 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 { useRef } from "react"; + +/** + * React hook that returns the value given on the initial render. + */ +export function useInitial(getValue: () => T): T { + const ref = useRef<{ value: T }>(); + ref.current ??= { value: getValue() }; + return ref.current.value; +} diff --git a/src/useLatest.ts b/src/useLatest.ts new file mode 100644 index 000000000..a0e1ecc7a --- /dev/null +++ b/src/useLatest.ts @@ -0,0 +1,31 @@ +/* +Copyright 2024 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 { RefObject, useRef } from "react"; + +export interface LatestRef extends RefObject { + current: T; +} + +/** + * React hook that returns a ref containing the value given on the latest + * render. + */ +export function useLatest(value: T): LatestRef { + const ref = useRef(value); + ref.current = value; + return ref; +} diff --git a/src/useReactiveState.ts b/src/useReactiveState.ts index f5daa1fe0..af18e84b2 100644 --- a/src/useReactiveState.ts +++ b/src/useReactiveState.ts @@ -44,7 +44,7 @@ export const useReactiveState = ( if ( prevDeps.current === undefined || deps.length !== prevDeps.current.length || - deps.some((d, i) => d !== prevDeps.current![i]) + deps.some((d, i) => !Object.is(d, prevDeps.current![i])) ) { state.current = updateFn(state.current); } diff --git a/src/video-grid/BigGrid.tsx b/src/video-grid/BigGrid.tsx deleted file mode 100644 index bde7eda6d..000000000 --- a/src/video-grid/BigGrid.tsx +++ /dev/null @@ -1,1070 +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 TinyQueue from "tinyqueue"; -import { RectReadOnly } from "react-use-measure"; -import { FC, memo, ReactNode } from "react"; -import { zip } from "lodash"; - -import { Slot } from "./NewVideoGrid"; -import { Layout } from "./Layout"; -import { count, findLastIndex } from "../array-utils"; -import styles from "./BigGrid.module.css"; -import { TileDescriptor } from "../state/CallViewModel"; - -/** - * A 1×1 cell in a grid which belongs to a tile. - */ -interface Cell { - /** - * The item displayed on the tile. - */ - readonly item: TileDescriptor; - /** - * Whether this cell is the origin (top left corner) of the tile. - */ - readonly origin: boolean; - /** - * The width, in columns, of the tile. - */ - readonly columns: number; - /** - * The height, in rows, of the tile. - */ - readonly rows: number; -} - -export interface Grid { - columns: number; - /** - * The cells of the grid, in left-to-right top-to-bottom order. - */ - cells: Cell[]; -} - -export interface SparseGrid { - columns: number; - /** - * The cells of the grid, in left-to-right top-to-bottom order. - * undefined = a gap in the grid. - */ - cells: (Cell | undefined)[]; -} - -/** - * Gets the paths that tiles should travel along in the grid to reach a - * particular destination. - * @param g The grid. - * @param dest The destination index. - * @param avoid A predicate defining the cells that paths should avoid going - * through. - * @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 or otherwise - * immovable. - */ -export function getPaths( - g: SparseGrid, - dest: number, - avoid: (cell: number) => boolean = (): boolean => false, -): (number | null)[] { - const destRow = row(dest, g); - const destColumn = column(dest, g); - - // This is Dijkstra's algorithm - - const distances = new Array(dest + 1).fill(Infinity); - distances[dest] = 0; - const edges = new Array(dest).fill(null); - edges[dest] = null; - const heap = new TinyQueue([dest], (i) => distances[i]); - - const visit = (curr: number, via: number, distanceVia: number): void => { - if (distanceVia < distances[curr]) { - distances[curr] = distanceVia; - edges[curr] = via; - heap.push(curr); - } - }; - - while (heap.length > 0) { - const via = heap.pop()!; - - if (!avoid(via)) { - const viaRow = row(via, g); - const viaColumn = column(via, g); - const viaCell = g.cells[via]; - const viaLargeTile = viaCell !== undefined && !is1By1(viaCell); - // Since it looks nicer to have paths go around large tiles, we impose an - // increased cost for moving through them - const distanceVia = distances[via] + (viaLargeTile ? 8 : 1); - - // Visit each neighbor - if (viaRow > 0) visit(via - g.columns, via, distanceVia); - if (viaColumn > 0) visit(via - 1, via, distanceVia); - if (viaColumn < (viaRow === destRow ? destColumn : g.columns - 1)) - visit(via + 1, via, distanceVia); - if ( - viaRow < destRow - 1 || - (viaRow === destRow - 1 && viaColumn <= destColumn) - ) - visit(via + g.columns, via, distanceVia); - } - } - - // The heap is empty, so we've generated all paths - return edges; -} - -const is1By1 = (c: Cell): boolean => c.columns === 1 && c.rows === 1; - -const findLast1By1Index = (g: SparseGrid): number | null => - findLastIndex(g.cells, (c) => c !== undefined && is1By1(c)); - -export function row(index: number, g: SparseGrid): number { - return Math.floor(index / g.columns); -} - -export function column(index: number, g: SparseGrid): number { - return ((index % g.columns) + g.columns) % g.columns; -} - -function inArea( - index: number, - start: number, - end: number, - g: SparseGrid, -): boolean { - const indexColumn = column(index, g); - const indexRow = row(index, g); - return ( - indexRow >= row(start, g) && - indexRow <= row(end, g) && - indexColumn >= column(start, g) && - indexColumn <= column(end, g) - ); -} - -function* cellsInArea( - start: number, - end: number, - g: SparseGrid, -): Generator { - const startColumn = column(start, g); - const endColumn = column(end, g); - for ( - let i = start; - i <= end; - i = - column(i, g) === endColumn - ? i + g.columns + startColumn - endColumn - : i + 1 - ) - yield i; -} - -export function forEachCellInArea( - start: number, - end: number, - g: G, - fn: (c: G["cells"][0], i: number) => void, -): void { - for (const i of cellsInArea(start, end, g)) fn(g.cells[i], i); -} - -function allCellsInArea( - start: number, - end: number, - g: G, - fn: (c: G["cells"][0], i: number) => boolean, -): boolean { - for (const i of cellsInArea(start, end, g)) { - if (!fn(g.cells[i], i)) return false; - } - - return true; -} - -/** - * Counts the number of cells in the area that satsify the given predicate. - */ -function countCellsInArea( - start: number, - end: number, - g: G, - predicate: (c: G["cells"][0], i: number) => boolean, -): number { - let count = 0; - for (const i of cellsInArea(start, end, g)) { - if (predicate(g.cells[i], i)) count++; - } - return count; -} - -const areaEnd = ( - start: number, - columns: number, - rows: number, - g: SparseGrid, -): number => start + columns - 1 + g.columns * (rows - 1); - -const cloneGrid = (g: G): G => ({ - ...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: SparseGrid, - ignoreGap: (cell: number) => boolean, -): number | null { - const last1By1Index = findLast1By1Index(g); - if (last1By1Index === null) return null; - - for (let i = 0; i < last1By1Index; i++) { - // To make the backfilling process look natural when there are multiple - // gaps, we actually scan each row from right to left - const j = i; /* - (row(i, g) === row(last1By1Index, g) - ? last1By1Index - : (row(i, g) + 1) * g.columns) - - 1 - - column(i, g);*/ - - if (!ignoreGap(j) && g.cells[j] === undefined) return j; - } - - return null; -} - -/** - * Moves the tile at index "from" over to index "to", displacing other tiles - * along the way. - * Precondition: the destination area must consist of only 1×1 tiles. - */ -function moveTileUnchecked(g: SparseGrid, from: number, to: number): void { - const tile = g.cells[from]!; - const fromEnd = areaEnd(from, tile.columns, tile.rows, g); - const toEnd = areaEnd(to, tile.columns, tile.rows, g); - - const displacedTiles: Cell[] = []; - forEachCellInArea(to, toEnd, g, (c, i) => { - if (c !== undefined && !inArea(i, from, fromEnd, g)) - displacedTiles.push(c!); - }); - - const movingCells: Cell[] = []; - forEachCellInArea(from, fromEnd, g, (c, i) => { - movingCells.push(c!); - g.cells[i] = undefined; - }); - - forEachCellInArea( - to, - toEnd, - g, - (_c, i) => (g.cells[i] = movingCells.shift()), - ); - forEachCellInArea( - from, - fromEnd, - g, - (_c, i) => (g.cells[i] ??= displacedTiles.shift()), - ); -} - -/** - * Moves the tile at index "from" over to index "to", if there is space. - */ -export function moveTile( - g: G, - from: number, - to: number, -): G { - 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 - ) { - const fromEnd = areaEnd(from, tile.columns, tile.rows, g); - const toEnd = areaEnd(to, tile.columns, tile.rows, g); - - // The contents of a given cell are 'displaceable' if it's empty, holds a - // 1×1 tile, or is part of the original tile we're trying to reposition - const displaceable = (c: Cell | undefined, i: number): boolean => - c === undefined || is1By1(c) || inArea(i, from, fromEnd, g); - - if (allCellsInArea(to, toEnd, g, displaceable)) { - // The target space is free; move - const gClone = cloneGrid(g); - moveTileUnchecked(gClone, from, to); - return gClone; - } - } - - // The target space isn't free; don't move - return g; -} - -/** - * Attempts to push a tile upwards by a number of rows, displacing 1×1 tiles. - * @returns The number of rows the tile was successfully pushed (may be less - * than requested if there are obstacles blocking movement). - */ -function pushTileUp( - g: SparseGrid, - from: number, - rows: number, - avoid: (cell: number) => boolean = (): boolean => false, -): number { - const tile = g.cells[from]!; - - for (let tryRows = rows; tryRows > 0; tryRows--) { - const to = from - tryRows * g.columns; - const toEnd = areaEnd(to, tile.columns, tile.rows, g); - - const cellsAboveAreDisplacable = - from - g.columns >= 0 && - allCellsInArea( - to, - Math.min(from - g.columns + tile.columns - 1, toEnd), - g, - (c, i) => (c === undefined || is1By1(c)) && !avoid(i), - ); - - if (cellsAboveAreDisplacable) { - moveTileUnchecked(g, from, to); - return tryRows; - } - } - - return 0; -} - -function trimTrailingGaps(g: SparseGrid): void { - // Shrink the array to remove trailing gaps - const newLength = (findLastIndex(g.cells, (c) => c !== undefined) ?? -1) + 1; - if (newLength !== g.cells.length) g.cells = g.cells.slice(0, newLength); -} - -/** - * Determines whether the given area is sufficiently clear of obstacles for - * vacateArea to work. - */ -function canVacateArea(g: SparseGrid, start: number, end: number): boolean { - const newCellCount = countCellsInArea(start, end, g, (c) => c !== undefined); - const newFullRows = Math.floor(newCellCount / g.columns); - return allCellsInArea( - start, - end - newFullRows * g.columns, - g, - (c) => c === undefined || is1By1(c), - ); -} - -/** - * Clears away all the tiles in a given area by pushing them elsewhere. - * Precondition: the area must first be checked with canVacateArea, and the only - * gaps in the given grid must lie either within the area being cleared, or - * after the last 1×1 tile. - */ -function vacateArea(g: SparseGrid, start: number, end: number): SparseGrid { - const newCellCount = countCellsInArea( - start, - end, - g, - (c, i) => c !== undefined || i >= g.cells.length, - ); - const newFullRows = Math.floor(newCellCount / g.columns); - const endRow = row(end, g); - - // To avoid subverting users' expectations, this operation should be the exact - // inverse of fillGaps. We do this by reverse-engineering a grid G with the - // area cleared out and structured such that fillGaps(G) = g. - - // A grid that will have the same structure as the final result, but be filled - // with fake data - const outputStructure: SparseGrid = { - columns: g.columns, - cells: new Array(g.cells.length + newCellCount), - }; - - // The first step in populating outputStructure is to copy over all the large - // tiles, pushing those tiles downwards that fillGaps would push upwards - g.cells.forEach((cell, fromStart) => { - if (cell?.origin && !is1By1(cell)) { - const fromEnd = areaEnd(fromStart, cell.columns, cell.rows, g); - const offset = - row(fromStart, g) + newFullRows > endRow ? newFullRows * g.columns : 0; - forEachCellInArea(fromStart, fromEnd, g, (c, i) => { - outputStructure.cells[i + offset] = c; - }); - } - }); - - // Then, we need to fill it in with the same number of 1×1 tiles as appear in - // the input - const oneByOneTileCount = count(g.cells, (c) => c !== undefined && is1By1(c)); - let oneByOneTilesDistributed = 0; - - for (let i = 0; i < outputStructure.cells.length; i++) { - if (outputStructure.cells[i] === undefined) { - if (inArea(i, start, end, g)) { - // Leave the requested area clear - outputStructure.cells[i] = undefined; - } else if (oneByOneTilesDistributed < oneByOneTileCount) { - outputStructure.cells[i] = { - // Fake data because we only care about the grid's structure - item: {} as unknown as TileDescriptor, - origin: true, - columns: 1, - rows: 1, - }; - oneByOneTilesDistributed++; - } - } - } - - // Lastly, handle the edge case where there were gaps in the input after the - // last 1×1 tile by resizing the cells array to delete these gaps - trimTrailingGaps(outputStructure); - - // outputStructure is now fully populated, and so running fillGaps on it - // should produce a grid with the same structure as the input - const inputStructure = fillGaps( - outputStructure, - false, - (i) => inArea(i, start, end, g) && g.cells[i] === undefined, - ); - - // We exploit the fact that g and inputStructure have the same structure to - // create a mapping between cells in the structure grids and cells in g - const structureMapping = new Map(zip(inputStructure.cells, g.cells)); - - // And finally, we can use that mapping to swap the fake data in - // outputStructure with the real thing - return { - columns: g.columns, - cells: outputStructure.cells.map((placeholder) => - structureMapping.get(placeholder), - ), - }; -} - -/** - * Backfill any gaps in the grid. - */ -export function fillGaps( - g: SparseGrid, - packLargeTiles?: true, - ignoreGap?: () => false, -): Grid; -export function fillGaps( - g: SparseGrid, - packLargeTiles?: boolean, - ignoreGap?: (cell: number) => boolean, -): SparseGrid; -export function fillGaps( - g: SparseGrid, - packLargeTiles = true, - ignoreGap: (cell: number) => boolean = (): boolean => false, -): SparseGrid { - const lastGap = findLastIndex( - g.cells, - (c, i) => c === undefined && !ignoreGap(i), - ); - if (lastGap === null) return g; // There are no gaps to fill - const lastGapRow = row(lastGap, g); - - const result = cloneGrid(g); - - // This will be the size of the grid after we're done here (assuming we're - // allowed to pack the large tiles into the rest of the grid as necessary) - let idealLength = count( - result.cells, - (c, i) => c !== undefined || ignoreGap(i), - ); - const fullRowsRemoved = Math.floor( - (g.cells.length - idealLength) / g.columns, - ); - - // Step 1: Push all large tiles below the last gap upwards, so that they move - // roughly the same distance that we're expecting 1×1 tiles to move - if (fullRowsRemoved > 0) { - for ( - let i = (lastGapRow + 1) * result.columns; - i < result.cells.length; - i++ - ) { - const cell = result.cells[i]; - if (cell?.origin && !is1By1(cell)) - pushTileUp(result, i, fullRowsRemoved, ignoreGap); - } - } - - // Step 2: Deal with any large tiles that are still hanging off the bottom - if (packLargeTiles) { - for (let i = result.cells.length - 1; i >= idealLength; i--) { - const cell = result.cells[i]; - if (cell !== undefined && !is1By1(cell)) { - // First, try to just push it upwards a bit more - const originIndex = - i - (cell.columns - 1) - result.columns * (cell.rows - 1); - const pushed = pushTileUp(result, originIndex, 1, ignoreGap) === 1; - - // If that failed, collapse the tile to 1×1 so it can be dealt with in - // step 3 - if (!pushed) { - const collapsedTile: Cell = { - item: cell.item, - origin: true, - columns: 1, - rows: 1, - }; - forEachCellInArea(originIndex, i, result, (_c, j) => { - result.cells[j] = undefined; - }); - result.cells[i] = collapsedTile; - // Collapsing the tile makes the final grid size smaller - idealLength -= cell.columns * cell.rows - 1; - } - } - } - } - - // Step 3: Fill all remaining gaps with 1×1 tiles - let gap = getNextGap(result, ignoreGap); - - if (gap !== null) { - const pathsToEnd = getPaths(result, findLast1By1Index(result)!, ignoreGap); - - do { - let filled = false; - let to = gap; - let from = pathsToEnd[gap]; - - // First, attempt to fill the gap by moving 1×1 tiles backwards from the - // end of the grid along a set path - while (from !== null) { - const toCell = result.cells[to] as Cell | undefined; - const fromCell = result.cells[from] as Cell | undefined; - - // Skip over slots that are already full - if (toCell !== undefined) { - to = pathsToEnd[to]!; - // Skip over large tiles. Also, we might run into gaps along the path - // created during the filling of previous gaps. Skip over those too; - // they'll be picked up on the next iteration of the outer loop. - } else if (fromCell === undefined || !is1By1(fromCell)) { - from = pathsToEnd[from]; - } else { - result.cells[to] = result.cells[from]; - result.cells[from] = undefined; - filled = true; - to = pathsToEnd[to]!; - from = pathsToEnd[from]; - } - } - - // In case the path approach failed, fall back to taking the very last 1×1 - // tile, and just dropping it into place - if (!filled) { - const last1By1Index = findLast1By1Index(result)!; - result.cells[gap] = result.cells[last1By1Index]; - result.cells[last1By1Index] = undefined; - } - - gap = getNextGap(result, ignoreGap); - } while (gap !== null); - } - - trimTrailingGaps(result); - return result; -} - -// TODO: replace all usages of this function with vacateArea, as this results in -// somewhat unpredictable movement -function createRows(g: SparseGrid, count: number, atRow: number): SparseGrid { - const result = { - columns: g.columns, - cells: new Array(g.cells.length + g.columns * count), - }; - const offsetAfterNewRows = g.columns * count; - - // Copy tiles from the original grid to the new one, with the new rows - // inserted at the target location - g.cells.forEach((c, from) => { - if (c?.origin) { - const offset = row(from, g) >= atRow ? offsetAfterNewRows : 0; - forEachCellInArea( - from, - areaEnd(from, c.columns, c.rows, g), - g, - (c, i) => { - result.cells[i + offset] = c; - }, - ); - } - }); - - return result; -} - -/** - * Adds a set of new items into the grid. - */ -export function addItems( - items: TileDescriptor[], - g: SparseGrid, -): SparseGrid { - let result: SparseGrid = cloneGrid(g); - - for (const item of items) { - const cell = { - item, - origin: true, - columns: 1, - rows: 1, - }; - - let placeAt: number; - - 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; - } else { - // This item wants to be placed near another; let's put it on a row - // directly below the related tile - const placeNear = result.cells.findIndex( - (c) => c?.item.id === item.placeNear, - ); - if (placeNear === -1) { - // Can't find the related tile, so let's give up and place it at the end - placeAt = result.cells.length; - } else { - const placeNearCell = result.cells[placeNear]!; - const placeNearEnd = areaEnd( - placeNear, - placeNearCell.columns, - placeNearCell.rows, - result, - ); - - result = createRows(result, 1, row(placeNearEnd, result) + 1); - placeAt = - placeNear + - Math.floor(placeNearCell.columns / 2) + - result.columns * placeNearCell.rows; - } - } - - result.cells[placeAt] = cell; - - 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(result, item); - } - } - - return result; -} - -const largeTileDimensions = (g: SparseGrid): [number, number] => [ - Math.min(3, Math.max(2, g.columns - 1)), - 2, -]; - -const extraLargeTileDimensions = (g: SparseGrid): [number, number] => - g.columns > 3 ? [4, 3] : [g.columns, 2]; - -export function cycleTileSize( - g: G, - tile: TileDescriptor, -): G { - 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; - const fromHeight = fromCell.rows; - - const [baseDimensions, enlargedDimensions] = fromCell.item.largeBaseSize - ? [largeTileDimensions(g), extraLargeTileDimensions(g)] - : [[1, 1], largeTileDimensions(g)]; - // The target dimensions, which toggle between the base and enlarged sizes - const [toWidth, toHeight] = - fromWidth === baseDimensions[0] && fromHeight === baseDimensions[1] - ? enlargedDimensions - : baseDimensions; - - return setTileSize(g, from, toWidth, toHeight); -} - -/** - * Finds the cell nearest to 'nearestTo' that satsifies the given predicate. - * @param shouldScan A predicate constraining the bounds of the search. - */ -function findNearestCell( - g: G, - nearestTo: number, - shouldScan: (index: number) => boolean, - predicate: (cell: G["cells"][0], index: number) => boolean, -): number | null { - const scanLocations = new Set([nearestTo]); - - for (const scanLocation of scanLocations) { - if (shouldScan(scanLocation)) { - if (predicate(g.cells[scanLocation], scanLocation)) return scanLocation; - - // Scan outwards in all directions - const scanColumn = column(scanLocation, g); - const scanRow = row(scanLocation, g); - if (scanColumn > 0) scanLocations.add(scanLocation - 1); - if (scanColumn < g.columns - 1) scanLocations.add(scanLocation + 1); - if (scanRow > 0) scanLocations.add(scanLocation - g.columns); - scanLocations.add(scanLocation + g.columns); - } - } - - return null; -} - -/** - * Changes the size of a tile, rearranging the grid to make space. - * @param tileId The ID of the tile to modify. - * @param g The grid. - * @returns The updated grid. - */ -export function setTileSize( - g: G, - from: number, - toWidth: number, - toHeight: number, -): G { - const fromCell = g.cells[from]!; - const fromWidth = fromCell.columns; - const fromHeight = fromCell.rows; - const fromEnd = areaEnd(from, fromWidth, fromHeight, g); - const newGridSize = - g.cells.length + toWidth * toHeight - fromWidth * fromHeight; - - const toColumn = Math.max( - 0, - Math.min( - g.columns - toWidth, - column(from, g) + Math.trunc((fromWidth - toWidth) / 2), - ), - ); - const toRow = Math.max( - 0, - row(from, g) + Math.trunc((fromHeight - toHeight) / 2), - ); - const targetDest = toColumn + toRow * g.columns; - - const gridWithoutTile = cloneGrid(g); - forEachCellInArea(from, fromEnd, gridWithoutTile, (_c, i) => { - gridWithoutTile.cells[i] = undefined; - }); - - const placeTile = ( - to: number, - toEnd: number, - grid: Grid | SparseGrid, - ): void => { - forEachCellInArea(to, toEnd, grid, (_c, i) => { - grid.cells[i] = { - item: fromCell.item, - origin: i === to, - columns: toWidth, - rows: toHeight, - }; - }); - }; - - if (toWidth <= fromWidth && toHeight <= fromHeight) { - // The tile is shrinking, which can always happen in-place - const to = targetDest; - const toEnd = areaEnd(to, toWidth, toHeight, g); - - const result: SparseGrid = gridWithoutTile; - placeTile(to, toEnd, result); - return fillGaps(result, true, (i: number) => inArea(i, to, toEnd, g)) as G; - } else if (toWidth >= fromWidth && toHeight >= fromHeight) { - // The tile is growing, which might be able to happen in-place - const to = findNearestCell( - gridWithoutTile, - targetDest, - (i) => { - const end = areaEnd(i, toWidth, toHeight, g); - return ( - column(i, g) + toWidth - 1 < g.columns && - inArea(from, i, end, g) && - inArea(fromEnd, i, end, g) - ); - }, - (_c, i) => { - const end = areaEnd(i, toWidth, toHeight, g); - return end < newGridSize && canVacateArea(gridWithoutTile, i, end); - }, - ); - - if (to !== null) { - const toEnd = areaEnd(to, toWidth, toHeight, g); - const result = vacateArea(gridWithoutTile, to, toEnd); - - placeTile(to, toEnd, result); - return result as G; - } - } - - // Catch-all path for when the tile is neither strictly shrinking nor - // growing, or when there's not enough space for it to grow in-place - - const packedGridWithoutTile = fillGaps(gridWithoutTile, false); - - const to = findNearestCell( - packedGridWithoutTile, - targetDest, - (i) => i < newGridSize && column(i, g) + toWidth - 1 < g.columns, - (_c, i) => { - const end = areaEnd(i, toWidth, toHeight, g); - return end < newGridSize && canVacateArea(packedGridWithoutTile, i, end); - }, - ); - - if (to === null) return g; // There's no space anywhere; give up - - const toEnd = areaEnd(to, toWidth, toHeight, g); - const result = vacateArea(packedGridWithoutTile, to, toEnd); - - placeTile(to, toEnd, result); - return result as G; -} - -/** - * Resizes the grid to a new column width. - */ -export function resize(g: Grid, columns: number): Grid { - const result: SparseGrid = { columns, cells: [] }; - const [largeColumns, largeRows] = largeTileDimensions(result); - - // Copy each tile from the old grid to the resized one in the same order - - // The next index in the result grid to copy a tile to - let next = 0; - - for (const cell of g.cells) { - if (cell.origin) { - // TODO make aware of extra large tiles - const [nextColumns, nextRows] = is1By1(cell) - ? [1, 1] - : [largeColumns, largeRows]; - - // If there isn't enough space left on this row, jump to the next row - if (columns - column(next, result) < nextColumns) - next = columns * (Math.floor(next / columns) + 1); - const nextEnd = areaEnd(next, nextColumns, nextRows, result); - - // Expand the cells array as necessary - if (result.cells.length <= nextEnd) - result.cells.push(...new Array(nextEnd + 1 - result.cells.length)); - - // Copy the tile into place - forEachCellInArea(next, nextEnd, result, (_c, i) => { - result.cells[i] = { - item: cell.item, - origin: i === next, - columns: nextColumns, - rows: nextRows, - }; - }); - - next = nextEnd + 1; - } - } - - return fillGaps(result); -} - -/** - * Promotes speakers to the first page of the grid. - */ -export function promoteSpeakers(g: SparseGrid): void { - // 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 - const firstPageEnd = g.columns * 4; - - for (let from = firstPageEnd; from < g.cells.length; from++) { - const fromCell = g.cells[from]; - // Don't bother trying to promote enlarged tiles - if (fromCell?.item.isSpeaker && is1By1(fromCell)) { - // Promote this tile by making 10 attempts to place it on the first page - for (let j = 0; j < 10; j++) { - const to = Math.floor(Math.random() * firstPageEnd); - const toCell = g.cells[to]; - if (toCell === undefined || is1By1(toCell)) { - moveTileUnchecked(g, from, to); - break; - } - } - } - } -} - -/** - * The algorithm for updating a grid with a new set of tiles. - */ -function updateTiles(g: Grid, tiles: TileDescriptor[]): Grid { - // 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: SparseGrid = { - ...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: Grid, bounds: RectReadOnly): Grid { - const columns = Math.max(2, Math.floor(bounds.width * 0.0055)); - return columns === g.columns ? g : resize(g, columns); -} - -const Slots: FC<{ s: Grid }> = 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(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(slotCount); - for (let i = 0; i < slotCount; i++) - slots[i] = ; - - return ( -
- {slots} -
- ); -}); - -Slots.displayName = "Slots"; - -/** - * 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: SparseGrid, - 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: Grid, - from: TileDescriptor, - to: TileDescriptor, - xPositionOnFrom: number, - yPositionOnFrom: number, - xPositionOnTo: number, - yPositionOnTo: number, -): Grid { - 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 = { - emptyState: { columns: 4, cells: [] }, - updateTiles, - updateBounds, - getTiles: (g: Grid) => - g.cells.filter((c) => c.origin).map((c) => c!.item as T), - canDragTile: () => true, - dragTile, - toggleFocus: cycleTileSize, - Slots, - rememberState: false, -}; diff --git a/src/video-grid/Layout.tsx b/src/video-grid/Layout.tsx deleted file mode 100644 index b540cbe17..000000000 --- a/src/video-grid/Layout.tsx +++ /dev/null @@ -1,195 +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 { ComponentType, ReactNode, useCallback, useMemo, useRef } from "react"; - -import type { RectReadOnly } from "react-use-measure"; -import { useReactiveState } from "../useReactiveState"; -import { TileDescriptor } from "../state/CallViewModel"; - -/** - * 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; -}; - -interface UseLayout { - state: State; - orderedItems: TileDescriptor[]; - generation: number; - canDragTile: (tile: TileDescriptor) => boolean; - dragTile: ( - from: TileDescriptor, - to: TileDescriptor, - xPositionOnFrom: number, - yPositionOnFrom: number, - xPositionOnTo: number, - yPositionOnTo: number, - ) => void; - toggleFocus: ((tile: TileDescriptor) => void) | undefined; - slots: ReactNode; -} - -/** - * 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 function useLayout( - layout: Layout, - items: TileDescriptor[], - bounds: RectReadOnly, - layoutStates: LayoutStatesMap, -): UseLayout { - 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): void => - setState((s) => layout.toggleFocus!(s, tile))), - [layout, setState], - ), - slots: , - }; -} diff --git a/src/video-grid/NewVideoGrid.tsx b/src/video-grid/NewVideoGrid.tsx deleted file mode 100644 index 6de5f58dd..000000000 --- a/src/video-grid/NewVideoGrid.tsx +++ /dev/null @@ -1,382 +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 { SpringRef, TransitionFn, useTransition } from "@react-spring/web"; -import { EventTypes, Handler, useScroll } from "@use-gesture/react"; -import { - CSSProperties, - FC, - ReactNode, - useEffect, - useMemo, - useRef, - useState, -} from "react"; -import useMeasure from "react-use-measure"; -import { zip } from "lodash"; - -import styles from "./NewVideoGrid.module.css"; -import { - VideoGridProps as Props, - TileSpring, - ChildrenProperties, -} from "./VideoGrid"; -import { useReactiveState } from "../useReactiveState"; -import { useMergedRefs } from "../useMergedRefs"; -import { TileWrapper } from "./TileWrapper"; -import { BigGrid } from "./BigGrid"; -import { useLayout } from "./Layout"; -import { TileDescriptor } from "../state/CallViewModel"; - -interface Rect { - x: number; - y: number; - width: number; - height: number; -} - -interface Tile extends Rect { - item: TileDescriptor; -} - -interface DragState { - tileId: string; - tileX: number; - tileY: number; - cursorX: number; - cursorY: number; -} - -interface TapData { - tileId: string; - ts: number; -} - -interface SlotProps { - style?: CSSProperties; -} - -export const Slot: FC = ({ style }) => ( -
-); - -/** - * An interactive, animated grid of video tiles. - */ -export function NewVideoGrid({ - items, - disableAnimations, - layoutStates, - children, -}: Props): ReactNode { - // Overview: This component lays out tiles by rendering an invisible template - // grid of "slots" for tiles to go in. Once rendered, it uses the DOM API to - // get the dimensions of each slot, feeding these numbers back into - // react-spring to let the actual tiles move freely atop the template. - - // To know when the rendered grid becomes consistent with the layout we've - // requested, we give it a data-generation attribute which holds the ID of the - // most recently rendered generation of the grid, and watch it with a - // MutationObserver. - - const [slotsRoot, setSlotsRoot] = useState(null); - const [renderedGeneration, setRenderedGeneration] = useState(0); - - useEffect(() => { - if (slotsRoot !== null) { - setRenderedGeneration( - parseInt(slotsRoot.getAttribute("data-generation")!), - ); - - const observer = new MutationObserver((mutations) => { - if (mutations.some((m) => m.type === "attributes")) { - setRenderedGeneration( - parseInt(slotsRoot.getAttribute("data-generation")!), - ); - } - }); - - observer.observe(slotsRoot, { attributes: true }); - return () => observer.disconnect(); - } - }, [slotsRoot, setRenderedGeneration]); - - const [gridRef1, gridBounds] = useMeasure(); - const gridRef2 = useRef(null); - const gridRef = useMergedRefs(gridRef1, gridRef2); - - const slotRects = useMemo(() => { - if (slotsRoot === null) return []; - - const slots = slotsRoot.getElementsByClassName(styles.slot); - const rects = new Array(slots.length); - for (let i = 0; i < slots.length; i++) { - const slot = slots[i] as HTMLElement; - rects[i] = { - x: slot.offsetLeft, - y: slot.offsetTop, - width: slot.offsetWidth, - height: slot.offsetHeight, - }; - } - - return rects; - // 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 - }, [slotsRoot, renderedGeneration, 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 [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( - 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') - return items.map((item) => ({ ...tileRects.get(item)!, item })); - }, - [slotRects, grid, renderedGeneration], - ); - - // Drag state is stored in a ref rather than component state, because we use - // react-spring's imperative API during gestures to improve responsiveness - const dragState = useRef(null); - - const [tileTransitions, springRef] = useTransition( - tiles, - () => ({ - key: ({ item }: Tile) => item.id, - from: ({ x, y, width, height }: Tile) => ({ - opacity: 0, - scale: 0, - shadow: 0, - shadowSpread: 0, - zIndex: 1, - x, - y, - width, - height, - immediate: disableAnimations, - }), - enter: { opacity: 1, scale: 1, immediate: disableAnimations }, - update: ({ item, x, y, width, height }: Tile) => - item.id === dragState.current?.tileId - ? null - : { - x, - y, - width, - height, - immediate: disableAnimations, - }, - leave: { opacity: 0, scale: 0, immediate: disableAnimations }, - 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, TileSpring>, SpringRef]; - - // Because we're using react-spring in imperative mode, we're responsible for - // firing animations manually whenever the tiles array updates - useEffect(() => { - springRef.start(); - }, [tiles, springRef]); - - const animateDraggedTile = (endOfGesture: boolean): void => { - const { tileId, tileX, tileY, cursorX, cursorY } = dragState.current!; - const tile = tiles.find((t) => t.item.id === tileId)!; - - springRef.current - .find((c) => (c.item as Tile).item.id === tileId) - ?.start( - endOfGesture - ? { - scale: 1, - zIndex: 1, - shadow: 0, - x: tile.x, - y: tile.y, - width: tile.width, - height: tile.height, - immediate: - disableAnimations || ((key): boolean => key === "zIndex"), - // Allow the tile's position to settle before pushing its - // z-index back down - delay: (key) => (key === "zIndex" ? 500 : 0), - } - : { - scale: 1.1, - zIndex: 2, - shadow: 15, - x: tileX, - y: tileY, - immediate: - disableAnimations || - ((key): boolean => - key === "zIndex" || key === "x" || key === "y"), - }, - ); - - const overTile = tiles.find( - (t) => - cursorX >= t.x && - cursorX < t.x + t.width && - cursorY >= t.y && - cursorY < t.y + t.height, - ); - - 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 lastTap = useRef(null); - - // 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. - // Instead, we pass this callback to each tile via a ref, to let them bind the - // gesture using the much more sensible ref-based method. - const onTileDrag = ( - tileId: string, - - { - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - tap, - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - initial: [initialX, initialY], - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - delta: [dx, dy], - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - last, - }: Parameters>[0], - ): void => { - if (tap) { - const now = Date.now(); - - if ( - tileId === lastTap.current?.tileId && - now - lastTap.current.ts < 500 - ) { - toggleFocus?.(items.find((i) => i.id === tileId)!); - lastTap.current = null; - } else { - lastTap.current = { tileId, ts: now }; - } - } else { - const tileController = springRef.current.find( - (c) => (c.item as Tile).item.id === tileId, - )!; - - if (canDragTile((tileController.item as Tile).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; - } - } - }; - - const onTileDragRef = useRef(onTileDrag); - onTileDragRef.current = onTileDrag; - - const scrollOffset = useRef(0); - - useScroll( - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - ({ xy: [, y], delta: [, dy] }) => { - scrollOffset.current = y; - - if (dragState.current !== null) { - dragState.current.tileY += dy; - dragState.current.cursorY += dy; - animateDraggedTile(false); - } - }, - { target: gridRef2 }, - ); - - // Render nothing if the grid has yet to be generated - if (grid === null) { - return
; - } - - return ( -
-
- {slots} -
- {tileTransitions((spring, tile) => ( - - {children as (props: ChildrenProperties) => ReactNode} - - ))} -
- ); -} diff --git a/test/video-grid/VideoGrid-test.ts b/test/grid/LegacyGrid-test.ts similarity index 96% rename from test/video-grid/VideoGrid-test.ts rename to test/grid/LegacyGrid-test.ts index cf15c0221..44f82d42d 100644 --- a/test/video-grid/VideoGrid-test.ts +++ b/test/grid/LegacyGrid-test.ts @@ -15,7 +15,7 @@ limitations under the License. */ import { TileDescriptor } from "../../src/state/CallViewModel"; -import { Tile, reorderTiles } from "../../src/video-grid/VideoGrid"; +import { Tile, reorderTiles } from "../../src/grid/LegacyGrid"; const alice: Tile = { key: "alice", diff --git a/test/video-grid/BigGrid-test.ts b/test/video-grid/BigGrid-test.ts deleted file mode 100644 index 3d29db6cc..000000000 --- a/test/video-grid/BigGrid-test.ts +++ /dev/null @@ -1,493 +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 { TileDescriptor } from "../../src/state/CallViewModel"; -import { - addItems, - column, - cycleTileSize, - fillGaps, - forEachCellInArea, - Grid, - SparseGrid, - resize, - row, - moveTile, -} from "../../src/video-grid/BigGrid"; - -/** - * Builds a grid from a string specifying the contents of each cell as a letter. - */ -function mkGrid(spec: string): Grid { - const secondNewline = spec.indexOf("\n", 1); - const columns = secondNewline === -1 ? spec.length : secondNewline - 1; - const cells = spec.match(/[a-z ]/g) ?? ([] as string[]); - const areas = new Set(cells); - areas.delete(" "); // Space represents an empty cell, not an area - const grid: Grid = { columns, cells: new Array(cells.length) }; - - for (const area of areas) { - const start = cells.indexOf(area); - const end = cells.lastIndexOf(area); - const rows = row(end, grid) - row(start, grid) + 1; - const columns = column(end, grid) - column(start, grid) + 1; - - forEachCellInArea(start, end, grid, (_c, i) => { - grid.cells[i] = { - item: { id: area } as unknown as TileDescriptor, - origin: i === start, - rows, - columns, - }; - }); - } - - return grid; -} - -/** - * Turns a grid into a string showing the contents of each cell as a letter. - */ -function showGrid(g: Grid): string { - let result = "\n"; - for (let i = 0; i < g.cells.length; i++) { - if (i > 0 && i % g.columns == 0) result += "\n"; - result += g.cells[i]?.item.id ?? " "; - } - return result; -} - -function testFillGaps(title: string, input: string, output: string): void { - test(`fillGaps ${title}`, () => { - expect(showGrid(fillGaps(mkGrid(input)))).toBe(output); - }); -} - -testFillGaps( - "does nothing on an empty grid", - ` -`, - ` -`, -); - -testFillGaps( - "does nothing if there are no gaps", - ` -ab -cd -ef`, - ` -ab -cd -ef`, -); - -testFillGaps( - "fills a gap", - ` -a b -cde -f`, - ` -cab -fde`, -); - -testFillGaps( - "fills multiple gaps", - ` -a bc -defgh - ijkl -mno`, - ` -aebch -difgl -mjnok`, -); - -testFillGaps( - "fills a big gap with 1×1 tiles", - ` -abcd -e f -g h -ijkl`, - ` -abcd -ehkf -glji`, -); - -testFillGaps( - "fills a big gap with a large tile", - ` - -aa -bc`, - ` -aa -cb`, -); - -testFillGaps( - "prefers moving around large tiles", - ` -a bc -ddde -dddf -ghij -k`, - ` -abce -dddf -dddj -kghi`, -); - -testFillGaps( - "moves through large tiles if necessary", - ` -a bc -dddd -efgh -i`, - ` -afbc -dddd -iegh`, -); - -testFillGaps( - "keeps a large tile from hanging off the bottom", - ` -abcd -efgh - -ii -ii`, - ` -abcd -iigh -iief`, -); - -testFillGaps( - "collapses large tiles trapped at the bottom", - ` -abcd -e fg -hh -hh - ii - ii`, - ` -abcd -hhfg -hhie`, -); - -testFillGaps( - "gives up on pushing large tiles upwards when not possible", - ` -aa -aa -bccd -eccf -ghij`, - ` -aadf -aaji -bcch -eccg`, -); - -function testCycleTileSize( - title: string, - tileId: string, - input: string, - output: string, -): void { - test(`cycleTileSize ${title}`, () => { - const grid = mkGrid(input); - const tile = grid.cells.find((c) => c?.item.id === tileId)!.item; - expect(showGrid(cycleTileSize(grid, tile))).toBe(output); - }); -} - -testCycleTileSize( - "expands a tile to 2×2 in a 3 column layout", - "c", - ` -abc -def -ghi`, - ` -acc -dcc -gbe -ifh`, -); - -testCycleTileSize( - "expands a tile to 3×3 in a 4 column layout", - "g", - ` -abcd -efgh`, - ` -acdh -bggg -fggg -e`, -); - -testCycleTileSize( - "restores a tile to 1×1", - "b", - ` -abbc -dbbe -fghi -jk`, - ` -abhc -djge -fik`, -); - -testCycleTileSize( - "expands a tile even in a crowded grid", - "c", - ` -abb -cbb -dde -ddf -ghi -klm`, - ` -abb -gbb -dde -ddf -ccm -cch -lik`, -); - -testCycleTileSize( - "does nothing if the tile has no room to expand", - "c", - ` -abb -cbb -dde -ddf`, - ` -abb -cbb -dde -ddf`, -); - -test("cycleTileSize is its own inverse", () => { - const input = ` -abc -def -ghi -jk`; - - const grid = mkGrid(input); - let gridAfter = grid; - - const toggle = (tileId: string): void => { - const tile = grid.cells.find((c) => c?.item.id === tileId)!.item; - gridAfter = cycleTileSize(gridAfter, tile); - }; - - // Toggle a series of tiles - toggle("j"); - toggle("h"); - toggle("a"); - // Now do the same thing in reverse - toggle("a"); - toggle("h"); - toggle("j"); - - // The grid should be back to its original state - expect(showGrid(gridAfter)).toBe(input); -}); - -function testAddItems( - title: string, - items: TileDescriptor[], - input: string, - output: string, -): void { - test(`addItems ${title}`, () => { - expect(showGrid(addItems(items, mkGrid(input) as SparseGrid) as Grid)).toBe( - output, - ); - }); -} - -testAddItems( - "appends 1×1 tiles", - ["e", "f"].map((i) => ({ id: i }) as unknown as TileDescriptor), - ` -aab -aac -d`, - ` -aab -aac -def`, -); - -testAddItems( - "places one tile near another on request", - [{ id: "g", placeNear: "b" } as unknown as TileDescriptor], - ` -abc -def`, - ` -abc - g -def`, -); - -testAddItems( - "places items with a large base size", - [{ id: "g", largeBaseSize: true } as unknown as TileDescriptor], - ` -abc -def`, - ` -abc -ggf -gge -d`, -); - -function testMoveTile( - title: string, - from: number, - to: number, - input: string, - output: string, -): void { - test(`moveTile ${title}`, () => { - expect(showGrid(moveTile(mkGrid(input), from, to))).toBe(output); - }); -} - -testMoveTile( - "refuses to move a tile too far to the left", - 1, - -1, - ` -abc`, - ` -abc`, -); - -testMoveTile( - "refuses to move a tile too far to the right", - 1, - 3, - ` -abc`, - ` -abc`, -); - -testMoveTile( - "moves a large tile to an unoccupied space", - 3, - 1, - ` -a b -ccd -cce`, - ` -acc -bcc -d e`, -); - -testMoveTile( - "refuses to move a large tile to an occupied space", - 3, - 1, - ` -abb -ccd -cce`, - ` -abb -ccd -cce`, -); - -function testResize( - title: string, - columns: number, - input: string, - output: string, -): void { - test(`resize ${title}`, () => { - expect(showGrid(resize(mkGrid(input), columns))).toBe(output); - }); -} - -testResize( - "contracts the grid", - 2, - ` -abbb -cbbb -ddde -dddf -gh`, - ` -af -bb -bb -dd -dd -ch -eg`, -); - -testResize( - "expands the grid", - 4, - ` -af -bb -bb -ch -dd -dd -eg`, - ` -afcd -bbbg -bbbe -h`, -);