Compare commits

...

23 Commits

Author SHA1 Message Date
Robin
8d826b2f31 Avoid showing gradients in especially small windows 2026-07-09 12:59:29 +02:00
Robin
32e30dd4f5 Merge branch 'livekit' into mobile-gradient 2026-07-09 12:56:28 +02:00
Robin
bb06905aa2 Merge pull request #4099 from element-hq/stable-class-names
Use vitest's default for scoped class names
2026-07-09 12:54:19 +02:00
Timo
f2c42c314c Merge pull request #4088 from element-hq/toger5/logger-getChild-fix
fix getChild before rageshake setup
2026-07-08 18:19:04 +02:00
Robin
4b50a191ba Fix end-to-end tests 2026-07-08 17:13:15 +02:00
Timo K.
137093350b Merge branch 'livekit' into toger5/logger-getChild-fix 2026-07-08 17:04:42 +02:00
Robin
9f681b87a0 Merge branch 'livekit' into mobile-gradient 2026-07-08 15:47:59 +02:00
Robin
27315a7090 Rename bgStyle to 'background' 2026-07-08 14:01:38 +02:00
Robin
bebe79d109 Create type alias for background style 2026-07-08 13:56:10 +02:00
Robin
4ca0270115 Fix broken gradient SVG 2026-07-08 13:52:24 +02:00
Timo K.
eefa6f843d fmt 2026-07-07 19:52:50 +02:00
Timo K.
2b84c587f2 lint 2026-07-07 19:45:22 +02:00
Timo K.
706cddafb0 Add lint rule to make sure future child loggers are setup correctly 2026-07-07 19:34:03 +02:00
Timo K.
a6921e2bfb all the other logs 2026-07-07 19:12:11 +02:00
Timo K.
3cc396f42c fix getChild before rageshake setup 2026-07-07 19:05:10 +02:00
Robin
e68321e16c Merge branch 'livekit' into mobile-gradient 2026-07-06 12:17:04 +02:00
Robin
7840f4bccb Merge branch 'livekit' into mobile-gradient 2026-07-02 13:00:38 +02:00
Robin
900d0abd27 Let page background shine through the footer
By visually separating it from other page content with an additional gradient background only when necessary.
2026-06-30 16:29:20 +02:00
Robin
a3d3a1e951 Add "sound waves" to transparent spotlight tiles
To indicate when the user is speaking.
2026-06-30 16:29:20 +02:00
Robin
8fa3f33f37 Let page background shine through spotlight tiles
By giving spotlight tiles a transparent background in certain layouts.
2026-06-30 16:29:20 +02:00
Robin
360a4ff026 Add mobile gradient background 2026-06-30 16:29:20 +02:00
Robin
8c6a16f3d7 Add URL param to control background style 2026-06-30 16:29:20 +02:00
Robin
9d21581259 Dismiss sections release announcement in end-to-end tests 2026-06-30 16:29:20 +02:00
38 changed files with 510 additions and 198 deletions

View File

@@ -34,6 +34,7 @@
"/*\nCopyright %%CURRENT_YEAR%% Element Creations Ltd.\n\nSPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial\nPlease see LICENSE in the repository root for full details.\n*/\n\n"
],
"element-call/no-observablescope-leak": "error",
"element-call/no-top-level-logger-get-child": "error",
"jsdoc/empty-tags": "error",
"jsdoc/check-property-names": "error",
"jsdoc/require-param-description": "warn",

View File

@@ -67,6 +67,7 @@ These parameters are relevant to both [widget](./embedded_standalone.md) and [st
| `showControls` | `true` or `false` | No, defaults to `true` | No, defaults to `true` | Displays controls like mute, screen-share, invite, and hangup buttons during a call. |
| `skipLobby` (deprecated: use `intent` instead) | `true` or `false` | No. If `intent` is explicitly `start_call` then defaults to `true`. Otherwise defaults to `false` | No, defaults to `false` | Skips the lobby to join a call directly, can be combined with preload in widget. When `true` the audio and video inputs will be muted by default. (This means there currently is no way to start without muted video if one wants to skip the lobby. Also not in widget mode.) |
| `theme` | One of: `light`, `dark`, `light-high-contrast`, `dark-high-contrast` | No, defaults to `dark` | No, defaults to `dark` | UI theme to use. |
| `background` | One of: `solid`, `gradient` | No, defaults to `gradient` | No, defaults to `gradient` | Visual style of the page background. |
| `viaServers` | Comma separated list of [Matrix Server Names](https://spec.matrix.org/v1.12/appendices/#server-name) | Not applicable | No | Homeserver for joining a room, non-empty value required for rooms not on the users default homeserver. |
| `sendNotificationType` | `ring` or `notification` | No | No | Will send a "ring" or "notification" `m.rtc.notification` event if the user is the first one in the call. |
| `autoLeave` | `true` or `false` | No, defaults to `false` | No, defaults to `false` | Whether the app should automatically leave the call when there is no one left in the call. |

View File

@@ -0,0 +1,92 @@
/*
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 { ESLintUtils } from "@typescript-eslint/utils";
/**
* Node types that introduce a new non-module scope. A getChild() call nested
* inside any of these is considered "not at the top level".
*/
const FUNCTION_OR_CLASS_TYPES = new Set([
"FunctionDeclaration",
"FunctionExpression",
"ArrowFunctionExpression",
"ClassBody",
]);
const rule = ESLintUtils.RuleCreator(
() => "https://github.com/element-hq/element-call",
)({
name: "no-top-level-logger-get-child",
meta: {
type: "problem",
docs: {
description:
"Disallow calling logger.getChild() at the top level of a module." +
"`getChild` has to be called after the rageshake logger `init()`." +
"If it is called at the top level the child logger will never be setup for rageshakes.",
},
messages: {
noTopLevelGetChild:
"Do not call logger.getChild() at the top level of a module; move it inside a function or class instead that gets called after rageshake logger `init()` is called.",
},
schema: [],
},
create(context) {
// Tracks the local binding names that refer to the logger imported from
// 'matrix-js-sdk/lib/logger', e.g. both `logger` and `rootLogger` in:
// import { logger } from "matrix-js-sdk/lib/logger";
// import { logger as rootLogger } from "matrix-js-sdk/lib/logger";
const loggerNames = new Set();
return {
ImportDeclaration(node) {
if (node.source.value !== "matrix-js-sdk/lib/logger") return;
for (const specifier of node.specifiers) {
if (
specifier.type === "ImportSpecifier" &&
specifier.imported.name === "logger"
) {
loggerNames.add(specifier.local.name);
}
}
},
CallExpression(node) {
// Must be a non-computed member expression call: something.getChild(...)
if (
node.callee.type !== "MemberExpression" ||
node.callee.computed ||
node.callee.property.type !== "Identifier" ||
node.callee.property.name !== "getChild"
)
return;
// The receiver must be one of the tracked logger names.
const object = node.callee.object;
if (object.type !== "Identifier" || !loggerNames.has(object.name))
return;
// Flag the call only when it is at module top level — i.e. there is no
// enclosing function or class body anywhere in the ancestor chain.
const ancestors = context.sourceCode.getAncestors(node);
const isTopLevel = !ancestors.some((a) =>
FUNCTION_OR_CLASS_TYPES.has(a.type),
);
if (isTopLevel) {
context.report({
messageId: "noTopLevelGetChild",
node,
});
}
},
};
},
});
export default rule;

View File

@@ -2,5 +2,7 @@ module.exports = {
rules: {
"copyright-header": require("./CopyrightHeader").default,
"no-observablescope-leak": require("./NoObservableScopeLeak").default,
"no-top-level-logger-get-child": require("./NoTopLevelLoggerGetChild")
.default,
},
};

View File

@@ -125,21 +125,10 @@ async function expectVideoTilesCount(page: Page, count: number): Promise<void> {
});
// There should be `count` video elements, visible and autoplaying
await expect(page.locator("video")).toHaveCount(count);
await expect(async () => {
const videoBlockCount = await page
.locator("video")
.evaluateAll(
(videos: Element[]) =>
videos.filter(
(v: Element) => window.getComputedStyle(v).display === "block",
).length,
);
expect(videoBlockCount).toBe(count);
}).toPass({
timeout: 10000,
});
await expect(page.locator("video").filter({ visible: true })).toHaveCount(
count,
{ timeout: 10000 },
);
}
export const SpaHelpers = {

View File

@@ -385,25 +385,9 @@ export class TestHelpers {
frame: FrameLocator,
count: number,
): Promise<void> {
// XXX we need to be better at our HTML markup and accessibility, it would make
// this kind of stuff way easier to test if we could look out for aria attributes.
await expect
.poll(
async () => {
return await frame
.locator("video")
.evaluateAll(
(videos: Element[]) =>
videos.filter(
(v: Element) =>
window.getComputedStyle(v).display === "block",
).length,
);
},
{
timeout: 10000,
},
)
.toBe(count);
await expect(frame.locator("video").filter({ visible: true })).toHaveCount(
count,
{ timeout: 10000 },
);
}
}

