Merge pull request #2325 from robintown/unified-grid

Unified grid layout
This commit is contained in:
Robin
2024-07-12 14:50:35 -04:00
committed by GitHub
32 changed files with 1873 additions and 2595 deletions

View File

@@ -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",

View File

@@ -1,5 +1,5 @@
/*
Copyright 2022 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.
@@ -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 {

View File

@@ -1,5 +1,5 @@
/*
Copyright 2022 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.
@@ -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<HTMLElement> {
className?: string;
}
export const Header: FC<HeaderProps> = ({ children, className, ...rest }) => {
return (
<header className={classNames(styles.header, className)} {...rest}>
{children}
</header>
);
};
export const Header = forwardRef<HTMLElement, HeaderProps>(
({ children, className, ...rest }, ref) => {
return (
<header
ref={ref}
className={classNames(styles.header, className)}
{...rest}
>
{children}
</header>
);
},
);
Header.displayName = "Header";
interface LeftNavProps extends HTMLAttributes<HTMLElement> {
children: ReactNode;

View File

@@ -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;
}

465
src/grid/Grid.tsx Normal file
View File

@@ -0,0 +1,465 @@
/*
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,
LegacyRef,
ReactNode,
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";
import { TileSpringUpdate } from "./LegacyGrid";
interface Rect {
x: number;
y: number;
width: number;
height: number;
}
interface Tile<Model> 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<SlotProps> = ({ tile, style, className, ...props }) => (
<div
className={classNames(className, styles.slot)}
data-tile={tile}
style={style}
{...props}
/>
);
export interface LayoutProps<Model, R extends HTMLElement> {
ref: LegacyRef<R>;
model: Model;
}
export interface TileProps<Model, R extends HTMLElement> {
ref: LegacyRef<R>;
className?: string;
style?: ComponentProps<typeof animated.div>["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<LayoutModel, TileModel, R extends HTMLElement> {
/**
* Defines the ID and model of each tile present in the layout.
*/
tiles: (model: LayoutModel) => Map<string, TileModel>;
/**
* 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<LayoutProps<LayoutModel, R>>;
/**
* 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<LayoutModel, TileModel, LayoutRef>;
/**
* The component used to render each tile in the layout.
*/
Tile: ComponentType<TileProps<TileModel, TileRef>>;
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<LayoutModel, TileModel, LayoutRef, TileRef>): 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<HTMLElement | null>(null);
const gridRef = useMergedRefs<HTMLElement>(gridRef1, gridRef2);
const [layoutRoot, setLayoutRoot] = useState<HTMLElement | null>(null);
const [generation, setGeneration] = useState<number | null>(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 (): void => observer.disconnect();
}
}, [layoutRoot, setGeneration]);
const slotRects = useMemo(() => {
const rects = new Map<string, Rect>();
if (layoutRoot !== null) {
const slots = layoutRoot.getElementsByClassName(
styles.slot,
) as HTMLCollectionOf<HTMLElement>;
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<Tile<TileModel>[]>(() => {
const items: Tile<TileModel>[] = [];
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<DragState | null>(null);
const [tileTransitions, springRef] = useTransition(
tiles,
() => ({
key: ({ id }: Tile<TileModel>): string => id,
from: ({ x, y, width, height }: Tile<TileModel>): TileSpringUpdate => ({
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<TileModel>): TileSpringUpdate | null =>
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<Tile<TileModel>, TileSpring>,
SpringRef<TileSpring>,
];
// 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<TileModel>).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): number => (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<Handler<"drag", EventTypes["drag"]>>[0],
): void => {
if (!tap) {
const tileController = springRef.current.find(
(c) => (c.item as Tile<TileModel>).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 (
<div
ref={gridRef}
className={classNames(className, styles.grid)}
style={style}
>
<Layout ref={layoutRef} model={model} />
{tileTransitions((spring, { id, model, width, height }) => (
<TileWrapper
key={id}
id={id}
onDrag={dragCallbacks.get(id) ? onTileDragRef : null}
targetWidth={width}
targetHeight={height}
model={model}
Tile={Tile}
{...spring}
/>
))}
</div>
);
}

View File

@@ -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;
}

189
src/grid/GridLayout.tsx Normal file
View File

@@ -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<GridLayoutModel, MediaViewModel, HTMLDivElement>;
fixed: LayoutSystem<GridLayoutModel, MediaViewModel[], HTMLDivElement>;
}
const slotMinHeight = 130;
const slotMaxAspectRatio = 17 / 9;
const slotMinAspectRatio = 4 / 3;
export const gridLayoutSystems = (
minBounds: StateObservable<Bounds>,
floatingAlignment: BehaviorSubject<Alignment>,
): 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<StateObservable<Alignment>>(() =>
state(
floatingAlignment.pipe(
distinctUntilChanged(
(a1, a2) => a1.block === a2.block && a1.inline === a2.inline,
),
),
),
),
);
const [generation] = useReactiveState<number>(
(prev) => (prev === undefined ? 0 : prev + 1),
[model.spotlight === undefined, width, height, alignment],
);
return (
<div
ref={ref}
className={styles.fixed}
data-generation={generation}
style={{ height }}
>
{model.spotlight && (
<Slot
className={styles.slot}
tile="spotlight"
data-block-alignment={alignment.block}
data-inline-alignment={alignment.inline}
/>
)}
</div>
);
}),
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<number>(
(prev) => (prev === undefined ? 0 : prev + 1),
[model.grid, width, minHeight],
);
return (
<div
ref={ref}
data-generation={generation}
className={styles.scrolling}
style={
{
width,
"--gap": `${gap}px`,
"--width": `${Math.floor(slotWidth)}px`,
"--height": `${Math.floor(slotHeight)}px`,
} as GridCSSProperties
}
>
{model.grid.map((tile) => (
<Slot className={styles.slot} tile={tile.id} />
))}
</div>
);
}),
},
});

