mirror of
https://github.com/vector-im/element-call.git
synced 2026-08-20 20:49:20 +00:00
Compare commits
7 Commits
more-perfo
...
matthew/wp
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a774866a66 | ||
|
|
89b76eff23 | ||
|
|
8d7ceeec93 | ||
|
|
dd9b961546 | ||
|
|
b89b696dbb | ||
|
|
455d4e4963 | ||
|
|
ec20a636d3 |
12
index.html
12
index.html
@@ -3,6 +3,18 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
|
||||
<!-- Preload the bundled Twemoji COLR font so emoji glyphs render on the
|
||||
first paint. Without this, environments without a system colour-emoji
|
||||
font (notably WPEWebKit on Linux) briefly show fallback/tofu before the
|
||||
font finishes loading via the @font-face declaration in index.css. -->
|
||||
<link
|
||||
rel="preload"
|
||||
href="/src/fonts/Twemoji_Mozilla/TwemojiMozilla-colr.woff2"
|
||||
as="font"
|
||||
type="font/woff2"
|
||||
crossorigin
|
||||
/>
|
||||
|
||||
<% if (packageType === "full") { %>
|
||||
<link rel="icon" type="image/svg+xml" href="favicon.png" />
|
||||
<link rel="preload" href="/config.json" as="fetch" />
|
||||
|
||||
@@ -179,6 +179,14 @@ export interface UrlConfiguration {
|
||||
* Whether to hide the screen-sharing button.
|
||||
*/
|
||||
hideScreensharing: boolean;
|
||||
/**
|
||||
* Whether to hide tiles representing the local device. This includes the
|
||||
* local camera tile and any other MatrixRTC memberships originating from
|
||||
* the same device (e.g. a `+publish` twin used to publish media). Useful
|
||||
* for kiosk-style embeddings where the embedder does not want the local
|
||||
* user to see themselves.
|
||||
*/
|
||||
hideLocalTiles: boolean;
|
||||
|
||||
/**
|
||||
* Whether the app is allowed to use fallback STUN servers for ICE in case the
|
||||
@@ -369,6 +377,7 @@ export const computeUrlParams = (search = "", hash = ""): UrlParams => {
|
||||
header: platform === "desktop" ? HeaderStyle.None : HeaderStyle.AppBar,
|
||||
showControls: true,
|
||||
hideScreensharing: false,
|
||||
hideLocalTiles: false,
|
||||
allowIceFallback: true,
|
||||
perParticipantE2EE: true,
|
||||
controlledAudioDevices: platform === "desktop" ? false : true,
|
||||
@@ -424,6 +433,7 @@ export const computeUrlParams = (search = "", hash = ""): UrlParams => {
|
||||
header: HeaderStyle.Standard,
|
||||
showControls: true,
|
||||
hideScreensharing: false,
|
||||
hideLocalTiles: false,
|
||||
allowIceFallback: false,
|
||||
perParticipantE2EE: false,
|
||||
controlledAudioDevices: false,
|
||||
@@ -470,6 +480,7 @@ export const computeUrlParams = (search = "", hash = ""): UrlParams => {
|
||||
header: parser.getEnumParam("header", HeaderStyle),
|
||||
showControls: parser.getFlag("showControls"),
|
||||
hideScreensharing: parser.getFlag("hideScreensharing"),
|
||||
hideLocalTiles: parser.getFlag("hideLocalTiles"),
|
||||
allowIceFallback: parser.getFlag("allowIceFallback"),
|
||||
perParticipantE2EE: parser.getFlag("perParticipantE2EE"),
|
||||
controlledAudioDevices: parser.getFlag("controlledAudioDevices"),
|
||||
|
||||
BIN
src/fonts/Twemoji_Mozilla/TwemojiMozilla-colr.woff2
Normal file
BIN
src/fonts/Twemoji_Mozilla/TwemojiMozilla-colr.woff2
Normal file
Binary file not shown.
@@ -19,7 +19,49 @@ Please see LICENSE in the repository root for full details.
|
||||
layer(compound);
|
||||
@import url("@vector-im/compound-web/dist/style.css") layer(compound.components);
|
||||
|
||||
/* Twemoji COLR — bundled so that environments without a system colour-emoji
|
||||
font (notably WPEWebKit on Linux) still render reactions and other emoji
|
||||
glyphs correctly. */
|
||||
@font-face {
|
||||
font-family: "Twemoji";
|
||||
font-weight: 400;
|
||||
font-display: block;
|
||||
src: url("./fonts/Twemoji_Mozilla/TwemojiMozilla-colr.woff2") format("woff2");
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Twemoji";
|
||||
font-weight: 500;
|
||||
font-display: block;
|
||||
src: url("./fonts/Twemoji_Mozilla/TwemojiMozilla-colr.woff2") format("woff2");
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Twemoji";
|
||||
font-weight: 600;
|
||||
font-display: block;
|
||||
src: url("./fonts/Twemoji_Mozilla/TwemojiMozilla-colr.woff2") format("woff2");
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Twemoji";
|
||||
font-weight: 700;
|
||||
font-display: block;
|
||||
src: url("./fonts/Twemoji_Mozilla/TwemojiMozilla-colr.woff2") format("woff2");
|
||||
}
|
||||
|
||||
:root {
|
||||
/* "Twemoji" is placed FIRST in the stack so emoji codepoints render via
|
||||
the bundled COLR font on platforms whose system fonts lack a colour
|
||||
emoji face (e.g. WPEWebKit on Linux). The COLR file only contains emoji
|
||||
glyphs, so text characters fall through to Inter as normal. Putting it
|
||||
last (as a fallback) was insufficient: WebKit's emoji-font matching
|
||||
heuristic does not always honour an @font-face registration buried late
|
||||
in the family stack — it has to be first to be guaranteed to win. */
|
||||
--cpd-font-family-sans:
|
||||
"Twemoji", Inter, "Inter Fallback: Helvetica Neue",
|
||||
"Inter Fallback: Segoe UI", "Inter Fallback: Roboto",
|
||||
"Inter Fallback: Ubuntu", "Inter Fallback: Fira Sans",
|
||||
"Inter Fallback: Noto Sans", "Inter Fallback: Arial", "Helvetica Neue",
|
||||
"Segoe UI", Roboto, Ubuntu, "Fira Sans", "Noto Sans", Arial, sans-serif;
|
||||
|
||||
--font-scale: 1;
|
||||
--font-size-micro: calc(10px * var(--font-scale));
|
||||
--font-size-caption: calc(12px * var(--font-scale));
|
||||
@@ -79,6 +121,10 @@ body {
|
||||
background-position: center;
|
||||
color: var(--cpd-color-text-primary);
|
||||
color-scheme: dark;
|
||||
/* Apply the Compound font stack to the page. Without this rule
|
||||
--cpd-font-family-sans is only consumed by avatars and nothing else,
|
||||
so the Twemoji fallback never reaches reactions / emoji rendering. */
|
||||
font-family: var(--cpd-font-family-sans);
|
||||
margin: 0;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
@@ -117,12 +163,12 @@ body,
|
||||
Compound Web is where these variables ultimately get consumed to set the page's
|
||||
font-family. */
|
||||
body[data-platform="android"] {
|
||||
--cpd-font-family-sans: "Roboto", "Noto", "Inter", sans-serif;
|
||||
--cpd-font-family-sans: "Twemoji", "Roboto", "Noto", "Inter", sans-serif;
|
||||
}
|
||||
|
||||
body[data-platform="ios"] {
|
||||
--cpd-font-family-sans:
|
||||
-apple-system, BlinkMacSystemFont, "Inter", sans-serif;
|
||||
"Twemoji", -apple-system, BlinkMacSystemFont, "Inter", sans-serif;
|
||||
}
|
||||
|
||||
@layer compound-legacy {
|
||||
|
||||
23
src/room/CallClock.module.css
Normal file
23
src/room/CallClock.module.css
Normal file
@@ -0,0 +1,23 @@
|
||||
/*
|
||||
Copyright 2026 New Vector Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
.clock {
|
||||
position: fixed;
|
||||
inset-block-start: var(--cpd-space-6x);
|
||||
inset-inline-start: var(--cpd-space-6x);
|
||||
z-index: 2147483647;
|
||||
padding: var(--cpd-space-2x) var(--cpd-space-3x);
|
||||
border-radius: var(--cpd-space-2x);
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
color: #fff;
|
||||
font-size: var(--font-size-body);
|
||||
font-variant-numeric: tabular-nums;
|
||||
line-height: 1;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
38
src/room/CallClock.tsx
Normal file
38
src/room/CallClock.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
/*
|
||||
Copyright 2026 New Vector Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { type FC, useEffect, useRef, useState } from "react";
|
||||
|
||||
import styles from "./CallClock.module.css";
|
||||
|
||||
const pad = (n: number): string => n.toString().padStart(2, "0");
|
||||
|
||||
export const CallClock: FC = () => {
|
||||
const startedAt = useRef(Date.now());
|
||||
const [now, setNow] = useState(() => Date.now());
|
||||
|
||||
useEffect(() => {
|
||||
const id = window.setInterval(() => setNow(Date.now()), 1000);
|
||||
return (): void => window.clearInterval(id);
|
||||
}, []);
|
||||
|
||||
const elapsed = Math.max(0, Math.floor((now - startedAt.current) / 1000));
|
||||
const h = Math.floor(elapsed / 3600);
|
||||
const m = Math.floor((elapsed % 3600) / 60);
|
||||
const s = elapsed % 60;
|
||||
const text = h > 0 ? `${h}:${pad(m)}:${pad(s)}` : `${pad(m)}:${pad(s)}`;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={styles.clock}
|
||||
role="timer"
|
||||
aria-label="Call duration"
|
||||
>
|
||||
{text}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -54,7 +54,12 @@ const ErrorPage: FC<ErrorPageProps> = ({
|
||||
widget,
|
||||
}: ErrorPageProps): ReactElement => {
|
||||
const { t } = useTranslation();
|
||||
logger.error("Error boundary caught:", error);
|
||||
const cause = error?.cause;
|
||||
logger.error(
|
||||
`Error boundary caught: name=${error?.name} message=${error?.message} category=${error?.category}`,
|
||||
error,
|
||||
...(cause ? ["caused by:", cause] : []),
|
||||
);
|
||||
let icon: ComponentType<SVGAttributes<SVGElement>>;
|
||||
switch (error.category) {
|
||||
case ErrorCategory.CONFIGURATION_ISSUE:
|
||||
@@ -128,7 +133,9 @@ export const GroupCallErrorBoundary = ({
|
||||
const callError =
|
||||
error instanceof ElementCallError
|
||||
? error
|
||||
: new UnknownCallError(error instanceof Error ? error : new Error());
|
||||
: new UnknownCallError(
|
||||
error instanceof Error ? error : new Error(String(error)),
|
||||
);
|
||||
return (
|
||||
<ErrorPage
|
||||
widget={widget ?? null}
|
||||
|
||||
@@ -67,6 +67,7 @@ import {
|
||||
} from "../reactions/useReactionsSender";
|
||||
import { ReactionsAudioRenderer } from "./ReactionAudioRenderer";
|
||||
import { ReactionsOverlay } from "./ReactionsOverlay";
|
||||
import { CallClock } from "./CallClock";
|
||||
import { CallEventAudioRenderer } from "./CallEventAudioRenderer";
|
||||
import {
|
||||
debugTileLayout as debugTileLayoutSetting,
|
||||
@@ -364,13 +365,17 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
}
|
||||
case HeaderStyle.None:
|
||||
// Cosmetic header to fill out space while still affecting the bounds of
|
||||
// the grid
|
||||
header = (
|
||||
<div
|
||||
className={classNames(styles.header, styles.filler)}
|
||||
ref={headerRef}
|
||||
/>
|
||||
);
|
||||
// the grid. In kiosk mode (showControls=false) we drop it entirely so
|
||||
// the layout fills the window edge-to-edge — mirrors the existing
|
||||
// suppression of the footer in this combo.
|
||||
if (showControls) {
|
||||
header = (
|
||||
<div
|
||||
className={classNames(styles.header, styles.filler)}
|
||||
ref={headerRef}
|
||||
/>
|
||||
);
|
||||
}
|
||||
break;
|
||||
case HeaderStyle.Standard:
|
||||
header = (
|
||||
@@ -433,6 +438,12 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
// need to remove them from the accessibility tree and block focus.
|
||||
const contentObscured = reconnecting || earpieceMode;
|
||||
|
||||
// In kiosk mode (showControls=false), suppress the per-tile fullscreen and
|
||||
// zoom (expand/collapse) buttons when we're already rendering a single tile
|
||||
// edge-to-edge in spotlight-expanded layout — they'd just be visual noise.
|
||||
const suppressSpotlightTileButtons =
|
||||
!showControls && layout.type === "spotlight-expanded";
|
||||
|
||||
const Tile = useMemo(
|
||||
() =>
|
||||
function Tile({
|
||||
@@ -469,7 +480,10 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
ref={ref}
|
||||
vm={model}
|
||||
expanded={spotlightExpanded}
|
||||
onToggleExpanded={onToggleExpanded}
|
||||
onToggleExpanded={
|
||||
suppressSpotlightTileButtons ? null : onToggleExpanded
|
||||
}
|
||||
hideFullscreen={suppressSpotlightTileButtons}
|
||||
targetWidth={targetWidth}
|
||||
targetHeight={targetHeight}
|
||||
showIndicators={showSpotlightIndicatorsValue}
|
||||
@@ -479,7 +493,7 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
/>
|
||||
);
|
||||
},
|
||||
[vm, openProfile, contentObscured],
|
||||
[vm, openProfile, contentObscured, suppressSpotlightTileButtons],
|
||||
);
|
||||
|
||||
const layouts = useMemo(() => {
|
||||
@@ -628,6 +642,8 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
{reconnectingToast}
|
||||
{earpieceOverlay}
|
||||
<ReactionsOverlay vm={vm} />
|
||||
{/* Clock hidden for now (retained for re-enabling later) */}
|
||||
{false && <CallClock />}
|
||||
{footer}
|
||||
{layout.type !== "pip" && (
|
||||
<>
|
||||
|
||||
@@ -11,6 +11,11 @@
|
||||
|
||||
.reaction {
|
||||
font-size: 32pt;
|
||||
/* WPEWebKit on Linux has no system colour-emoji font and its emoji-font
|
||||
matching heuristic does not always honour an `@font-face` registration
|
||||
buried late in the family stack. Putting "Twemoji" first guarantees the
|
||||
bundled COLR font is picked for the emoji codepoint. */
|
||||
font-family: "Twemoji", var(--cpd-font-family-sans);
|
||||
/* Reactions are "active" for 3 seconds (as per REACTION_ACTIVE_TIME_MS), give a bit more time for it to fade out. */
|
||||
animation-duration: 4s;
|
||||
animation-name: reaction-up;
|
||||
|
||||
@@ -197,6 +197,11 @@ const smallMobileCallThreshold = 3;
|
||||
// with the interface
|
||||
const showFooterMs = 4000;
|
||||
|
||||
// Suffix used by MatrixRTC members who participate twice from the same
|
||||
// physical device — once with the bare device ID (typically receive-only)
|
||||
// and once with this suffix appended (the publishing twin).
|
||||
const PUBLISH_SUFFIX = "+publish";
|
||||
|
||||
export type GridMode = "grid" | "spotlight";
|
||||
|
||||
export type WindowMode = "normal" | "narrow" | "flat" | "pip";
|
||||
@@ -704,6 +709,18 @@ export function createCallViewModel$(
|
||||
]) {
|
||||
const computeMediaId = (m: MatrixLivekitMember): string =>
|
||||
`${m.userId}:${m.membership$.value.deviceId}`;
|
||||
// A member may participate twice from the same physical device by
|
||||
// suffixing their device ID with "+publish" — one membership for
|
||||
// receiving and one for publishing media. We treat both as the same
|
||||
// logical device for de-duplication and local-tile filtering.
|
||||
const baseDeviceId = (deviceId: string): string =>
|
||||
deviceId.endsWith(PUBLISH_SUFFIX)
|
||||
? deviceId.slice(0, -PUBLISH_SUFFIX.length)
|
||||
: deviceId;
|
||||
const isPublishTwin = (m: MatrixLivekitMember): boolean =>
|
||||
m.membership$.value.deviceId.endsWith(PUBLISH_SUFFIX);
|
||||
const computeBaseId = (m: MatrixLivekitMember): string =>
|
||||
`${m.userId}:${baseDeviceId(m.membership$.value.deviceId)}`;
|
||||
|
||||
const localUserMediaId = localMatrixLivekitMember
|
||||
? computeMediaId(localMatrixLivekitMember)
|
||||
@@ -715,10 +732,28 @@ export function createCallViewModel$(
|
||||
const remoteWithoutLocal = matrixLivekitMembers.value.filter(
|
||||
(m) => computeMediaId(m) !== localUserMediaId,
|
||||
);
|
||||
const allMatrixLivekitMembers = [
|
||||
...localAsArray,
|
||||
...remoteWithoutLocal,
|
||||
];
|
||||
const candidates = [...localAsArray, ...remoteWithoutLocal];
|
||||
|
||||
// For any (user, device) that has a +publish membership, hide its
|
||||
// non-suffixed sibling — the publishing twin is the one carrying
|
||||
// the media, so the receive-only twin's tile would just be empty.
|
||||
const publishTwinBaseIds = new Set(
|
||||
candidates.filter(isPublishTwin).map(computeBaseId),
|
||||
);
|
||||
|
||||
const { hideLocalTiles } = getUrlParams();
|
||||
const localBaseId = `${userId}:${deviceId}`;
|
||||
|
||||
const allMatrixLivekitMembers = candidates.filter((m) => {
|
||||
// Hide all tiles originating from the local device, including
|
||||
// the +publish twin.
|
||||
if (hideLocalTiles && computeBaseId(m) === localBaseId)
|
||||
return false;
|
||||
// Drop the receive-only sibling when a +publish twin is present.
|
||||
if (!isPublishTwin(m) && publishTwinBaseIds.has(computeBaseId(m)))
|
||||
return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
for (const matrixLivekitMember of allMatrixLivekitMembers) {
|
||||
const { userId, participant, connection$, membership$ } =
|
||||
@@ -1141,66 +1176,90 @@ export function createCallViewModel$(
|
||||
map((spotlight) => ({ type: "pip", spotlight })),
|
||||
);
|
||||
|
||||
const layoutMediaByWindowMode$: Observable<LayoutMedia> = windowMode$.pipe(
|
||||
switchMap((windowMode) => {
|
||||
switch (windowMode) {
|
||||
case "normal":
|
||||
return gridMode$.pipe(
|
||||
switchMap((gridMode) => {
|
||||
switch (gridMode) {
|
||||
case "grid":
|
||||
return oneOnOneLayoutMedia$.pipe(
|
||||
switchMap((oneOnOne) =>
|
||||
oneOnOne === null ? gridLayoutMedia$ : of(oneOnOne),
|
||||
),
|
||||
);
|
||||
case "spotlight":
|
||||
return spotlightExpanded$.pipe(
|
||||
switchMap((expanded) =>
|
||||
expanded
|
||||
? spotlightExpandedLayoutMedia$
|
||||
: spotlightLandscapeLayoutMedia$,
|
||||
),
|
||||
);
|
||||
}
|
||||
}),
|
||||
);
|
||||
case "narrow":
|
||||
return oneOnOneLayoutMedia$.pipe(
|
||||
switchMap((oneOnOne) =>
|
||||
oneOnOne === null
|
||||
? combineLatest([grid$, spotlight$], (grid, spotlight) =>
|
||||
grid.length > smallMobileCallThreshold ||
|
||||
spotlight.some((vm) => vm.type === "screen share")
|
||||
? spotlightPortraitLayoutMedia$
|
||||
: gridLayoutMedia$,
|
||||
).pipe(switchAll())
|
||||
: // The expanded spotlight layout makes for a better one-on-one
|
||||
// experience in narrow windows
|
||||
spotlightExpandedLayoutMedia$,
|
||||
),
|
||||
);
|
||||
case "flat":
|
||||
return gridMode$.pipe(
|
||||
switchMap((gridMode) => {
|
||||
switch (gridMode) {
|
||||
case "grid":
|
||||
// Yes, grid mode actually gets you a "spotlight" layout in
|
||||
// this window mode.
|
||||
return spotlightLandscapeLayoutMedia$;
|
||||
case "spotlight":
|
||||
return spotlightExpandedLayoutMedia$;
|
||||
}
|
||||
}),
|
||||
);
|
||||
case "pip":
|
||||
return pipLayoutMedia$;
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
// In kiosk mode (showControls=false) with a single user-media tile and
|
||||
// no screen share, render that tile edge-to-edge as a full-window
|
||||
// spotlight rather than as a floating PiP inside a one-on-one layout.
|
||||
// This is what we'd produce on a small form-factor 1:1 anyway.
|
||||
const soloKioskLayoutMedia$: Observable<SpotlightExpandedLayoutMedia | null> =
|
||||
getUrlParams().showControls
|
||||
? of(null)
|
||||
: combineLatest([userMedia$, screenShares$]).pipe(
|
||||
map(([userMedia, screenShares]) => {
|
||||
if (screenShares.length > 0) return null;
|
||||
if (userMedia.length !== 1) return null;
|
||||
return {
|
||||
type: "spotlight-expanded" as const,
|
||||
spotlight: [userMedia[0]],
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
/**
|
||||
* The media to be used to produce a layout.
|
||||
*/
|
||||
const layoutMedia$ = scope.behavior<LayoutMedia>(
|
||||
windowMode$.pipe(
|
||||
switchMap((windowMode) => {
|
||||
switch (windowMode) {
|
||||
case "normal":
|
||||
return gridMode$.pipe(
|
||||
switchMap((gridMode) => {
|
||||
switch (gridMode) {
|
||||
case "grid":
|
||||
return oneOnOneLayoutMedia$.pipe(
|
||||
switchMap((oneOnOne) =>
|
||||
oneOnOne === null ? gridLayoutMedia$ : of(oneOnOne),
|
||||
),
|
||||
);
|
||||
case "spotlight":
|
||||
return spotlightExpanded$.pipe(
|
||||
switchMap((expanded) =>
|
||||
expanded
|
||||
? spotlightExpandedLayoutMedia$
|
||||
: spotlightLandscapeLayoutMedia$,
|
||||
),
|
||||
);
|
||||
}
|
||||
}),
|
||||
);
|
||||
case "narrow":
|
||||
return oneOnOneLayoutMedia$.pipe(
|
||||
switchMap((oneOnOne) =>
|
||||
oneOnOne === null
|
||||
? combineLatest([grid$, spotlight$], (grid, spotlight) =>
|
||||
grid.length > smallMobileCallThreshold ||
|
||||
spotlight.some((vm) => vm.type === "screen share")
|
||||
? spotlightPortraitLayoutMedia$
|
||||
: gridLayoutMedia$,
|
||||
).pipe(switchAll())
|
||||
: // The expanded spotlight layout makes for a better one-on-one
|
||||
// experience in narrow windows
|
||||
spotlightExpandedLayoutMedia$,
|
||||
),
|
||||
);
|
||||
case "flat":
|
||||
return gridMode$.pipe(
|
||||
switchMap((gridMode) => {
|
||||
switch (gridMode) {
|
||||
case "grid":
|
||||
// Yes, grid mode actually gets you a "spotlight" layout in
|
||||
// this window mode.
|
||||
return spotlightLandscapeLayoutMedia$;
|
||||
case "spotlight":
|
||||
return spotlightExpandedLayoutMedia$;
|
||||
}
|
||||
}),
|
||||
);
|
||||
case "pip":
|
||||
return pipLayoutMedia$;
|
||||
}
|
||||
}),
|
||||
soloKioskLayoutMedia$.pipe(
|
||||
switchMap((solo) =>
|
||||
solo !== null ? of<LayoutMedia>(solo) : layoutMediaByWindowMode$,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
|
||||
@@ -131,10 +131,38 @@ export function createConnectionManager$({
|
||||
// Combine local and remote transports into one transport array
|
||||
// and set the forceOldJwtEndpoint property on the local transport
|
||||
map(([remoteTransports, localTransport]) => {
|
||||
let localTransportAsArray: LocalTransportWithSFUConfig[] = [];
|
||||
if (localTransport) {
|
||||
localTransportAsArray = [localTransport];
|
||||
// Defer all transport processing until we know our own local
|
||||
// transport. Without this, the snapshot taken between
|
||||
// remoteTransports$ first emitting and localTransport$
|
||||
// emitting causes us to create a subscribe-only Connection
|
||||
// for any remote member's URL — and then, when localTransport$
|
||||
// does emit, that Connection is keyed differently
|
||||
// (`[URL, sfuConfig]` vs. `[URL, undefined]`) and gets
|
||||
// destroyed and recreated. The destroy races with the
|
||||
// freshly-started LK connection on the new Connection (and,
|
||||
// worse, the two Connections fetch JWTs via different
|
||||
// endpoints — the legacy `/sfu/get` for the local one and
|
||||
// the Matrix-2.0 delegation endpoint for the
|
||||
// subscribe-only — so they end up with *different*
|
||||
// LK identities for the same Matrix user, which the SFU then
|
||||
// gets confused about). Holding back until localTransport is
|
||||
// resolved means the dedup against the local URL has the
|
||||
// chance to filter out matching remotes before any
|
||||
// subscribe-only Connection is started, so that race never
|
||||
// happens. Cascade calls (where some remote URLs genuinely
|
||||
// don't match the local one) just see those subscribe-only
|
||||
// Connections come up the moment the local transport
|
||||
// resolves, with no functional difference vs. coming up at
|
||||
// remoteTransports$' first emission.
|
||||
if (!localTransport) {
|
||||
logger.debug(
|
||||
"localTransport not yet resolved; deferring remote transport processing",
|
||||
);
|
||||
return new Epoch([], remoteTransports.epoch);
|
||||
}
|
||||
const localTransportAsArray: LocalTransportWithSFUConfig[] = [
|
||||
localTransport,
|
||||
];
|
||||
const dedupedRemote = removeDuplicateTransports(remoteTransports.value);
|
||||
const remoteWithoutLocal = dedupedRemote.filter(
|
||||
(transport) =>
|
||||
|
||||
@@ -384,6 +384,7 @@ interface Props {
|
||||
focusable: boolean;
|
||||
className?: string;
|
||||
style?: ComponentProps<typeof animated.div>["style"];
|
||||
hideFullscreen?: boolean;
|
||||
}
|
||||
|
||||
export const SpotlightTile: FC<Props> = ({
|
||||
@@ -397,6 +398,7 @@ export const SpotlightTile: FC<Props> = ({
|
||||
focusable = true,
|
||||
className,
|
||||
style,
|
||||
hideFullscreen = false,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [ourRef, root$] = useObservableRef<HTMLDivElement | null>(null);
|
||||
@@ -520,7 +522,7 @@ export const SpotlightTile: FC<Props> = ({
|
||||
{visibleMedia?.type === "screen share" && !visibleMedia.local && (
|
||||
<ScreenShareVolumeButton vm={visibleMedia} />
|
||||
)}
|
||||
{platform === "desktop" && (
|
||||
{platform === "desktop" && !hideFullscreen && (
|
||||
<button
|
||||
className={classNames(styles.expand)}
|
||||
aria-label={"maximise"}
|
||||
|
||||
Reference in New Issue
Block a user