Merge pull request #2325 from robintown/unified-grid
Unified grid layout
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
465
src/grid/Grid.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
54
src/grid/GridLayout.module.css
Normal file
54
src/grid/GridLayout.module.css
Normal 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
189
src/grid/GridLayout.tsx
Normal 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>
|
||||
);
|
||||
}),
|
||||
},
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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",
|
||||
};
|
||||
23
src/grid/TileWrapper.module.css
Normal file
23
src/grid/TileWrapper.module.css
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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} />
|
||||
)}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
81
src/tile/GridTile.module.css
Normal file
81
src/tile/GridTile.module.css
Normal 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%;
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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
130
src/tile/MediaView.tsx
Normal 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";
|
||||
153
src/tile/SpotlightTile.module.css
Normal file
153
src/tile/SpotlightTile.module.css
Normal 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
263
src/tile/SpotlightTile.tsx
Normal 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
26
src/useInitial.ts
Normal 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
31
src/useLatest.ts
Normal 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;
|
||||
}
|
||||
@@ -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
@@ -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} />,
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
@@ -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`,
|
||||
);
|
||||
Reference in New Issue
Block a user