Compare commits

...

7 Commits

Author SHA1 Message Date
Matthew Hodgson
a774866a66 actually log error boundaries fully 2026-05-13 18:07:21 +00:00
Matthew Hodgson
89b76eff23 use spotlight view for 1:1s when showControls=false and hide more buttons 2026-05-11 22:37:13 +00:00
Matthew Hodgson
8d7ceeec93 log error boundaries properly in the console 2026-05-11 22:36:19 +00:00
Matthew Hodgson
dd9b961546 Call Clock overlay (used for debugging) 2026-05-11 22:35:05 +00:00
Matthew Hodgson
b89b696dbb add Twemoji 2026-05-11 22:33:37 +00:00
Matthew Hodgson
455d4e4963 quick experimental kiosk mode which hides local tiles.
src/UrlParams.ts — added hideLocalTiles: boolean to UrlConfiguration, parsed it via parser.getFlag(hideLocalTiles), and defaulted it to false in both the SPA fallback and the widget intent preset.

  src/state/CallViewModel/CallViewModel.ts — in the userMedia$ generator:
  - Added a module-level PUBLISH_SUFFIX = +publish constant.
  - Added base-device-ID and +publish-twin helpers.
  - Always: when a +publish membership exists for (userId, baseDeviceId), the non-suffixed sibling is dropped (it would just be an empty receive-only tile).
  - When hideLocalTiles is set: every membership whose (userId, baseDeviceId) matches the local user/device is dropped — covering both the local camera tile and any +publish twin from this device.
2026-05-10 19:04:59 +00:00
Matthew Hodgson
ec20a636d3 fix a race where EC would disconnect from LK in pre-multi-SFU mode
if it gets told to connect to the SFU it's already connected to
2026-05-10 16:53:47 +00:00
12 changed files with 324 additions and 77 deletions

View File

@@ -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" />

View File

@@ -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"),

Binary file not shown.

View File

@@ -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 {

View 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
View 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>
);
};

View File

@@ -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}

View File

@@ -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" && (
<>

View File

@@ -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;

View File

@@ -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$,
),
),
);

View File

@@ -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) =>

View File

@@ -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"}