mirror of
https://github.com/vector-im/element-call.git
synced 2026-08-23 20:59:21 +00:00
Compare commits
23 Commits
stable-cla
...
mobile-gra
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8d826b2f31 | ||
|
|
32e30dd4f5 | ||
|
|
bb06905aa2 | ||
|
|
f2c42c314c | ||
|
|
4b50a191ba | ||
|
|
137093350b | ||
|
|
9f681b87a0 | ||
|
|
27315a7090 | ||
|
|
bebe79d109 | ||
|
|
4ca0270115 | ||
|
|
eefa6f843d | ||
|
|
2b84c587f2 | ||
|
|
706cddafb0 | ||
|
|
a6921e2bfb | ||
|
|
3cc396f42c | ||
|
|
e68321e16c | ||
|
|
7840f4bccb | ||
|
|
900d0abd27 | ||
|
|
a3d3a1e951 | ||
|
|
8fa3f33f37 | ||
|
|
360a4ff026 | ||
|
|
8c6a16f3d7 | ||
|
|
9d21581259 |
@@ -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",
|
||||
|
||||
@@ -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 user’s 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. |
|
||||
|
||||
92
eslint/NoTopLevelLoggerGetChild.js
Normal file
92
eslint/NoTopLevelLoggerGetChild.js
Normal 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;
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
18
src/App.tsx
18
src/App.tsx
@@ -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;
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
})}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
|
Before Width: | Height: | Size: 938 B After Width: | Height: | Size: 938 B |
@@ -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 |
86
src/graphics/mobile-gradient.svg
Normal file
86
src/graphics/mobile-gradient.svg
Normal 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 |
@@ -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 {
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ export function pipLayout(
|
||||
update.registerSpotlight(
|
||||
media.spotlight,
|
||||
platform === "desktop" ? false : true,
|
||||
"transparent",
|
||||
);
|
||||
const tiles = update.build();
|
||||
return [
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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">,
|
||||
) {}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
))}
|
||||
|
||||
Reference in New Issue
Block a user