View File

@@ -1,5 +1,5 @@
/*
Copyright 2022 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,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;

View File

@@ -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.
@@ -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;
@@ -86,7 +87,7 @@ export interface TileSpringUpdate extends Partial<TileSpring> {
type LayoutDirection = "vertical" | "horizontal";
export function useVideoGridLayout(hasScreenshareFeeds: boolean): {
export function useLegacyGridLayout(hasScreenshareFeeds: boolean): {
layout: Layout;
setLayout: (layout: Layout) => void;
} {
@@ -838,20 +839,19 @@ export interface ChildrenProperties<T> {
data: T;
}
export interface VideoGridProps<T> {
export interface LegacyGridProps<T, R extends HTMLElement> {
items: TileDescriptor<T>[];
layout: Layout;
disableAnimations: boolean;
layoutStates: LayoutStatesMap;
children: (props: ChildrenProperties<T>) => ReactNode;
Tile: ComponentType<TileProps<T, R>>;
}
export function VideoGrid<T>({
export function LegacyGrid<T, R extends HTMLElement>({
items,
layout,
disableAnimations,
children,
}: VideoGridProps<T>): ReactNode {
Tile,
}: LegacyGridProps<T, R>): ReactNode {
// Place the PiP in the bottom right corner by default
const [pipXRatio, setPipXRatio] = useState(1);
const [pipYRatio, setPipYRatio] = useState(1);
@@ -1378,7 +1378,7 @@ export function VideoGrid<T>({
);
return (
<div className={styles.videoGrid} ref={gridRef} {...bindGrid()}>
<div className={styles.grid} ref={gridRef} {...bindGrid()}>
{springs.map((spring, i) => {
const tile = tiles[i];
const tilePosition = tilePositions[tile.order];
@@ -1387,20 +1387,19 @@ export function VideoGrid<T>({
<TileWrapper
key={tile.key}
id={tile.key}
onDragRef={onTileDragRef}
onDrag={onTileDragRef}
targetWidth={tilePosition.width}
targetHeight={tilePosition.height}
data={tile.item.data}
model={tile.item.data}
Tile={Tile}
{...spring}
>
{children as (props: ChildrenProperties<unknown>) => ReactNode}
</TileWrapper>
/>
);
})}
</div>
);
}
VideoGrid.defaultProps = {
LegacyGrid.defaultProps = {
layout: "grid",
};

View File

@@ -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;
}

View File

@@ -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,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<T> {
interface Props<M, R extends HTMLElement> {
id: string;
onDragRef: RefObject<
onDrag: RefObject<
(
tileId: string,
state: Parameters<Handler<"drag", EventTypes["drag"]>>[0],
) => void
>;
> | null;
targetWidth: number;
targetHeight: number;
data: T;
model: M;
Tile: ComponentType<TileProps<M, R>>;
opacity: SpringValue<number>;
scale: SpringValue<number>;
shadow: SpringValue<number>;
shadowSpread: SpringValue<number>;
zIndex: SpringValue<number>;
x: SpringValue<number>;
y: SpringValue<number>;
width: SpringValue<number>;
height: SpringValue<number>;
children: (props: ChildrenProperties<T>) => ReactNode;
}
const TileWrapper_ = memo(
<T,>({
<M, R extends HTMLElement>({
id,
onDragRef,
onDrag,
targetWidth,
targetHeight,
data,
model,
Tile,
opacity,
scale,
shadow,
shadowSpread,
zIndex,
x,
y,
width,
height,
children,
}: Props<T>) => {
const ref = useRef<HTMLElement | null>(null);
}: Props<M, R>) => {
const ref = useRef<R | null>(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,
})}
</>
<Tile
ref={ref}
className={classNames(styles.tile, { [styles.draggable]: onDrag })}
style={{
opacity,
scale,
zIndex,
x,
y,
width,
height,
}}
targetWidth={targetWidth}
targetHeight={targetHeight}
model={model}
/>
);
},
);
@@ -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 <T>(props: Props<T>) => JSX.Element;
export const TileWrapper = TileWrapper_ as <M, R extends HTMLElement>(
props: Props<M, R>,
) => JSX.Element;

View File

@@ -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;
}

View File

@@ -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,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<MediaViewModel>;
export interface ActiveCallProps
extends Omit<InCallViewProps, "livekitRoom" | "connState"> {
@@ -153,7 +170,7 @@ export const InCallView: FC<InCallViewProps> = subscribe(
}, [connState, onLeave]);
const containerRef1 = useRef<HTMLDivElement | null>(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<InCallViewProps> = 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<InCallViewProps> = subscribe(
useEffect(() => {
widget?.api.transport.send(
layout === "grid"
legacyLayout === "grid"
? ElementWidgetActions.TileLayout
: ElementWidgetActions.SpotlightLayout,
{},
);
}, [layout]);
}, [legacyLayout]);
useEffect(() => {
if (widget) {
const onTileLayout = (ev: CustomEvent<IWidgetApiRequest>): void => {
setLayout("grid");
setLegacyLayout("grid");
widget!.api.transport.reply(ev.detail, {});
};
const onSpotlightLayout = (
ev: CustomEvent<IWidgetApiRequest>,
): void => {
setLayout("spotlight");
setLegacyLayout("spotlight");
widget!.api.transport.reply(ev.detail, {});
};
@@ -231,7 +247,7 @@ export const InCallView: FC<InCallViewProps> = subscribe(
);
};
}
}, [setLayout]);
}, [setLegacyLayout]);
const mobile = boundsValid && bounds.width <= 660;
const reducedControls = boundsValid && bounds.width <= 340;
@@ -244,8 +260,21 @@ export const InCallView: FC<InCallViewProps> = 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(
() => (hasSpotlight ? [...items, dummySpotlightItem] : items),
[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,66 +288,8 @@ export const InCallView: FC<InCallViewProps> = 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 renderContent = (): JSX.Element => {
if (items.length === 0) {
return (
<div className={styles.centerMessage}>
<p>{t("waiting_for_participants")}</p>
</div>
);
}
if (maximisedParticipant) {
return (
<VideoTile
vm={maximisedParticipant.data}
maximised={true}
fullscreen={maximisedParticipant === fullscreenItem}
onToggleFullscreen={toggleFullscreen}
targetHeight={bounds.height}
targetWidth={bounds.width}
key={maximisedParticipant.id}
showSpeakingIndicator={false}
onOpenProfile={openProfile}
/>
);
}
return (
<Grid
items={items}
layout={layout}
disableAnimations={prefersReducedMotion || isSafari}
layoutStates={layoutStates}
>
{({ data: vm, ...props }): ReactNode => (
<VideoTile
vm={vm}
maximised={false}
fullscreen={false}
onToggleFullscreen={toggleFullscreen}
showSpeakingIndicator={items.length > 2}
onOpenProfile={openProfile}
{...props}
ref={props.ref as Ref<HTMLDivElement>}
/>
)}
</Grid>
);
};
const rageshakeRequestModalProps = useRageshakeRequestModal(
rtcSession.room.roomId,
);
const [settingsModalOpen, setSettingsModalOpen] = useState(false);
const [settingsTab, setSettingsTab] = useState(defaultSettingsTab);
@@ -336,6 +307,169 @@ export const InCallView: FC<InCallViewProps> = subscribe(
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<HTMLDivElement, TileProps<MediaViewModel[], HTMLDivElement>>(
function SpotlightTileView(
{ className, style, targetWidth, targetHeight, model },
ref,
) {
return (
<SpotlightTile
ref={ref}
vms={model}
maximised={false}
fullscreen={false}
onToggleFullscreen={toggleSpotlightFullscreen}
targetWidth={targetWidth}
targetHeight={targetHeight}
className={className}
style={style}
/>
);
},
),
[toggleSpotlightFullscreen],
);
const GridTileView = useMemo(
() =>
forwardRef<HTMLDivElement, TileProps<MediaViewModel, HTMLDivElement>>(
function GridTileView(
{ className, style, targetWidth, targetHeight, model },
ref,
) {
return (
<GridTile
ref={ref}
vm={model}
maximised={false}
fullscreen={false}
onToggleFullscreen={toggleFullscreen}
onOpenProfile={openProfile}
targetWidth={targetWidth}
targetHeight={targetHeight}
className={className}
style={style}
showSpeakingIndicator={showSpeakingIndicators}
/>
);
},
),
[toggleFullscreen, openProfile, showSpeakingIndicators],
);
const renderContent = (): JSX.Element => {
if (items.length === 0) {
return (
<div className={styles.centerMessage}>
<p>{t("waiting_for_participants")}</p>
</div>
);
}
if (maximisedParticipant !== null) {
const fullscreen = maximisedParticipant === fullscreenItem;
if (maximisedParticipant.id === "spotlight") {
return (
<SpotlightTile
vms={layout.spotlight!}
maximised={true}
fullscreen={fullscreen}
onToggleFullscreen={toggleSpotlightFullscreen}
targetWidth={gridBounds.height}
targetHeight={gridBounds.width}
/>
);
}
return (
<GridTile
vm={maximisedParticipant.data}
maximised={true}
fullscreen={fullscreen}
onToggleFullscreen={toggleFullscreen}
targetHeight={gridBounds.height}
targetWidth={gridBounds.width}
key={maximisedParticipant.id}
showSpeakingIndicator={false}
onOpenProfile={openProfile}
/>
);
}
// 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 (
<>
<Grid
className={styles.scrollingGrid}
model={layout}
system={scrolling}
Tile={GridTileView}
/>
<Grid
className={styles.fixedGrid}
style={{ insetBlockStart: headerBounds.bottom }}
model={layout}
system={fixed}
Tile={SpotlightTileView}
/>
</>
);
} else {
return (
<LegacyGrid
items={items}
layout={legacyLayout}
disableAnimations={prefersReducedMotion}
Tile={GridTileView}
/>
);
}
};
const rageshakeRequestModalProps = useRageshakeRequestModal(
rtcSession.room.roomId,
);
const toggleScreensharing = useCallback(async () => {
exitFullscreen();
await localParticipant.setScreenShareEnabled(!isScreenShareEnabled, {
@@ -395,6 +529,7 @@ export const InCallView: FC<InCallViewProps> = subscribe(
);
footer = (
<div
ref={footerRef}
className={classNames(
showControls
? styles.footer
@@ -417,8 +552,8 @@ export const InCallView: FC<InCallViewProps> = subscribe(
{!mobile && !hideHeader && showControls && (
<LayoutToggle
className={styles.layout}
layout={layout}
setLayout={setLayout}
layout={legacyLayout}
setLayout={setGridMode}
/>
)}
</div>
@@ -428,7 +563,7 @@ export const InCallView: FC<InCallViewProps> = subscribe(
return (
<div className={styles.inRoom} ref={containerRef}>
{!hideHeader && maximisedParticipant === null && (
<Header>
<Header className={styles.header} ref={headerRef}>
<LeftNav>
<RoomHeaderInfo
id={matrixInfo.roomId}
@@ -445,11 +580,9 @@ export const InCallView: FC<InCallViewProps> = subscribe(
</RightNav>
</Header>
)}
<div className={styles.controlsOverlay}>
<RoomAudioRenderer />
{renderContent()}
{footer}
</div>
<RoomAudioRenderer />
{renderContent()}
{footer}
{!noControls && (
<RageshakeRequestModal {...rageshakeRequestModalProps} />
)}

View File

@@ -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.
@@ -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<Props> = ({
// 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<CreateLocalTracksOptions["audio"]>();
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<Props> = ({
// 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(

View File

@@ -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)),

View File

@@ -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<boolean>;
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.

View File

@@ -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<T>(value: T): Observable<T> {
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<T>(initialValue: T): [Observable<T>, Ref<T>] {
const subject = useInitial(() => new BehaviorSubject(initialValue));
const ref = useCallback((value: T) => subject.next(value), [subject]);
return [subject, ref];
}

View File

@@ -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%;
}

View File

@@ -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<HTMLDivElement>;
className?: string;
style?: ComponentProps<typeof animated.div>["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<HTMLDivElement, TileProps>(
(
{
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 (
<animated.div
className={classNames(styles.videoTile, className, {
[styles.maximised]: maximised,
[styles.videoMuted]: !videoEnabled,
})}
style={style}
ref={mergedRef}
data-testid="videoTile"
{...props}
>
<div className={styles.bg}>
<Avatar
id={member?.userId ?? displayName}
name={displayName}
size={Math.round(Math.min(targetWidth, targetHeight) / 2)}
src={member?.getMxcAvatarUrl()}
className={styles.avatar}
/>
{video.publication !== undefined && (
<VideoTrack
trackRef={video}
// There's no reason for this to be focusable
tabIndex={-1}
disablePictureInPicture
/>
)}
</div>
<div className={styles.fg}>
<div className={styles.nameTag}>
{nameTagLeadingIcon}
<Text as="span" size="sm" weight="medium" className={styles.name}>
{nameTag}
</Text>
{unencryptedWarning && (
<Tooltip
label={t("common.unencrypted")}
side="bottom"
isTriggerInteractive={false}
>
<ErrorIcon
width={20}
height={20}
aria-label={t("common.unencrypted")}
className={styles.errorIcon}
/>
</Tooltip>
)}
</div>
{primaryButton}
{secondaryButton}
</div>
</animated.div>
);
},
);
Tile.displayName = "Tile";
import { MediaView } from "./MediaView";
interface UserMediaTileProps {
vm: UserMediaViewModel;
@@ -175,8 +52,6 @@ interface UserMediaTileProps {
style?: ComponentProps<typeof animated.div>["style"];
targetWidth: number;
targetHeight: number;
nameTag: string;
displayName: string;
maximised: boolean;
onOpenProfile: () => void;
showSpeakingIndicator: boolean;
@@ -190,8 +65,6 @@ const UserMediaTile = subscribe<UserMediaTileProps, HTMLDivElement>(
style,
targetWidth,
targetHeight,
nameTag,
displayName,
maximised,
onOpenProfile,
showSpeakingIndicator,
@@ -199,6 +72,7 @@ const UserMediaTile = subscribe<UserMediaTileProps, HTMLDivElement>(
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<UserMediaTileProps, HTMLDivElement>(
);
const tile = (
<Tile
tileRef={ref}
className={classNames(className, {
[styles.mirror]: mirror,
<MediaView
ref={ref}
className={classNames(className, styles.tile, {
[styles.speaking]: showSpeakingIndicator && speaking,
[styles.cropVideo]: cropVideo,
})}
data-maximised={maximised}
style={style}
targetWidth={targetWidth}
targetHeight={targetHeight}
video={video}
videoFit={cropVideo ? "cover" : "contain"}
mirror={mirror}
member={vm.member}
videoEnabled={videoEnabled}
maximised={maximised}
unencryptedWarning={unencryptedWarning}
nameTagLeadingIcon={
<MicIcon
@@ -334,8 +208,6 @@ interface ScreenShareTileProps {
style?: ComponentProps<typeof animated.div>["style"];
targetWidth: number;
targetHeight: number;
nameTag: string;
displayName: string;
maximised: boolean;
fullscreen: boolean;
onToggleFullscreen: (itemId: string) => void;
@@ -349,8 +221,6 @@ const ScreenShareTile = subscribe<ScreenShareTileProps, HTMLDivElement>(
style,
targetWidth,
targetHeight,
nameTag,
displayName,
maximised,
fullscreen,
onToggleFullscreen,
@@ -358,6 +228,7 @@ const ScreenShareTile = subscribe<ScreenShareTileProps, HTMLDivElement>(
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<ScreenShareTileProps, HTMLDivElement>(
const FullScreenIcon = fullscreen ? CollapseIcon : ExpandIcon;
return (
<Tile
<MediaView
ref={ref}
className={classNames(className, styles.screenshare)}
className={classNames(className, styles.tile, {
[styles.maximised]: maximised,
})}
data-maximised={maximised}
style={style}
targetWidth={targetWidth}
targetHeight={targetHeight}
video={video}
videoFit="contain"
mirror={false}
member={vm.member}
videoEnabled={true}
maximised={maximised}
videoEnabled
unencryptedWarning={unencryptedWarning}
nameTag={nameTag}
displayName={displayName}
@@ -415,7 +290,7 @@ interface Props {
showSpeakingIndicator: boolean;
}
export const VideoTile = forwardRef<HTMLDivElement, Props>(
export const GridTile = forwardRef<HTMLDivElement, Props>(
(
{
vm,
@@ -431,30 +306,6 @@ export const VideoTile = forwardRef<HTMLDivElement, Props>(
},
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 (
<UserMediaTile
@@ -464,8 +315,6 @@ export const VideoTile = forwardRef<HTMLDivElement, Props>(
vm={vm}
targetWidth={targetWidth}
targetHeight={targetHeight}
nameTag={nameTag}
displayName={displayName}
maximised={maximised}
onOpenProfile={onOpenProfile}
showSpeakingIndicator={showSpeakingIndicator}
@@ -480,8 +329,6 @@ export const VideoTile = forwardRef<HTMLDivElement, Props>(
vm={vm}
targetWidth={targetWidth}
targetHeight={targetHeight}
nameTag={nameTag}
displayName={displayName}
maximised={maximised}
fullscreen={fullscreen}
onToggleFullscreen={onToggleFullscreen}
@@ -491,4 +338,4 @@ export const VideoTile = forwardRef<HTMLDivElement, Props>(
},
);
VideoTile.displayName = "VideoTile";
GridTile.displayName = "GridTile";

View File

@@ -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);
border-radius: var(--media-view-border-radius);
}
/* 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;
}
@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;
}
.videoTile.cropVideo video {
.media[data-video-fit="cover"] video {
object-fit: cover;
}
.videoTile.videoMuted video {
.media[data-video-fit="contain"] video {
object-fit: contain;
}
.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%;
}

130
src/tile/MediaView.tsx Normal file
View File

@@ -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<typeof animated.div> {
className?: string;
style?: ComponentProps<typeof animated.div>["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<HTMLDivElement, Props>(
(
{
className,
style,
targetWidth,
targetHeight,
video,
videoFit,
mirror,
member,
videoEnabled,
unencryptedWarning,
nameTagLeadingIcon,
nameTag,
displayName,
primaryButton,
secondaryButton,
...props
},
ref,
) => {
const { t } = useTranslation();
return (
<animated.div
className={classNames(styles.media, className, {
[styles.mirror]: mirror,
[styles.videoMuted]: !videoEnabled,
})}
style={style}
ref={ref}
data-testid="videoTile"
data-video-fit={videoFit}
{...props}
>
<div className={styles.bg}>
<Avatar
id={member?.userId ?? displayName}
name={displayName}
size={Math.round(Math.min(targetWidth, targetHeight) / 2)}
src={member?.getMxcAvatarUrl()}
className={styles.avatar}
/>
{video.publication !== undefined && (
<VideoTrack
trackRef={video}
// There's no reason for this to be focusable
tabIndex={-1}
disablePictureInPicture
/>
)}
</div>
<div className={styles.fg}>
<div className={styles.nameTag}>
{nameTagLeadingIcon}
<Text as="span" size="sm" weight="medium" className={styles.name}>
{nameTag}
</Text>
{unencryptedWarning && (
<Tooltip
label={t("common.unencrypted")}
side="bottom"
isTriggerInteractive={false}
>
<ErrorIcon
width={20}
height={20}
aria-label={t("common.unencrypted")}
className={styles.errorIcon}
/>
</Tooltip>
)}
</div>
{primaryButton}
{secondaryButton}
</div>
</animated.div>
);
},
);
MediaView.displayName = "MediaView";

View File

@@ -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;
}

263
src/tile/SpotlightTile.tsx Normal file
View File

@@ -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<IntersectionObserver>;
/**
* Whether this item should act as a scroll snapping point.
*/
snap: boolean;
}
const SpotlightItem = subscribe<SpotlightItemProps, HTMLDivElement>(
({ vm, targetWidth, targetHeight, intersectionObserver, snap }, theirRef) => {
const ourRef = useRef<HTMLDivElement | null>(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 (
<MediaView
ref={ref}
data-id={vm.id}
className={classNames(styles.item, { [styles.snap]: snap })}
targetWidth={targetWidth}
targetHeight={targetHeight}
video={video}
videoFit={cropVideo ? "cover" : "contain"}
mirror={mirror}
member={vm.member}
videoEnabled={videoEnabled}
unencryptedWarning={unencryptedWarning}
nameTag={nameTag}
displayName={displayName}
/>
);
},
);
interface Props {
vms: MediaViewModel[];
maximised: boolean;
fullscreen: boolean;
onToggleFullscreen: () => void;
targetWidth: number;
targetHeight: number;
className?: string;
style?: ComponentProps<typeof animated.div>["style"];
}
export const SpotlightTile = forwardRef<HTMLDivElement, Props>(
(
{
vms,
maximised,
fullscreen,
onToggleFullscreen,
targetWidth,
targetHeight,
className,
style,
},
theirRef,
) => {
const { t } = useTranslation();
const [root, ourRef] = useObservableRef<HTMLDivElement | null>(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<Observable<IntersectionObserver>>(
() =>
root.pipe(
map(
(r) =>
new IntersectionObserver(
(entries) => {
const visible = entries.find((e) => e.isIntersecting);
if (visible !== undefined)
setVisibleId(visible.target.getAttribute("data-id")!);
},
{ root: r, threshold: 0.5 },
),
),
),
);
const [scrollToId, setScrollToId] = useReactiveState<string | null>(
(prev) =>
prev == null || prev === visibleId || 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 (
<animated.div
ref={ref}
className={classNames(className, styles.tile, {
[styles.maximised]: maximised,
})}
style={style}
>
{canGoBack && (
<button
className={classNames(styles.advance, styles.back)}
aria-label={t("common.back")}
onClick={onBackClick}
>
<ChevronLeftIcon aria-hidden width={24} height={24} />
</button>
)}
<Glass className={styles.border}>
{/* Similarly we need a wrapper element here because Glass expects a
single child */}
<div className={styles.contents}>
{vms.map((vm) => (
<SpotlightItem
key={vm.id}
vm={vm}
targetWidth={targetWidth}
targetHeight={targetHeight}
intersectionObserver={intersectionObserver}
snap={scrollToId === null || scrollToId === vm.id}
/>
))}
</div>
</Glass>
<button
className={classNames(styles.fullScreen)}
aria-label={
fullscreen
? t("video_tile.full_screen")
: t("video_tile.exit_full_screen")
}
onClick={onToggleFullscreen}
>
<FullScreenIcon aria-hidden width={20} height={20} />
</button>
{canGoToNext && (
<button
className={classNames(styles.advance, styles.next)}
aria-label={t("common.next")}
onClick={onNextClick}
>
<ChevronRightIcon aria-hidden width={24} height={24} />
</button>
)}
</animated.div>
);
},
);
SpotlightTile.displayName = "SpotlightTile";

26
src/useInitial.ts Normal file
View File

@@ -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<T>(getValue: () => T): T {
const ref = useRef<{ value: T }>();
ref.current ??= { value: getValue() };
return ref.current.value;
}

31
src/useLatest.ts Normal file
View File

@@ -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<T> extends RefObject<T> {
current: T;
}
/**
* React hook that returns a ref containing the value given on the latest
* render.
*/
export function useLatest<T>(value: T): LatestRef<T> {
const ref = useRef<T>(value);
ref.current = value;
return ref;
}

View File

@@ -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.
@@ -44,7 +44,8 @@ export const useReactiveState = <T>(
if (
prevDeps.current === undefined ||
deps.length !== prevDeps.current.length ||
deps.some((d, i) => d !== prevDeps.current![i])
// Deps might be NaN, so we compare with Object.is rather than ===
deps.some((d, i) => !Object.is(d, prevDeps.current![i]))
) {
state.current = updateFn(state.current);
}

File diff suppressed because it is too large Load Diff

View File

@@ -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<State> {
/**
* The layout state for zero tiles.
*/
readonly emptyState: State;
/**
* Updates/adds/removes tiles in a way that looks natural in the context of
* the given initial state.
*/
readonly updateTiles: <T>(s: State, tiles: TileDescriptor<T>[]) => State;
/**
* Adapts the layout to a new container size.
*/
readonly updateBounds: (s: State, bounds: RectReadOnly) => State;
/**
* Gets tiles in the order created by the layout.
*/
readonly getTiles: <T>(s: State) => TileDescriptor<T>[];
/**
* Determines whether a tile is draggable.
*/
readonly canDragTile: <T>(s: State, tile: TileDescriptor<T>) => boolean;
/**
* Drags the tile 'from' to the location of the tile 'to' (if possible).
* The position parameters are numbers in the range [0, 1) describing the
* specific positions on 'from' and 'to' that the drag gesture is targeting.
*/
readonly dragTile: <T>(
s: State,
from: TileDescriptor<T>,
to: TileDescriptor<T>,
xPositionOnFrom: number,
yPositionOnFrom: number,
xPositionOnTo: number,
yPositionOnTo: number,
) => State;
/**
* Toggles the focus of the given tile (if this layout has the concept of
* focus).
*/
readonly toggleFocus?: <T>(s: State, tile: TileDescriptor<T>) => State;
/**
* A React component generating the slot elements for a given layout state.
*/
readonly Slots: ComponentType<{ s: State }>;
/**
* Whether the state of this layout should be remembered even while a
* different layout is active.
*/
readonly rememberState: boolean;
}
/**
* A version of Map with stronger types that allow us to save layout states in a
* type-safe way.
*/
export interface LayoutStatesMap {
get<State>(layout: Layout<State>): State | undefined;
set<State>(layout: Layout<State>, state: State): LayoutStatesMap;
delete<State>(layout: Layout<State>): boolean;
}
/**
* Hook creating a Map to store layout states in.
*/
export const useLayoutStates = (): LayoutStatesMap => {
const layoutStates = useRef<Map<unknown, unknown>>();
if (layoutStates.current === undefined) layoutStates.current = new Map();
return layoutStates.current as LayoutStatesMap;
};
interface UseLayout<State, T> {
state: State;
orderedItems: TileDescriptor<T>[];
generation: number;
canDragTile: (tile: TileDescriptor<T>) => boolean;
dragTile: (
from: TileDescriptor<T>,
to: TileDescriptor<T>,
xPositionOnFrom: number,
yPositionOnFrom: number,
xPositionOnTo: number,
yPositionOnTo: number,
) => void;
toggleFocus: ((tile: TileDescriptor<T>) => 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<State, T>(
layout: Layout<State>,
items: TileDescriptor<T>[],
bounds: RectReadOnly,
layoutStates: LayoutStatesMap,
): UseLayout<State, T> {
const prevLayout = useRef<Layout<unknown>>();
const prevState = layoutStates.get(layout);
const [state, setState] = useReactiveState<State>(() => {
// If the bounds aren't known yet, don't add anything to the layout
if (bounds.width === 0) {
return layout.emptyState;
} else {
if (
prevLayout.current !== undefined &&
layout !== prevLayout.current &&
!prevLayout.current.rememberState
)
layoutStates.delete(prevLayout.current);
const baseState = layoutStates.get(layout) ?? layout.emptyState;
return layout.updateTiles(layout.updateBounds(baseState, bounds), items);
}
}, [layout, items, bounds]);
const generation = useRef<number>(0);
if (state !== prevState) generation.current++;
prevLayout.current = layout as Layout<unknown>;
// No point in remembering an empty state, plus it would end up clobbering the
// real saved state while restoring a layout
if (state !== layout.emptyState) layoutStates.set(layout, state);
return {
state,
orderedItems: useMemo(() => layout.getTiles<T>(state), [layout, state]),
generation: generation.current,
canDragTile: useCallback(
(tile: TileDescriptor<T>) => layout.canDragTile(state, tile),
[layout, state],
),
dragTile: useCallback(
(
from: TileDescriptor<T>,
to: TileDescriptor<T>,
xPositionOnFrom: number,
yPositionOnFrom: number,
xPositionOnTo: number,
yPositionOnTo: number,
) =>
setState((s) =>
layout.dragTile(
s,
from,
to,
xPositionOnFrom,
yPositionOnFrom,
xPositionOnTo,
yPositionOnTo,
),
),
[layout, setState],
),
toggleFocus: useMemo(
() =>
layout.toggleFocus &&
((tile: TileDescriptor<T>): void =>
setState((s) => layout.toggleFocus!(s, tile))),
[layout, setState],
),
slots: <layout.Slots s={state} />,
};
}

View File

@@ -1,389 +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,
TileSpringUpdate,
} 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<T> extends Rect {
item: TileDescriptor<T>;
}
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<SlotProps> = ({ style }) => (
<div className={styles.slot} style={style} />
);
/**
* An interactive, animated grid of video tiles.
*/
export function NewVideoGrid<T>({
items,
disableAnimations,
layoutStates,
children,
}: Props<T>): 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<HTMLDivElement | null>(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 (): void => observer.disconnect();
}
}, [slotsRoot, setRenderedGeneration]);
const [gridRef1, gridBounds] = useMeasure();
const gridRef2 = useRef<HTMLDivElement | null>(null);
const gridRef = useMergedRefs(gridRef1, gridRef2);
const slotRects = useMemo(() => {
if (slotsRoot === null) return [];
const slots = slotsRoot.getElementsByClassName(styles.slot);
const rects = new Array<Rect>(slots.length);
for (let i = 0; i < slots.length; i++) {
const slot = slots[i] as HTMLElement;
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<Tile<T>[]>(
(prevTiles) => {
// If React hasn't yet rendered the current generation of the grid, skip
// the update, because grid and slotRects will be out of sync
if (renderedGeneration !== generation) return prevTiles ?? [];
const tileRects = new Map(
zip(orderedItems, slotRects) as [TileDescriptor<T>, Rect][],
);
// In order to not break drag gestures, it's critical that we render tiles
// in a stable order (that of 'items')
return items.map((item) => ({ ...tileRects.get(item)!, item }));
},
[slotRects, grid, 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<DragState | null>(null);
const [tileTransitions, springRef] = useTransition(
tiles,
() => ({
key: ({ item }: Tile<T>): string => item.id,
from: ({ x, y, width, height }: Tile<T>): TileSpringUpdate => ({
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<T>): TileSpringUpdate | null =>
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<Tile<T>, TileSpring>, SpringRef<TileSpring>];
// Because we're using react-spring in imperative mode, we're responsible for
// firing animations manually whenever the tiles array updates
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<T>).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): number => (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<TapData | null>(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<Handler<"drag", EventTypes["drag"]>>[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<T>).item.id === tileId,
)!;
if (canDragTile((tileController.item as Tile<T>).item)) {
if (dragState.current === null) {
const tileSpring = tileController.get();
dragState.current = {
tileId,
tileX: tileSpring.x,
tileY: tileSpring.y,
cursorX: initialX - gridBounds.x,
cursorY: initialY - gridBounds.y + scrollOffset.current,
};
}
dragState.current.tileX += dx;
dragState.current.tileY += dy;
dragState.current.cursorX += dx;
dragState.current.cursorY += dy;
animateDraggedTile(last);
if (last) dragState.current = null;
}
}
};
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 <div ref={gridRef} className={styles.grid} />;
}
return (
<div ref={gridRef} className={styles.grid}>
<div
ref={setSlotsRoot}
className={styles.slots}
data-generation={generation}
>
{slots}
</div>
{tileTransitions((spring, tile) => (
<TileWrapper
key={tile.item.id}
id={tile.item.id}
onDragRef={onTileDragRef}
targetWidth={tile.width}
targetHeight={tile.height}
data={tile.item.data}
{...spring}
>
{children as (props: ChildrenProperties<T>) => ReactNode}
</TileWrapper>
))}
</div>
);
}

View File

@@ -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.
@@ -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<unknown> = {
key: "alice",

View File

@@ -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<unknown>,
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<unknown>[],
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<unknown>),
`
aab
aac
d`,
`
aab
aac
def`,
);
testAddItems(
"places one tile near another on request",
[{ id: "g", placeNear: "b" } as unknown as TileDescriptor<unknown>],
`
abc
def`,
`
abc
g
def`,
);
testAddItems(
"places items with a large base size",
[{ id: "g", largeBaseSize: true } as unknown as TileDescriptor<unknown>],
`
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`,
);