mirror of
https://github.com/vector-im/element-call.git
synced 2026-07-18 18:59:23 +00:00
Merge pull request #3916 from element-hq/one-on-one-portrait
Create a proper one-on-one call layout for portrait screens
This commit is contained in:
@@ -1,5 +1,21 @@
|
|||||||
.bar {
|
.bar {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Pseudo-element for the gradient background */
|
||||||
|
.bar::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset-inline: 0;
|
||||||
|
/* Extend the gradient beyond the bottom of the header for readability */
|
||||||
|
inset-block: -24px;
|
||||||
|
z-index: var(--call-view-header-footer-layer);
|
||||||
|
background: linear-gradient(
|
||||||
|
0deg,
|
||||||
|
rgba(0, 0, 0, 0) 0%,
|
||||||
|
var(--cpd-color-bg-canvas-default) 100%
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
.bar > header {
|
.bar > header {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
|||||||
Please see LICENSE in the repository root for full details.
|
Please see LICENSE in the repository root for full details.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { type BehaviorSubject, type Observable } from "rxjs";
|
import { type Observable } from "rxjs";
|
||||||
import { type ComponentType } from "react";
|
import { type ComponentType } from "react";
|
||||||
|
|
||||||
import { type LayoutProps } from "./Grid";
|
import { type LayoutProps } from "./Grid";
|
||||||
@@ -16,37 +16,18 @@ export interface Bounds {
|
|||||||
height: number;
|
height: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Alignment {
|
|
||||||
inline: "start" | "end";
|
|
||||||
block: "start" | "end";
|
|
||||||
}
|
|
||||||
|
|
||||||
export const defaultSpotlightAlignment: Alignment = {
|
|
||||||
inline: "end",
|
|
||||||
block: "end",
|
|
||||||
};
|
|
||||||
export const defaultPipAlignment: Alignment = { inline: "end", block: "start" };
|
|
||||||
|
|
||||||
export interface CallLayoutInputs {
|
export interface CallLayoutInputs {
|
||||||
/**
|
/**
|
||||||
* The minimum bounds of the layout area.
|
* The minimum bounds of the layout area.
|
||||||
*/
|
*/
|
||||||
minBounds$: Observable<Bounds>;
|
minBounds$: Observable<Bounds>;
|
||||||
/**
|
|
||||||
* The alignment of the floating spotlight tile, if present.
|
|
||||||
*/
|
|
||||||
spotlightAlignment$: BehaviorSubject<Alignment>;
|
|
||||||
/**
|
|
||||||
* The alignment of the small picture-in-picture tile, if present.
|
|
||||||
*/
|
|
||||||
pipAlignment$: BehaviorSubject<Alignment>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CallLayoutOutputs<Model> {
|
export interface CallLayoutOutputs<Model> {
|
||||||
/**
|
/**
|
||||||
* Whether the scrolling layer of the layout should appear on top.
|
* Which layer should appear in the foreground.
|
||||||
*/
|
*/
|
||||||
scrollingOnTop: boolean;
|
foreground: "fixed" | "scrolling";
|
||||||
/**
|
/**
|
||||||
* The visually fixed (non-scrolling) layer of the layout.
|
* The visually fixed (non-scrolling) layer of the layout.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -32,9 +32,8 @@ interface GridCSSProperties extends CSSProperties {
|
|||||||
*/
|
*/
|
||||||
export const makeGridLayout: CallLayout<GridLayoutModel> = ({
|
export const makeGridLayout: CallLayout<GridLayoutModel> = ({
|
||||||
minBounds$,
|
minBounds$,
|
||||||
spotlightAlignment$,
|
|
||||||
}) => ({
|
}) => ({
|
||||||
scrollingOnTop: false,
|
foreground: "fixed",
|
||||||
|
|
||||||
// The "fixed" (non-scrolling) part of the layout is where the spotlight tile
|
// The "fixed" (non-scrolling) part of the layout is where the spotlight tile
|
||||||
// lives
|
// lives
|
||||||
@@ -42,7 +41,7 @@ export const makeGridLayout: CallLayout<GridLayoutModel> = ({
|
|||||||
useUpdateLayout();
|
useUpdateLayout();
|
||||||
const alignment = useObservableEagerState(
|
const alignment = useObservableEagerState(
|
||||||
useInitial(() =>
|
useInitial(() =>
|
||||||
spotlightAlignment$.pipe(
|
model.spotlightAlignment$.pipe(
|
||||||
distinctUntilChanged(
|
distinctUntilChanged(
|
||||||
(a1, a2) => a1.block === a2.block && a1.inline === a2.inline,
|
(a1, a2) => a1.block === a2.block && a1.inline === a2.inline,
|
||||||
),
|
),
|
||||||
@@ -52,11 +51,11 @@ export const makeGridLayout: CallLayout<GridLayoutModel> = ({
|
|||||||
|
|
||||||
const onDragSpotlight: DragCallback = useCallback(
|
const onDragSpotlight: DragCallback = useCallback(
|
||||||
({ xRatio, yRatio }) =>
|
({ xRatio, yRatio }) =>
|
||||||
spotlightAlignment$.next({
|
model.spotlightAlignment$.next({
|
||||||
block: yRatio < 0.5 ? "start" : "end",
|
block: yRatio < 0.5 ? "start" : "end",
|
||||||
inline: xRatio < 0.5 ? "start" : "end",
|
inline: xRatio < 0.5 ? "start" : "end",
|
||||||
}),
|
}),
|
||||||
[],
|
[model.spotlightAlignment$],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -22,12 +22,6 @@ Please see LICENSE in the repository root for full details.
|
|||||||
inset: 0;
|
inset: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.spotlight {
|
|
||||||
position: absolute;
|
|
||||||
inline-size: 404px;
|
|
||||||
block-size: 233px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.slot[data-block-alignment="start"] {
|
.slot[data-block-alignment="start"] {
|
||||||
inset-block-end: unset;
|
inset-block-end: unset;
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
/*
|
/*
|
||||||
Copyright 2024 New Vector Ltd.
|
Copyright 2024 New Vector Ltd.
|
||||||
|
Copyright 2026 Element Creations Ltd.
|
||||||
|
|
||||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
Please see LICENSE in the repository root for full details.
|
Please see LICENSE in the repository root for full details.
|
||||||
@@ -9,31 +10,35 @@ import { type ReactNode, useCallback, useMemo } from "react";
|
|||||||
import { useObservableEagerState } from "observable-hooks";
|
import { useObservableEagerState } from "observable-hooks";
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
|
|
||||||
import { type OneOnOneLayout as OneOnOneLayoutModel } from "../state/layout-types.ts";
|
import { type OneOnOneLandscapeLayout as OneOnOneLandscapeLayoutModel } from "../state/layout-types.ts";
|
||||||
import { type CallLayout, arrangeTiles } from "./CallLayout";
|
import { type CallLayout, arrangeTiles } from "./CallLayout";
|
||||||
import styles from "./OneOnOneLayout.module.css";
|
import styles from "./OneOnOneLandscapeLayout.module.css";
|
||||||
import { type DragCallback, useUpdateLayout } from "./Grid";
|
import { type DragCallback, useUpdateLayout } from "./Grid";
|
||||||
import { useBehavior } from "../useBehavior";
|
import { useBehavior } from "../useBehavior";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* An implementation of the "one-on-one" layout, in which the remote participant
|
* An implementation of the "one-on-one" layout for landscape screens, in which
|
||||||
* is shown at maximum size, overlaid by a small view of the local participant.
|
* the remote participant is shown at maximum size, overlaid by a small view of
|
||||||
|
* the local participant.
|
||||||
*/
|
*/
|
||||||
export const makeOneOnOneLayout: CallLayout<OneOnOneLayoutModel> = ({
|
export const makeOneOnOneLandscapeLayout: CallLayout<
|
||||||
minBounds$,
|
OneOnOneLandscapeLayoutModel
|
||||||
pipAlignment$,
|
> = ({ minBounds$ }) => ({
|
||||||
}) => ({
|
foreground: "fixed",
|
||||||
scrollingOnTop: false,
|
|
||||||
|
|
||||||
fixed: function OneOnOneLayoutFixed({ ref }): ReactNode {
|
fixed: function OneOnOneLandscapeLayoutFixed({ ref }): ReactNode {
|
||||||
useUpdateLayout();
|
useUpdateLayout();
|
||||||
return <div ref={ref} />;
|
return <div ref={ref} />;
|
||||||
},
|
},
|
||||||
|
|
||||||
scrolling: function OneOnOneLayoutScrolling({ ref, model, Slot }): ReactNode {
|
scrolling: function OneOnOneLandscapeLayoutScrolling({
|
||||||
|
ref,
|
||||||
|
model,
|
||||||
|
Slot,
|
||||||
|
}): ReactNode {
|
||||||
useUpdateLayout();
|
useUpdateLayout();
|
||||||
const { width, height } = useObservableEagerState(minBounds$);
|
const { width, height } = useObservableEagerState(minBounds$);
|
||||||
const pipAlignmentValue = useBehavior(pipAlignment$);
|
const pipAlignment = useBehavior(model.pipAlignment$);
|
||||||
const { tileWidth, tileHeight } = useMemo(
|
const { tileWidth, tileHeight } = useMemo(
|
||||||
() => arrangeTiles(width, height, 1),
|
() => arrangeTiles(width, height, 1),
|
||||||
[width, height],
|
[width, height],
|
||||||
@@ -41,11 +46,11 @@ export const makeOneOnOneLayout: CallLayout<OneOnOneLayoutModel> = ({
|
|||||||
|
|
||||||
const onDragLocalTile: DragCallback = useCallback(
|
const onDragLocalTile: DragCallback = useCallback(
|
||||||
({ xRatio, yRatio }) =>
|
({ xRatio, yRatio }) =>
|
||||||
pipAlignment$.next({
|
model.pipAlignment$.next({
|
||||||
block: yRatio < 0.5 ? "start" : "end",
|
block: yRatio < 0.5 ? "start" : "end",
|
||||||
inline: xRatio < 0.5 ? "start" : "end",
|
inline: xRatio < 0.5 ? "start" : "end",
|
||||||
}),
|
}),
|
||||||
[],
|
[model.pipAlignment$],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -61,8 +66,8 @@ export const makeOneOnOneLayout: CallLayout<OneOnOneLayoutModel> = ({
|
|||||||
id={model.pip.id}
|
id={model.pip.id}
|
||||||
model={model.pip}
|
model={model.pip}
|
||||||
onDrag={onDragLocalTile}
|
onDrag={onDragLocalTile}
|
||||||
data-block-alignment={pipAlignmentValue.block}
|
data-block-alignment={pipAlignment.block}
|
||||||
data-inline-alignment={pipAlignmentValue.inline}
|
data-inline-alignment={pipAlignment.inline}
|
||||||
/>
|
/>
|
||||||
</Slot>
|
</Slot>
|
||||||
</div>
|
</div>
|
||||||
46
src/grid/OneOnOnePortraitLayout.module.css
Normal file
46
src/grid/OneOnOnePortraitLayout.module.css
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2026 Element Creations Ltd.
|
||||||
|
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
Please see LICENSE in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
.layer {
|
||||||
|
block-size: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spotlight {
|
||||||
|
block-size: 100%;
|
||||||
|
inline-size: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pip {
|
||||||
|
position: absolute;
|
||||||
|
inset: var(--cpd-space-4x);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pip[data-size="sm"] {
|
||||||
|
inline-size: 88px;
|
||||||
|
block-size: 132px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pip[data-size="lg"] {
|
||||||
|
inline-size: 140px;
|
||||||
|
block-size: 210px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pip[data-block-alignment="start"] {
|
||||||
|
inset-block-end: unset;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pip[data-block-alignment="end"] {
|
||||||
|
inset-block-start: unset;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pip[data-inline-alignment="start"] {
|
||||||
|
inset-inline-end: unset;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pip[data-inline-alignment="end"] {
|
||||||
|
inset-inline-start: unset;
|
||||||
|
}
|
||||||
74
src/grid/OneOnOnePortraitLayout.tsx
Normal file
74
src/grid/OneOnOnePortraitLayout.tsx
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2024 New Vector Ltd.
|
||||||
|
Copyright 2026 Element Creations Ltd.
|
||||||
|
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
Please see LICENSE in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { type ReactNode, useCallback } from "react";
|
||||||
|
import classNames from "classnames";
|
||||||
|
|
||||||
|
import { type OneOnOnePortraitLayout as OneOnOnePortraitLayoutModel } from "../state/layout-types.ts";
|
||||||
|
import { type CallLayout } from "./CallLayout";
|
||||||
|
import styles from "./OneOnOnePortraitLayout.module.css";
|
||||||
|
import { type DragCallback, useUpdateLayout } from "./Grid";
|
||||||
|
import { useBehavior } from "../useBehavior";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An implementation of the "one-on-one" layout for portrait screens, in which
|
||||||
|
* the remote participant is shown at maximum size, overlaid by a small view of
|
||||||
|
* the local participant.
|
||||||
|
*/
|
||||||
|
export const makeOneOnOnePortraitLayout: CallLayout<
|
||||||
|
OneOnOnePortraitLayoutModel
|
||||||
|
> = () => ({
|
||||||
|
foreground: "scrolling",
|
||||||
|
|
||||||
|
fixed: function OneOnOnePortraitLayoutFixed({ ref, model, Slot }): ReactNode {
|
||||||
|
useUpdateLayout();
|
||||||
|
return (
|
||||||
|
<div ref={ref} className={styles.layer}>
|
||||||
|
<Slot
|
||||||
|
className={styles.spotlight}
|
||||||
|
id="spotlight"
|
||||||
|
model={model.spotlight}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
scrolling: function OneOnOnePortraitLayoutScrolling({
|
||||||
|
ref,
|
||||||
|
model,
|
||||||
|
Slot,
|
||||||
|
}): ReactNode {
|
||||||
|
useUpdateLayout();
|
||||||
|
const pipSize = useBehavior(model.pipSize$);
|
||||||
|
const pipAlignment = useBehavior(model.pipAlignment$);
|
||||||
|
const onDragLocalTile: DragCallback = useCallback(
|
||||||
|
({ xRatio, yRatio }) =>
|
||||||
|
model.pipAlignment$.next({
|
||||||
|
block: yRatio < 0.5 ? "start" : "end",
|
||||||
|
inline: xRatio < 0.5 ? "start" : "end",
|
||||||
|
}),
|
||||||
|
[model.pipAlignment$],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={ref} className={styles.layer}>
|
||||||
|
{model.pip && (
|
||||||
|
<Slot
|
||||||
|
className={classNames(styles.pip)}
|
||||||
|
id={model.pip.id}
|
||||||
|
model={model.pip}
|
||||||
|
onDrag={onDragLocalTile}
|
||||||
|
data-size={pipSize}
|
||||||
|
data-block-alignment={pipAlignment.block}
|
||||||
|
data-inline-alignment={pipAlignment.inline}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -19,8 +19,8 @@ import { useBehavior } from "../useBehavior";
|
|||||||
*/
|
*/
|
||||||
export const makeSpotlightExpandedLayout: CallLayout<
|
export const makeSpotlightExpandedLayout: CallLayout<
|
||||||
SpotlightExpandedLayoutModel
|
SpotlightExpandedLayoutModel
|
||||||
> = ({ pipAlignment$ }) => ({
|
> = () => ({
|
||||||
scrollingOnTop: true,
|
foreground: "scrolling",
|
||||||
|
|
||||||
fixed: function SpotlightExpandedLayoutFixed({
|
fixed: function SpotlightExpandedLayoutFixed({
|
||||||
ref,
|
ref,
|
||||||
@@ -46,15 +46,15 @@ export const makeSpotlightExpandedLayout: CallLayout<
|
|||||||
Slot,
|
Slot,
|
||||||
}): ReactNode {
|
}): ReactNode {
|
||||||
useUpdateLayout();
|
useUpdateLayout();
|
||||||
const pipAlignmentValue = useBehavior(pipAlignment$);
|
const pipAlignment = useBehavior(model.pipAlignment$);
|
||||||
|
|
||||||
const onDragPip: DragCallback = useCallback(
|
const onDragPip: DragCallback = useCallback(
|
||||||
({ xRatio, yRatio }) =>
|
({ xRatio, yRatio }) =>
|
||||||
pipAlignment$.next({
|
model.pipAlignment$.next({
|
||||||
block: yRatio < 0.5 ? "start" : "end",
|
block: yRatio < 0.5 ? "start" : "end",
|
||||||
inline: xRatio < 0.5 ? "start" : "end",
|
inline: xRatio < 0.5 ? "start" : "end",
|
||||||
}),
|
}),
|
||||||
[],
|
[model.pipAlignment$],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -65,8 +65,8 @@ export const makeSpotlightExpandedLayout: CallLayout<
|
|||||||
id={model.pip.id}
|
id={model.pip.id}
|
||||||
model={model.pip}
|
model={model.pip}
|
||||||
onDrag={onDragPip}
|
onDrag={onDragPip}
|
||||||
data-block-alignment={pipAlignmentValue.block}
|
data-block-alignment={pipAlignment.block}
|
||||||
data-inline-alignment={pipAlignmentValue.inline}
|
data-inline-alignment={pipAlignment.inline}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ import { useUpdateLayout, useVisibleTiles } from "./Grid";
|
|||||||
export const makeSpotlightLandscapeLayout: CallLayout<
|
export const makeSpotlightLandscapeLayout: CallLayout<
|
||||||
SpotlightLandscapeLayoutModel
|
SpotlightLandscapeLayoutModel
|
||||||
> = ({ minBounds$ }) => ({
|
> = ({ minBounds$ }) => ({
|
||||||
scrollingOnTop: false,
|
foreground: "scrolling",
|
||||||
|
|
||||||
fixed: function SpotlightLandscapeLayoutFixed({
|
fixed: function SpotlightLandscapeLayoutFixed({
|
||||||
ref,
|
ref,
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ interface GridCSSProperties extends CSSProperties {
|
|||||||
export const makeSpotlightPortraitLayout: CallLayout<
|
export const makeSpotlightPortraitLayout: CallLayout<
|
||||||
SpotlightPortraitLayoutModel
|
SpotlightPortraitLayoutModel
|
||||||
> = ({ minBounds$ }) => ({
|
> = ({ minBounds$ }) => ({
|
||||||
scrollingOnTop: false,
|
foreground: "fixed",
|
||||||
|
|
||||||
fixed: function SpotlightPortraitLayoutFixed({
|
fixed: function SpotlightPortraitLayoutFixed({
|
||||||
ref,
|
ref,
|
||||||
|
|||||||
@@ -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 {
|
.header.filler {
|
||||||
block-size: var(--cpd-space-6x);
|
block-size: var(--cpd-space-6x);
|
||||||
background: none;
|
background: none;
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ import {
|
|||||||
import useMeasure from "react-use-measure";
|
import useMeasure from "react-use-measure";
|
||||||
import { type MatrixRTCSession } from "matrix-js-sdk/lib/matrixrtc";
|
import { type MatrixRTCSession } from "matrix-js-sdk/lib/matrixrtc";
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
import { BehaviorSubject, map } from "rxjs";
|
import { map } from "rxjs";
|
||||||
import { useObservable } from "observable-hooks";
|
import { useObservable } from "observable-hooks";
|
||||||
import { logger as rootLogger } from "matrix-js-sdk/lib/logger";
|
import { logger as rootLogger } from "matrix-js-sdk/lib/logger";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@@ -51,12 +51,9 @@ import { SpotlightTile } from "../tile/SpotlightTile";
|
|||||||
import { type EncryptionSystem } from "../e2ee/sharedKeyManagement";
|
import { type EncryptionSystem } from "../e2ee/sharedKeyManagement";
|
||||||
import { E2eeType } from "../e2ee/e2eeType";
|
import { E2eeType } from "../e2ee/e2eeType";
|
||||||
import { makeGridLayout } from "../grid/GridLayout";
|
import { makeGridLayout } from "../grid/GridLayout";
|
||||||
import {
|
import { type CallLayoutOutputs } from "../grid/CallLayout";
|
||||||
type CallLayoutOutputs,
|
import { makeOneOnOneLandscapeLayout } from "../grid/OneOnOneLandscapeLayout";
|
||||||
defaultPipAlignment,
|
import { makeOneOnOnePortraitLayout } from "../grid/OneOnOnePortraitLayout";
|
||||||
defaultSpotlightAlignment,
|
|
||||||
} from "../grid/CallLayout";
|
|
||||||
import { makeOneOnOneLayout } from "../grid/OneOnOneLayout";
|
|
||||||
import { makeSpotlightExpandedLayout } from "../grid/SpotlightExpandedLayout";
|
import { makeSpotlightExpandedLayout } from "../grid/SpotlightExpandedLayout";
|
||||||
import { makeSpotlightLandscapeLayout } from "../grid/SpotlightLandscapeLayout";
|
import { makeSpotlightLandscapeLayout } from "../grid/SpotlightLandscapeLayout";
|
||||||
import { makeSpotlightPortraitLayout } from "../grid/SpotlightPortraitLayout";
|
import { makeSpotlightPortraitLayout } from "../grid/SpotlightPortraitLayout";
|
||||||
@@ -93,6 +90,13 @@ import { useLatest } from "../useLatest.ts";
|
|||||||
import { CallFooter } from "../components/CallFooter.tsx";
|
import { CallFooter } from "../components/CallFooter.tsx";
|
||||||
import { SettingsIconButton } from "../button/Button.tsx";
|
import { SettingsIconButton } from "../button/Button.tsx";
|
||||||
|
|
||||||
|
declare module "react" {
|
||||||
|
interface CSSProperties {
|
||||||
|
"--call-view-safe-area-inset-top"?: string;
|
||||||
|
"--call-view-safe-area-inset-bottom"?: string;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const logger = rootLogger.getChild("[InCallView]");
|
const logger = rootLogger.getChild("[InCallView]");
|
||||||
|
|
||||||
export interface ActiveCallProps extends Omit<
|
export interface ActiveCallProps extends Omit<
|
||||||
@@ -239,10 +243,11 @@ export const InCallView: FC<InCallViewProps> = ({
|
|||||||
const audioParticipants = useBehavior(vm.livekitRoomItems$);
|
const audioParticipants = useBehavior(vm.livekitRoomItems$);
|
||||||
const participantCount = useBehavior(vm.participantCount$);
|
const participantCount = useBehavior(vm.participantCount$);
|
||||||
const reconnecting = useBehavior(vm.reconnecting$);
|
const reconnecting = useBehavior(vm.reconnecting$);
|
||||||
const windowMode = useBehavior(vm.windowMode$);
|
|
||||||
const layout = useBehavior(vm.layout$);
|
const layout = useBehavior(vm.layout$);
|
||||||
|
const edgeToEdge = useBehavior(vm.edgeToEdge$);
|
||||||
const tileStoreGeneration = useBehavior(vm.tileStoreGeneration$);
|
const tileStoreGeneration = useBehavior(vm.tileStoreGeneration$);
|
||||||
const [debugTileLayout] = useSetting(debugTileLayoutSetting);
|
const [debugTileLayout] = useSetting(debugTileLayoutSetting);
|
||||||
|
const showNameTags = useBehavior(vm.showNameTags$);
|
||||||
const gridMode = useBehavior(vm.gridMode$);
|
const gridMode = useBehavior(vm.gridMode$);
|
||||||
const showHeader = useBehavior(vm.showHeader$);
|
const showHeader = useBehavior(vm.showHeader$);
|
||||||
const showFooter = useBehavior(vm.showFooter$);
|
const showFooter = useBehavior(vm.showFooter$);
|
||||||
@@ -325,15 +330,14 @@ export const InCallView: FC<InCallViewProps> = ({
|
|||||||
width: bounds.width,
|
width: bounds.width,
|
||||||
height:
|
height:
|
||||||
bounds.height -
|
bounds.height -
|
||||||
headerBounds.height -
|
(edgeToEdge ? 0 : headerBounds.height + footerBounds.height),
|
||||||
(windowMode === "flat" ? 0 : footerBounds.height),
|
|
||||||
}),
|
}),
|
||||||
[
|
[
|
||||||
bounds.width,
|
bounds.width,
|
||||||
bounds.height,
|
bounds.height,
|
||||||
headerBounds.height,
|
headerBounds.height,
|
||||||
footerBounds.height,
|
footerBounds.height,
|
||||||
windowMode,
|
edgeToEdge,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
const gridBoundsObservable$ = useObservable(
|
const gridBoundsObservable$ = useObservable(
|
||||||
@@ -341,13 +345,6 @@ export const InCallView: FC<InCallViewProps> = ({
|
|||||||
[gridBounds],
|
[gridBounds],
|
||||||
);
|
);
|
||||||
|
|
||||||
const spotlightAlignment$ = useInitial(
|
|
||||||
() => new BehaviorSubject(defaultSpotlightAlignment),
|
|
||||||
);
|
|
||||||
const pipAlignment$ = useInitial(
|
|
||||||
() => new BehaviorSubject(defaultPipAlignment),
|
|
||||||
);
|
|
||||||
|
|
||||||
const setGridMode = useCallback(
|
const setGridMode = useCallback(
|
||||||
(mode: GridMode) => vm.setGridMode(mode),
|
(mode: GridMode) => vm.setGridMode(mode),
|
||||||
[vm],
|
[vm],
|
||||||
@@ -356,49 +353,47 @@ export const InCallView: FC<InCallViewProps> = ({
|
|||||||
useAppBarHidden(!showHeader);
|
useAppBarHidden(!showHeader);
|
||||||
|
|
||||||
let header: ReactNode = null;
|
let header: ReactNode = null;
|
||||||
if (showHeader) {
|
switch (headerStyle) {
|
||||||
switch (headerStyle) {
|
case HeaderStyle.AppBar: {
|
||||||
case HeaderStyle.AppBar: {
|
// dont build a header here. The AppBar will take care of it.
|
||||||
// dont build a header here. The AppBar will take care of it.
|
break;
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
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
|
// The reconnecting toast cannot be dismissed
|
||||||
@@ -445,12 +440,11 @@ export const InCallView: FC<InCallViewProps> = ({
|
|||||||
}: TileProps<TileViewModel, HTMLDivElement>): ReactNode {
|
}: TileProps<TileViewModel, HTMLDivElement>): ReactNode {
|
||||||
const spotlightExpanded = useBehavior(vm.spotlightExpanded$);
|
const spotlightExpanded = useBehavior(vm.spotlightExpanded$);
|
||||||
const onToggleExpanded = useBehavior(vm.toggleSpotlightExpanded$);
|
const onToggleExpanded = useBehavior(vm.toggleSpotlightExpanded$);
|
||||||
const showSpeakingIndicatorsValue = useBehavior(
|
const showSpotlightIndicators = useBehavior(
|
||||||
vm.showSpeakingIndicators$,
|
|
||||||
);
|
|
||||||
const showSpotlightIndicatorsValue = useBehavior(
|
|
||||||
vm.showSpotlightIndicators$,
|
vm.showSpotlightIndicators$,
|
||||||
);
|
);
|
||||||
|
const showSpeakingIndicators = useBehavior(vm.showSpeakingIndicators$);
|
||||||
|
const showNameTags = useBehavior(vm.showNameTags$);
|
||||||
|
|
||||||
return model instanceof GridTileViewModel ? (
|
return model instanceof GridTileViewModel ? (
|
||||||
<GridTile
|
<GridTile
|
||||||
@@ -461,7 +455,8 @@ export const InCallView: FC<InCallViewProps> = ({
|
|||||||
targetHeight={targetHeight}
|
targetHeight={targetHeight}
|
||||||
className={classNames(className, styles.tile)}
|
className={classNames(className, styles.tile)}
|
||||||
style={style}
|
style={style}
|
||||||
showSpeakingIndicators={showSpeakingIndicatorsValue}
|
showSpeakingIndicators={showSpeakingIndicators}
|
||||||
|
showNameTags={showNameTags}
|
||||||
focusable={!contentObscured}
|
focusable={!contentObscured}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
@@ -472,7 +467,8 @@ export const InCallView: FC<InCallViewProps> = ({
|
|||||||
onToggleExpanded={onToggleExpanded}
|
onToggleExpanded={onToggleExpanded}
|
||||||
targetWidth={targetWidth}
|
targetWidth={targetWidth}
|
||||||
targetHeight={targetHeight}
|
targetHeight={targetHeight}
|
||||||
showIndicators={showSpotlightIndicatorsValue}
|
showIndicators={showSpotlightIndicators}
|
||||||
|
showNameTags={showNameTags}
|
||||||
focusable={!contentObscured}
|
focusable={!contentObscured}
|
||||||
className={classNames(className, styles.tile)}
|
className={classNames(className, styles.tile)}
|
||||||
style={style}
|
style={style}
|
||||||
@@ -483,19 +479,16 @@ export const InCallView: FC<InCallViewProps> = ({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const layouts = useMemo(() => {
|
const layouts = useMemo(() => {
|
||||||
const inputs = {
|
const inputs = { minBounds$: gridBoundsObservable$ };
|
||||||
minBounds$: gridBoundsObservable$,
|
|
||||||
spotlightAlignment$,
|
|
||||||
pipAlignment$,
|
|
||||||
};
|
|
||||||
return {
|
return {
|
||||||
grid: makeGridLayout(inputs),
|
grid: makeGridLayout(inputs),
|
||||||
"spotlight-landscape": makeSpotlightLandscapeLayout(inputs),
|
"spotlight-landscape": makeSpotlightLandscapeLayout(inputs),
|
||||||
"spotlight-portrait": makeSpotlightPortraitLayout(inputs),
|
"spotlight-portrait": makeSpotlightPortraitLayout(inputs),
|
||||||
"spotlight-expanded": makeSpotlightExpandedLayout(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 => {
|
const renderContent = (): JSX.Element => {
|
||||||
if (layout.type === "pip") {
|
if (layout.type === "pip") {
|
||||||
@@ -508,6 +501,7 @@ export const InCallView: FC<InCallViewProps> = ({
|
|||||||
targetWidth={gridBounds.width}
|
targetWidth={gridBounds.width}
|
||||||
targetHeight={gridBounds.height}
|
targetHeight={gridBounds.height}
|
||||||
showIndicators={false}
|
showIndicators={false}
|
||||||
|
showNameTags={showNameTags}
|
||||||
focusable={!contentObscured}
|
focusable={!contentObscured}
|
||||||
aria-hidden={contentObscured}
|
aria-hidden={contentObscured}
|
||||||
/>
|
/>
|
||||||
@@ -521,8 +515,18 @@ export const InCallView: FC<InCallViewProps> = ({
|
|||||||
className={styles.fixedGrid}
|
className={styles.fixedGrid}
|
||||||
style={{
|
style={{
|
||||||
insetBlockStart:
|
insetBlockStart:
|
||||||
headerBounds.height > 0 ? headerBounds.bottom : bounds.top,
|
edgeToEdge || headerBounds.height === 0 ? 0 : headerBounds.bottom,
|
||||||
height: gridBounds.height,
|
height: edgeToEdge ? "100%" : gridBounds.height,
|
||||||
|
// If edge-to-edge, compute new safe area insets that account for the
|
||||||
|
// header and footer.
|
||||||
|
"--call-view-safe-area-inset-top":
|
||||||
|
edgeToEdge && header && showHeader
|
||||||
|
? `calc(env(safe-area-inset-top) + ${headerBounds.height}px)`
|
||||||
|
: undefined,
|
||||||
|
"--call-view-safe-area-inset-bottom":
|
||||||
|
edgeToEdge && showFooter
|
||||||
|
? `calc(env(safe-area-inset-bottom) + ${footerBounds.height}px)`
|
||||||
|
: undefined,
|
||||||
}}
|
}}
|
||||||
model={layout}
|
model={layout}
|
||||||
Layout={layers.fixed}
|
Layout={layers.fixed}
|
||||||
@@ -540,19 +544,24 @@ export const InCallView: FC<InCallViewProps> = ({
|
|||||||
aria-hidden={contentObscured}
|
aria-hidden={contentObscured}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
// The grid tiles go *under* the spotlight in the portrait layout, but
|
|
||||||
// *over* the spotlight in the expanded layout
|
// Put the right layer in the foreground for the requested layout
|
||||||
return layout.type === "spotlight-expanded" ? (
|
switch (layers.foreground) {
|
||||||
<>
|
case "fixed":
|
||||||
{fixedGrid}
|
return (
|
||||||
{scrollingGrid}
|
<>
|
||||||
</>
|
{scrollingGrid}
|
||||||
) : (
|
{fixedGrid}
|
||||||
<>
|
</>
|
||||||
{scrollingGrid}
|
);
|
||||||
{fixedGrid}
|
case "scrolling":
|
||||||
</>
|
return (
|
||||||
);
|
<>
|
||||||
|
{fixedGrid}
|
||||||
|
{scrollingGrid}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const rageshakeRequestModalProps = useRageshakeRequestModal(
|
const rageshakeRequestModalProps = useRageshakeRequestModal(
|
||||||
@@ -575,7 +584,7 @@ export const InCallView: FC<InCallViewProps> = ({
|
|||||||
ref={footerRef}
|
ref={footerRef}
|
||||||
hidden={!showFooter}
|
hidden={!showFooter}
|
||||||
hideControls={!showControls}
|
hideControls={!showControls}
|
||||||
asOverlay={windowMode === "flat"}
|
asOverlay={edgeToEdge}
|
||||||
asPip={layout.type === "pip"}
|
asPip={layout.type === "pip"}
|
||||||
// Hide the logo for both embedded solutions. mobile: HeaderStyle.AppBar and desktop: HeaderStyle.None.
|
// Hide the logo for both embedded solutions. mobile: HeaderStyle.AppBar and desktop: HeaderStyle.None.
|
||||||
hideLogo={headerStyle !== HeaderStyle.Standard}
|
hideLogo={headerStyle !== HeaderStyle.Standard}
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ import {
|
|||||||
aliceParticipant,
|
aliceParticipant,
|
||||||
aliceRtcMember,
|
aliceRtcMember,
|
||||||
aliceUserId,
|
aliceUserId,
|
||||||
|
bob,
|
||||||
bobId,
|
bobId,
|
||||||
bobRtcMember,
|
bobRtcMember,
|
||||||
local,
|
local,
|
||||||
@@ -133,12 +134,19 @@ export interface SpotlightExpandedLayoutSummary {
|
|||||||
pip?: string;
|
pip?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface OneOnOneLayoutSummary {
|
export interface OneOnOneLandscapeLayoutSummary {
|
||||||
type: "one-on-one";
|
type: "one-on-one-landscape";
|
||||||
spotlight: string;
|
spotlight: string;
|
||||||
pip: string;
|
pip: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface OneOnOnePortraitLayoutSummary {
|
||||||
|
type: "one-on-one-portrait";
|
||||||
|
spotlight: string[];
|
||||||
|
pip?: string;
|
||||||
|
pipSize: "sm" | "lg";
|
||||||
|
}
|
||||||
|
|
||||||
export interface PipLayoutSummary {
|
export interface PipLayoutSummary {
|
||||||
type: "pip";
|
type: "pip";
|
||||||
spotlight: string[];
|
spotlight: string[];
|
||||||
@@ -149,7 +157,8 @@ export type LayoutSummary =
|
|||||||
| SpotlightLandscapeLayoutSummary
|
| SpotlightLandscapeLayoutSummary
|
||||||
| SpotlightPortraitLayoutSummary
|
| SpotlightPortraitLayoutSummary
|
||||||
| SpotlightExpandedLayoutSummary
|
| SpotlightExpandedLayoutSummary
|
||||||
| OneOnOneLayoutSummary
|
| OneOnOneLandscapeLayoutSummary
|
||||||
|
| OneOnOnePortraitLayoutSummary
|
||||||
| PipLayoutSummary;
|
| PipLayoutSummary;
|
||||||
|
|
||||||
function summarizeLayout$(l$: Observable<Layout>): Observable<LayoutSummary> {
|
function summarizeLayout$(l$: Observable<Layout>): Observable<LayoutSummary> {
|
||||||
@@ -187,7 +196,7 @@ function summarizeLayout$(l$: Observable<Layout>): Observable<LayoutSummary> {
|
|||||||
pip: pip?.id,
|
pip: pip?.id,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
case "one-on-one":
|
case "one-on-one-landscape":
|
||||||
return combineLatest(
|
return combineLatest(
|
||||||
[l.spotlight.media$, l.pip.media$],
|
[l.spotlight.media$, l.pip.media$],
|
||||||
(spotlight, pip) => ({
|
(spotlight, pip) => ({
|
||||||
@@ -196,6 +205,20 @@ function summarizeLayout$(l$: Observable<Layout>): Observable<LayoutSummary> {
|
|||||||
pip: pip.id,
|
pip: pip.id,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
case "one-on-one-portrait":
|
||||||
|
return combineLatest(
|
||||||
|
[
|
||||||
|
l.spotlight.media$,
|
||||||
|
l.pip?.media$ ?? constant(undefined),
|
||||||
|
l.pipSize$,
|
||||||
|
],
|
||||||
|
(spotlight, pip, pipSize) => ({
|
||||||
|
type: l.type,
|
||||||
|
spotlight: spotlight.map((vm) => vm.id),
|
||||||
|
pip: pip?.id,
|
||||||
|
pipSize,
|
||||||
|
}),
|
||||||
|
);
|
||||||
case "pip":
|
case "pip":
|
||||||
return l.spotlight.media$.pipe(
|
return l.spotlight.media$.pipe(
|
||||||
map((spotlight) => ({
|
map((spotlight) => ({
|
||||||
@@ -405,7 +428,7 @@ describe.each([
|
|||||||
expectedLayoutMarbles,
|
expectedLayoutMarbles,
|
||||||
{
|
{
|
||||||
a: {
|
a: {
|
||||||
type: "one-on-one",
|
type: "one-on-one-landscape",
|
||||||
pip: `${localId}:0`,
|
pip: `${localId}:0`,
|
||||||
spotlight: `${aliceId}:0`,
|
spotlight: `${aliceId}:0`,
|
||||||
},
|
},
|
||||||
@@ -421,6 +444,85 @@ describe.each([
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("one-on-one portrait layout shows local tile when video is enabled", () => {
|
||||||
|
withTestScheduler(({ behavior, schedule, expectObservable }) => {
|
||||||
|
// Local participant enables their video, then disables it
|
||||||
|
const videoInputMarbles = " ny--n";
|
||||||
|
// While tile is shown, tap the screen twice
|
||||||
|
const tapScreenInputMarbles = "--aa-";
|
||||||
|
// Layout should show local tile, make it small, enlarge it again, then hide it
|
||||||
|
const expectedLayoutMarbles = "abcba";
|
||||||
|
|
||||||
|
withCallViewModel(
|
||||||
|
{
|
||||||
|
remoteParticipants$: constant([aliceParticipant]),
|
||||||
|
roomMembers: [local, alice],
|
||||||
|
rtcMembers$: constant([localRtcMember, aliceRtcMember]),
|
||||||
|
videoEnabled: new Map([
|
||||||
|
[localParticipant, behavior(videoInputMarbles, yesNo)],
|
||||||
|
]),
|
||||||
|
windowSize$: constant({ width: 380, height: 700 }), // Mobile phone in portrait
|
||||||
|
},
|
||||||
|
(vm) => {
|
||||||
|
schedule(tapScreenInputMarbles, { a: () => vm.tapScreen() });
|
||||||
|
|
||||||
|
expectObservable(vm.edgeToEdge$).toBe("y", yesNo); // Edge-to-edge-layout
|
||||||
|
expectObservable(summarizeLayout$(vm.layout$)).toBe(
|
||||||
|
expectedLayoutMarbles,
|
||||||
|
{
|
||||||
|
a: {
|
||||||
|
type: "one-on-one-portrait",
|
||||||
|
spotlight: [`${aliceId}:0`],
|
||||||
|
pip: undefined,
|
||||||
|
pipSize: "lg",
|
||||||
|
},
|
||||||
|
b: {
|
||||||
|
type: "one-on-one-portrait",
|
||||||
|
spotlight: [`${aliceId}:0`],
|
||||||
|
pip: `${localId}:0`,
|
||||||
|
pipSize: "lg",
|
||||||
|
},
|
||||||
|
c: {
|
||||||
|
type: "one-on-one-portrait",
|
||||||
|
spotlight: [`${aliceId}:0`],
|
||||||
|
pip: `${localId}:0`,
|
||||||
|
pipSize: "sm",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("one-on-one portrait layout shows name tags in room with 3 members", () => {
|
||||||
|
withTestScheduler(({ behavior, schedule, expectObservable }) => {
|
||||||
|
withCallViewModel(
|
||||||
|
{
|
||||||
|
remoteParticipants$: constant([aliceParticipant]),
|
||||||
|
// Both Alice and Bob are with us in the room
|
||||||
|
roomMembers: [local, alice, bob],
|
||||||
|
rtcMembers$: constant([localRtcMember, aliceRtcMember]),
|
||||||
|
windowSize$: constant({ width: 380, height: 700 }), // Mobile phone in portrait
|
||||||
|
},
|
||||||
|
(vm) => {
|
||||||
|
// Uses one-on-one portrait layout
|
||||||
|
expectObservable(summarizeLayout$(vm.layout$)).toBe("a", {
|
||||||
|
a: {
|
||||||
|
type: "one-on-one-portrait",
|
||||||
|
spotlight: [`${aliceId}:0`],
|
||||||
|
pip: undefined,
|
||||||
|
pipSize: "lg",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
// It wouldn't be clear whether Alice or Bob is the remote video tile,
|
||||||
|
// so the interface must put a name tag on it
|
||||||
|
expectObservable(vm.showNameTags$).toBe("y", yesNo);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test("participants stay in the same order unless to appear/disappear", () => {
|
test("participants stay in the same order unless to appear/disappear", () => {
|
||||||
withTestScheduler(({ behavior, schedule, expectObservable }) => {
|
withTestScheduler(({ behavior, schedule, expectObservable }) => {
|
||||||
const visibilityInputMarbles = "a";
|
const visibilityInputMarbles = "a";
|
||||||
@@ -576,7 +678,7 @@ describe.each([
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("layout reacts to window size", () => {
|
test("layout reacts to window size", () => {
|
||||||
withTestScheduler(({ behavior, schedule, expectObservable }) => {
|
withTestScheduler(({ behavior, expectObservable }) => {
|
||||||
const windowSizeInputMarbles = "abc";
|
const windowSizeInputMarbles = "abc";
|
||||||
const expectedLayoutMarbles = " abc";
|
const expectedLayoutMarbles = " abc";
|
||||||
withCallViewModel(
|
withCallViewModel(
|
||||||
@@ -584,7 +686,7 @@ describe.each([
|
|||||||
remoteParticipants$: constant([aliceParticipant]),
|
remoteParticipants$: constant([aliceParticipant]),
|
||||||
rtcMembers$: constant([localRtcMember, aliceRtcMember]),
|
rtcMembers$: constant([localRtcMember, aliceRtcMember]),
|
||||||
windowSize$: behavior(windowSizeInputMarbles, {
|
windowSize$: behavior(windowSizeInputMarbles, {
|
||||||
a: { width: 300, height: 600 }, // Start very narrow, like a phone
|
a: { width: 380, height: 700 }, // Start very narrow, like a phone
|
||||||
b: { width: 1000, height: 800 }, // Go to normal desktop window size
|
b: { width: 1000, height: 800 }, // Go to normal desktop window size
|
||||||
c: { width: 200, height: 180 }, // Go to PiP size
|
c: { width: 200, height: 180 }, // Go to PiP size
|
||||||
}),
|
}),
|
||||||
@@ -595,13 +697,14 @@ describe.each([
|
|||||||
{
|
{
|
||||||
a: {
|
a: {
|
||||||
// This is the expected one-on-one layout for a narrow window
|
// This is the expected one-on-one layout for a narrow window
|
||||||
type: "spotlight-expanded",
|
type: "one-on-one-portrait",
|
||||||
spotlight: [`${aliceId}:0`],
|
spotlight: [`${aliceId}:0`],
|
||||||
pip: `${localId}:0`,
|
pip: undefined,
|
||||||
|
pipSize: "lg",
|
||||||
},
|
},
|
||||||
b: {
|
b: {
|
||||||
// In a larger window, expect the normal one-on-one layout
|
// In a larger window, expect the normal one-on-one layout
|
||||||
type: "one-on-one",
|
type: "one-on-one-landscape",
|
||||||
pip: `${localId}:0`,
|
pip: `${localId}:0`,
|
||||||
spotlight: `${aliceId}:0`,
|
spotlight: `${aliceId}:0`,
|
||||||
},
|
},
|
||||||
@@ -956,7 +1059,7 @@ describe.each([
|
|||||||
grid: [`${localId}:0`],
|
grid: [`${localId}:0`],
|
||||||
},
|
},
|
||||||
b: {
|
b: {
|
||||||
type: "one-on-one",
|
type: "one-on-one-landscape",
|
||||||
pip: `${localId}:0`,
|
pip: `${localId}:0`,
|
||||||
spotlight: `${aliceId}:0`,
|
spotlight: `${aliceId}:0`,
|
||||||
},
|
},
|
||||||
@@ -999,7 +1102,7 @@ describe.each([
|
|||||||
grid: [`${localId}:0`],
|
grid: [`${localId}:0`],
|
||||||
},
|
},
|
||||||
b: {
|
b: {
|
||||||
type: "one-on-one",
|
type: "one-on-one-landscape",
|
||||||
pip: `${localId}:0`,
|
pip: `${localId}:0`,
|
||||||
spotlight: `${aliceId}:0`,
|
spotlight: `${aliceId}:0`,
|
||||||
},
|
},
|
||||||
@@ -1009,7 +1112,7 @@ describe.each([
|
|||||||
grid: [`${localId}:0`, `${aliceId}:0`, `${daveId}:0`],
|
grid: [`${localId}:0`, `${aliceId}:0`, `${daveId}:0`],
|
||||||
},
|
},
|
||||||
d: {
|
d: {
|
||||||
type: "one-on-one",
|
type: "one-on-one-landscape",
|
||||||
pip: `${localId}:0`,
|
pip: `${localId}:0`,
|
||||||
spotlight: `${daveId}:0`,
|
spotlight: `${daveId}:0`,
|
||||||
},
|
},
|
||||||
@@ -1227,7 +1330,7 @@ describe.each([
|
|||||||
// ringing the entire time (even once timed out)
|
// ringing the entire time (even once timed out)
|
||||||
expectObservable(summarizeLayout$(vm.layout$)).toBe("a", {
|
expectObservable(summarizeLayout$(vm.layout$)).toBe("a", {
|
||||||
a: {
|
a: {
|
||||||
type: "one-on-one",
|
type: "one-on-one-landscape",
|
||||||
spotlight: `${localId}:0`,
|
spotlight: `${localId}:0`,
|
||||||
pip: `ringing:${aliceUserId}`,
|
pip: `ringing:${aliceUserId}`,
|
||||||
},
|
},
|
||||||
@@ -1266,12 +1369,12 @@ describe.each([
|
|||||||
// ringing the entire time
|
// ringing the entire time
|
||||||
expectObservable(summarizeLayout$(vm.layout$)).toBe("a 20ms b", {
|
expectObservable(summarizeLayout$(vm.layout$)).toBe("a 20ms b", {
|
||||||
a: {
|
a: {
|
||||||
type: "one-on-one",
|
type: "one-on-one-landscape",
|
||||||
spotlight: `${localId}:0`,
|
spotlight: `${localId}:0`,
|
||||||
pip: `ringing:${aliceUserId}`,
|
pip: `ringing:${aliceUserId}`,
|
||||||
},
|
},
|
||||||
b: {
|
b: {
|
||||||
type: "one-on-one",
|
type: "one-on-one-landscape",
|
||||||
spotlight: `${aliceId}:0`,
|
spotlight: `${aliceId}:0`,
|
||||||
pip: `${localId}:0`,
|
pip: `${localId}:0`,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ import {
|
|||||||
tap,
|
tap,
|
||||||
throttleTime,
|
throttleTime,
|
||||||
timer,
|
timer,
|
||||||
|
BehaviorSubject,
|
||||||
} from "rxjs";
|
} from "rxjs";
|
||||||
import { logger as rootLogger } from "matrix-js-sdk/lib/logger";
|
import { logger as rootLogger } from "matrix-js-sdk/lib/logger";
|
||||||
import {
|
import {
|
||||||
@@ -68,7 +69,8 @@ import { setPipEnabled$ } from "../../controls";
|
|||||||
import { TileStore } from "../TileStore";
|
import { TileStore } from "../TileStore";
|
||||||
import { gridLikeLayout } from "../GridLikeLayout";
|
import { gridLikeLayout } from "../GridLikeLayout";
|
||||||
import { spotlightExpandedLayout } from "../SpotlightExpandedLayout";
|
import { spotlightExpandedLayout } from "../SpotlightExpandedLayout";
|
||||||
import { oneOnOneLayout } from "../OneOnOneLayout";
|
import { oneOnOneLandscapeLayout } from "../OneOnOneLandscapeLayout";
|
||||||
|
import { oneOnOnePortraitLayout } from "../OneOnOnePortraitLayout";
|
||||||
import { pipLayout } from "../PipLayout";
|
import { pipLayout } from "../PipLayout";
|
||||||
import { type EncryptionSystem } from "../../e2ee/sharedKeyManagement";
|
import { type EncryptionSystem } from "../../e2ee/sharedKeyManagement";
|
||||||
import {
|
import {
|
||||||
@@ -86,10 +88,12 @@ import { getUrlParams, HeaderStyle } from "../../UrlParams";
|
|||||||
import { type ProcessorState } from "../../livekit/TrackProcessorContext";
|
import { type ProcessorState } from "../../livekit/TrackProcessorContext";
|
||||||
import { ElementWidgetActions, widget } from "../../widget";
|
import { ElementWidgetActions, widget } from "../../widget";
|
||||||
import {
|
import {
|
||||||
|
type Alignment,
|
||||||
type GridLayoutMedia,
|
type GridLayoutMedia,
|
||||||
type Layout,
|
type Layout,
|
||||||
type LayoutMedia,
|
type LayoutMedia,
|
||||||
type OneOnOneLayoutMedia,
|
type OneOnOneLandscapeLayoutMedia,
|
||||||
|
type OneOnOnePortraitLayoutMedia,
|
||||||
type SpotlightExpandedLayoutMedia,
|
type SpotlightExpandedLayoutMedia,
|
||||||
type SpotlightLandscapeLayoutMedia,
|
type SpotlightLandscapeLayoutMedia,
|
||||||
type SpotlightPortraitLayoutMedia,
|
type SpotlightPortraitLayoutMedia,
|
||||||
@@ -327,16 +331,6 @@ export interface CallViewModel {
|
|||||||
{ sender: string; emoji: string; startX: number }[]
|
{ sender: string; emoji: string; startX: number }[]
|
||||||
>;
|
>;
|
||||||
|
|
||||||
// window/layout
|
|
||||||
/**
|
|
||||||
* The general shape of the window.
|
|
||||||
*/
|
|
||||||
windowMode$: Behavior<WindowMode>;
|
|
||||||
spotlightExpanded$: Behavior<boolean>;
|
|
||||||
toggleSpotlightExpanded$: Behavior<(() => void) | null>;
|
|
||||||
gridMode$: Behavior<GridMode>;
|
|
||||||
setGridMode: (value: GridMode) => void;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The layout of tiles in the call interface.
|
* The layout of tiles in the call interface.
|
||||||
*/
|
*/
|
||||||
@@ -347,10 +341,20 @@ export interface CallViewModel {
|
|||||||
tileStoreGeneration$: Behavior<number>;
|
tileStoreGeneration$: Behavior<number>;
|
||||||
showSpotlightIndicators$: Behavior<boolean>;
|
showSpotlightIndicators$: Behavior<boolean>;
|
||||||
showSpeakingIndicators$: Behavior<boolean>;
|
showSpeakingIndicators$: Behavior<boolean>;
|
||||||
|
showNameTags$: Behavior<boolean>;
|
||||||
|
spotlightExpanded$: Behavior<boolean>;
|
||||||
|
toggleSpotlightExpanded$: Behavior<(() => void) | null>;
|
||||||
|
gridMode$: Behavior<GridMode>;
|
||||||
|
setGridMode: (value: GridMode) => void;
|
||||||
|
|
||||||
// header/footer visibility
|
// header/footer visibility
|
||||||
showHeader$: Behavior<boolean>;
|
showHeader$: Behavior<boolean>;
|
||||||
showFooter$: Behavior<boolean>;
|
showFooter$: Behavior<boolean>;
|
||||||
|
/**
|
||||||
|
* Whether the call layout should be displayed edge-to-edge, with the footer
|
||||||
|
* and header as overlays.
|
||||||
|
*/
|
||||||
|
edgeToEdge$: Behavior<boolean>;
|
||||||
|
|
||||||
// audio routing
|
// audio routing
|
||||||
/**
|
/**
|
||||||
@@ -777,6 +781,7 @@ export function createCallViewModel$(
|
|||||||
callPickupState === "timeout" ||
|
callPickupState === "timeout" ||
|
||||||
callPickupState === "decline"
|
callPickupState === "decline"
|
||||||
) {
|
) {
|
||||||
|
// TODO: Respect io.element.functional_members
|
||||||
for (const member of roomMembers.values()) {
|
for (const member of roomMembers.values()) {
|
||||||
if (!userMedia.some((vm) => vm.userId === member.userId))
|
if (!userMedia.some((vm) => vm.userId === member.userId))
|
||||||
yield {
|
yield {
|
||||||
@@ -1057,6 +1062,7 @@ export function createCallViewModel$(
|
|||||||
[grid$, spotlight$],
|
[grid$, spotlight$],
|
||||||
(grid, spotlight) => ({
|
(grid, spotlight) => ({
|
||||||
type: "grid",
|
type: "grid",
|
||||||
|
edgeToEdge: false,
|
||||||
spotlight: spotlight.some((vm) => vm.type === "screen share")
|
spotlight: spotlight.some((vm) => vm.type === "screen share")
|
||||||
? spotlight
|
? spotlight
|
||||||
: undefined,
|
: undefined,
|
||||||
@@ -1067,6 +1073,7 @@ export function createCallViewModel$(
|
|||||||
const spotlightLandscapeLayoutMedia$: Observable<SpotlightLandscapeLayoutMedia> =
|
const spotlightLandscapeLayoutMedia$: Observable<SpotlightLandscapeLayoutMedia> =
|
||||||
combineLatest([grid$, spotlight$], (grid, spotlight) => ({
|
combineLatest([grid$, spotlight$], (grid, spotlight) => ({
|
||||||
type: "spotlight-landscape",
|
type: "spotlight-landscape",
|
||||||
|
edgeToEdge: false,
|
||||||
spotlight,
|
spotlight,
|
||||||
grid,
|
grid,
|
||||||
}));
|
}));
|
||||||
@@ -1074,16 +1081,20 @@ export function createCallViewModel$(
|
|||||||
const spotlightPortraitLayoutMedia$: Observable<SpotlightPortraitLayoutMedia> =
|
const spotlightPortraitLayoutMedia$: Observable<SpotlightPortraitLayoutMedia> =
|
||||||
combineLatest([grid$, spotlight$], (grid, spotlight) => ({
|
combineLatest([grid$, spotlight$], (grid, spotlight) => ({
|
||||||
type: "spotlight-portrait",
|
type: "spotlight-portrait",
|
||||||
|
edgeToEdge: false,
|
||||||
spotlight,
|
spotlight,
|
||||||
grid,
|
grid,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const spotlightExpandedLayoutMedia$: Observable<SpotlightExpandedLayoutMedia> =
|
const spotlightExpandedLayoutMedia$ = (
|
||||||
|
edgeToEdge: boolean,
|
||||||
|
): Observable<SpotlightExpandedLayoutMedia> =>
|
||||||
spotlightAndPip$.pipe(
|
spotlightAndPip$.pipe(
|
||||||
switchMap(({ spotlight, pip$ }) =>
|
switchMap(({ spotlight, pip$ }) =>
|
||||||
pip$.pipe(
|
pip$.pipe(
|
||||||
map((pip) => ({
|
map((pip) => ({
|
||||||
type: "spotlight-expanded" as const,
|
type: "spotlight-expanded" as const,
|
||||||
|
edgeToEdge,
|
||||||
spotlight,
|
spotlight,
|
||||||
pip: pip ?? undefined,
|
pip: pip ?? undefined,
|
||||||
})),
|
})),
|
||||||
@@ -1091,55 +1102,88 @@ export function createCallViewModel$(
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
const oneOnOneLayoutMedia$: Observable<OneOnOneLayoutMedia | null> =
|
const oneOnOneLayoutMedia$: Observable<{
|
||||||
combineLatest([userMedia$, screenShares$]).pipe(
|
local: LocalUserMediaViewModel;
|
||||||
switchMap(([userMedia, screenShares]) => {
|
remote: UserMediaViewModel | RingingMediaViewModel;
|
||||||
// One-on-one layout only supports 2 user media, no screen shares
|
} | null> = combineLatest([userMedia$, screenShares$]).pipe(
|
||||||
if (userMedia.length <= 2 && screenShares.length === 0) {
|
switchMap(([userMedia, screenShares]) => {
|
||||||
const local = userMedia.find(
|
// One-on-one layout only supports 2 user media, no screen shares
|
||||||
(vm): vm is WrappedUserMediaViewModel & LocalUserMediaViewModel =>
|
if (userMedia.length <= 2 && screenShares.length === 0) {
|
||||||
vm.type === "user" && vm.local,
|
const local = userMedia.find(
|
||||||
|
(vm): vm is WrappedUserMediaViewModel & LocalUserMediaViewModel =>
|
||||||
|
vm.type === "user" && vm.local,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (local !== undefined) {
|
||||||
|
const remote = userMedia.find(
|
||||||
|
(vm): vm is WrappedUserMediaViewModel & RemoteUserMediaViewModel =>
|
||||||
|
vm.type === "user" && !vm.local,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (local !== undefined) {
|
if (remote !== undefined) return of({ local, remote });
|
||||||
const remote = userMedia.find(
|
|
||||||
(
|
// If there's no other user media in the call (could still happen in
|
||||||
vm,
|
// this branch due to the duplicate tiles option), we could possibly
|
||||||
): vm is WrappedUserMediaViewModel & RemoteUserMediaViewModel =>
|
// show ringing media instead
|
||||||
vm.type === "user" && !vm.local,
|
if (userMedia.length === 1)
|
||||||
|
return ringingMedia$.pipe(
|
||||||
|
map((ringingMedia) => {
|
||||||
|
return ringingMedia.length === 1
|
||||||
|
? {
|
||||||
|
local,
|
||||||
|
remote: ringingMedia[0],
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (remote !== undefined)
|
|
||||||
return of({
|
|
||||||
type: "one-on-one" as const,
|
|
||||||
spotlight: remote,
|
|
||||||
pip: local,
|
|
||||||
});
|
|
||||||
|
|
||||||
// If there's no other user media in the call (could still happen in
|
|
||||||
// this branch due to the duplicate tiles option), we could possibly
|
|
||||||
// show ringing media instead
|
|
||||||
if (userMedia.length === 1)
|
|
||||||
return ringingMedia$.pipe(
|
|
||||||
map((ringingMedia) => {
|
|
||||||
return ringingMedia.length === 1
|
|
||||||
? {
|
|
||||||
type: "one-on-one" as const,
|
|
||||||
spotlight: local,
|
|
||||||
pip: ringingMedia[0],
|
|
||||||
}
|
|
||||||
: null;
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return of(null);
|
return of(null);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const oneOnOneLandscapeLayoutMedia$: Observable<OneOnOneLandscapeLayoutMedia | null> =
|
||||||
|
oneOnOneLayoutMedia$.pipe(
|
||||||
|
map((media) => {
|
||||||
|
if (media === null) return null;
|
||||||
|
return media.remote.type === "ringing"
|
||||||
|
? {
|
||||||
|
type: "one-on-one-landscape" as const,
|
||||||
|
edgeToEdge: false,
|
||||||
|
spotlight: media.local,
|
||||||
|
pip: media.remote,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
type: "one-on-one-landscape" as const,
|
||||||
|
edgeToEdge: false,
|
||||||
|
spotlight: media.remote,
|
||||||
|
pip: media.local,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const oneOnOnePortraitLayoutMedia$: Observable<OneOnOnePortraitLayoutMedia | null> =
|
||||||
|
oneOnOneLayoutMedia$.pipe(
|
||||||
|
switchMap((media) => {
|
||||||
|
if (media === null) return of(null);
|
||||||
|
return media.local.videoEnabled$.pipe(
|
||||||
|
map((videoEnabled) => ({
|
||||||
|
type: "one-on-one-portrait" as const,
|
||||||
|
edgeToEdge: true as const,
|
||||||
|
spotlight: media.remote,
|
||||||
|
pip: videoEnabled ? media.local : undefined,
|
||||||
|
})),
|
||||||
|
);
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
const pipLayoutMedia$: Observable<LayoutMedia> = spotlight$.pipe(
|
const pipLayoutMedia$: Observable<LayoutMedia> = spotlight$.pipe(
|
||||||
map((spotlight) => ({ type: "pip", spotlight })),
|
map((spotlight) => ({
|
||||||
|
type: "pip",
|
||||||
|
edgeToEdge: platform !== "desktop",
|
||||||
|
spotlight,
|
||||||
|
})),
|
||||||
);
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1154,7 +1198,7 @@ export function createCallViewModel$(
|
|||||||
switchMap((gridMode) => {
|
switchMap((gridMode) => {
|
||||||
switch (gridMode) {
|
switch (gridMode) {
|
||||||
case "grid":
|
case "grid":
|
||||||
return oneOnOneLayoutMedia$.pipe(
|
return oneOnOneLandscapeLayoutMedia$.pipe(
|
||||||
switchMap((oneOnOne) =>
|
switchMap((oneOnOne) =>
|
||||||
oneOnOne === null ? gridLayoutMedia$ : of(oneOnOne),
|
oneOnOne === null ? gridLayoutMedia$ : of(oneOnOne),
|
||||||
),
|
),
|
||||||
@@ -1163,7 +1207,7 @@ export function createCallViewModel$(
|
|||||||
return spotlightExpanded$.pipe(
|
return spotlightExpanded$.pipe(
|
||||||
switchMap((expanded) =>
|
switchMap((expanded) =>
|
||||||
expanded
|
expanded
|
||||||
? spotlightExpandedLayoutMedia$
|
? spotlightExpandedLayoutMedia$(false)
|
||||||
: spotlightLandscapeLayoutMedia$,
|
: spotlightLandscapeLayoutMedia$,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -1171,7 +1215,7 @@ export function createCallViewModel$(
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
case "narrow":
|
case "narrow":
|
||||||
return oneOnOneLayoutMedia$.pipe(
|
return oneOnOnePortraitLayoutMedia$.pipe(
|
||||||
switchMap((oneOnOne) =>
|
switchMap((oneOnOne) =>
|
||||||
oneOnOne === null
|
oneOnOne === null
|
||||||
? combineLatest([grid$, spotlight$], (grid, spotlight) =>
|
? combineLatest([grid$, spotlight$], (grid, spotlight) =>
|
||||||
@@ -1180,9 +1224,7 @@ export function createCallViewModel$(
|
|||||||
? spotlightPortraitLayoutMedia$
|
? spotlightPortraitLayoutMedia$
|
||||||
: gridLayoutMedia$,
|
: gridLayoutMedia$,
|
||||||
).pipe(switchAll())
|
).pipe(switchAll())
|
||||||
: // The expanded spotlight layout makes for a better one-on-one
|
: of(oneOnOne),
|
||||||
// experience in narrow windows
|
|
||||||
spotlightExpandedLayoutMedia$,
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
case "flat":
|
case "flat":
|
||||||
@@ -1194,7 +1236,7 @@ export function createCallViewModel$(
|
|||||||
// this window mode.
|
// this window mode.
|
||||||
return spotlightLandscapeLayoutMedia$;
|
return spotlightLandscapeLayoutMedia$;
|
||||||
case "spotlight":
|
case "spotlight":
|
||||||
return spotlightExpandedLayoutMedia$;
|
return spotlightExpandedLayoutMedia$(true);
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -1205,6 +1247,193 @@ export function createCallViewModel$(
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const showSpotlightIndicators$ = scope.behavior<boolean>(
|
||||||
|
layoutMedia$.pipe(map((l) => l.type !== "grid")),
|
||||||
|
);
|
||||||
|
|
||||||
|
const showSpeakingIndicators$ = scope.behavior<boolean>(
|
||||||
|
layoutMedia$.pipe(
|
||||||
|
map((l) => {
|
||||||
|
switch (l.type) {
|
||||||
|
case "spotlight-landscape":
|
||||||
|
case "spotlight-portrait":
|
||||||
|
// If the spotlight is showing the active speaker, we can do without
|
||||||
|
// speaking indicators as they're a redundant visual cue. But if
|
||||||
|
// screen sharing feeds are in the spotlight we still need them.
|
||||||
|
return l.spotlight.some((m) => m.type === "screen share");
|
||||||
|
// In expanded spotlight layout, the active speaker is always shown in
|
||||||
|
// the picture-in-picture tile so there is no need for speaking
|
||||||
|
// indicators. And in one-on-one layout there's no question as to who is
|
||||||
|
// speaking.
|
||||||
|
case "spotlight-expanded":
|
||||||
|
case "one-on-one-landscape":
|
||||||
|
case "one-on-one-portrait":
|
||||||
|
return false;
|
||||||
|
default:
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const showNameTags$ = scope.behavior<boolean>(
|
||||||
|
layoutMedia$.pipe(
|
||||||
|
switchMap((l) =>
|
||||||
|
l.type === "pip" || l.type === "one-on-one-portrait"
|
||||||
|
? matrixRoomMembers$.pipe(
|
||||||
|
map(
|
||||||
|
(members) =>
|
||||||
|
// Hide name tags by default in these layouts. For safety we
|
||||||
|
// still need to show them in case it wouldn't be clear who
|
||||||
|
// the spotlight media belongs to.
|
||||||
|
// TODO: Respect io.element.functional_members (while still
|
||||||
|
// being careful to never show a functional member's media
|
||||||
|
// without a name tag!)
|
||||||
|
// TODO: Only hide name tags in DMs, not group chats that just
|
||||||
|
// happen to have only 2 users
|
||||||
|
members.size > 2,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: of(true),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const toggleSpotlightExpanded$ = scope.behavior<(() => void) | null>(
|
||||||
|
windowMode$.pipe(
|
||||||
|
switchMap((mode) =>
|
||||||
|
mode === "normal"
|
||||||
|
? layoutMedia$.pipe(
|
||||||
|
map(
|
||||||
|
(l) =>
|
||||||
|
l.type === "spotlight-landscape" ||
|
||||||
|
l.type === "spotlight-expanded",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: of(false),
|
||||||
|
),
|
||||||
|
distinctUntilChanged(),
|
||||||
|
map((enabled) =>
|
||||||
|
enabled ? (): void => spotlightExpandedToggle$.next() : null,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const edgeToEdge$ = scope.behavior<boolean>(
|
||||||
|
layoutMedia$.pipe(map(({ edgeToEdge }) => edgeToEdge)),
|
||||||
|
);
|
||||||
|
|
||||||
|
const screenTap$ = new Subject<void>();
|
||||||
|
const controlsTap$ = new Subject<void>();
|
||||||
|
const screenHover$ = new Subject<void>();
|
||||||
|
const screenUnhover$ = new Subject<void>();
|
||||||
|
|
||||||
|
const naturallyShowFooter$ = scope.behavior<boolean>(
|
||||||
|
edgeToEdge$.pipe(
|
||||||
|
switchMap((edgeToEdge) => {
|
||||||
|
if (!edgeToEdge) return of(true);
|
||||||
|
|
||||||
|
// Sadly Firefox has some layering glitches that prevent the footer
|
||||||
|
// from appearing properly. They happen less often if we never hide
|
||||||
|
// the footer.
|
||||||
|
if (isFirefox()) return of(true);
|
||||||
|
|
||||||
|
// Layout is edge-to-edge; show/hide the footer in response to interactions
|
||||||
|
return windowMode$.pipe(
|
||||||
|
switchMap((mode) => {
|
||||||
|
const showInitially = mode !== "flat";
|
||||||
|
const timeout$ = mode === "flat" ? timer(showFooterMs) : NEVER;
|
||||||
|
|
||||||
|
return merge(
|
||||||
|
screenTap$.pipe(map(() => "tap screen" as const)),
|
||||||
|
controlsTap$.pipe(map(() => "tap controls" as const)),
|
||||||
|
screenHover$.pipe(map(() => "hover" as const)),
|
||||||
|
).pipe(
|
||||||
|
switchScan((state, interaction) => {
|
||||||
|
switch (interaction) {
|
||||||
|
case "tap screen":
|
||||||
|
return state
|
||||||
|
? // Toggle visibility on tap
|
||||||
|
of(false)
|
||||||
|
: // Hide after a timeout
|
||||||
|
timeout$.pipe(
|
||||||
|
map(() => false),
|
||||||
|
startWith(true),
|
||||||
|
);
|
||||||
|
case "tap controls":
|
||||||
|
// The user is interacting with things, so reset the timeout
|
||||||
|
return timeout$.pipe(
|
||||||
|
map(() => false),
|
||||||
|
startWith(true),
|
||||||
|
);
|
||||||
|
case "hover":
|
||||||
|
// Show on hover and hide after a timeout
|
||||||
|
return race(timeout$, screenUnhover$.pipe(take(1))).pipe(
|
||||||
|
map(() => false),
|
||||||
|
startWith(true),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, showInitially),
|
||||||
|
startWith(showInitially),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const urlParams = getUrlParams();
|
||||||
|
const showFooterUrlParams = !(
|
||||||
|
urlParams.header === HeaderStyle.None && urlParams.showControls === false
|
||||||
|
);
|
||||||
|
const showFooter$ = scope.behavior(
|
||||||
|
naturallyShowFooter$.pipe(
|
||||||
|
map((naturallyShowFooter) => naturallyShowFooter && showFooterUrlParams),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const showHeader$ = scope.behavior<boolean>(
|
||||||
|
windowMode$.pipe(
|
||||||
|
switchMap((mode) => {
|
||||||
|
// In small windows the header would be too obstructive
|
||||||
|
if (mode === "pip" || mode === "flat") return of(false);
|
||||||
|
// In edge-to-edge layouts, couple the visibility of the header
|
||||||
|
// to that of the footer
|
||||||
|
return edgeToEdge$.pipe(
|
||||||
|
switchMap((edgeToEdge) => (edgeToEdge ? showFooter$ : of(true))),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The alignment of the floating spotlight tile, if present.
|
||||||
|
*/
|
||||||
|
const spotlightAlignment$ = new BehaviorSubject<Alignment>({
|
||||||
|
inline: "end",
|
||||||
|
block: "end",
|
||||||
|
});
|
||||||
|
/**
|
||||||
|
* The size of the small picture-in-picture tile, if present, when in portrait.
|
||||||
|
*/
|
||||||
|
const portraitPipSize$ = scope.behavior(
|
||||||
|
showFooter$.pipe(map((showFooter) => (showFooter ? "lg" : "sm"))),
|
||||||
|
);
|
||||||
|
/**
|
||||||
|
* The alignment of the small picture-in-picture tile, if present, when in portrait.
|
||||||
|
*/
|
||||||
|
const portraitPipAlignment$ = new BehaviorSubject<Alignment>({
|
||||||
|
inline: "end",
|
||||||
|
block: "end",
|
||||||
|
});
|
||||||
|
/**
|
||||||
|
* The alignment of the small picture-in-picture tile, if present, when in landscape.
|
||||||
|
*/
|
||||||
|
const landscapePipAlignment$ = new BehaviorSubject<Alignment>({
|
||||||
|
inline: "end",
|
||||||
|
block: "start",
|
||||||
|
});
|
||||||
|
|
||||||
// There is a cyclical dependency here: the layout algorithms want to know
|
// There is a cyclical dependency here: the layout algorithms want to know
|
||||||
// which tiles are on screen, but to know which tiles are on screen we have to
|
// which tiles are on screen, but to know which tiles are on screen we have to
|
||||||
// first render a layout. To deal with this we assume initially that no tiles
|
// first render a layout. To deal with this we assume initially that no tiles
|
||||||
@@ -1231,16 +1460,33 @@ export function createCallViewModel$(
|
|||||||
case "spotlight-portrait":
|
case "spotlight-portrait":
|
||||||
[layout, newTiles] = gridLikeLayout(
|
[layout, newTiles] = gridLikeLayout(
|
||||||
media,
|
media,
|
||||||
|
spotlightAlignment$,
|
||||||
visibleTiles,
|
visibleTiles,
|
||||||
setVisibleTiles,
|
setVisibleTiles,
|
||||||
prevTiles,
|
prevTiles,
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
case "spotlight-expanded":
|
case "spotlight-expanded":
|
||||||
[layout, newTiles] = spotlightExpandedLayout(media, prevTiles);
|
[layout, newTiles] = spotlightExpandedLayout(
|
||||||
|
media,
|
||||||
|
landscapePipAlignment$,
|
||||||
|
prevTiles,
|
||||||
|
);
|
||||||
break;
|
break;
|
||||||
case "one-on-one":
|
case "one-on-one-landscape":
|
||||||
[layout, newTiles] = oneOnOneLayout(media, prevTiles);
|
[layout, newTiles] = oneOnOneLandscapeLayout(
|
||||||
|
media,
|
||||||
|
landscapePipAlignment$,
|
||||||
|
prevTiles,
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case "one-on-one-portrait":
|
||||||
|
[layout, newTiles] = oneOnOnePortraitLayout(
|
||||||
|
media,
|
||||||
|
portraitPipSize$,
|
||||||
|
portraitPipAlignment$,
|
||||||
|
prevTiles,
|
||||||
|
);
|
||||||
break;
|
break;
|
||||||
case "pip":
|
case "pip":
|
||||||
[layout, newTiles] = pipLayout(media, prevTiles);
|
[layout, newTiles] = pipLayout(media, prevTiles);
|
||||||
@@ -1268,130 +1514,6 @@ export function createCallViewModel$(
|
|||||||
layoutInternals$.pipe(map(({ tiles }) => tiles.generation)),
|
layoutInternals$.pipe(map(({ tiles }) => tiles.generation)),
|
||||||
);
|
);
|
||||||
|
|
||||||
const showSpotlightIndicators$ = scope.behavior<boolean>(
|
|
||||||
layout$.pipe(map((l) => l.type !== "grid")),
|
|
||||||
);
|
|
||||||
|
|
||||||
const showSpeakingIndicators$ = scope.behavior<boolean>(
|
|
||||||
layout$.pipe(
|
|
||||||
switchMap((l) => {
|
|
||||||
switch (l.type) {
|
|
||||||
case "spotlight-landscape":
|
|
||||||
case "spotlight-portrait":
|
|
||||||
// If the spotlight is showing the active speaker, we can do without
|
|
||||||
// speaking indicators as they're a redundant visual cue. But if
|
|
||||||
// screen sharing feeds are in the spotlight we still need them.
|
|
||||||
return l.spotlight.media$.pipe(
|
|
||||||
map((models: MediaViewModel[]) =>
|
|
||||||
models.some((m) => m.type === "screen share"),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
// In expanded spotlight layout, the active speaker is always shown in
|
|
||||||
// the picture-in-picture tile so there is no need for speaking
|
|
||||||
// indicators. And in one-on-one layout there's no question as to who is
|
|
||||||
// speaking.
|
|
||||||
case "spotlight-expanded":
|
|
||||||
case "one-on-one":
|
|
||||||
return of(false);
|
|
||||||
default:
|
|
||||||
return of(true);
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
const toggleSpotlightExpanded$ = scope.behavior<(() => void) | null>(
|
|
||||||
windowMode$.pipe(
|
|
||||||
switchMap((mode) =>
|
|
||||||
mode === "normal"
|
|
||||||
? layout$.pipe(
|
|
||||||
map(
|
|
||||||
(l) =>
|
|
||||||
l.type === "spotlight-landscape" ||
|
|
||||||
l.type === "spotlight-expanded",
|
|
||||||
),
|
|
||||||
)
|
|
||||||
: of(false),
|
|
||||||
),
|
|
||||||
distinctUntilChanged(),
|
|
||||||
map((enabled) =>
|
|
||||||
enabled ? (): void => spotlightExpandedToggle$.next() : null,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
const screenTap$ = new Subject<void>();
|
|
||||||
const controlsTap$ = new Subject<void>();
|
|
||||||
const screenHover$ = new Subject<void>();
|
|
||||||
const screenUnhover$ = new Subject<void>();
|
|
||||||
|
|
||||||
const showHeader$ = scope.behavior<boolean>(
|
|
||||||
windowMode$.pipe(map((mode) => mode !== "pip" && mode !== "flat")),
|
|
||||||
);
|
|
||||||
|
|
||||||
const urlParams = getUrlParams();
|
|
||||||
const showFooterUrlParams = !(
|
|
||||||
urlParams.header === HeaderStyle.None && urlParams.showControls === false
|
|
||||||
);
|
|
||||||
const showFooterLayout$ = scope.behavior<boolean>(
|
|
||||||
windowMode$.pipe(
|
|
||||||
switchMap((mode) => {
|
|
||||||
switch (mode) {
|
|
||||||
case "pip":
|
|
||||||
return of(platform === "desktop" ? true : false);
|
|
||||||
case "normal":
|
|
||||||
case "narrow":
|
|
||||||
return of(true);
|
|
||||||
case "flat":
|
|
||||||
// Sadly Firefox has some layering glitches that prevent the footer
|
|
||||||
// from appearing properly. They happen less often if we never hide
|
|
||||||
// the footer.
|
|
||||||
if (isFirefox()) return of(true);
|
|
||||||
// Show/hide the footer in response to interactions
|
|
||||||
return merge(
|
|
||||||
screenTap$.pipe(map(() => "tap screen" as const)),
|
|
||||||
controlsTap$.pipe(map(() => "tap controls" as const)),
|
|
||||||
screenHover$.pipe(map(() => "hover" as const)),
|
|
||||||
).pipe(
|
|
||||||
switchScan((state, interaction) => {
|
|
||||||
switch (interaction) {
|
|
||||||
case "tap screen":
|
|
||||||
return state
|
|
||||||
? // Toggle visibility on tap
|
|
||||||
of(false)
|
|
||||||
: // Hide after a timeout
|
|
||||||
timer(showFooterMs).pipe(
|
|
||||||
map(() => false),
|
|
||||||
startWith(true),
|
|
||||||
);
|
|
||||||
case "tap controls":
|
|
||||||
// The user is interacting with things, so reset the timeout
|
|
||||||
return timer(showFooterMs).pipe(
|
|
||||||
map(() => false),
|
|
||||||
startWith(true),
|
|
||||||
);
|
|
||||||
case "hover":
|
|
||||||
// Show on hover and hide after a timeout
|
|
||||||
return race(
|
|
||||||
timer(showFooterMs),
|
|
||||||
screenUnhover$.pipe(take(1)),
|
|
||||||
).pipe(
|
|
||||||
map(() => false),
|
|
||||||
startWith(true),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}, false),
|
|
||||||
startWith(false),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
const showFooter$ = scope.behavior(
|
|
||||||
showFooterLayout$.pipe(
|
|
||||||
map((showFooter) => showFooter && showFooterUrlParams),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
/**
|
/**
|
||||||
* Whether audio is currently being output through the earpiece.
|
* Whether audio is currently being output through the earpiece.
|
||||||
*/
|
*/
|
||||||
@@ -1595,7 +1717,6 @@ export function createCallViewModel$(
|
|||||||
audibleReactions$: audibleReactions$,
|
audibleReactions$: audibleReactions$,
|
||||||
visibleReactions$: visibleReactions$,
|
visibleReactions$: visibleReactions$,
|
||||||
|
|
||||||
windowMode$: windowMode$,
|
|
||||||
spotlightExpanded$: spotlightExpanded$,
|
spotlightExpanded$: spotlightExpanded$,
|
||||||
toggleSpotlightExpanded$: toggleSpotlightExpanded$,
|
toggleSpotlightExpanded$: toggleSpotlightExpanded$,
|
||||||
gridMode$: gridMode$,
|
gridMode$: gridMode$,
|
||||||
@@ -1621,8 +1742,10 @@ export function createCallViewModel$(
|
|||||||
tileStoreGeneration$: tileStoreGeneration$,
|
tileStoreGeneration$: tileStoreGeneration$,
|
||||||
showSpotlightIndicators$: showSpotlightIndicators$,
|
showSpotlightIndicators$: showSpotlightIndicators$,
|
||||||
showSpeakingIndicators$: showSpeakingIndicators$,
|
showSpeakingIndicators$: showSpeakingIndicators$,
|
||||||
|
showNameTags$,
|
||||||
showHeader$: showHeader$,
|
showHeader$: showHeader$,
|
||||||
showFooter$: showFooter$,
|
showFooter$: showFooter$,
|
||||||
|
edgeToEdge$,
|
||||||
earpieceMode$: earpieceMode$,
|
earpieceMode$: earpieceMode$,
|
||||||
audioOutputSwitcher$: audioOutputSwitcher$,
|
audioOutputSwitcher$: audioOutputSwitcher$,
|
||||||
reconnecting$: localMembership.reconnecting$,
|
reconnecting$: localMembership.reconnecting$,
|
||||||
|
|||||||
@@ -8,11 +8,11 @@ Please see LICENSE in the repository root for full details.
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
ConnectionState,
|
ConnectionState,
|
||||||
type LocalParticipant,
|
|
||||||
type Participant,
|
type Participant,
|
||||||
ParticipantEvent,
|
ParticipantEvent,
|
||||||
type RemoteParticipant,
|
type RemoteParticipant,
|
||||||
type Room as LivekitRoom,
|
type Room as LivekitRoom,
|
||||||
|
type TrackPublication,
|
||||||
} from "livekit-client";
|
} from "livekit-client";
|
||||||
import { SyncState } from "matrix-js-sdk/lib/sync";
|
import { SyncState } from "matrix-js-sdk/lib/sync";
|
||||||
import { BehaviorSubject, combineLatest, map, of } from "rxjs";
|
import { BehaviorSubject, combineLatest, map, of } from "rxjs";
|
||||||
@@ -72,6 +72,7 @@ export interface CallViewModelInputs {
|
|||||||
roomMembers: RoomMember[];
|
roomMembers: RoomMember[];
|
||||||
livekitConnectionState$: Behavior<ConnectionState>;
|
livekitConnectionState$: Behavior<ConnectionState>;
|
||||||
speaking: Map<Participant, Behavior<boolean>>;
|
speaking: Map<Participant, Behavior<boolean>>;
|
||||||
|
videoEnabled: Map<Participant, Behavior<boolean>>;
|
||||||
sharingScreen: Map<Participant, Behavior<boolean>>;
|
sharingScreen: Map<Participant, Behavior<boolean>>;
|
||||||
mediaDevices: MediaDevices;
|
mediaDevices: MediaDevices;
|
||||||
initialSyncState: SyncState;
|
initialSyncState: SyncState;
|
||||||
@@ -98,6 +99,7 @@ export function withCallViewModel(mode: MatrixRTCMode) {
|
|||||||
ConnectionState.Connected,
|
ConnectionState.Connected,
|
||||||
),
|
),
|
||||||
speaking = new Map(),
|
speaking = new Map(),
|
||||||
|
videoEnabled = new Map(),
|
||||||
sharingScreen = new Map(),
|
sharingScreen = new Map(),
|
||||||
mediaDevices = mockMediaDevices({}),
|
mediaDevices = mockMediaDevices({}),
|
||||||
initialSyncState = SyncState.Syncing,
|
initialSyncState = SyncState.Syncing,
|
||||||
@@ -151,11 +153,19 @@ export function withCallViewModel(mode: MatrixRTCMode) {
|
|||||||
.mockReturnValue(remoteParticipants$);
|
.mockReturnValue(remoteParticipants$);
|
||||||
const mediaSpy = vi
|
const mediaSpy = vi
|
||||||
.spyOn(ComponentsCore, "observeParticipantMedia")
|
.spyOn(ComponentsCore, "observeParticipantMedia")
|
||||||
.mockImplementation((p) =>
|
.mockImplementation((p) => {
|
||||||
of({ participant: p } as Partial<
|
return (videoEnabled.get(p) ?? constant(false)).pipe(
|
||||||
ComponentsCore.ParticipantMedia<LocalParticipant>
|
map((videoEnabled) => ({
|
||||||
> as ComponentsCore.ParticipantMedia<LocalParticipant>),
|
participant: p,
|
||||||
);
|
isMicrophoneEnabled: false,
|
||||||
|
isCameraEnabled: videoEnabled,
|
||||||
|
isScreenShareEnabled: false,
|
||||||
|
cameraTrack: {
|
||||||
|
isMuted: !videoEnabled,
|
||||||
|
} as unknown as TrackPublication,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
});
|
||||||
const eventsSpy = vi
|
const eventsSpy = vi
|
||||||
.spyOn(ComponentsCore, "observeParticipantEvents")
|
.spyOn(ComponentsCore, "observeParticipantEvents")
|
||||||
.mockImplementation((p, ...eventTypes) => {
|
.mockImplementation((p, ...eventTypes) => {
|
||||||
|
|||||||
@@ -5,7 +5,13 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
|||||||
Please see LICENSE in the repository root for full details.
|
Please see LICENSE in the repository root for full details.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { type Layout, type LayoutMedia } from "./layout-types.ts";
|
import { type BehaviorSubject } from "rxjs";
|
||||||
|
|
||||||
|
import {
|
||||||
|
type Alignment,
|
||||||
|
type Layout,
|
||||||
|
type LayoutMedia,
|
||||||
|
} from "./layout-types.ts";
|
||||||
import { type TileStore } from "./TileStore";
|
import { type TileStore } from "./TileStore";
|
||||||
|
|
||||||
export type GridLikeLayoutType =
|
export type GridLikeLayoutType =
|
||||||
@@ -19,6 +25,7 @@ export type GridLikeLayoutType =
|
|||||||
*/
|
*/
|
||||||
export function gridLikeLayout(
|
export function gridLikeLayout(
|
||||||
media: LayoutMedia & { type: GridLikeLayoutType },
|
media: LayoutMedia & { type: GridLikeLayoutType },
|
||||||
|
spotlightAlignment$: BehaviorSubject<Alignment>,
|
||||||
visibleTiles: number,
|
visibleTiles: number,
|
||||||
setVisibleTiles: (value: number) => void,
|
setVisibleTiles: (value: number) => void,
|
||||||
prevTiles: TileStore,
|
prevTiles: TileStore,
|
||||||
@@ -37,6 +44,7 @@ export function gridLikeLayout(
|
|||||||
type: media.type,
|
type: media.type,
|
||||||
spotlight: tiles.spotlightTile,
|
spotlight: tiles.spotlightTile,
|
||||||
grid: tiles.gridTiles,
|
grid: tiles.gridTiles,
|
||||||
|
spotlightAlignment$,
|
||||||
setVisibleTiles,
|
setVisibleTiles,
|
||||||
} as Layout & { type: GridLikeLayoutType },
|
} as Layout & { type: GridLikeLayoutType },
|
||||||
tiles,
|
tiles,
|
||||||
|
|||||||
@@ -1,29 +1,39 @@
|
|||||||
/*
|
/*
|
||||||
Copyright 2024 New Vector Ltd.
|
Copyright 2024 New Vector Ltd.
|
||||||
|
Copyright 2026 Element Creations Ltd.
|
||||||
|
|
||||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
Please see LICENSE in the repository root for full details.
|
Please see LICENSE in the repository root for full details.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { type OneOnOneLayout, type OneOnOneLayoutMedia } from "./layout-types";
|
import { type BehaviorSubject } from "rxjs";
|
||||||
|
|
||||||
|
import {
|
||||||
|
type Alignment,
|
||||||
|
type OneOnOneLandscapeLayout,
|
||||||
|
type OneOnOneLandscapeLayoutMedia,
|
||||||
|
} from "./layout-types";
|
||||||
import { type TileStore } from "./TileStore";
|
import { type TileStore } from "./TileStore";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Produces a one-on-one layout with the given media.
|
* Produces a one-on-one landscape layout with the given media.
|
||||||
*/
|
*/
|
||||||
export function oneOnOneLayout(
|
export function oneOnOneLandscapeLayout(
|
||||||
media: OneOnOneLayoutMedia,
|
media: OneOnOneLandscapeLayoutMedia,
|
||||||
|
pipAlignment$: BehaviorSubject<Alignment>,
|
||||||
prevTiles: TileStore,
|
prevTiles: TileStore,
|
||||||
): [OneOnOneLayout, TileStore] {
|
): [OneOnOneLandscapeLayout, TileStore] {
|
||||||
const update = prevTiles.from(2);
|
const update = prevTiles.from(2);
|
||||||
update.registerGridTile(media.pip);
|
update.registerGridTile(media.pip);
|
||||||
update.registerGridTile(media.spotlight);
|
update.registerGridTile(media.spotlight);
|
||||||
const tiles = update.build();
|
const tiles = update.build();
|
||||||
|
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
type: media.type,
|
type: media.type,
|
||||||
spotlight: tiles.gridTilesByMedia.get(media.spotlight)!,
|
spotlight: tiles.gridTilesByMedia.get(media.spotlight)!,
|
||||||
pip: tiles.gridTilesByMedia.get(media.pip)!,
|
pip: tiles.gridTilesByMedia.get(media.pip)!,
|
||||||
|
pipAlignment$,
|
||||||
},
|
},
|
||||||
tiles,
|
tiles,
|
||||||
];
|
];
|
||||||
43
src/state/OneOnOnePortraitLayout.ts
Normal file
43
src/state/OneOnOnePortraitLayout.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2024 New Vector Ltd.
|
||||||
|
Copyright 2026 Element Creations Ltd.
|
||||||
|
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
Please see LICENSE in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { type BehaviorSubject } from "rxjs";
|
||||||
|
|
||||||
|
import {
|
||||||
|
type Alignment,
|
||||||
|
type OneOnOnePortraitLayout,
|
||||||
|
type OneOnOnePortraitLayoutMedia,
|
||||||
|
} from "./layout-types";
|
||||||
|
import { type TileStore } from "./TileStore";
|
||||||
|
import { type Behavior } from "./Behavior";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Produces a one-on-one portrait layout with the given media.
|
||||||
|
*/
|
||||||
|
export function oneOnOnePortraitLayout(
|
||||||
|
media: OneOnOnePortraitLayoutMedia,
|
||||||
|
pipSize$: Behavior<"sm" | "lg">,
|
||||||
|
pipAlignment$: BehaviorSubject<Alignment>,
|
||||||
|
prevTiles: TileStore,
|
||||||
|
): [OneOnOnePortraitLayout, TileStore] {
|
||||||
|
const update = prevTiles.from(media.pip === undefined ? 0 : 1);
|
||||||
|
update.registerSpotlight([media.spotlight], true);
|
||||||
|
if (media.pip !== undefined) update.registerGridTile(media.pip);
|
||||||
|
const tiles = update.build();
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
type: media.type,
|
||||||
|
spotlight: tiles.spotlightTile!,
|
||||||
|
pip: media.pip && tiles.gridTilesByMedia.get(media.pip),
|
||||||
|
pipSize$,
|
||||||
|
pipAlignment$,
|
||||||
|
},
|
||||||
|
tiles,
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -5,7 +5,10 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
|||||||
Please see LICENSE in the repository root for full details.
|
Please see LICENSE in the repository root for full details.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { type BehaviorSubject } from "rxjs";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
type Alignment,
|
||||||
type SpotlightExpandedLayout,
|
type SpotlightExpandedLayout,
|
||||||
type SpotlightExpandedLayoutMedia,
|
type SpotlightExpandedLayoutMedia,
|
||||||
} from "./layout-types";
|
} from "./layout-types";
|
||||||
@@ -16,6 +19,7 @@ import { type TileStore } from "./TileStore";
|
|||||||
*/
|
*/
|
||||||
export function spotlightExpandedLayout(
|
export function spotlightExpandedLayout(
|
||||||
media: SpotlightExpandedLayoutMedia,
|
media: SpotlightExpandedLayoutMedia,
|
||||||
|
pipAlignment$: BehaviorSubject<Alignment>,
|
||||||
prevTiles: TileStore,
|
prevTiles: TileStore,
|
||||||
): [SpotlightExpandedLayout, TileStore] {
|
): [SpotlightExpandedLayout, TileStore] {
|
||||||
const update = prevTiles.from(1);
|
const update = prevTiles.from(1);
|
||||||
@@ -27,7 +31,8 @@ export function spotlightExpandedLayout(
|
|||||||
{
|
{
|
||||||
type: media.type,
|
type: media.type,
|
||||||
spotlight: tiles.spotlightTile!,
|
spotlight: tiles.spotlightTile!,
|
||||||
pip: tiles.gridTiles[0],
|
pip: tiles.gridTiles.at(0),
|
||||||
|
pipAlignment$,
|
||||||
},
|
},
|
||||||
tiles,
|
tiles,
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
|||||||
Please see LICENSE in the repository root for full details.
|
Please see LICENSE in the repository root for full details.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { type BehaviorSubject } from "rxjs";
|
||||||
|
|
||||||
import { type LocalUserMediaViewModel } from "./media/LocalUserMediaViewModel.ts";
|
import { type LocalUserMediaViewModel } from "./media/LocalUserMediaViewModel.ts";
|
||||||
import { type MediaViewModel } from "./media/MediaViewModel.ts";
|
import { type MediaViewModel } from "./media/MediaViewModel.ts";
|
||||||
import { type RingingMediaViewModel } from "./media/RingingMediaViewModel.ts";
|
import { type RingingMediaViewModel } from "./media/RingingMediaViewModel.ts";
|
||||||
@@ -13,39 +15,53 @@ import {
|
|||||||
type GridTileViewModel,
|
type GridTileViewModel,
|
||||||
type SpotlightTileViewModel,
|
type SpotlightTileViewModel,
|
||||||
} from "./TileViewModel.ts";
|
} from "./TileViewModel.ts";
|
||||||
|
import { type Behavior } from "./Behavior.ts";
|
||||||
|
|
||||||
export interface GridLayoutMedia {
|
export interface GridLayoutMedia {
|
||||||
type: "grid";
|
type: "grid";
|
||||||
|
edgeToEdge: false;
|
||||||
spotlight?: MediaViewModel[];
|
spotlight?: MediaViewModel[];
|
||||||
grid: UserMediaViewModel[];
|
grid: UserMediaViewModel[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SpotlightLandscapeLayoutMedia {
|
export interface SpotlightLandscapeLayoutMedia {
|
||||||
type: "spotlight-landscape";
|
type: "spotlight-landscape";
|
||||||
|
edgeToEdge: false;
|
||||||
spotlight: MediaViewModel[];
|
spotlight: MediaViewModel[];
|
||||||
grid: UserMediaViewModel[];
|
grid: UserMediaViewModel[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SpotlightPortraitLayoutMedia {
|
export interface SpotlightPortraitLayoutMedia {
|
||||||
type: "spotlight-portrait";
|
type: "spotlight-portrait";
|
||||||
|
edgeToEdge: false;
|
||||||
spotlight: MediaViewModel[];
|
spotlight: MediaViewModel[];
|
||||||
grid: UserMediaViewModel[];
|
grid: UserMediaViewModel[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SpotlightExpandedLayoutMedia {
|
export interface SpotlightExpandedLayoutMedia {
|
||||||
type: "spotlight-expanded";
|
type: "spotlight-expanded";
|
||||||
|
edgeToEdge: boolean;
|
||||||
spotlight: MediaViewModel[];
|
spotlight: MediaViewModel[];
|
||||||
pip?: UserMediaViewModel;
|
pip?: UserMediaViewModel;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface OneOnOneLayoutMedia {
|
export interface OneOnOneLandscapeLayoutMedia {
|
||||||
type: "one-on-one";
|
type: "one-on-one-landscape";
|
||||||
|
edgeToEdge: false;
|
||||||
spotlight: UserMediaViewModel;
|
spotlight: UserMediaViewModel;
|
||||||
pip: LocalUserMediaViewModel | RingingMediaViewModel;
|
pip: LocalUserMediaViewModel | RingingMediaViewModel;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface OneOnOnePortraitLayoutMedia {
|
||||||
|
type: "one-on-one-portrait";
|
||||||
|
edgeToEdge: true;
|
||||||
|
spotlight: UserMediaViewModel | RingingMediaViewModel;
|
||||||
|
pip?: LocalUserMediaViewModel;
|
||||||
|
}
|
||||||
|
|
||||||
export interface PipLayoutMedia {
|
export interface PipLayoutMedia {
|
||||||
type: "pip";
|
type: "pip";
|
||||||
|
edgeToEdge: boolean;
|
||||||
spotlight: MediaViewModel[];
|
spotlight: MediaViewModel[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,13 +70,20 @@ export type LayoutMedia =
|
|||||||
| SpotlightLandscapeLayoutMedia
|
| SpotlightLandscapeLayoutMedia
|
||||||
| SpotlightPortraitLayoutMedia
|
| SpotlightPortraitLayoutMedia
|
||||||
| SpotlightExpandedLayoutMedia
|
| SpotlightExpandedLayoutMedia
|
||||||
| OneOnOneLayoutMedia
|
| OneOnOneLandscapeLayoutMedia
|
||||||
|
| OneOnOnePortraitLayoutMedia
|
||||||
| PipLayoutMedia;
|
| PipLayoutMedia;
|
||||||
|
|
||||||
|
export interface Alignment {
|
||||||
|
inline: "start" | "end";
|
||||||
|
block: "start" | "end";
|
||||||
|
}
|
||||||
|
|
||||||
export interface GridLayout {
|
export interface GridLayout {
|
||||||
type: "grid";
|
type: "grid";
|
||||||
spotlight?: SpotlightTileViewModel;
|
spotlight?: SpotlightTileViewModel;
|
||||||
grid: GridTileViewModel[];
|
grid: GridTileViewModel[];
|
||||||
|
spotlightAlignment$: BehaviorSubject<Alignment>;
|
||||||
setVisibleTiles: (value: number) => void;
|
setVisibleTiles: (value: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,12 +105,22 @@ export interface SpotlightExpandedLayout {
|
|||||||
type: "spotlight-expanded";
|
type: "spotlight-expanded";
|
||||||
spotlight: SpotlightTileViewModel;
|
spotlight: SpotlightTileViewModel;
|
||||||
pip?: GridTileViewModel;
|
pip?: GridTileViewModel;
|
||||||
|
pipAlignment$: BehaviorSubject<Alignment>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface OneOnOneLayout {
|
export interface OneOnOneLandscapeLayout {
|
||||||
type: "one-on-one";
|
type: "one-on-one-landscape";
|
||||||
spotlight: GridTileViewModel;
|
spotlight: GridTileViewModel;
|
||||||
pip: GridTileViewModel;
|
pip: GridTileViewModel;
|
||||||
|
pipAlignment$: BehaviorSubject<Alignment>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OneOnOnePortraitLayout {
|
||||||
|
type: "one-on-one-portrait";
|
||||||
|
spotlight: SpotlightTileViewModel;
|
||||||
|
pip?: GridTileViewModel;
|
||||||
|
pipSize$: Behavior<"sm" | "lg">;
|
||||||
|
pipAlignment$: BehaviorSubject<Alignment>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PipLayout {
|
export interface PipLayout {
|
||||||
@@ -104,5 +137,6 @@ export type Layout =
|
|||||||
| SpotlightLandscapeLayout
|
| SpotlightLandscapeLayout
|
||||||
| SpotlightPortraitLayout
|
| SpotlightPortraitLayout
|
||||||
| SpotlightExpandedLayout
|
| SpotlightExpandedLayout
|
||||||
| OneOnOneLayout
|
| OneOnOneLandscapeLayout
|
||||||
|
| OneOnOnePortraitLayout
|
||||||
| PipLayout;
|
| PipLayout;
|
||||||
|
|||||||
@@ -72,6 +72,10 @@ borders don't support gradients */
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tile.edgeToEdge {
|
||||||
|
--media-view-border-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.muteIcon[data-muted="true"] {
|
.muteIcon[data-muted="true"] {
|
||||||
color: var(--cpd-color-icon-secondary);
|
color: var(--cpd-color-icon-secondary);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ test("GridTile is accessible", async () => {
|
|||||||
targetWidth={300}
|
targetWidth={300}
|
||||||
targetHeight={200}
|
targetHeight={200}
|
||||||
showSpeakingIndicators
|
showSpeakingIndicators
|
||||||
|
showNameTags
|
||||||
focusable
|
focusable
|
||||||
/>
|
/>
|
||||||
</ReactionsSenderProvider>,
|
</ReactionsSenderProvider>,
|
||||||
@@ -109,6 +110,7 @@ test("GridTile displays ringing media", async () => {
|
|||||||
targetWidth={300}
|
targetWidth={300}
|
||||||
targetHeight={200}
|
targetHeight={200}
|
||||||
showSpeakingIndicators
|
showSpeakingIndicators
|
||||||
|
showNameTags
|
||||||
focusable
|
focusable
|
||||||
/>
|
/>
|
||||||
</ReactionsSenderProvider>,
|
</ReactionsSenderProvider>,
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ interface TileProps {
|
|||||||
targetHeight: number;
|
targetHeight: number;
|
||||||
displayName: string;
|
displayName: string;
|
||||||
mxcAvatarUrl: string | undefined;
|
mxcAvatarUrl: string | undefined;
|
||||||
|
showNameTags: boolean;
|
||||||
focusable: boolean;
|
focusable: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -398,6 +399,7 @@ interface GridTileProps {
|
|||||||
className?: string;
|
className?: string;
|
||||||
style?: ComponentProps<typeof animated.div>["style"];
|
style?: ComponentProps<typeof animated.div>["style"];
|
||||||
showSpeakingIndicators: boolean;
|
showSpeakingIndicators: boolean;
|
||||||
|
showNameTags: boolean;
|
||||||
focusable: boolean;
|
focusable: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -419,9 +421,9 @@ export const GridTile: FC<GridTileProps> = ({
|
|||||||
<RingingMediaTile
|
<RingingMediaTile
|
||||||
ref={ref}
|
ref={ref}
|
||||||
vm={media}
|
vm={media}
|
||||||
{...props}
|
|
||||||
displayName={displayName}
|
displayName={displayName}
|
||||||
mxcAvatarUrl={mxcAvatarUrl}
|
mxcAvatarUrl={mxcAvatarUrl}
|
||||||
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
} else if (media.local) {
|
} else if (media.local) {
|
||||||
|
|||||||
@@ -85,6 +85,7 @@ unconditionally select the container so we can use cqmin units */
|
|||||||
calc(var(--media-view-border-radius) - var(--cpd-space-3x))
|
calc(var(--media-view-border-radius) - var(--cpd-space-3x))
|
||||||
);
|
);
|
||||||
padding: var(--fg-inset);
|
padding: var(--fg-inset);
|
||||||
|
transition: padding 0.3s;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr auto;
|
grid-template-columns: 1fr auto;
|
||||||
grid-template-rows: 1fr auto;
|
grid-template-rows: 1fr auto;
|
||||||
@@ -94,6 +95,12 @@ unconditionally select the container so we can use cqmin units */
|
|||||||
contain: strict;
|
contain: strict;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion) {
|
||||||
|
.fg {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.nameTag {
|
.nameTag {
|
||||||
grid-area: nameTag;
|
grid-area: nameTag;
|
||||||
place-self: end start;
|
place-self: end start;
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ describe("MediaView", () => {
|
|||||||
targetHeight: 200,
|
targetHeight: 200,
|
||||||
mirror: false,
|
mirror: false,
|
||||||
unencryptedWarning: false,
|
unencryptedWarning: false,
|
||||||
|
showNameTags: true,
|
||||||
video: trackReference,
|
video: trackReference,
|
||||||
userId: "@alice:example.com",
|
userId: "@alice:example.com",
|
||||||
mxcAvatarUrl: undefined,
|
mxcAvatarUrl: undefined,
|
||||||
@@ -107,6 +108,16 @@ describe("MediaView", () => {
|
|||||||
expect(screen.getByRole("img", { name: "Not encrypted" })).toBeTruthy();
|
expect(screen.getByRole("img", { name: "Not encrypted" })).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("is shown and accessible even with name tag hidden", async () => {
|
||||||
|
const { container } = render(
|
||||||
|
<TooltipProvider>
|
||||||
|
<MediaView {...baseProps} unencryptedWarning showNameTags={false} />
|
||||||
|
</TooltipProvider>,
|
||||||
|
);
|
||||||
|
expect(await axe(container)).toHaveNoViolations();
|
||||||
|
screen.getByRole("img", { name: "Not encrypted" });
|
||||||
|
});
|
||||||
|
|
||||||
test("is not shown", () => {
|
test("is not shown", () => {
|
||||||
render(
|
render(
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ interface Props extends ComponentProps<typeof animated.div> {
|
|||||||
videoEnabled: boolean;
|
videoEnabled: boolean;
|
||||||
unencryptedWarning: boolean;
|
unencryptedWarning: boolean;
|
||||||
status?: { text: string; Icon: ComponentType<SVGAttributes<SVGElement>> };
|
status?: { text: string; Icon: ComponentType<SVGAttributes<SVGElement>> };
|
||||||
|
showNameTags: boolean;
|
||||||
nameTagLeadingIcon?: ReactNode;
|
nameTagLeadingIcon?: ReactNode;
|
||||||
displayName: string;
|
displayName: string;
|
||||||
mxcAvatarUrl: string | undefined;
|
mxcAvatarUrl: string | undefined;
|
||||||
@@ -72,6 +73,7 @@ export const MediaView: FC<Props> = ({
|
|||||||
userId,
|
userId,
|
||||||
videoEnabled,
|
videoEnabled,
|
||||||
unencryptedWarning,
|
unencryptedWarning,
|
||||||
|
showNameTags,
|
||||||
nameTagLeadingIcon,
|
nameTagLeadingIcon,
|
||||||
displayName,
|
displayName,
|
||||||
mxcAvatarUrl,
|
mxcAvatarUrl,
|
||||||
@@ -94,6 +96,23 @@ export const MediaView: FC<Props> = ({
|
|||||||
|
|
||||||
const avatarSize = Math.round(Math.min(targetWidth, targetHeight) / 2);
|
const avatarSize = Math.round(Math.min(targetWidth, targetHeight) / 2);
|
||||||
|
|
||||||
|
const warnings = unencryptedWarning && (
|
||||||
|
<Tooltip
|
||||||
|
label={t("common.unencrypted")}
|
||||||
|
placement="bottom"
|
||||||
|
isTriggerInteractive={false}
|
||||||
|
nonInteractiveTriggerTabIndex={focusable ? undefined : -1}
|
||||||
|
>
|
||||||
|
<ErrorSolidIcon
|
||||||
|
width={20}
|
||||||
|
height={20}
|
||||||
|
className={styles.errorIcon}
|
||||||
|
role="img"
|
||||||
|
aria-label={t("common.unencrypted")}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<animated.div
|
<animated.div
|
||||||
className={classNames(styles.media, className, {
|
className={classNames(styles.media, className, {
|
||||||
@@ -184,34 +203,23 @@ export const MediaView: FC<Props> = ({
|
|||||||
</Text>
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
)*/}
|
)*/}
|
||||||
<div className={styles.nameTag}>
|
{showNameTags && targetWidth >= 100 ? (
|
||||||
{nameTagLeadingIcon}
|
<div className={styles.nameTag}>
|
||||||
<Text
|
{nameTagLeadingIcon}
|
||||||
as="span"
|
<Text
|
||||||
size="sm"
|
as="span"
|
||||||
weight="medium"
|
size="sm"
|
||||||
className={styles.name}
|
weight="medium"
|
||||||
data-testid="name_tag"
|
className={styles.name}
|
||||||
>
|
data-testid="name_tag"
|
||||||
{displayName}
|
|
||||||
</Text>
|
|
||||||
{unencryptedWarning && (
|
|
||||||
<Tooltip
|
|
||||||
label={t("common.unencrypted")}
|
|
||||||
placement="bottom"
|
|
||||||
isTriggerInteractive={false}
|
|
||||||
nonInteractiveTriggerTabIndex={focusable ? undefined : -1}
|
|
||||||
>
|
>
|
||||||
<ErrorSolidIcon
|
{displayName}
|
||||||
width={20}
|
</Text>
|
||||||
height={20}
|
{warnings}
|
||||||
className={styles.errorIcon}
|
</div>
|
||||||
role="img"
|
) : (
|
||||||
aria-label={t("common.unencrypted")}
|
warnings
|
||||||
/>
|
)}
|
||||||
</Tooltip>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{primaryButton}
|
{primaryButton}
|
||||||
</div>
|
</div>
|
||||||
</animated.div>
|
</animated.div>
|
||||||
|
|||||||
@@ -35,7 +35,9 @@ Please see LICENSE in the repository root for full details.
|
|||||||
|
|
||||||
.maximised .item {
|
.maximised .item {
|
||||||
/* Ensure that foreground elements lie within the safe area */
|
/* Ensure that foreground elements lie within the safe area */
|
||||||
--media-view-fg-inset: 10px calc(env(safe-area-inset-right) + 10px) 10px
|
--media-view-fg-inset: calc(var(--call-view-safe-area-inset-top, 0px) + 10px)
|
||||||
|
calc(env(safe-area-inset-right) + 10px)
|
||||||
|
calc(var(--call-view-safe-area-inset-bottom, 0px) + 10px)
|
||||||
calc(env(safe-area-inset-left) + 10px);
|
calc(env(safe-area-inset-left) + 10px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ test("SpotlightTile is accessible", async () => {
|
|||||||
expanded={false}
|
expanded={false}
|
||||||
onToggleExpanded={toggleExpanded}
|
onToggleExpanded={toggleExpanded}
|
||||||
showIndicators
|
showIndicators
|
||||||
|
showNameTags
|
||||||
focusable={true}
|
focusable={true}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
@@ -106,6 +107,7 @@ test("Screen share volume UI is shown when screen share has audio", async () =>
|
|||||||
expanded={false}
|
expanded={false}
|
||||||
onToggleExpanded={toggleExpanded}
|
onToggleExpanded={toggleExpanded}
|
||||||
showIndicators
|
showIndicators
|
||||||
|
showNameTags
|
||||||
focusable
|
focusable
|
||||||
/>
|
/>
|
||||||
</TooltipProvider>,
|
</TooltipProvider>,
|
||||||
@@ -135,6 +137,7 @@ test("Screen share volume UI is hidden when screen share has no audio", async ()
|
|||||||
expanded={false}
|
expanded={false}
|
||||||
onToggleExpanded={toggleExpanded}
|
onToggleExpanded={toggleExpanded}
|
||||||
showIndicators
|
showIndicators
|
||||||
|
showNameTags
|
||||||
focusable
|
focusable
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
@@ -171,6 +174,7 @@ test("SpotlightTile displays ringing media", async () => {
|
|||||||
expanded={false}
|
expanded={false}
|
||||||
onToggleExpanded={toggleExpanded}
|
onToggleExpanded={toggleExpanded}
|
||||||
showIndicators
|
showIndicators
|
||||||
|
showNameTags
|
||||||
focusable={true}
|
focusable={true}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ interface SpotlightItemBaseProps {
|
|||||||
userId: string;
|
userId: string;
|
||||||
displayName: string;
|
displayName: string;
|
||||||
mxcAvatarUrl: string | undefined;
|
mxcAvatarUrl: string | undefined;
|
||||||
|
showNameTags: boolean;
|
||||||
focusable: boolean;
|
focusable: boolean;
|
||||||
"aria-hidden"?: boolean;
|
"aria-hidden"?: boolean;
|
||||||
}
|
}
|
||||||
@@ -244,6 +245,7 @@ interface SpotlightItemProps {
|
|||||||
* The height this tile will have once its animations have settled.
|
* The height this tile will have once its animations have settled.
|
||||||
*/
|
*/
|
||||||
targetHeight: number;
|
targetHeight: number;
|
||||||
|
showNameTags: boolean;
|
||||||
focusable: boolean;
|
focusable: boolean;
|
||||||
intersectionObserver$: Observable<IntersectionObserver>;
|
intersectionObserver$: Observable<IntersectionObserver>;
|
||||||
/**
|
/**
|
||||||
@@ -258,6 +260,7 @@ const SpotlightItem: FC<SpotlightItemProps> = ({
|
|||||||
vm,
|
vm,
|
||||||
targetWidth,
|
targetWidth,
|
||||||
targetHeight,
|
targetHeight,
|
||||||
|
showNameTags,
|
||||||
focusable,
|
focusable,
|
||||||
intersectionObserver$,
|
intersectionObserver$,
|
||||||
snap,
|
snap,
|
||||||
@@ -293,6 +296,7 @@ const SpotlightItem: FC<SpotlightItemProps> = ({
|
|||||||
userId: vm.userId,
|
userId: vm.userId,
|
||||||
displayName,
|
displayName,
|
||||||
mxcAvatarUrl,
|
mxcAvatarUrl,
|
||||||
|
showNameTags,
|
||||||
focusable,
|
focusable,
|
||||||
"aria-hidden": ariaHidden,
|
"aria-hidden": ariaHidden,
|
||||||
};
|
};
|
||||||
@@ -381,6 +385,7 @@ interface Props {
|
|||||||
targetWidth: number;
|
targetWidth: number;
|
||||||
targetHeight: number;
|
targetHeight: number;
|
||||||
showIndicators: boolean;
|
showIndicators: boolean;
|
||||||
|
showNameTags: boolean;
|
||||||
focusable: boolean;
|
focusable: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
style?: ComponentProps<typeof animated.div>["style"];
|
style?: ComponentProps<typeof animated.div>["style"];
|
||||||
@@ -394,6 +399,7 @@ export const SpotlightTile: FC<Props> = ({
|
|||||||
targetWidth,
|
targetWidth,
|
||||||
targetHeight,
|
targetHeight,
|
||||||
showIndicators,
|
showIndicators,
|
||||||
|
showNameTags,
|
||||||
focusable = true,
|
focusable = true,
|
||||||
className,
|
className,
|
||||||
style,
|
style,
|
||||||
@@ -504,6 +510,7 @@ export const SpotlightTile: FC<Props> = ({
|
|||||||
vm={vm}
|
vm={vm}
|
||||||
targetWidth={targetWidth}
|
targetWidth={targetWidth}
|
||||||
targetHeight={targetHeight}
|
targetHeight={targetHeight}
|
||||||
|
showNameTags={showNameTags}
|
||||||
focusable={focusable}
|
focusable={focusable}
|
||||||
intersectionObserver$={intersectionObserver$}
|
intersectionObserver$={intersectionObserver$}
|
||||||
// This is how we get the container to scroll to the right media
|
// This is how we get the container to scroll to the right media
|
||||||
|
|||||||
Reference in New Issue
Block a user