View File

@@ -15,9 +15,8 @@ import { scan } from "rxjs";
import { type WidgetHelpers } from "../src/widget";
import { type LivekitRoomItem } from "../src/state/CallViewModel/CallViewModel";
export const logger = rootLogger.getChild("[MatrixRTCSdk]");
export const tryMakeSticky = (widget: WidgetHelpers): void => {
const logger = rootLogger.getChild("[MatrixRTCSdk]");
logger.info("try making sticky MatrixRTCSdk");
void widget.api
.setAlwaysOnScreen(true)

View File

@@ -52,7 +52,8 @@ import { getUrlParams } from "../src/UrlParams";
import { MuteStates } from "../src/state/MuteStates";
import { MediaDevices } from "../src/state/MediaDevices";
import { E2eeType } from "../src/e2ee/e2eeType";
import { currentAndPrev, logger, TEXT_LK_TOPIC, tryMakeSticky } from "./helper";
import { currentAndPrev, TEXT_LK_TOPIC, tryMakeSticky } from "./helper";
import { logger as rootLogger } from "matrix-js-sdk/lib/logger";
import {
ElementWidgetActions,
widget as _widget,
@@ -104,6 +105,7 @@ export async function createMatrixRTCSdk(
id: string = "",
sticky: boolean = false,
): Promise<MatrixRTCSdk> {
const logger = rootLogger.getChild("[MatrixRTCSdk]");
const scope = new ObservableScope();
// widget client

View File

@@ -30,7 +30,7 @@ import { useTheme } from "./useTheme";
import { ProcessorProvider } from "./livekit/TrackProcessorContext";
import { type AppViewModel } from "./state/AppViewModel";
import { MediaDevicesContext } from "./MediaDevicesContext";
import { getUrlParams, HeaderStyle } from "./UrlParams";
import { getUrlParams, HeaderStyle, useUrlParams } from "./UrlParams";
import { AppBar } from "./AppBar";
const SentryRoute = Sentry.withSentryReactRouterV7Routing(Route);
@@ -41,19 +41,17 @@ interface SimpleProviderProps {
const BackgroundProvider: FC<SimpleProviderProps> = ({ children }) => {
const { pathname } = useLocation();
const { background } = useUrlParams();
useEffect(() => {
let backgroundImage = "";
if (!["/login", "/register"].includes(pathname) && !widget) {
backgroundImage = "var(--background-gradient)";
}
document
.getElementsByTagName("body")[0]
.setAttribute("data-background", background);
}, [pathname, background]);
document.getElementsByTagName("body")[0].style.backgroundImage =
backgroundImage;
}, [pathname]);
return <>{children}</>;
return children;
};
const ThemeProvider: FC<SimpleProviderProps> = ({ children }) => {
useTheme();
return children;

View File

@@ -45,6 +45,11 @@ export enum HeaderStyle {
AppBar = "app_bar",
}
export enum BackgroundStyle {
Solid = "solid",
Gradient = "gradient",
}
/**
* The UrlProperties are used to pass required data to the widget.
* Those are different in different rooms, users, devices. They do not configure the behavior of the
@@ -145,6 +150,10 @@ export interface UrlProperties {
* can be "light", "dark", "light-high-contrast" or "dark-high-contrast".
*/
theme: string | null;
/**
* The visual style of the page background.
*/
background: BackgroundStyle;
}
/**
@@ -452,6 +461,9 @@ export const computeUrlParams = (search = "", hash = ""): UrlParams => {
fonts: parser.getAllParams("font"),
fontScale: Number.isNaN(fontScale) ? null : fontScale,
theme: parser.getParam("theme"),
background:
parser.getEnumParam("background", BackgroundStyle) ??
BackgroundStyle.Gradient,
viaServers: !isWidget ? parser.getParam("viaServers") : null,
homeserver: !isWidget ? parser.getParam("homeserver") : null,
posthogApiHost: parser.getParam("posthogApiHost"),

View File

@@ -19,11 +19,6 @@ Please see LICENSE in the repository root for full details.
padding-right: calc(env(safe-area-inset-right) + var(--cpd-space-6x));
padding-block: var(--cpd-space-10x)
calc(env(safe-area-inset-bottom) + var(--cpd-space-10x));
background: linear-gradient(
180deg,
rgba(0, 0, 0, 0) 0%,
var(--cpd-color-bg-canvas-default) 100%
);
}
.footer.hidden {

View File

@@ -112,11 +112,17 @@ export interface FooterState {
}
export interface FooterProps {
className?: string;
ref?: Ref<HTMLDivElement>;
children?: JSX.Element | JSX.Element[] | false;
vm: ViewModel<FooterSnapshot>;
}
export const CallFooter: FC<FooterProps> = ({ ref, children, vm }) => {
export const CallFooter: FC<FooterProps> = ({
className,
ref,
children,
vm,
}) => {
const asOverlay = useBehavior(vm.asOverlay$);
const showFooter = useBehavior(vm.showFooter$);
const hideControls = useBehavior(vm.hideControls$);
@@ -292,7 +298,7 @@ export const CallFooter: FC<FooterProps> = ({ ref, children, vm }) => {
<div
ref={ref}
data-testid="footer-container"
className={classNames(styles.footer, {
className={classNames(className, styles.footer, {
[styles.overlay]: asOverlay,
[styles.hidden]: !showFooter,
})}

View File

@@ -10,15 +10,15 @@ import {
type MatrixRTCSession,
MatrixRTCSessionEvent,
} from "matrix-js-sdk/lib/matrixrtc";
import { logger as rootLogger } from "matrix-js-sdk/lib/logger";
import { logger as rootLogger, type Logger } from "matrix-js-sdk/lib/logger";
import { type CallMembershipIdentityParts } from "matrix-js-sdk/lib/matrixrtc/EncryptionManager";
const logger = rootLogger.getChild("[MatrixKeyProvider]");
export class MatrixKeyProvider extends BaseKeyProvider {
private rtcSession?: MatrixRTCSession;
private logger: Logger;
public constructor() {
super({ ratchetWindowSize: 10, keyringSize: 256 });
this.logger = rootLogger.getChild("[MatrixKeyProvider]");
}
public setRTCSession(rtcSession: MatrixRTCSession): void {
@@ -60,12 +60,12 @@ export class MatrixKeyProvider extends BaseKeyProvider {
encryptionKeyIndex,
);
logger.debug(
this.logger.debug(
`Sent new key to livekit room=${this.rtcSession?.room.roomId} participantId=${rtcBackendIdentity} (before hash: ${membershipParts.userId}:${membershipParts.deviceId}) encryptionKeyIndex=${encryptionKeyIndex}`,
);
},
(e) => {
logger.error(
this.logger.error(
`Failed to create key material from buffer for livekit room=${this.rtcSession?.room.roomId} participantId before hash=${membershipParts.userId}:${membershipParts.deviceId} encryptionKeyIndex=${encryptionKeyIndex}`,
e,
);

View File

Before

Width:  |  Height:  |  Size: 938 B

After

Width:  |  Height:  |  Size: 938 B

View File

@@ -1,48 +0,0 @@
<svg width="1440" height="500" viewBox="0 0 1440 500" fill="none" xmlns="http://www.w3.org/2000/svg">
<g filter="url(#filter0_f_4162_80135)">
<circle cx="720" cy="1620" r="1500" fill="url(#paint0_linear_4162_80135)"/>
<circle cx="720" cy="1620" r="1498.92" stroke="white" stroke-opacity="0.5" stroke-width="2.16028" style="mix-blend-mode:overlay"/>
</g>
<g filter="url(#filter1_f_4162_80135)">
<circle cx="720" cy="1550.86" r="1272.86" fill="white"/>
</g>
<defs>
<filter id="filter0_f_4162_80135" x="-900" y="0" width="3240" height="3240" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
<feGaussianBlur stdDeviation="60" result="effect1_foregroundBlur_4162_80135"/>
</filter>
<filter id="filter1_f_4162_80135" x="-672.863" y="158" width="2785.73" height="2785.73" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
<feGaussianBlur stdDeviation="60" result="effect1_foregroundBlur_4162_80135"/>
</filter>
<linearGradient id="paint0_linear_4162_80135" x1="549.5" y1="120" x2="549.5" y2="505.5" gradientUnits="userSpaceOnUse">
<stop stop-color="#062993"/>
<stop offset="0.040404" stop-color="#02389D"/>
<stop offset="0.0808081" stop-color="#0045A6"/>
<stop offset="0.121212" stop-color="#0051AD"/>
<stop offset="0.161616" stop-color="#005DB4"/>
<stop offset="0.20202" stop-color="#0069BA"/>
<stop offset="0.242424" stop-color="#0075BB"/>
<stop offset="0.282828" stop-color="#0081BB"/>
<stop offset="0.323232" stop-color="#008CB9"/>
<stop offset="0.363636" stop-color="#0098B7"/>
<stop offset="0.40404" stop-color="#00A3B3"/>
<stop offset="0.444444" stop-color="#00AEAD"/>
<stop offset="0.484848" stop-color="#00B8A4"/>
<stop offset="0.525253" stop-color="#00C2A0"/>
<stop offset="0.565657" stop-color="#00CC99"/>
<stop offset="0.606061" stop-color="#3AD396"/>
<stop offset="0.646465" stop-color="#5DD898"/>
<stop offset="0.686869" stop-color="#79DD99"/>
<stop offset="0.727273" stop-color="#92E29B"/>
<stop offset="0.767677" stop-color="#A8E69F"/>
<stop offset="0.808081" stop-color="#BBEAA5"/>
<stop offset="0.848485" stop-color="#CDEEAE"/>
<stop offset="0.888889" stop-color="#DCF2B9"/>
<stop offset="0.929293" stop-color="#EAF6C7"/>
<stop offset="0.969697" stop-color="#F5FBD5"/>
</linearGradient>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 2.5 KiB

View File

@@ -0,0 +1,86 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
width="1200"
height="285"
viewBox="0 0 1200 285"
fill="none"
version="1.1"
id="svg5"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<g
filter="url(#filter0_f_3970_9366)"
id="g2"
transform="translate(422.00201)">
<path
d="m -164.342,495.134 c 188.2881,-188.288 494.684,-190 684.684,0"
stroke="url(#paint0_linear_3970_9366)"
stroke-width="235.517"
stroke-linecap="round"
id="path1"
style="stroke:url(#paint0_linear_3970_9366)" />
<path
d="m -164.342,495.134 c 188.2881,-188.288 494.684,-190 684.684,0"
stroke="url(#paint1_linear_3970_9366)"
style="mix-blend-mode:overlay;stroke:url(#paint1_linear_3970_9366)"
stroke-width="235.517"
stroke-linecap="round"
id="path2" />
</g>
<defs
id="defs5">
<filter
id="filter0_f_3970_9366"
x="-517.617"
y="-0.00012207"
width="1391.23"
height="848.409"
filterUnits="userSpaceOnUse"
color-interpolation-filters="sRGB">
<feFlood
flood-opacity="0"
result="BackgroundImageFix"
id="feFlood2" />
<feBlend
mode="normal"
in="SourceGraphic"
in2="BackgroundImageFix"
result="shape"
id="feBlend2" />
<feGaussianBlur
stdDeviation="117.758"
result="effect1_foregroundBlur_3970_9366"
id="feGaussianBlur2" />
</filter>
<linearGradient
id="paint0_linear_3970_9366"
x1="349.17099"
y1="323.96301"
x2="6.82898"
y2="666.30499"
gradientUnits="userSpaceOnUse">
<stop
stop-color="#0D5CBD"
id="stop2" />
<stop
offset="0.730863"
stop-color="#0DBDA8"
id="stop3" />
</linearGradient>
<linearGradient
id="paint1_linear_3970_9366"
x1="349.17099"
y1="323.96301"
x2="6.82898"
y2="666.30499"
gradientUnits="userSpaceOnUse">
<stop
stop-color="#0D5CBD"
id="stop4" />
<stop
offset="0.730863"
stop-color="#0DBDA8"
id="stop5" />
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

View File

@@ -55,7 +55,6 @@ layer(compound);
--small-drop-shadow: 0px 1.2px 2.4px 0px rgba(0, 0, 0, 0.15);
--big-drop-shadow: 0px 0px 24px 0px #1b1d221a;
--subtle-drop-shadow: 0px 1px 2px 0px rgba(16, 24, 40, 0.05);
--background-gradient: url("graphics/backgroundGradient.svg");
--call-view-overlay-layer: 1;
--call-view-header-footer-layer: 2;
@@ -74,9 +73,6 @@ layer(compound);
body {
background-color: var(--cpd-color-bg-canvas-default);
background-size: calc(max(1440px, 100vw)) calc(max(800px, 100vh));
background-repeat: no-repeat;
background-position: center;
color: var(--cpd-color-text-primary);
color-scheme: dark;
margin: 0;
@@ -85,6 +81,24 @@ body {
-webkit-tap-highlight-color: transparent;
}
@media (min-height: 330px) {
body[data-background="gradient"]::before {
content: "";
position: fixed;
inset: 0;
background-image: url("graphics/mobile-gradient.svg");
background-size: auto;
background-position: bottom;
background-repeat: no-repeat;
}
body[data-background="gradient"][data-platform="desktop"]::before {
background-image: url("graphics/desktop-gradient.svg");
background-size: calc(max(1440px, 100vw)) calc(max(800px, 100vh));
background-position: center;
}
}
/* This prohibits the view to scroll for pages smaller than 122px in width
we use this for mobile pip webviews */
.no-scroll-body {

View File

@@ -14,7 +14,7 @@ import {
AudioTrack,
type AudioTrackProps,
} from "@livekit/components-react";
import { logger } from "matrix-js-sdk/lib/logger";
import { logger as rootLogger } from "matrix-js-sdk/lib/logger";
import { useEarpieceAudioConfig } from "../MediaDevicesContext";
import { useReactiveState } from "../useReactiveState";
@@ -40,7 +40,6 @@ export interface MatrixAudioRendererProps {
muted?: boolean;
}
const prefixedLogger = logger.getChild("[MatrixAudioRenderer]");
/**
* Takes care of handling remote participants audio tracks and makes sure that microphones and screen share are audible.
*
@@ -60,6 +59,7 @@ export function LivekitRoomAudioRenderer({
validIdentities,
muted,
}: MatrixAudioRendererProps): ReactNode {
const logger = rootLogger.getChild("[MatrixAudioRenderer]");
const tracks = useTracks(
[
Track.Source.Microphone,
@@ -80,7 +80,7 @@ export function LivekitRoomAudioRenderer({
if (!isValid) {
// TODO make sure to also skip the warn logging for the local identity
// Log that there is an invalid identity, that means that someone is publishing audio that is not expected to be in the call.
prefixedLogger.warn(
logger.warn(
`Audio track ${ref.participant.identity} from ${url} has no matching matrix call member`,
`current members: ${validIdentities.join()}`,
`track will not get rendered`,

View File

@@ -14,6 +14,23 @@ Please see LICENSE in the repository root for full details.
overflow-y: auto;
}
/* Normally the footer uses a transparent background to allow our expressive
page gradients to shine through. However, we sometimes need to visually separate
it from the content underneath. If the call layout is overflowing, or if the
spotlight tile is maximised and displaying video, apply a gradient background. */
.overflowing > .footer,
.fixedGrid:has(
> .tile[data-maximised="true"]
.spotlightItem[data-background="transparent"][data-video-enabled="true"][aria-hidden="false"]
)
~ .footer {
background: linear-gradient(
180deg,
rgba(0, 0, 0, 0) 0%,
var(--cpd-color-bg-canvas-default) 100%
);
}
.header {
position: sticky;
flex-shrink: 0;
@@ -82,19 +99,18 @@ Please see LICENSE in the repository root for full details.
/* Disable pointer events so the overlay doesn't block interaction with
elements behind it */
pointer-events: none;
}
.fixedGrid > :not(:first-child),
.scrollingGrid > :not(:first-child) {
pointer-events: initial;
> :not(:first-child) {
pointer-events: initial;
}
.tile {
position: absolute;
inset-block-start: 0;
}
}
.tile {
position: absolute;
inset-block-start: 0;
}
.tile.maximised {
position: relative;
flex-grow: 1;
}

View File

@@ -94,8 +94,6 @@ declare module "react" {
}
}
const logger = rootLogger.getChild("[InCallView]");
export interface ActiveCallProps extends Omit<
InCallViewProps,
"vm" | "livekitRoom" | "connState" | "footerVm"
@@ -116,7 +114,7 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
const mediaDevices = useMediaDevices();
const trackProcessorState$ = useTrackProcessorObservable$();
useEffect(() => {
logger.info("START CALL VIEW SCOPE");
rootLogger.info("START CALL VIEW SCOPE");
const scope = new ObservableScope();
const reactionsReader = new ReactionsReader(scope, props.rtcSession);
const { autoLeaveWhenOthersLeft, waitForCallPickup, sendNotificationType } =
@@ -218,6 +216,7 @@ export const InCallView: FC<InCallViewProps> = ({
muteStates,
onShareClick,
}) => {
const logger = rootLogger.getChild("[InCallView]");
const { t } = useTranslation();
const { sendReaction, toggleRaisedHand } = useReactionsSender();
@@ -259,6 +258,7 @@ export const InCallView: FC<InCallViewProps> = ({
const reconnecting = useBehavior(vm.reconnecting$);
const layout = useBehavior(vm.layout$);
const edgeToEdge = useBehavior(vm.edgeToEdge$);
const overflowing = useBehavior(vm.overflowing$);
const showNameTags = useBehavior(vm.showNameTags$);
const showHeader = useBehavior(vm.showHeader$);
const settingsOpen = useBehavior(vm.settingsOpen$);
@@ -468,6 +468,7 @@ export const InCallView: FC<InCallViewProps> = ({
showRingingStatus={showRingingStatus}
focusable={!contentObscured}
className={classNames(className, styles.tile)}
itemClassName={styles.spotlightItem}
style={style}
/>
);
@@ -492,7 +493,9 @@ export const InCallView: FC<InCallViewProps> = ({
if (layout.type === "pip") {
return (
<SpotlightTile
className={classNames(styles.tile, styles.maximised)}
className={styles.tile}
itemClassName={styles.spotlightItem}
data-maximised
vm={layout.spotlight}
expanded
onToggleExpanded={null}
@@ -584,7 +587,7 @@ export const InCallView: FC<InCallViewProps> = ({
// Only hide the settings button if we have an AppBar header and we are showing the header
const footer = footerVm !== null && (
<CallFooter ref={footerRef} vm={footerVm} />
<CallFooter className={styles.footer} ref={footerRef} vm={footerVm} />
);
const allConnections = useBehavior(vm.allConnections$);
@@ -593,7 +596,9 @@ export const InCallView: FC<InCallViewProps> = ({
// and the footer is also viewable by moving focus into it, so this is fine.
// eslint-disable-next-line jsx-a11y/no-static-element-interactions
<div
className={styles.inRoom}
className={classNames(styles.inRoom, {
[styles.overflowing]: overflowing,
})}
ref={containerRef}
onPointerUp={onViewPointerUp}
onPointerMove={onPointerMove}

View File

@@ -164,7 +164,7 @@ exports[`InCallView > rendering > renders 1`] = `
class="_container_8084b5"
/>
<div
class="_footer_20b7b4"
class="_footer_4e7ff8 _footer_20b7b4"
data-testid="footer-container"
>
<div

View File

@@ -467,7 +467,7 @@ declare global {
// eslint-disable-next-line no-var, camelcase
var mx_rage_initStoragePromise: Promise<void> | undefined;
}
export let rageshakeLogger: Logger;
/**
* Configure rage shaking support for sending bug reports.
* Modifies globals.
@@ -477,7 +477,8 @@ export async function init(): Promise<void> {
global.mx_rage_logger = new ConsoleLogger();
// configure loglevel based loggers:
setLogExtension(logger, global.mx_rage_logger.log);
rageshakeLogger = logger;
setLogExtension(rageshakeLogger, global.mx_rage_logger.log);
// intercept console logging so that we can get matrix_sdk logs:
// this is nasty, but no logging hooks are provided

View File

@@ -39,8 +39,6 @@ import { type Behavior } from "../Behavior";
import { type Epoch, type ObservableScope } from "../ObservableScope";
import { type RoomMemberMap } from "./remoteMembers/MatrixMemberMetadata";
const logger = rootLogger.getChild("[CallNotificationLifecycle]");
export type AutoLeaveReason = "allOthersLeft" | "timeout" | "decline";
export interface RingAttempt {
@@ -114,6 +112,7 @@ export function createCallNotificationLifecycle$({
*/
autoLeave$: Observable<AutoLeaveReason>;
} {
const logger = rootLogger.getChild("[CallNotificationLifecycle]");
let ringAttempts$: Observable<RingAttempt> = NEVER;
if (options.waitForCallPickup)
ringAttempts$ = sentCallNotification$.pipe(

View File

@@ -40,7 +40,7 @@ import {
timer,
takeUntil,
} from "rxjs";
import { logger as rootLogger } from "matrix-js-sdk/lib/logger";
import { type Logger, logger as rootLogger } from "matrix-js-sdk/lib/logger";
import {
MembershipManagerEvent,
type LivekitTransportConfig,
@@ -157,7 +157,6 @@ import {
} from "../media/RingingMediaViewModel.ts";
import { type GridTileViewModel } from "../TileViewModel.ts";
const logger = rootLogger.getChild("[CallViewModel]");
//TODO
// Larger rename
// member,membership -> rtcMember
@@ -208,6 +207,7 @@ export type WindowMode = "normal" | "narrow" | "flat" | "pip";
interface LayoutScanState {
layout: Layout | null;
overflowing: boolean;
tiles: TileStore;
}
@@ -360,6 +360,10 @@ export interface CallViewModel {
* and header as overlays.
*/
edgeToEdge$: Behavior<boolean>;
/**
* Whether the call layout is overflowing the interface (causing it to scroll).
*/
overflowing$: Behavior<boolean>;
settingsOpen$: Behavior<boolean>;
setSettingsOpen$: Behavior<(open: boolean) => void>;
@@ -411,6 +415,7 @@ export function createCallViewModel$(
reactionsSubject$: Observable<Record<string, ReactionInfo>>,
trackProcessorState$: Behavior<ProcessorState>,
): CallViewModel {
const logger = rootLogger.getChild("[CallViewModel]");
const client = matrixRoom.client;
const userId = client.getUserId();
const deviceId = client.getDeviceId();
@@ -420,6 +425,7 @@ export function createCallViewModel$(
const livekitKeyProvider = getE2eeKeyProvider(
options.encryptionSystem,
matrixRTCSession,
logger,
);
// matrix_rtc_mode in config.json overrides the user's Developer Settings choice.
// It is validated at config load (src/config/Config.ts) so the cast is safe.
@@ -1467,7 +1473,7 @@ export function createCallViewModel$(
// 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
// 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 all tiles
// are visible, and loop the data back into the layouts with a Subject.
const visibleTiles$ = new Subject<number>();
const setVisibleTiles = (value: number): void => visibleTiles$.next(value);
@@ -1475,7 +1481,7 @@ export function createCallViewModel$(
const layoutInternals$ = scope.behavior<LayoutScanState & { layout: Layout }>(
combineLatest([
layoutMedia$,
visibleTiles$.pipe(startWith(0), distinctUntilChanged()),
visibleTiles$.pipe(startWith(Infinity), distinctUntilChanged()),
]).pipe(
scan<
[LayoutMedia, number],
@@ -1486,6 +1492,7 @@ export function createCallViewModel$(
let layout: Layout;
let newTiles: TileStore;
let pip: GridTileViewModel | undefined;
let overflowing = false;
switch (media.type) {
case "grid":
case "spotlight-landscape":
@@ -1497,6 +1504,7 @@ export function createCallViewModel$(
setVisibleTiles,
prevTiles,
);
overflowing = newTiles.gridTiles.length > visibleTiles;
break;
case "spotlight-expanded":
[layout, newTiles] = spotlightExpandedLayout(
@@ -1531,9 +1539,9 @@ export function createCallViewModel$(
tile.setShowOutline(tile === pip);
}
return { layout, tiles: newTiles };
return { layout, overflowing, tiles: newTiles };
},
{ layout: null, tiles: TileStore.empty() },
{ layout: null, overflowing: false, tiles: TileStore.empty() },
),
),
);
@@ -1545,6 +1553,10 @@ export function createCallViewModel$(
layoutInternals$.pipe(map(({ layout }) => layout)),
);
const overflowing$ = scope.behavior<boolean>(
layoutInternals$.pipe(map(({ overflowing }) => overflowing)),
);
/**
* The current generation of the tile store, exposed for debugging purposes.
*/
@@ -1786,6 +1798,7 @@ export function createCallViewModel$(
settingsOpen$: settingsOpen$,
setSettingsOpen$: setSettingsOpen$,
edgeToEdge$,
overflowing$,
earpieceMode$: earpieceMode$,
audioOutputSwitcher$: audioOutputSwitcher$,
reconnecting$: localMembership.reconnecting$,
@@ -1797,6 +1810,7 @@ export function createCallViewModel$(
function getE2eeKeyProvider(
e2eeSystem: EncryptionSystem,
rtcSession: MatrixRTCSession,
logger: Logger,
): BaseKeyProvider | undefined {
if (e2eeSystem.kind === E2eeType.NONE) return undefined;

View File

@@ -31,11 +31,6 @@ import { type ObservableScope } from "../../ObservableScope";
import { type Behavior } from "../../Behavior";
import { type NodeStyleEventEmitter } from "../../../utils/test";
/**
* Logger instance (scoped child) for homeserver connection updates.
*/
const logger = rootLogger.getChild("[HomeserverConnected]");
export type HomeserverDisconnectReason = "sync" | "membership" | "probablyLeft";
export interface HomeserverConnected {
@@ -70,6 +65,7 @@ export function createHomeserverConnected$(
Pick<MatrixRTCSession, "membershipStatus" | "probablyLeft">,
gracePeriodMs?: number,
): HomeserverConnected {
const logger = rootLogger.getChild("[HomeserverConnected]");
// Get grace period from parameter or config (default 10000ms)
const graceMs = gracePeriodMs ?? Config.get().sync_disconnect_grace_period_ms;

View File

@@ -25,7 +25,7 @@ import {
switchMap,
tap,
} from "rxjs";
import { logger as rootLogger } from "matrix-js-sdk/lib/logger";
import { logger as rootLogger, type Logger } from "matrix-js-sdk/lib/logger";
import { AutoDiscovery } from "matrix-js-sdk/lib/autodiscovery";
import { type CallMembershipIdentityParts } from "matrix-js-sdk/lib/matrixrtc/EncryptionManager";
@@ -46,8 +46,6 @@ import { areLivekitTransportsEqual } from "../remoteMembers/MatrixLivekitMembers
import { customLivekitUrl } from "../../../settings/settings.ts";
import { RtcTransportAutoDiscovery } from "./RtcTransportAutoDiscovery.ts";
const logger = rootLogger.getChild("[LocalTransport]");
/*
* It figures out “which LiveKit focus URL/alias the local user should use,”
* optionally aligning with the oldest member, and ensures the SFU path is primed
@@ -140,9 +138,14 @@ export const createLocalTransport$ = ({
forceJwtEndpoint,
delayId$,
}: Props): LocalTransport => {
const logger = rootLogger.getChild("[LocalTransport]");
// The LiveKit transport in use by the oldest RTC membership. `null` when the
// oldest member has no such transport.
const oldestMemberTransport$ = observerOldestMembership$(scope, memberships$);
const oldestMemberTransport$ = observerOldestMembership$(
scope,
memberships$,
logger,
);
const transportDiscovery = new RtcTransportAutoDiscovery({
client: client,
@@ -190,6 +193,7 @@ export const createLocalTransport$ = ({
roomId,
client,
delayId ?? undefined,
logger,
);
} catch (e) {
logger.error(
@@ -209,6 +213,7 @@ export const createLocalTransport$ = ({
client,
ownMembershipIdentity,
roomId,
logger,
);
}
@@ -248,6 +253,7 @@ export const createLocalTransport$ = ({
function observerOldestMembership$(
scope: ObservableScope,
memberships$: Behavior<Epoch<CallMembership[]>>,
logger: Logger,
): Behavior<LivekitTransportConfig | null> {
return scope.behavior<LivekitTransportConfig | null>(
memberships$.pipe(
@@ -307,6 +313,7 @@ async function doOpenIdAndJWTFromUrl(
> &
OpenIDClientParts,
delayId?: string,
logger?: Logger,
): Promise<LocalTransportWithSFUConfig> {
const sfuConfig = await getSFUConfigWithOpenID(
client,
@@ -337,6 +344,7 @@ function observeLocalTransportForOldestMembership(
OpenIDClientParts,
ownMembershipIdentity: CallMembershipIdentityParts,
roomId: string,
logger: Logger,
): LocalTransport {
// Ensure we can authenticate with the SFU.
const authenticatedOldestMemberTransport$ = oldestMemberTransport$.pipe(
@@ -355,6 +363,7 @@ function observeLocalTransportForOldestMembership(
roomId,
client,
undefined,
logger,
),
).pipe(
catchError((e: unknown) => {

View File

@@ -22,8 +22,6 @@ import {
} from "../../../utils/displayname";
import { type Behavior } from "../../Behavior";
const logger = rootLogger.getChild("[MatrixMemberMetadata]");
export type RoomMemberMap = Map<
string,
Pick<RoomMember, "userId" | "getMxcAvatarUrl" | "rawDisplayName">
@@ -67,6 +65,7 @@ export const memberDisplaynames$ = (
memberships$: Behavior<Pick<CallMembership, "userId">[]>,
roomMembers$: Behavior<RoomMemberMap>,
): Behavior<Map<string, string>> => {
const logger = rootLogger.getChild("[MatrixMemberMetadata]");
// This map tracks userIds that at some point needed disambiguation.
// This is a memory leak bound to the number of participants.
// A call application will always increase the memory if there have been more members in a call.

View File

@@ -26,7 +26,7 @@ export function oneOnOnePortraitLayout(
prevTiles: TileStore,
): [OneOnOnePortraitLayout, TileStore] {
const update = prevTiles.from(media.pip === undefined ? 0 : 1);
update.registerSpotlight([media.spotlight], true);
update.registerSpotlight([media.spotlight], true, "transparent");
if (media.pip !== undefined) update.registerGridTile(media.pip);
const tiles = update.build();

View File

@@ -21,6 +21,7 @@ export function pipLayout(
update.registerSpotlight(
media.spotlight,
platform === "desktop" ? false : true,
"transparent",
);
const tiles = update.build();
return [

View File

@@ -23,7 +23,7 @@ export function spotlightExpandedLayout(
prevTiles: TileStore,
): [SpotlightExpandedLayout, TileStore] {
const update = prevTiles.from(1);
update.registerSpotlight(media.spotlight, true);
update.registerSpotlight(media.spotlight, true, "transparent");
if (media.pip !== undefined) update.registerPipTile(media.pip);
const tiles = update.build();

View File

@@ -15,6 +15,8 @@ import { type MediaViewModel } from "./media/MediaViewModel";
import { type UserMediaViewModel } from "./media/UserMediaViewModel";
import { type RingingMediaViewModel } from "./media/RingingMediaViewModel";
type SpotlightBackground = "solid" | "transparent";
function debugEntries(entries: GridTileData[]): string[] {
return entries.map((e) => e.media.displayName$.value);
}
@@ -39,12 +41,29 @@ class SpotlightTileData {
this.maximised$.next(value);
}
private readonly background$: BehaviorSubject<SpotlightBackground>;
public get background(): SpotlightBackground {
return this.background$.value;
}
public set background(value: SpotlightBackground) {
this.background$.next(value);
}
public readonly vm: SpotlightTileViewModel;
public constructor(media: MediaViewModel[], maximised: boolean) {
public constructor(
media: MediaViewModel[],
maximised: boolean,
background: SpotlightBackground,
) {
this.media$ = new BehaviorSubject(media);
this.maximised$ = new BehaviorSubject(maximised);
this.vm = new SpotlightTileViewModel(this.media$, this.maximised$);
this.background$ = new BehaviorSubject(background);
this.vm = new SpotlightTileViewModel(
this.media$,
this.maximised$,
this.background$,
);
}
}
@@ -157,7 +176,11 @@ export class TileStoreBuilder {
* Sets the contents of the spotlight tile. If this is never called, there
* will be no spotlight tile.
*/
public registerSpotlight(media: MediaViewModel[], maximised: boolean): void {
public registerSpotlight(
media: MediaViewModel[],
maximised: boolean,
background: SpotlightBackground = "solid",
): void {
if (DEBUG_ENABLED)
logger.debug(
`[TileStore, ${this.generation}] register spotlight: ${media.map((m) => m.displayName$.value)}`,
@@ -169,11 +192,12 @@ export class TileStoreBuilder {
// Reuse the previous spotlight tile if it exists
if (this.prevSpotlight === null) {
this.spotlight = new SpotlightTileData(media, maximised);
this.spotlight = new SpotlightTileData(media, maximised, background);
} else {
this.spotlight = this.prevSpotlight;
this.spotlight.media = media;
this.spotlight.maximised = maximised;
this.spotlight.background = background;
}
}

View File

@@ -37,6 +37,7 @@ export class SpotlightTileViewModel {
public constructor(
public readonly media$: Behavior<MediaViewModel[]>,
public readonly maximised$: Behavior<boolean>,
public readonly background$: Behavior<"solid" | "transparent">,
) {}
}

View File

@@ -27,6 +27,10 @@ Please see LICENSE in the repository root for full details.
transform: translate(0);
}
.media[data-video-enabled="false"] video {
display: none;
}
.media.mirror video {
transform: scaleX(-1);
}
@@ -41,13 +45,64 @@ Please see LICENSE in the repository root for full details.
.bg {
grid-area: content;
background-color: var(--video-tile-background);
inline-size: 100%;
block-size: 100%;
border-radius: inherit;
contain: strict;
}
.media[data-background="solid"] .bg {
background-color: var(--video-tile-background);
}
.waves {
transition: opacity ease 0.3s;
}
.waves[data-visible="true"] {
opacity: 1;
}
.waves[data-visible="false"] {
opacity: 0;
@media not (prefers-reduced-motion) {
.wave {
transform: translate(-50%, -50%) scale(0.9);
}
}
}
.wave {
border: var(--cpd-border-width-1) solid var(--cpd-color-alpha-gray-300);
transition: transform ease 0.2s;
}
.wave,
.speakingBorder {
border-radius: var(--cpd-radius-pill-effect);
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
.speakingBorder {
background:
radial-gradient(#0467dd, #0bc491),
linear-gradient(0deg, #0467dd 0%, #0bc491 100%);
background-blend-mode: overlay, normal;
outline: var(--cpd-border-width-4) solid var(--cpd-color-bg-canvas-default);
&::after {
content: "";
position: absolute;
inset: var(--cpd-border-width-2);
border-radius: var(--cpd-radius-pill-effect);
background: var(--cpd-color-bg-canvas-default);
}
}
.avatar {
position: absolute;
top: 50%;
@@ -70,6 +125,35 @@ unconditionally select the container so we can use cqmin units */
inline-size: 50cqmin;
block-size: 50cqmin;
}
.waves + .avatar {
/* Make the avatar slightly smaller to accommodate sound waves, if present */
inline-size: 38cqmin;
block-size: 38cqmin;
}
.wave:nth-child(1) {
inline-size: calc(38cqmin + var(--cpd-space-5x) + 3 * var(--cpd-space-10x));
block-size: calc(38cqmin + var(--cpd-space-5x) + 3 * var(--cpd-space-10x));
background: var(--cpd-color-alpha-gray-200);
}
.wave:nth-child(2) {
inline-size: calc(38cqmin + var(--cpd-space-5x) + 2 * var(--cpd-space-10x));
block-size: calc(38cqmin + var(--cpd-space-5x) + 2 * var(--cpd-space-10x));
background: var(--cpd-color-alpha-gray-300);
}
.wave:nth-child(3) {
inline-size: calc(38cqmin + var(--cpd-space-5x) + var(--cpd-space-10x));
block-size: calc(38cqmin + var(--cpd-space-5x) + var(--cpd-space-10x));
background: var(--cpd-color-alpha-gray-400);
}
.speakingBorder {
inline-size: calc(38cqmin + var(--cpd-space-3x));
block-size: calc(38cqmin + var(--cpd-space-3x));
}
}
.avatar > img {

View File

@@ -125,30 +125,4 @@ describe("MediaView", () => {
).toBe(0);
});
});
describe("videoEnabled", () => {
test("just video is visible", () => {
render(
<TooltipProvider>
<MediaView {...baseProps} videoEnabled={true} />
</TooltipProvider>,
);
expect(screen.getByTestId("video")).toBeVisible();
expect(screen.queryAllByRole("img", { name: "some name" }).length).toBe(
0,
);
});
test("just avatar is visible", () => {
render(
<TooltipProvider>
<MediaView {...baseProps} videoEnabled={false} />
</TooltipProvider>,
);
expect(
screen.getByRole("img", { name: "@alice:example.com" }),
).toBeVisible();
expect(screen.getByTestId("video")).not.toBeVisible();
});
});
});

View File

@@ -34,6 +34,7 @@ interface Props extends ComponentProps<typeof animated.div> {
video: TrackReferenceOrPlaceholder | undefined;
videoFit: "cover" | "contain";
mirror: boolean;
soundWaves?: boolean;
userId: string;
videoEnabled: boolean;
unencryptedWarning: boolean;
@@ -43,6 +44,7 @@ interface Props extends ComponentProps<typeof animated.div> {
displayName: string;
mxcAvatarUrl: string | undefined;
avatarStyle?: "solid" | "translucent";
background?: "solid" | "transparent";
focusable: boolean;
primaryButton?: ReactNode;
raisedHandTime?: Date;
@@ -65,6 +67,7 @@ export const MediaView: FC<Props> = ({
video,
videoFit,
mirror,
soundWaves,
userId,
videoEnabled,
unencryptedWarning,
@@ -73,6 +76,7 @@ export const MediaView: FC<Props> = ({
displayName,
mxcAvatarUrl,
avatarStyle = "solid",
background = "solid",
focusable,
primaryButton,
status,
@@ -90,7 +94,10 @@ export const MediaView: FC<Props> = ({
const [handRaiseTimerVisible] = useSetting(showHandRaisedTimer);
const [showConnectionStats] = useSetting(showConnectionStatsSetting);
const avatarSize = Math.round(Math.min(targetWidth, targetHeight) / 2);
const avatarSize = Math.round(
Math.min(targetWidth, targetHeight) *
(soundWaves === undefined ? 0.5 : 0.38),
);
const warnings = unencryptedWarning && (
<Tooltip
@@ -117,10 +124,20 @@ export const MediaView: FC<Props> = ({
style={style}
ref={ref}
data-testid="videoTile"
data-video-enabled={video && videoEnabled}
data-video-fit={videoFit}
data-background={background}
{...props}
>
<div className={styles.bg}>
{soundWaves !== undefined && (
<div className={styles.waves} data-visible={soundWaves}>
<div className={styles.wave} />
<div className={styles.wave} />
<div className={styles.wave} />
<div className={styles.speakingBorder} />
</div>
)}
<Avatar
id={userId}
name={displayName}
@@ -136,7 +153,6 @@ export const MediaView: FC<Props> = ({
// There's no reason for this to be focusable
tabIndex={-1}
disablePictureInPicture
style={{ display: video && videoEnabled ? "block" : "none" }}
data-testid="video"
/>
)}

View File

@@ -23,7 +23,7 @@ Please see LICENSE in the repository root for full details.
scroll-behavior: smooth; */
}
.tile.maximised .contents {
.tile[data-maximised="true"] .contents {
border-radius: 0;
}
@@ -34,7 +34,7 @@ Please see LICENSE in the repository root for full details.
--media-view-fg-inset: 10px;
}
.maximised .item {
.tile[data-maximised="true"] .item {
/* Ensure that foreground elements lie within the safe area */
--media-view-fg-inset: calc(var(--call-view-safe-area-inset-top, 0px) + 10px)
calc(env(safe-area-inset-right) + 10px)
@@ -191,7 +191,7 @@ Please see LICENSE in the repository root for full details.
opacity: 1;
}
.maximised .indicators {
.tile[data-maximised="true"] .indicators {
inset-block-end: calc(-1 * var(--cpd-space-4x) - 2px);
justify-content: center;
}

View File

@@ -58,7 +58,13 @@ test("SpotlightTile is accessible", async () => {
const toggleExpanded = vi.fn();
const { container } = render(
<SpotlightTile
vm={new SpotlightTileViewModel(constant([vm1, vm2]), constant(false))}
vm={
new SpotlightTileViewModel(
constant([vm1, vm2]),
constant(false),
constant("solid"),
)
}
targetWidth={300}
targetHeight={200}
expanded={false}
@@ -101,7 +107,13 @@ test("Screen share volume UI is shown when screen share has audio", async () =>
const { container } = render(
<TooltipProvider>
<SpotlightTile
vm={new SpotlightTileViewModel(constant([vm]), constant(false))}
vm={
new SpotlightTileViewModel(
constant([vm]),
constant(false),
constant("solid"),
)
}
targetWidth={300}
targetHeight={200}
expanded={false}
@@ -132,7 +144,13 @@ test("Screen share volume UI is hidden when screen share has no audio", async ()
const toggleExpanded = vi.fn();
const { container } = render(
<SpotlightTile
vm={new SpotlightTileViewModel(constant([vm]), constant(false))}
vm={
new SpotlightTileViewModel(
constant([vm]),
constant(false),
constant("solid"),
)
}
targetWidth={300}
targetHeight={200}
expanded={false}
@@ -168,7 +186,13 @@ test("SpotlightTile displays ringing media", async () => {
const toggleExpanded = vi.fn();
const { container } = render(
<SpotlightTile
vm={new SpotlightTileViewModel(constant([vm]), constant(false))}
vm={
new SpotlightTileViewModel(
constant([vm]),
constant(false),
constant("solid"),
)
}
targetWidth={300}
targetHeight={200}
expanded={false}

View File

@@ -65,6 +65,7 @@ interface SpotlightItemBaseProps {
displayName: string;
mxcAvatarUrl: string | undefined;
showNameTags: boolean;
background: "solid" | "transparent";
focusable: boolean;
"aria-hidden"?: boolean;
}
@@ -78,6 +79,7 @@ interface SpotlightMemberMediaItemBaseProps extends SpotlightItemBaseProps {
interface SpotlightUserMediaItemBaseProps extends SpotlightMemberMediaItemBaseProps {
videoFit: "contain" | "cover";
videoEnabled: boolean;
soundWaves: boolean | undefined;
}
interface SpotlightLocalUserMediaItemProps extends SpotlightUserMediaItemBaseProps {
@@ -120,6 +122,7 @@ const SpotlightUserMediaItem: FC<SpotlightUserMediaItemProps> = ({
}) => {
const videoFit = useBehavior(vm.videoFit$);
const videoEnabled = useBehavior(vm.videoEnabled$);
const speaking = useBehavior(vm.speaking$);
// Whenever target bounds change, inform the viewModel
useEffect(() => {
@@ -132,6 +135,7 @@ const SpotlightUserMediaItem: FC<SpotlightUserMediaItemProps> = ({
RefAttributes<HTMLDivElement> = {
videoFit,
videoEnabled,
soundWaves: props.background === "transparent" ? speaking : undefined,
targetWidth,
targetHeight,
...props,
@@ -243,12 +247,14 @@ interface SpotlightItemProps {
targetHeight: number;
showNameTags: boolean;
showRingingStatus: boolean;
background: "solid" | "transparent";
focusable: boolean;
intersectionObserver$: Observable<IntersectionObserver>;
/**
* Whether this item should act as a scroll snapping point.
*/
snap: boolean;
className?: string;
"aria-hidden"?: boolean;
}
@@ -259,9 +265,11 @@ const SpotlightItem: FC<SpotlightItemProps> = ({
targetHeight,
showNameTags,
showRingingStatus,
background,
focusable,
intersectionObserver$,
snap,
className,
"aria-hidden": ariaHidden,
}) => {
const ourRef = useRef<HTMLDivElement | null>(null);
@@ -288,13 +296,14 @@ const SpotlightItem: FC<SpotlightItemProps> = ({
const baseProps: SpotlightItemBaseProps & RefAttributes<HTMLDivElement> = {
ref,
"data-id": vm.id,
className: classNames(styles.item, { [styles.snap]: snap }),
className: classNames(className, styles.item, { [styles.snap]: snap }),
targetWidth,
targetHeight,
userId: vm.userId,
displayName,
mxcAvatarUrl,
showNameTags,
background,
focusable,
"aria-hidden": ariaHidden,
};
@@ -391,6 +400,10 @@ interface Props {
showRingingStatus: boolean;
focusable: boolean;
className?: string;
/**
* CSS class of the individual spotlight items.
*/
itemClassName?: string;
style?: ComponentProps<typeof animated.div>["style"];
}
@@ -406,12 +419,14 @@ export const SpotlightTile: FC<Props> = ({
showRingingStatus,
focusable = true,
className,
itemClassName,
style,
}) => {
const { t } = useTranslation();
const [ourRef, root$] = useObservableRef<HTMLDivElement | null>(null);
const ref = useMergedRefs(ourRef, theirRef);
const maximised = useBehavior(vm.maximised$);
const background = useBehavior(vm.background$);
const media = useBehavior(vm.media$);
const [visibleId, setVisibleId] = useState<string | undefined>(media[0]?.id);
const latestMedia = useLatest(media);
@@ -492,9 +507,8 @@ export const SpotlightTile: FC<Props> = ({
return (
<animated.div
ref={ref}
className={classNames(className, styles.tile, {
[styles.maximised]: maximised,
})}
className={classNames(className, styles.tile)}
data-maximised={maximised}
style={style}
>
{canGoBack && (
@@ -516,6 +530,7 @@ export const SpotlightTile: FC<Props> = ({
targetHeight={targetHeight}
showRingingStatus={showRingingStatus}
showNameTags={showNameTags}
background={background}
focusable={focusable}
intersectionObserver$={intersectionObserver$}
// This is how we get the container to scroll to the right media
@@ -523,6 +538,7 @@ export const SpotlightTile: FC<Props> = ({
// remove all scroll snap points except for just the one media
// that we want to bring into view
snap={scrollToId === null || scrollToId === vm.id}
className={itemClassName}
aria-hidden={(scrollToId ?? visibleId) !== vm.id}
/>
))}