Create a proper one-on-one call layout for portrait screens

This commit is contained in:
Robin
2026-04-23 17:03:35 +02:00
parent b9f73e3e9a
commit b562a0f721
28 changed files with 911 additions and 403 deletions

View File

@@ -26,6 +26,33 @@ Please see LICENSE in the repository root for full details.
);
}
.header.hidden {
display: none;
}
.header.overlay {
/* Note that the header is still position: sticky in this case so that certain
tiles can move down out of the way of the header when visible. */
opacity: 1;
transition: opacity 0.15s;
}
.header.overlay.hidden {
display: flex;
opacity: 0;
pointer-events: none;
/* Switch to position: absolute so the header takes up no space in the layout
when hidden. */
position: absolute;
inset-block-start: 0;
inset-inline: 0;
}
.header.overlay:has(:focus-visible) {
opacity: 1;
pointer-events: initial;
}
.header.filler {
block-size: var(--cpd-space-6x);
background: none;

View File

@@ -21,7 +21,7 @@ import {
import useMeasure from "react-use-measure";
import { type MatrixRTCSession } from "matrix-js-sdk/lib/matrixrtc";
import classNames from "classnames";
import { BehaviorSubject, map } from "rxjs";
import { map } from "rxjs";
import { useObservable } from "observable-hooks";
import { logger as rootLogger } from "matrix-js-sdk/lib/logger";
import { useTranslation } from "react-i18next";
@@ -51,12 +51,9 @@ import { SpotlightTile } from "../tile/SpotlightTile";
import { type EncryptionSystem } from "../e2ee/sharedKeyManagement";
import { E2eeType } from "../e2ee/e2eeType";
import { makeGridLayout } from "../grid/GridLayout";
import {
type CallLayoutOutputs,
defaultPipAlignment,
defaultSpotlightAlignment,
} from "../grid/CallLayout";
import { makeOneOnOneLayout } from "../grid/OneOnOneLayout";
import { type CallLayoutOutputs } from "../grid/CallLayout";
import { makeOneOnOneLandscapeLayout } from "../grid/OneOnOneLandscapeLayout";
import { makeOneOnOnePortraitLayout } from "../grid/OneOnOnePortraitLayout";
import { makeSpotlightExpandedLayout } from "../grid/SpotlightExpandedLayout";
import { makeSpotlightLandscapeLayout } from "../grid/SpotlightLandscapeLayout";
import { makeSpotlightPortraitLayout } from "../grid/SpotlightPortraitLayout";
@@ -239,10 +236,11 @@ export const InCallView: FC<InCallViewProps> = ({
const audioParticipants = useBehavior(vm.livekitRoomItems$);
const participantCount = useBehavior(vm.participantCount$);
const reconnecting = useBehavior(vm.reconnecting$);
const windowMode = useBehavior(vm.windowMode$);
const layout = useBehavior(vm.layout$);
const edgeToEdge = useBehavior(vm.edgeToEdge$);
const tileStoreGeneration = useBehavior(vm.tileStoreGeneration$);
const [debugTileLayout] = useSetting(debugTileLayoutSetting);
const showNameTags = useBehavior(vm.showNameTags$);
const gridMode = useBehavior(vm.gridMode$);
const showHeader = useBehavior(vm.showHeader$);
const showFooter = useBehavior(vm.showFooter$);
@@ -325,15 +323,14 @@ export const InCallView: FC<InCallViewProps> = ({
width: bounds.width,
height:
bounds.height -
headerBounds.height -
(windowMode === "flat" ? 0 : footerBounds.height),
(edgeToEdge ? 0 : headerBounds.height + footerBounds.height),
}),
[
bounds.width,
bounds.height,
headerBounds.height,
footerBounds.height,
windowMode,
edgeToEdge,
],
);
const gridBoundsObservable$ = useObservable(
@@ -341,13 +338,6 @@ export const InCallView: FC<InCallViewProps> = ({
[gridBounds],
);
const spotlightAlignment$ = useInitial(
() => new BehaviorSubject(defaultSpotlightAlignment),
);
const pipAlignment$ = useInitial(
() => new BehaviorSubject(defaultPipAlignment),
);
const setGridMode = useCallback(
(mode: GridMode) => vm.setGridMode(mode),
[vm],
@@ -356,49 +346,47 @@ export const InCallView: FC<InCallViewProps> = ({
useAppBarHidden(!showHeader);
let header: ReactNode = null;
if (showHeader) {
switch (headerStyle) {
case HeaderStyle.AppBar: {
// dont build a header here. The AppBar will take care of it.
break;
}
case HeaderStyle.None:
// Cosmetic header to fill out space while still affecting the bounds of
// the grid
header = (
<div
className={classNames(styles.header, styles.filler)}
ref={headerRef}
/>
);
break;
case HeaderStyle.Standard:
header = (
<Header
className={styles.header}
ref={headerRef}
disconnectedBanner={false} // This screen has its own 'reconnecting' toast
>
<LeftNav>
<RoomHeaderInfo
id={matrixInfo.roomId}
name={matrixInfo.roomName}
avatarUrl={matrixInfo.roomAvatar}
encrypted={matrixInfo.e2eeSystem.kind !== E2eeType.NONE}
participantCount={participantCount}
/>
</LeftNav>
<RightNav>
{showControls && onShareClick !== null && (
<InviteButton
className={styles.invite}
onClick={onShareClick}
/>
)}
</RightNav>
</Header>
);
switch (headerStyle) {
case HeaderStyle.AppBar: {
// dont build a header here. The AppBar will take care of it.
break;
}
case HeaderStyle.None:
// Cosmetic header to fill out space while still affecting the bounds of
// the grid
header = showHeader && (
<div
className={classNames(styles.header, styles.filler)}
ref={headerRef}
/>
);
break;
case HeaderStyle.Standard:
header = (
<Header
className={classNames(styles.header, {
[styles.overlay]: edgeToEdge,
[styles.hidden]: !showHeader,
})}
ref={headerRef}
disconnectedBanner={false} // This screen has its own 'reconnecting' toast
>
<LeftNav>
<RoomHeaderInfo
id={matrixInfo.roomId}
name={matrixInfo.roomName}
avatarUrl={matrixInfo.roomAvatar}
encrypted={matrixInfo.e2eeSystem.kind !== E2eeType.NONE}
participantCount={participantCount}
/>
</LeftNav>
<RightNav>
{showControls && onShareClick !== null && (
<InviteButton className={styles.invite} onClick={onShareClick} />
)}
</RightNav>
</Header>
);
}
// The reconnecting toast cannot be dismissed
@@ -445,12 +433,11 @@ export const InCallView: FC<InCallViewProps> = ({
}: TileProps<TileViewModel, HTMLDivElement>): ReactNode {
const spotlightExpanded = useBehavior(vm.spotlightExpanded$);
const onToggleExpanded = useBehavior(vm.toggleSpotlightExpanded$);
const showSpeakingIndicatorsValue = useBehavior(
vm.showSpeakingIndicators$,
);
const showSpotlightIndicatorsValue = useBehavior(
const showSpotlightIndicators = useBehavior(
vm.showSpotlightIndicators$,
);
const showSpeakingIndicators = useBehavior(vm.showSpeakingIndicators$);
const showNameTags = useBehavior(vm.showNameTags$);
return model instanceof GridTileViewModel ? (
<GridTile
@@ -461,7 +448,8 @@ export const InCallView: FC<InCallViewProps> = ({
targetHeight={targetHeight}
className={classNames(className, styles.tile)}
style={style}
showSpeakingIndicators={showSpeakingIndicatorsValue}
showSpeakingIndicators={showSpeakingIndicators}
showNameTags={showNameTags}
focusable={!contentObscured}
/>
) : (
@@ -472,7 +460,8 @@ export const InCallView: FC<InCallViewProps> = ({
onToggleExpanded={onToggleExpanded}
targetWidth={targetWidth}
targetHeight={targetHeight}
showIndicators={showSpotlightIndicatorsValue}
showIndicators={showSpotlightIndicators}
showNameTags={showNameTags}
focusable={!contentObscured}
className={classNames(className, styles.tile)}
style={style}
@@ -483,19 +472,16 @@ export const InCallView: FC<InCallViewProps> = ({
);
const layouts = useMemo(() => {
const inputs = {
minBounds$: gridBoundsObservable$,
spotlightAlignment$,
pipAlignment$,
};
const inputs = { minBounds$: gridBoundsObservable$ };
return {
grid: makeGridLayout(inputs),
"spotlight-landscape": makeSpotlightLandscapeLayout(inputs),
"spotlight-portrait": makeSpotlightPortraitLayout(inputs),
"spotlight-expanded": makeSpotlightExpandedLayout(inputs),
"one-on-one": makeOneOnOneLayout(inputs),
"one-on-one-landscape": makeOneOnOneLandscapeLayout(inputs),
"one-on-one-portrait": makeOneOnOnePortraitLayout(inputs),
};
}, [gridBoundsObservable$, spotlightAlignment$, pipAlignment$]);
}, [gridBoundsObservable$]);
const renderContent = (): JSX.Element => {
if (layout.type === "pip") {
@@ -508,6 +494,7 @@ export const InCallView: FC<InCallViewProps> = ({
targetWidth={gridBounds.width}
targetHeight={gridBounds.height}
showIndicators={false}
showNameTags={showNameTags}
focusable={!contentObscured}
aria-hidden={contentObscured}
/>
@@ -521,8 +508,8 @@ export const InCallView: FC<InCallViewProps> = ({
className={styles.fixedGrid}
style={{
insetBlockStart:
headerBounds.height > 0 ? headerBounds.bottom : bounds.top,
height: gridBounds.height,
edgeToEdge || headerBounds.height === 0 ? 0 : headerBounds.bottom,
height: edgeToEdge ? "100%" : gridBounds.height,
}}
model={layout}
Layout={layers.fixed}
@@ -540,19 +527,24 @@ export const InCallView: FC<InCallViewProps> = ({
aria-hidden={contentObscured}
/>
);
// The grid tiles go *under* the spotlight in the portrait layout, but
// *over* the spotlight in the expanded layout
return layout.type === "spotlight-expanded" ? (
<>
{fixedGrid}
{scrollingGrid}
</>
) : (
<>
{scrollingGrid}
{fixedGrid}
</>
);
// Put the right layer in the foreground for the requested layout
switch (layers.foreground) {
case "fixed":
return (
<>
{scrollingGrid}
{fixedGrid}
</>
);
case "scrolling":
return (
<>
{fixedGrid}
{scrollingGrid}
</>
);
}
};
const rageshakeRequestModalProps = useRageshakeRequestModal(
@@ -575,7 +567,7 @@ export const InCallView: FC<InCallViewProps> = ({
ref={footerRef}
hidden={!showFooter}
hideControls={!showControls}
asOverlay={windowMode === "flat"}
asOverlay={edgeToEdge}
asPip={layout.type === "pip"}
// Hide the logo for both embedded solutions. mobile: HeaderStyle.AppBar and desktop: HeaderStyle.None.
hideLogo={headerStyle !== HeaderStyle.Standard}

View File

@@ -83,6 +83,11 @@ exports[`InCallView > rendering > renders 1`] = `
class="nav rightNav"
/>
</header>
<div
class="fixedGrid grid"
>
<div />
</div>
<div
class="scrollingGrid grid"
>
@@ -102,11 +107,6 @@ exports[`InCallView > rendering > renders 1`] = `
</div>
</div>
</div>
<div
class="fixedGrid grid"
>
<div />
</div>
<div
class="bg animate"
data-state="closed"