Skip to content

Commit 0c66ec5

Browse files
authored
Merge pull request #2894 from robintown/stable-visibility
Determine which tiles are on screen in a more stable manner
2 parents 92813bd + 53565dd commit 0c66ec5

14 files changed

+152
-141
lines changed

src/grid/Grid.tsx

Lines changed: 43 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,6 @@ import {
2424
createContext,
2525
forwardRef,
2626
memo,
27-
useCallback,
2827
useContext,
2928
useEffect,
3029
useMemo,
@@ -54,7 +53,6 @@ interface Tile<Model> {
5453
id: string;
5554
model: Model;
5655
onDrag: DragCallback | undefined;
57-
setVisible: (visible: boolean) => void;
5856
}
5957

6058
type PlacedTile<Model> = Tile<Model> & Rect;
@@ -88,7 +86,6 @@ interface SlotProps<Model> extends Omit<ComponentProps<"div">, "onDrag"> {
8886
id: string;
8987
model: Model;
9088
onDrag?: DragCallback;
91-
onVisibilityChange?: (visible: boolean) => void;
9289
style?: CSSProperties;
9390
className?: string;
9491
}
@@ -115,24 +112,47 @@ function offset(element: HTMLElement, relativeTo: Element): Offset {
115112
}
116113
}
117114

115+
export type VisibleTilesCallback = (visibleTiles: number) => void;
116+
118117
interface LayoutContext {
119118
setGeneration: Dispatch<SetStateAction<number | null>>;
119+
setVisibleTilesCallback: Dispatch<
120+
SetStateAction<VisibleTilesCallback | null>
121+
>;
120122
}
121123

122124
const LayoutContext = createContext<LayoutContext | null>(null);
123125

126+
function useLayoutContext(): LayoutContext {
127+
const context = useContext(LayoutContext);
128+
if (context === null)
129+
throw new Error("useUpdateLayout called outside a Grid layout context");
130+
return context;
131+
}
132+
124133
/**
125134
* Enables Grid to react to layout changes. You must call this in your Layout
126135
* component or else Grid will not be reactive.
127136
*/
128137
export function useUpdateLayout(): void {
129-
const context = useContext(LayoutContext);
130-
if (context === null)
131-
throw new Error("useUpdateLayout called outside a Grid layout context");
132-
138+
const { setGeneration } = useLayoutContext();
133139
// On every render, tell Grid that the layout may have changed
134-
useEffect(() =>
135-
context.setGeneration((prev) => (prev === null ? 0 : prev + 1)),
140+
useEffect(() => setGeneration((prev) => (prev === null ? 0 : prev + 1)));
141+
}
142+
143+
/**
144+
* Asks Grid to call a callback whenever the number of visible tiles may have
145+
* changed.
146+
*/
147+
export function useVisibleTiles(callback: VisibleTilesCallback): void {
148+
const { setVisibleTilesCallback } = useLayoutContext();
149+
useEffect(
150+
() => setVisibleTilesCallback(() => callback),
151+
[callback, setVisibleTilesCallback],
152+
);
153+
useEffect(
154+
() => (): void => setVisibleTilesCallback(null),
155+
[setVisibleTilesCallback],
136156
);
137157
}
138158

@@ -245,39 +265,20 @@ export function Grid<
245265
const windowHeight = useObservableEagerState(windowHeightObservable);
246266
const [layoutRoot, setLayoutRoot] = useState<HTMLElement | null>(null);
247267
const [generation, setGeneration] = useState<number | null>(null);
268+
const [visibleTilesCallback, setVisibleTilesCallback] =
269+
useState<VisibleTilesCallback | null>(null);
248270
const tiles = useInitial(() => new Map<string, Tile<TileModel>>());
249271
const prefersReducedMotion = usePrefersReducedMotion();
250272

251273
const Slot: FC<SlotProps<TileModel>> = useMemo(
252274
() =>
253-
function Slot({
254-
id,
255-
model,
256-
onDrag,
257-
onVisibilityChange,
258-
style,
259-
className,
260-
...props
261-
}) {
275+
function Slot({ id, model, onDrag, style, className, ...props }) {
262276
const ref = useRef<HTMLDivElement | null>(null);
263-
const prevVisible = useRef<boolean | null>(null);
264-
const setVisible = useCallback(
265-
(visible: boolean) => {
266-
if (
267-
onVisibilityChange !== undefined &&
268-
visible !== prevVisible.current
269-
) {
270-
onVisibilityChange(visible);
271-
prevVisible.current = visible;
272-
}
273-
},
274-
[onVisibilityChange],
275-
);
276277

277278
useEffect(() => {
278-
tiles.set(id, { id, model, onDrag, setVisible });
279+
tiles.set(id, { id, model, onDrag });
279280
return (): void => void tiles.delete(id);
280-
}, [id, model, onDrag, setVisible]);
281+
}, [id, model, onDrag]);
281282

282283
return (
283284
<div
@@ -307,7 +308,10 @@ export function Grid<
307308
[],
308309
);
309310

310-
const context: LayoutContext = useMemo(() => ({ setGeneration }), []);
311+
const context: LayoutContext = useMemo(
312+
() => ({ setGeneration, setVisibleTilesCallback }),
313+
[setVisibleTilesCallback],
314+
);
311315

312316
// Combine the tile definitions and slots together to create placed tiles
313317
const placedTiles = useMemo(() => {
@@ -342,9 +346,11 @@ export function Grid<
342346
);
343347

344348
useEffect(() => {
345-
for (const tile of placedTiles)
346-
tile.setVisible(tile.y + tile.height <= visibleHeight);
347-
}, [placedTiles, visibleHeight]);
349+
visibleTilesCallback?.(
350+
placedTiles.filter((tile) => tile.y + tile.height <= visibleHeight)
351+
.length,
352+
);
353+
}, [placedTiles, visibleTilesCallback, visibleHeight]);
348354

349355
// Drag state is stored in a ref rather than component state, because we use
350356
// react-spring's imperative API during gestures to improve responsiveness

src/grid/GridLayout.tsx

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import { type GridLayout as GridLayoutModel } from "../state/CallViewModel";
1313
import styles from "./GridLayout.module.css";
1414
import { useInitial } from "../useInitial";
1515
import { type CallLayout, arrangeTiles } from "./CallLayout";
16-
import { type DragCallback, useUpdateLayout } from "./Grid";
16+
import { type DragCallback, useUpdateLayout, useVisibleTiles } from "./Grid";
1717

1818
interface GridCSSProperties extends CSSProperties {
1919
"--gap": string;
@@ -73,6 +73,7 @@ export const makeGridLayout: CallLayout<GridLayoutModel> = ({
7373
// The scrolling part of the layout is where all the grid tiles live
7474
scrolling: forwardRef(function GridLayout({ model, Slot }, ref) {
7575
useUpdateLayout();
76+
useVisibleTiles(model.setVisibleTiles);
7677
const { width, height: minHeight } = useObservableEagerState(minBounds);
7778
const { gap, tileWidth, tileHeight } = useMemo(
7879
() => arrangeTiles(width, minHeight, model.grid.length),
@@ -93,13 +94,7 @@ export const makeGridLayout: CallLayout<GridLayoutModel> = ({
9394
}
9495
>
9596
{model.grid.map((m) => (
96-
<Slot
97-
key={m.id}
98-
className={styles.slot}
99-
id={m.id}
100-
model={m}
101-
onVisibilityChange={m.setVisible}
102-
/>
97+
<Slot key={m.id} className={styles.slot} id={m.id} model={m} />
10398
))}
10499
</div>
105100
);

src/grid/OneOnOneLayout.tsx

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,6 @@ export const makeOneOnOneLayout: CallLayout<OneOnOneLayoutModel> = ({
5252
<Slot
5353
id={model.remote.id}
5454
model={model.remote}
55-
onVisibilityChange={model.remote.setVisible}
5655
className={styles.container}
5756
style={{ width: tileWidth, height: tileHeight }}
5857
>
@@ -61,7 +60,6 @@ export const makeOneOnOneLayout: CallLayout<OneOnOneLayoutModel> = ({
6160
id={model.local.id}
6261
model={model.local}
6362
onDrag={onDragLocalTile}
64-
onVisibilityChange={model.local.setVisible}
6563
data-block-alignment={pipAlignmentValue.block}
6664
data-inline-alignment={pipAlignmentValue.inline}
6765
/>

src/grid/SpotlightExpandedLayout.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,6 @@ export const makeSpotlightExpandedLayout: CallLayout<
6363
id={model.pip.id}
6464
model={model.pip}
6565
onDrag={onDragPip}
66-
onVisibilityChange={model.pip.setVisible}
6766
data-block-alignment={pipAlignmentValue.block}
6867
data-inline-alignment={pipAlignmentValue.inline}
6968
/>

src/grid/SpotlightLandscapeLayout.tsx

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import classNames from "classnames";
1212
import { type CallLayout } from "./CallLayout";
1313
import { type SpotlightLandscapeLayout as SpotlightLandscapeLayoutModel } from "../state/CallViewModel";
1414
import styles from "./SpotlightLandscapeLayout.module.css";
15-
import { useUpdateLayout } from "./Grid";
15+
import { useUpdateLayout, useVisibleTiles } from "./Grid";
1616

1717
/**
1818
* An implementation of the "spotlight landscape" layout, in which the spotlight
@@ -50,6 +50,7 @@ export const makeSpotlightLandscapeLayout: CallLayout<
5050
ref,
5151
) {
5252
useUpdateLayout();
53+
useVisibleTiles(model.setVisibleTiles);
5354
useObservableEagerState(minBounds);
5455
const withIndicators =
5556
useObservableEagerState(model.spotlight.media).length > 1;
@@ -63,13 +64,7 @@ export const makeSpotlightLandscapeLayout: CallLayout<
6364
/>
6465
<div className={styles.grid}>
6566
{model.grid.map((m) => (
66-
<Slot
67-
key={m.id}
68-
className={styles.slot}
69-
id={m.id}
70-
model={m}
71-
onVisibilityChange={m.setVisible}
72-
/>
67+
<Slot key={m.id} className={styles.slot} id={m.id} model={m} />
7368
))}
7469
</div>
7570
</div>

src/grid/SpotlightPortraitLayout.tsx

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import classNames from "classnames";
1212
import { type CallLayout, arrangeTiles } from "./CallLayout";
1313
import { type SpotlightPortraitLayout as SpotlightPortraitLayoutModel } from "../state/CallViewModel";
1414
import styles from "./SpotlightPortraitLayout.module.css";
15-
import { useUpdateLayout } from "./Grid";
15+
import { useUpdateLayout, useVisibleTiles } from "./Grid";
1616

1717
interface GridCSSProperties extends CSSProperties {
1818
"--grid-gap": string;
@@ -54,6 +54,7 @@ export const makeSpotlightPortraitLayout: CallLayout<
5454
ref,
5555
) {
5656
useUpdateLayout();
57+
useVisibleTiles(model.setVisibleTiles);
5758
const { width } = useObservableEagerState(minBounds);
5859
const { gap, tileWidth, tileHeight } = arrangeTiles(
5960
width,
@@ -84,13 +85,7 @@ export const makeSpotlightPortraitLayout: CallLayout<
8485
/>
8586
<div className={styles.grid}>
8687
{model.grid.map((m) => (
87-
<Slot
88-
key={m.id}
89-
className={styles.slot}
90-
id={m.id}
91-
model={m}
92-
onVisibilityChange={m.setVisible}
93-
/>
88+
<Slot key={m.id} className={styles.slot} id={m.id} model={m} />
9489
))}
9590
</div>
9691
</div>

src/state/CallViewModel.test.ts

Lines changed: 57 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -376,16 +376,16 @@ test("screen sharing activates spotlight layout", () => {
376376

377377
test("participants stay in the same order unless to appear/disappear", () => {
378378
withTestScheduler(({ hot, schedule, expectObservable }) => {
379-
const modeInputMarbles = " a";
379+
const visibilityInputMarbles = "a";
380380
// First Bob speaks, then Dave, then Alice
381-
const aSpeakingInputMarbles = "n- 1998ms - 1999ms y";
382-
const bSpeakingInputMarbles = "ny 1998ms n 1999ms -";
383-
const dSpeakingInputMarbles = "n- 1998ms y 1999ms n";
381+
const aSpeakingInputMarbles = " n- 1998ms - 1999ms y";
382+
const bSpeakingInputMarbles = " ny 1998ms n 1999ms -";
383+
const dSpeakingInputMarbles = " n- 1998ms y 1999ms n";
384384
// Nothing should change when Bob speaks, because Bob is already on screen.
385385
// When Dave speaks he should switch with Alice because she's the one who
386386
// hasn't spoken at all. Then when Alice speaks, she should return to her
387387
// place at the top.
388-
const expectedLayoutMarbles = "a 1999ms b 1999ms a 57999ms c 1999ms a";
388+
const expectedLayoutMarbles = " a 1999ms b 1999ms a 57999ms c 1999ms a";
389389

390390
withCallViewModel(
391391
of([aliceParticipant, bobParticipant, daveParticipant]),
@@ -397,15 +397,12 @@ test("participants stay in the same order unless to appear/disappear", () => {
397397
[daveParticipant, hot(dSpeakingInputMarbles, { y: true, n: false })],
398398
]),
399399
(vm) => {
400-
schedule(modeInputMarbles, {
400+
schedule(visibilityInputMarbles, {
401401
a: () => {
402402
// We imagine that only three tiles (the first three) will be visible
403403
// on screen at a time
404404
vm.layout.subscribe((layout) => {
405-
if (layout.type === "grid") {
406-
for (let i = 0; i < layout.grid.length; i++)
407-
layout.grid[i].setVisible(i < 3);
408-
}
405+
if (layout.type === "grid") layout.setVisibleTiles(3);
409406
});
410407
},
411408
});
@@ -435,6 +432,56 @@ test("participants stay in the same order unless to appear/disappear", () => {
435432
});
436433
});
437434

435+
test("participants adjust order when space becomes constrained", () => {
436+
withTestScheduler(({ hot, schedule, expectObservable }) => {
437+
// Start with all tiles on screen then shrink to 3
438+
const visibilityInputMarbles = "a-b";
439+
// Bob and Dave speak
440+
const bSpeakingInputMarbles = " ny";
441+
const dSpeakingInputMarbles = " ny";
442+
// Nothing should change when Bob or Dave initially speak, because they are
443+
// on screen. When the screen becomes smaller Alice should move off screen
444+
// to make way for the speakers (specifically, she should swap with Dave).
445+
const expectedLayoutMarbles = " a-b";
446+
447+
withCallViewModel(
448+
of([aliceParticipant, bobParticipant, daveParticipant]),
449+
of([aliceRtcMember, bobRtcMember, daveRtcMember]),
450+
of(ConnectionState.Connected),
451+
new Map([
452+
[bobParticipant, hot(bSpeakingInputMarbles, { y: true, n: false })],
453+
[daveParticipant, hot(dSpeakingInputMarbles, { y: true, n: false })],
454+
]),
455+
(vm) => {
456+
let setVisibleTiles: ((value: number) => void) | null = null;
457+
vm.layout.subscribe((layout) => {
458+
if (layout.type === "grid") setVisibleTiles = layout.setVisibleTiles;
459+
});
460+
schedule(visibilityInputMarbles, {
461+
a: () => setVisibleTiles!(Infinity),
462+
b: () => setVisibleTiles!(3),
463+
});
464+
465+
expectObservable(summarizeLayout(vm.layout)).toBe(
466+
expectedLayoutMarbles,
467+
{
468+
a: {
469+
type: "grid",
470+
spotlight: undefined,
471+
grid: ["local:0", `${aliceId}:0`, `${bobId}:0`, `${daveId}:0`],
472+
},
473+
b: {
474+
type: "grid",
475+
spotlight: undefined,
476+
grid: ["local:0", `${daveId}:0`, `${bobId}:0`, `${aliceId}:0`],
477+
},
478+
},
479+
);
480+
},
481+
);
482+
});
483+
});
484+
438485
test("spotlight speakers swap places", () => {
439486
withTestScheduler(({ hot, schedule, expectObservable }) => {
440487
// Go immediately into spotlight mode for the test

0 commit comments

Comments
 (0)