Merge pull request #4046 from element-hq/header-subtitle

Move ringing status indicator to header on mobile
This commit is contained in:
Robin
2026-06-22 13:14:55 +02:00
committed by GitHub
21 changed files with 598 additions and 320 deletions

View File

@@ -1,6 +1,12 @@
.bar { .bar {
flex-shrink: 0; flex-shrink: 0;
position: relative; position: relative;
z-index: var(--call-view-header-footer-layer);
padding-left: var(--content-inset-left);
padding-right: var(--content-inset-right);
padding-top: env(safe-area-inset-top);
opacity: 1;
transition: opacity 0.15s;
} }
/* Pseudo-element for the gradient background */ /* Pseudo-element for the gradient background */
@@ -9,8 +15,7 @@
position: absolute; position: absolute;
inset-inline: 0; inset-inline: 0;
/* Extend the gradient beyond the bottom of the header for readability */ /* Extend the gradient beyond the bottom of the header for readability */
inset-block: -24px; inset-block: 0 -16px;
z-index: var(--call-view-header-footer-layer);
background: linear-gradient( background: linear-gradient(
0deg, 0deg,
rgba(0, 0, 0, 0) 0%, rgba(0, 0, 0, 0) 0%,
@@ -18,21 +23,122 @@
); );
} }
.bar.hidden {
opacity: 0;
pointer-events: none;
/* Switch to position: absolute so the bar takes up no space in the layout
when hidden. */
position: absolute;
inset-block-start: 0;
inset-inline: 0;
}
.bar:has(:focus-visible) {
opacity: 1;
pointer-events: initial;
}
.bar > header { .bar > header {
flex-shrink: 0;
position: sticky; position: sticky;
inset-inline: 0; inset-inline: 0;
inset-block-start: 0; inset-block-start: 0;
block-size: 64px; display: grid;
z-index: var(--call-view-header-footer-layer); grid-template-columns: 1fr auto 1fr;
grid-template-rows:
var(--cpd-space-3x) minmax(var(--cpd-space-10x), auto)
var(--cpd-space-3x);
grid-template-areas:
". . ."
"primaryButton title secondaryButton"
". . .";
place-items: center;
column-gap: var(--cpd-space-2x);
} }
.bar svg path { .bar:has(.subtitle) > header {
fill: var(--cpd-color-icon-primary); grid-template-rows:
var(--cpd-space-3x) minmax(var(--cpd-space-10x), auto) var(--cpd-space-5x)
minmax(var(--cpd-space-8x), auto);
grid-template-areas:
". . ."
"primaryButton title secondaryButton"
". . ."
"subtitle subtitle subtitle";
} }
.bar > header > h1 { .primaryButton {
grid-area: primaryButton;
justify-self: start;
}
.title {
grid-area: title;
}
.subtitle {
grid-area: subtitle;
svg {
color: var(--cpd-color-icon-tertiary);
margin-inline-end: var(--cpd-space-2x);
block-size: 1.2em;
inline-size: 1.2em;
vertical-align: text-bottom;
}
}
.title,
.subtitle {
margin: 0; margin: 0;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
} }
.secondaryButton {
grid-area: secondaryButton;
justify-self: end;
}
.primaryButton,
.secondaryButton {
svg * {
color: var(--cpd-color-icon-primary);
}
}
body[data-platform="ios"] {
.bar > header {
grid-template-rows: minmax(var(--cpd-space-11x), auto) var(--cpd-space-4x);
grid-template-areas: "primaryButton title secondaryButton";
}
.bar:has(.subtitle) > header {
grid-template-rows:
minmax(var(--cpd-space-6x), auto) minmax(var(--cpd-space-5x), auto)
var(--cpd-space-4x);
grid-template-areas:
"primaryButton title secondaryButton"
"primaryButton subtitle secondaryButton";
.title {
align-self: end;
/* Nudge the title and subtitle even closer together to replicate native
iOS styles */
transform: translateY(2px);
}
.subtitle {
align-self: start;
}
}
.subtitle {
color: var(--cpd-color-text-secondary);
svg {
display: none;
}
}
}

View File

@@ -5,21 +5,33 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details. Please see LICENSE in the repository root for full details.
*/ */
import { type FC, type ReactNode } from "react";
import { render } from "@testing-library/react"; import { render } from "@testing-library/react";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { TooltipProvider } from "@vector-im/compound-web"; import { TooltipProvider } from "@vector-im/compound-web";
import { AppBar } from "./AppBar"; import { AppBar, useAppBarSubtitle, useAppBarTitle } from "./AppBar";
const content = <p>This is the content.</p>;
function snapshotAppBar(content: ReactNode): void {
const { container } = render(
<TooltipProvider>
<AppBar>{content}</AppBar>
</TooltipProvider>,
);
expect(container).toMatchSnapshot();
}
describe("AppBar", () => { describe("AppBar", () => {
it("renders", () => { it("renders", () => snapshotAppBar(content));
const { container } = render(
<TooltipProvider> it("renders with title and subtitle", () => {
<AppBar> const TestComponent: FC = () => {
<p>This is the content.</p> useAppBarTitle("Title");
</AppBar> useAppBarSubtitle("Subtitle");
</TooltipProvider>, return content;
); };
expect(container).toMatchSnapshot(); snapshotAppBar(<TestComponent />);
}); });
}); });

View File

@@ -16,7 +16,8 @@ import {
type MouseEvent, type MouseEvent,
type ReactNode, type ReactNode,
} from "react"; } from "react";
import { Heading, IconButton, Tooltip } from "@vector-im/compound-web"; import classNames from "classnames";
import { Heading, IconButton, Text, Tooltip } from "@vector-im/compound-web";
import { import {
ArrowLeftIcon, ArrowLeftIcon,
ChevronLeftIcon, ChevronLeftIcon,
@@ -25,12 +26,12 @@ import {
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { logger } from "matrix-js-sdk/lib/logger"; import { logger } from "matrix-js-sdk/lib/logger";
import { Header, LeftNav, RightNav } from "./Header";
import { platform } from "./Platform"; import { platform } from "./Platform";
import styles from "./AppBar.module.css"; import styles from "./AppBar.module.css";
interface AppBarContext { interface AppBarContext {
setTitle: (value: string) => void; setTitle: (value: string) => void;
setSubtitle: (value: ReactNode) => void;
setSecondaryButton: (value: ReactNode) => void; setSecondaryButton: (value: ReactNode) => void;
setPrimaryButtonIconKind: (value: "back" | "minimise") => void; setPrimaryButtonIconKind: (value: "back" | "minimise") => void;
setHidden: (value: boolean) => void; setHidden: (value: boolean) => void;
@@ -54,6 +55,7 @@ export const AppBar: FC<Props> = ({ children }) => {
}, []); }, []);
const [title, setTitle] = useState<string>(""); const [title, setTitle] = useState<string>("");
const [subtitle, setSubtitle] = useState<ReactNode>(undefined);
const [hidden, setHidden] = useState<boolean>(false); const [hidden, setHidden] = useState<boolean>(false);
const [secondaryButton, setSecondaryButton] = useState<ReactNode | null>( const [secondaryButton, setSecondaryButton] = useState<ReactNode | null>(
null, null,
@@ -65,53 +67,64 @@ export const AppBar: FC<Props> = ({ children }) => {
const context = useMemo( const context = useMemo(
() => ({ () => ({
setTitle, setTitle,
setSubtitle,
setSecondaryButton, setSecondaryButton,
setHidden, setHidden,
setPrimaryButtonIconKind, setPrimaryButtonIconKind,
}), }),
[setTitle, setHidden, setSecondaryButton, setPrimaryButtonIconKind], [
setTitle,
setSubtitle,
setHidden,
setSecondaryButton,
setPrimaryButtonIconKind,
],
); );
const BackIcon = platform === "android" ? ArrowLeftIcon : ChevronLeftIcon; const BackIcon = platform === "android" ? ArrowLeftIcon : ChevronLeftIcon;
return ( return (
<> <>
<div {/* Wrap the header in a div due to annoying z-index issues with the
style={{ display: hidden ? "none" : "block" }} gradient background */}
className={styles.bar} <div className={classNames(styles.bar, { [styles.hidden]: hidden })}>
> <header>
<Header <Tooltip label={t("common.back")}>
// App bar is mainly seen in the call view, which has its own <IconButton
// 'reconnecting' toast className={styles.primaryButton}
disconnectedBanner={false} // We render the back button (PrimaryButtonIcon) the same size as the native os.
> // We render the minimise icon (default) smaller as per designs.
<LeftNav> size={primaryButtonIcon === "back" ? "32px" : "24px"}
<Tooltip label={t("common.back")}> onClick={onBackClick}
<IconButton >
// We render the back button (PrimaryButtonIcon) the same size as the native os. {primaryButtonIcon === "back" ? (
// We render the minimise icon (default) smaller as per designs. <BackIcon aria-hidden />
size={primaryButtonIcon === "back" ? "32px" : "24px"} ) : (
onClick={onBackClick} <CollapseIcon aria-hidden />
> )}
{primaryButtonIcon === "back" ? ( </IconButton>
<BackIcon aria-hidden /> </Tooltip>
) : (
<CollapseIcon aria-hidden />
)}
</IconButton>
</Tooltip>
</LeftNav>
{title && ( {title && (
<Heading <Heading
className={styles.title}
type="body" type="body"
size="lg" size={platform === "ios" ? "md" : "lg"}
weight={platform === "android" ? "medium" : "semibold"} weight={platform === "ios" ? "semibold" : "medium"}
> >
{title} {title}
</Heading> </Heading>
)} )}
<RightNav>{secondaryButton}</RightNav> {subtitle && (
</Header> <Text
className={styles.subtitle}
as="span"
size={platform === "ios" ? "sm" : "lg"}
>
{subtitle}
</Text>
)}
<div className={styles.secondaryButton}>{secondaryButton}</div>
</header>
</div> </div>
<AppBarContext value={context}>{children}</AppBarContext> <AppBarContext value={context}>{children}</AppBarContext>
</> </>
@@ -132,6 +145,20 @@ export function useAppBarTitle(title: string): void {
}, [title, setTitle]); }, [title, setTitle]);
} }
/**
* React hook which sets the subtitle to be shown in the app bar, if present. It
* is an error to call this hook from multiple sites in the same component tree.
*/
export function useAppBarSubtitle(subtitle: ReactNode): void {
const setSubtitle = use(AppBarContext)?.setSubtitle;
useEffect(() => {
if (setSubtitle !== undefined) {
setSubtitle(subtitle);
return (): void => setSubtitle("");
}
}, [subtitle, setSubtitle]);
}
/** /**
* React hook which sets the primary button icon kind. Can only be "minimise" or "back" * React hook which sets the primary button icon kind. Can only be "minimise" or "back"
* It is an error to call this hook from multiple sites in the same component tree. * It is an error to call this hook from multiple sites in the same component tree.

View File

@@ -4,43 +4,89 @@ exports[`AppBar > renders 1`] = `
<div> <div>
<div <div
class="bar" class="bar"
style="display: block;"
> >
<header <header>
class="header" <button
> aria-labelledby="_r_0_"
<div class="_icon-button_1215g_8 primaryButton"
class="nav leftNav" data-kind="primary"
role="button"
style="--cpd-icon-button-size: 24px;"
tabindex="0"
> >
<button <div
aria-labelledby="_r_0_" class="_indicator-icon_147l5_17"
class="_icon-button_1215g_8" style="--cpd-icon-button-size: 100%;"
data-kind="primary"
role="button"
style="--cpd-icon-button-size: 24px;"
tabindex="0"
> >
<div <svg
class="_indicator-icon_147l5_17" aria-hidden="true"
style="--cpd-icon-button-size: 100%;" fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
> >
<svg <path
aria-hidden="true" d="M12 11.034a1 1 0 0 0 .29.702l.005.005c.18.18.43.29.705.29h8a1 1 0 0 0 0-2h-5.586L22 3.445a1 1 0 0 0-1.414-1.414L14 8.617V3.031a1 1 0 1 0-2 0zm0 1.963a1 1 0 0 0-.29-.702l-.005-.004A1 1 0 0 0 11 12H3a1 1 0 1 0 0 2h5.586L2 20.586A1 1 0 1 0 3.414 22L10 15.414V21a1 1 0 0 0 2 0z"
fill="currentColor" />
height="1em" </svg>
viewBox="0 0 24 24" </div>
width="1em" </button>
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12 11.034a1 1 0 0 0 .29.702l.005.005c.18.18.43.29.705.29h8a1 1 0 0 0 0-2h-5.586L22 3.445a1 1 0 0 0-1.414-1.414L14 8.617V3.031a1 1 0 1 0-2 0zm0 1.963a1 1 0 0 0-.29-.702l-.005-.004A1 1 0 0 0 11 12H3a1 1 0 1 0 0 2h5.586L2 20.586A1 1 0 1 0 3.414 22L10 15.414V21a1 1 0 0 0 2 0z"
/>
</svg>
</div>
</button>
</div>
<div <div
class="nav rightNav" class="secondaryButton"
/>
</header>
</div>
<p>
This is the content.
</p>
</div>
`;
exports[`AppBar > renders with title and subtitle 1`] = `
<div>
<div
class="bar"
>
<header>
<button
aria-labelledby="_r_6_"
class="_icon-button_1215g_8 primaryButton"
data-kind="primary"
role="button"
style="--cpd-icon-button-size: 24px;"
tabindex="0"
>
<div
class="_indicator-icon_147l5_17"
style="--cpd-icon-button-size: 100%;"
>
<svg
aria-hidden="true"
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12 11.034a1 1 0 0 0 .29.702l.005.005c.18.18.43.29.705.29h8a1 1 0 0 0 0-2h-5.586L22 3.445a1 1 0 0 0-1.414-1.414L14 8.617V3.031a1 1 0 1 0-2 0zm0 1.963a1 1 0 0 0-.29-.702l-.005-.004A1 1 0 0 0 11 12H3a1 1 0 1 0 0 2h5.586L2 20.586A1 1 0 1 0 3.414 22L10 15.414V21a1 1 0 0 0 2 0z"
/>
</svg>
</div>
</button>
<h1
class="_typography_6v6n8_153 _font-body-lg-medium_6v6n8_79 title"
>
Title
</h1>
<span
class="_typography_6v6n8_153 _font-body-lg-regular_6v6n8_69 subtitle"
>
Subtitle
</span>
<div
class="secondaryButton"
/> />
</header> </header>
</div> </div>

View File

@@ -173,9 +173,7 @@ export function createCallFooterViewModel(
callModel.setSettingsOpen$, callModel.setSettingsOpen$,
]).pipe( ]).pipe(
map(([isPip, showHeader, setSettingsOpen]) => map(([isPip, showHeader, setSettingsOpen]) =>
!isPip && !isPip && headerStyle !== HeaderStyle.AppBar && showControls
!(headerStyle === HeaderStyle.AppBar && showHeader) &&
showControls
? (): void => setSettingsOpen(true) ? (): void => setSettingsOpen(true)
: undefined, : undefined,
), ),

View File

@@ -7,7 +7,6 @@ Please see LICENSE in the repository root for full details.
*/ */
import { import {
afterEach,
beforeEach, beforeEach,
describe, describe,
expect, expect,
@@ -15,12 +14,7 @@ import {
type MockedFunction, type MockedFunction,
vi, vi,
} from "vitest"; } from "vitest";
import { import { render, type RenderResult } from "@testing-library/react";
render,
type RenderResult,
getByRole,
screen,
} from "@testing-library/react";
import { type LocalParticipant } from "livekit-client"; import { type LocalParticipant } from "livekit-client";
import { BehaviorSubject, of } from "rxjs"; import { BehaviorSubject, of } from "rxjs";
import { BrowserRouter } from "react-router-dom"; import { BrowserRouter } from "react-router-dom";
@@ -50,7 +44,6 @@ import { useRoomEncryptionSystem } from "../e2ee/sharedKeyManagement";
import { LivekitRoomAudioRenderer } from "../livekit/MatrixAudioRenderer"; import { LivekitRoomAudioRenderer } from "../livekit/MatrixAudioRenderer";
import { MediaDevicesContext } from "../MediaDevicesContext"; import { MediaDevicesContext } from "../MediaDevicesContext";
import { type MediaDevices as ECMediaDevices } from "../state/MediaDevices"; import { type MediaDevices as ECMediaDevices } from "../state/MediaDevices";
import { constant } from "../state/Behavior";
import { AppBar } from "../AppBar"; import { AppBar } from "../AppBar";
import { initializeWidget } from "../widget"; import { initializeWidget } from "../widget";
@@ -195,45 +188,6 @@ describe("InCallView", () => {
}); });
}); });
describe("settings button with AppBar header", () => {
beforeEach(() => {
// getUrlParams() reads window.location directly rather than from the
// React Router context, so MemoryRouter alone is not enough to make
// it see "header=app_bar". Push the real URL so both paths agree.
window.history.pushState({}, "", "?header=app_bar");
});
afterEach(() => {
window.history.pushState({}, "", "/");
});
it("mobile portrait, is visible in the header", () => {
createInCallView({
withAppBar: true,
callViewModelOptions: {
// Narrow like a mobile phone in portrait orientation
windowSize$: constant({ width: 400, height: 700 }),
},
});
getByRole(screen.getByRole("banner"), "button", {
name: "Settings",
});
});
it("mobile landscape, is not visible anywhere", () => {
const { queryByRole } = createInCallView({
withAppBar: true,
callViewModelOptions: {
// Flat like a mobile phone in landscape orientation
windowSize$: constant({ width: 700, height: 400 }),
},
});
expect(queryByRole("button", { name: "Settings" })).not.toBeVisible();
});
});
describe("audioOutputSwitcher", () => { describe("audioOutputSwitcher", () => {
it("is visible and can be clicked", async () => { it("is visible and can be clicked", async () => {
const user = userEvent.setup(); const user = userEvent.setup();

View File

@@ -44,7 +44,6 @@ import {
createCallViewModel$, createCallViewModel$,
} from "../state/CallViewModel/CallViewModel.ts"; } from "../state/CallViewModel/CallViewModel.ts";
import { Grid, type TileProps } from "../grid/Grid"; import { Grid, type TileProps } from "../grid/Grid";
import { useInitial } from "../useInitial";
import { SpotlightTile } from "../tile/SpotlightTile"; import { SpotlightTile } from "../tile/SpotlightTile";
import { type EncryptionSystem } from "../e2ee/sharedKeyManagement"; import { type EncryptionSystem } from "../e2ee/sharedKeyManagement";
import { E2eeType } from "../e2ee/e2eeType"; import { E2eeType } from "../e2ee/e2eeType";
@@ -69,22 +68,23 @@ import { LivekitRoomAudioRenderer } from "../livekit/MatrixAudioRenderer.tsx";
import { muteAllAudio$ } from "../state/MuteAllAudioModel.ts"; import { muteAllAudio$ } from "../state/MuteAllAudioModel.ts";
import { useMediaDevices } from "../MediaDevicesContext.ts"; import { useMediaDevices } from "../MediaDevicesContext.ts";
import { EarpieceOverlay } from "./EarpieceOverlay.tsx"; import { EarpieceOverlay } from "./EarpieceOverlay.tsx";
import { useAppBarHidden, useAppBarSecondaryButton } from "../AppBar.tsx"; import {
useAppBarHidden,
useAppBarSecondaryButton,
useAppBarSubtitle,
} from "../AppBar.tsx";
import { useBehavior } from "../useBehavior.ts"; import { useBehavior } from "../useBehavior.ts";
import { Toast } from "../Toast.tsx"; import { Toast } from "../Toast.tsx";
import overlayStyles from "../Overlay.module.css"; import overlayStyles from "../Overlay.module.css";
import { prefetchSounds } from "../soundUtils";
import { useAudioContext } from "../useAudioContext";
import ringtoneMp3 from "../sound/ringtone.mp3?url";
import ringtoneOgg from "../sound/ringtone.ogg?url";
import { useTrackProcessorObservable$ } from "../livekit/TrackProcessorContext.tsx"; import { useTrackProcessorObservable$ } from "../livekit/TrackProcessorContext.tsx";
import { type Layout } from "../state/layout-types.ts"; import { type Layout } from "../state/layout-types.ts";
import { ObservableScope } from "../state/ObservableScope.ts"; import { ObservableScope } from "../state/ObservableScope.ts";
import { useLatest } from "../useLatest.ts";
import { CallFooter, type FooterSnapshot } from "../components/CallFooter.tsx"; import { CallFooter, type FooterSnapshot } from "../components/CallFooter.tsx";
import { SettingsIconButton } from "../button/Button.tsx"; import { SettingsIconButton } from "../button/Button.tsx";
import { createCallFooterViewModel } from "../components/CallFooterViewModel.tsx"; import { createCallFooterViewModel } from "../components/CallFooterViewModel.tsx";
import { type ViewModel } from "../state/ViewModel.ts"; import { type ViewModel } from "../state/ViewModel.ts";
import { RingingStatus } from "../tile/RingingStatus.tsx";
import { RingingAudioRenderer } from "./RingingAudioRenderer.tsx";
declare module "react" { declare module "react" {
interface CSSProperties { interface CSSProperties {
@@ -240,20 +240,6 @@ export const InCallView: FC<InCallViewProps> = ({
const { showControls, header: headerStyle } = useUrlParams(); const { showControls, header: headerStyle } = useUrlParams();
const muteAllAudio = useBehavior(muteAllAudio$); const muteAllAudio = useBehavior(muteAllAudio$);
// Preload a waiting and decline sounds
const pickupPhaseSoundCache = useInitial(async () => {
return prefetchSounds({
waiting: { mp3: ringtoneMp3, ogg: ringtoneOgg },
});
});
const pickupPhaseAudio = useAudioContext({
sounds: pickupPhaseSoundCache,
latencyHint: "interactive",
muted: muteAllAudio,
});
const latestPickupPhaseAudio = useLatest(pickupPhaseAudio);
const toggleAudio = useBehavior(muteStates.audio.toggle$); const toggleAudio = useBehavior(muteStates.audio.toggle$);
const toggleVideo = useBehavior(muteStates.video.toggle$); const toggleVideo = useBehavior(muteStates.video.toggle$);
const setAudioEnabled = useBehavior(muteStates.audio.setEnabled$); const setAudioEnabled = useBehavior(muteStates.audio.setEnabled$);
@@ -266,7 +252,7 @@ export const InCallView: FC<InCallViewProps> = ({
() => void toggleRaisedHand(), () => void toggleRaisedHand(),
); );
const ringingIntent = useBehavior(vm.ringingIntent$); const ringingVm = useBehavior(vm.ringingVm$);
const audioParticipants = useBehavior(vm.livekitRoomItems$); const audioParticipants = useBehavior(vm.livekitRoomItems$);
const participantCount = useBehavior(vm.participantCount$); const participantCount = useBehavior(vm.participantCount$);
const reconnecting = useBehavior(vm.reconnecting$); const reconnecting = useBehavior(vm.reconnecting$);
@@ -286,22 +272,6 @@ export const InCallView: FC<InCallViewProps> = ({
throw fatalCallError; throw fatalCallError;
} }
// While ringing, loop the ringtone
useEffect((): void | (() => void) => {
const audio = latestPickupPhaseAudio.current;
if (ringingIntent !== null && audio) {
const endSound = audio.playSoundLooping(
"waiting",
audio.soundDuration["waiting"] ?? 1,
);
return () => {
void endSound().catch((e) => {
logger.error("Failed to stop ringing sound", e);
});
};
}
}, [ringingIntent, latestPickupPhaseAudio]);
// iOS Safari doesn't reliably fire `click` on plain <div>s, so we listen // iOS Safari doesn't reliably fire `click` on plain <div>s, so we listen
// for `pointerup` instead. Scrolls end in `pointercancel`, not `pointerup`, // for `pointerup` instead. Scrolls end in `pointercancel`, not `pointerup`,
// so this still only fires for taps. // so this still only fires for taps.
@@ -363,6 +333,11 @@ export const InCallView: FC<InCallViewProps> = ({
); );
useAppBarHidden(!showHeader); useAppBarHidden(!showHeader);
useAppBarSubtitle(
ringingVm && vm.ringingStatusLocation === "app_bar" && (
<RingingStatus vm={ringingVm} />
),
);
let header: ReactNode = null; let header: ReactNode = null;
switch (headerStyle) { switch (headerStyle) {
@@ -457,6 +432,7 @@ export const InCallView: FC<InCallViewProps> = ({
); );
const showSpeakingIndicators = useBehavior(vm.showSpeakingIndicators$); const showSpeakingIndicators = useBehavior(vm.showSpeakingIndicators$);
const showNameTags = useBehavior(vm.showNameTags$); const showNameTags = useBehavior(vm.showNameTags$);
const showRingingStatus = vm.ringingStatusLocation === "tile";
return model instanceof GridTileViewModel ? ( return model instanceof GridTileViewModel ? (
<GridTile <GridTile
@@ -469,6 +445,7 @@ export const InCallView: FC<InCallViewProps> = ({
style={style} style={style}
showSpeakingIndicators={showSpeakingIndicators} showSpeakingIndicators={showSpeakingIndicators}
showNameTags={showNameTags} showNameTags={showNameTags}
showRingingStatus={showRingingStatus}
focusable={!contentObscured} focusable={!contentObscured}
/> />
) : ( ) : (
@@ -481,6 +458,7 @@ export const InCallView: FC<InCallViewProps> = ({
targetHeight={targetHeight} targetHeight={targetHeight}
showIndicators={showSpotlightIndicators} showIndicators={showSpotlightIndicators}
showNameTags={showNameTags} showNameTags={showNameTags}
showRingingStatus={showRingingStatus}
focusable={!contentObscured} focusable={!contentObscured}
className={classNames(className, styles.tile)} className={classNames(className, styles.tile)}
style={style} style={style}
@@ -515,6 +493,7 @@ export const InCallView: FC<InCallViewProps> = ({
targetHeight={gridBounds.height} targetHeight={gridBounds.height}
showIndicators={false} showIndicators={false}
showNameTags={showNameTags} showNameTags={showNameTags}
showRingingStatus={vm.ringingStatusLocation === "tile"}
focusable={!contentObscured} focusable={!contentObscured}
aria-hidden={contentObscured} aria-hidden={contentObscured}
/> />
@@ -626,6 +605,7 @@ export const InCallView: FC<InCallViewProps> = ({
{renderContent()} {renderContent()}
<CallEventAudioRenderer vm={vm} muted={muteAllAudio} /> <CallEventAudioRenderer vm={vm} muted={muteAllAudio} />
<ReactionsAudioRenderer vm={vm} muted={muteAllAudio} /> <ReactionsAudioRenderer vm={vm} muted={muteAllAudio} />
<RingingAudioRenderer vm={ringingVm} muted={muteAllAudio} />
{reconnectingToast} {reconnectingToast}
{earpieceOverlay} {earpieceOverlay}
<ReactionsOverlay vm={vm} /> <ReactionsOverlay vm={vm} />

View File

@@ -130,16 +130,14 @@ describe("LobbyView", () => {
}); });
it("renders with AppBar android", async () => { it("renders with AppBar android", async () => {
const { container } = renderLobbyView( const { container, getByRole } = renderLobbyView(
{ {
waitingForInvite: true, waitingForInvite: true,
}, },
true, true,
"android", "android",
); );
expect( getByRole("banner");
container.getElementsByClassName(headerStyles.header).length,
).toBeTruthy();
// Check that the primary button uses ArrowLeftIcon (the back/return icon), // Check that the primary button uses ArrowLeftIcon (the back/return icon),
// not the default CollapseIcon // not the default CollapseIcon
const { container: iconContainer } = render(<ArrowLeftIcon />); const { container: iconContainer } = render(<ArrowLeftIcon />);
@@ -147,7 +145,7 @@ describe("LobbyView", () => {
.querySelector("path")! .querySelector("path")!
.getAttribute("d"); .getAttribute("d");
const primaryButtonSvgPath = container const primaryButtonSvgPath = container
.querySelector(".leftNav button") .querySelector(".primaryButton")
?.querySelector("path") ?.querySelector("path")
?.getAttribute("d"); ?.getAttribute("d");
expect(primaryButtonSvgPath).toBe(expectedSvgPath); expect(primaryButtonSvgPath).toBe(expectedSvgPath);
@@ -156,16 +154,14 @@ describe("LobbyView", () => {
}); });
it("renders with AppBar ios", async () => { it("renders with AppBar ios", async () => {
const { container } = renderLobbyView( const { container, getByRole } = renderLobbyView(
{ {
waitingForInvite: true, waitingForInvite: true,
}, },
true, true,
"ios", "ios",
); );
expect( getByRole("banner");
container.getElementsByClassName(headerStyles.header).length,
).toBeTruthy();
// Check that the primary button uses ArrowLeftIcon (the back/return icon), // Check that the primary button uses ArrowLeftIcon (the back/return icon),
// not the default CollapseIcon // not the default CollapseIcon
const { container: iconContainer } = render(<ChevronLeftIcon />); const { container: iconContainer } = render(<ChevronLeftIcon />);
@@ -173,7 +169,7 @@ describe("LobbyView", () => {
.querySelector("path")! .querySelector("path")!
.getAttribute("d"); .getAttribute("d");
const primaryButtonSvgPath = container const primaryButtonSvgPath = container
.querySelector(".leftNav button") .querySelector(".primaryButton")
?.querySelector("path") ?.querySelector("path")
?.getAttribute("d"); ?.getAttribute("d");
expect(primaryButtonSvgPath).toBe(expectedSvgPath); expect(primaryButtonSvgPath).toBe(expectedSvgPath);

View File

@@ -0,0 +1,59 @@
/*
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 { expect, type MockedFunction, test, vi } from "vitest";
import { act, render } from "@testing-library/react";
import { BehaviorSubject } from "rxjs";
import { useAudioContext } from "../useAudioContext";
import { createRingingMedia } from "../state/media/RingingMediaViewModel";
import { alice, aliceId } from "../utils/test-fixtures";
import { constant } from "../state/Behavior";
import { RingingAudioRenderer } from "./RingingAudioRenderer";
import { prefetchSounds } from "../soundUtils";
vi.mock("../useAudioContext");
vi.mock("../soundUtils");
test("ringtone plays on loop while ringing", () => {
(prefetchSounds as MockedFunction<typeof prefetchSounds>).mockResolvedValue({
sound: new ArrayBuffer(0),
});
const endSoundLooping = vi.fn().mockReturnValue(Promise.resolve());
const playSoundLooping = vi.fn().mockReturnValue(endSoundLooping);
(useAudioContext as MockedFunction<typeof useAudioContext>).mockReturnValue({
playSound: vi.fn(),
playSoundLooping,
soundDuration: {},
});
const pickupState$ = new BehaviorSubject<"ringing" | "timeout" | "decline">(
"ringing",
);
const vm = createRingingMedia({
id: aliceId,
userId: alice.userId,
displayName$: constant("Alice"),
mxcAvatarUrl$: constant(undefined),
intent: "audio",
pickupState$,
});
// Begin ringing
render(<RingingAudioRenderer vm={vm} muted={false} />);
expect(playSoundLooping).toHaveBeenCalledExactlyOnceWith(
"ringtone",
expect.any(Number),
);
expect(endSoundLooping).not.toHaveBeenCalled();
vi.clearAllMocks();
// End ringing
act(() => pickupState$.next("decline"));
expect(playSoundLooping).not.toHaveBeenCalled();
expect(endSoundLooping).toHaveBeenCalledExactlyOnceWith();
});

View File

@@ -0,0 +1,72 @@
/*
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 { useEffect, type FC } from "react";
import { logger } from "matrix-js-sdk/lib/logger";
import { type RingingMediaViewModel } from "../state/media/RingingMediaViewModel";
import { useBehavior } from "../useBehavior";
import { useInitial } from "../useInitial";
import { prefetchSounds } from "../soundUtils";
import ringtoneMp3 from "../sound/ringtone.mp3?url";
import ringtoneOgg from "../sound/ringtone.ogg?url";
import { type UseAudioContext, useAudioContext } from "../useAudioContext";
import { useLatest } from "../useLatest";
interface RingingAudioRendererProps {
vm: RingingMediaViewModel | null;
muted: boolean;
}
export const RingingAudioRenderer: FC<RingingAudioRendererProps> = ({
vm,
muted,
}) => {
// Preload a waiting and decline sounds
const sounds = useInitial(async () => {
return prefetchSounds({
ringtone: { mp3: ringtoneMp3, ogg: ringtoneOgg },
});
});
const audio = useAudioContext({
sounds,
latencyHint: "interactive",
muted,
});
return vm && <ActiveRingingAudioRenderer vm={vm} audio={audio} />;
};
interface ActiveRingingAudioRendererProps {
vm: RingingMediaViewModel;
audio: UseAudioContext<"ringtone"> | null;
}
const ActiveRingingAudioRenderer: FC<ActiveRingingAudioRendererProps> = ({
vm,
audio,
}) => {
const audio_ = useLatest(audio);
const pickupState = useBehavior(vm.pickupState$);
// While ringing, loop the ringtone
useEffect((): void | (() => void) => {
if (pickupState === "ringing" && audio_.current) {
const endSound = audio_.current.playSoundLooping(
"ringtone",
audio_.current.soundDuration["ringtone"] ?? 1,
);
return () => {
void endSound().catch((e) => {
logger.error("Failed to stop ringing sound", e);
});
};
}
}, [pickupState, audio_]);
return null;
};

View File

@@ -4,43 +4,36 @@ exports[`LobbyView > renders with AppBar android 1`] = `
<div> <div>
<div <div
class="bar" class="bar"
style="display: block;"
> >
<header <header>
class="header" <button
> aria-labelledby="_r_36_"
<div class="_icon-button_1215g_8 primaryButton"
class="nav leftNav" data-kind="primary"
role="button"
style="--cpd-icon-button-size: 32px;"
tabindex="0"
> >
<button <div
aria-labelledby="_r_36_" class="_indicator-icon_147l5_17"
class="_icon-button_1215g_8" style="--cpd-icon-button-size: 100%;"
data-kind="primary"
role="button"
style="--cpd-icon-button-size: 32px;"
tabindex="0"
> >
<div <svg
class="_indicator-icon_147l5_17" aria-hidden="true"
style="--cpd-icon-button-size: 100%;" fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
> >
<svg <path
aria-hidden="true" d="M12.207 5.293a1 1 0 0 1 0 1.414L7.914 11H18.5a1 1 0 1 1 0 2H7.914l4.293 4.293a1 1 0 0 1-1.414 1.414l-6-6a1 1 0 0 1 0-1.414l6-6a1 1 0 0 1 1.414 0"
fill="currentColor" />
height="1em" </svg>
viewBox="0 0 24 24" </div>
width="1em" </button>
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12.207 5.293a1 1 0 0 1 0 1.414L7.914 11H18.5a1 1 0 1 1 0 2H7.914l4.293 4.293a1 1 0 0 1-1.414 1.414l-6-6a1 1 0 0 1 0-1.414l6-6a1 1 0 0 1 1.414 0"
/>
</svg>
</div>
</button>
</div>
<div <div
class="nav rightNav" class="secondaryButton"
/> />
</header> </header>
</div> </div>
@@ -242,43 +235,36 @@ exports[`LobbyView > renders with AppBar ios 1`] = `
<div> <div>
<div <div
class="bar" class="bar"
style="display: block;"
> >
<header <header>
class="header" <button
> aria-labelledby="_r_4a_"
<div class="_icon-button_1215g_8 primaryButton"
class="nav leftNav" data-kind="primary"
role="button"
style="--cpd-icon-button-size: 32px;"
tabindex="0"
> >
<button <div
aria-labelledby="_r_4a_" class="_indicator-icon_147l5_17"
class="_icon-button_1215g_8" style="--cpd-icon-button-size: 100%;"
data-kind="primary"
role="button"
style="--cpd-icon-button-size: 32px;"
tabindex="0"
> >
<div <svg
class="_indicator-icon_147l5_17" aria-hidden="true"
style="--cpd-icon-button-size: 100%;" fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
> >
<svg <path
aria-hidden="true" d="m13.3 17.3-4.6-4.6a.9.9 0 0 1-.213-.325A1.1 1.1 0 0 1 8.425 12q0-.2.062-.375A.9.9 0 0 1 8.7 11.3l4.6-4.6a.95.95 0 0 1 .7-.275q.425 0 .7.275a.95.95 0 0 1 .275.7.95.95 0 0 1-.275.7L10.8 12l3.9 3.9a.95.95 0 0 1 .275.7.95.95 0 0 1-.275.7.95.95 0 0 1-.7.275.95.95 0 0 1-.7-.275"
fill="currentColor" />
height="1em" </svg>
viewBox="0 0 24 24" </div>
width="1em" </button>
xmlns="http://www.w3.org/2000/svg"
>
<path
d="m13.3 17.3-4.6-4.6a.9.9 0 0 1-.213-.325A1.1 1.1 0 0 1 8.425 12q0-.2.062-.375A.9.9 0 0 1 8.7 11.3l4.6-4.6a.95.95 0 0 1 .7-.275q.425 0 .7.275a.95.95 0 0 1 .275.7.95.95 0 0 1-.275.7L10.8 12l3.9 3.9a.95.95 0 0 1 .275.7.95.95 0 0 1-.275.7.95.95 0 0 1-.7.275.95.95 0 0 1-.7-.275"
/>
</svg>
</div>
</button>
</div>
<div <div
class="nav rightNav" class="secondaryButton"
/> />
</header> </header>
</div> </div>

View File

@@ -1420,10 +1420,13 @@ describe.each([
}, },
}); });
// Should ring for 30ms and then time out expectObservable(vm.ringingVm$).toBe("(ab)", {
expectObservable(vm.ringingIntent$).toBe("(ab) 26ms a", {
a: null, a: null,
b: "audio", b: expect.objectContaining({
type: "ringing",
userId: alice.userId,
intent: "audio",
}),
}); });
// Layout should show placeholder media for the participant we're // Layout should show placeholder media for the participant we're
// ringing the entire time (even once timed out) // ringing the entire time (even once timed out)
@@ -1463,9 +1466,13 @@ describe.each([
}); });
// Should ring until Alice joins // Should ring until Alice joins
expectObservable(vm.ringingIntent$).toBe("(ab) 17ms a", { expectObservable(vm.ringingVm$).toBe("(ab) 17ms a", {
a: null, a: null,
b: "audio", b: expect.objectContaining({
type: "ringing",
userId: alice.userId,
intent: "audio",
}),
}); });
// Layout should show placeholder media for the participant we're // Layout should show placeholder media for the participant we're
// ringing the entire time // ringing the entire time

View File

@@ -39,12 +39,10 @@ import {
throttleTime, throttleTime,
timer, timer,
takeUntil, takeUntil,
concat,
} from "rxjs"; } from "rxjs";
import { logger as rootLogger } from "matrix-js-sdk/lib/logger"; import { logger as rootLogger } from "matrix-js-sdk/lib/logger";
import { import {
MembershipManagerEvent, MembershipManagerEvent,
type RTCCallIntent,
type LivekitTransportConfig, type LivekitTransportConfig,
type MatrixRTCSession, type MatrixRTCSession,
} from "matrix-js-sdk/lib/matrixrtc"; } from "matrix-js-sdk/lib/matrixrtc";
@@ -231,9 +229,13 @@ export interface CallViewModel {
// lifecycle // lifecycle
autoLeave$: Observable<AutoLeaveReason>; autoLeave$: Observable<AutoLeaveReason>;
/** /**
* Whether we are ringing a call recipient. Contains the ringing intent if so. * View model for info relating to ringing, timing out, calling back, etc.
*/ */
ringingIntent$: Behavior<RTCCallIntent | null>; ringingVm$: Behavior<RingingMediaViewModel | null>;
/**
* Which visual element the ringing status should be shown in.
*/
ringingStatusLocation: "app_bar" | "tile";
/** Observable that emits when the user should leave the call (hangup pressed, widget action, error). /** Observable that emits when the user should leave the call (hangup pressed, widget action, error).
* THIS DOES NOT LEAVE THE CALL YET. The only way to leave the call (send the hangup event) is * THIS DOES NOT LEAVE THE CALL YET. The only way to leave the call (send the hangup event) is
* - by ending the scope * - by ending the scope
@@ -1702,15 +1704,9 @@ export function createCallViewModel$(
return { return {
autoLeave$: autoLeave$, autoLeave$: autoLeave$,
ringingIntent$: scope.behavior( ringingVm$: ringingMedia$,
ringAttempts$.pipe( ringingStatusLocation:
switchMap(({ intent, outcome$ }) => urlParams.header === HeaderStyle.AppBar ? "app_bar" : "tile",
// Hold the intent as the value until the ring attempt completes
concat(of(intent), NEVER.pipe(takeUntil(outcome$)), of(null)),
),
startWith<RTCCallIntent | null>(null),
),
),
leave$: leave$, leave$: leave$,
hangup: (): void => userHangup$.next(), hangup: (): void => userHangup$.next(),
join: localMembership.requestJoinAndPublish, join: localMembership.requestJoinAndPublish,

View File

@@ -77,6 +77,7 @@ test("GridTile is accessible", async () => {
targetHeight={200} targetHeight={200}
showSpeakingIndicators showSpeakingIndicators
showNameTags showNameTags
showRingingStatus
focusable focusable
/> />
</ReactionsSenderProvider>, </ReactionsSenderProvider>,
@@ -108,6 +109,7 @@ test("GridTile displays ringing media", async () => {
targetHeight={200} targetHeight={200}
showSpeakingIndicators showSpeakingIndicators
showNameTags showNameTags
showRingingStatus
focusable focusable
/> />
</ReactionsSenderProvider>, </ReactionsSenderProvider>,

View File

@@ -29,15 +29,13 @@ import {
UserProfileIcon, UserProfileIcon,
VolumeOffSolidIcon, VolumeOffSolidIcon,
SwitchCameraSolidIcon, SwitchCameraSolidIcon,
VideoCallSolidIcon,
VoiceCallSolidIcon,
EndCallIcon,
} from "@vector-im/compound-design-tokens/assets/web/icons"; } from "@vector-im/compound-design-tokens/assets/web/icons";
import { import {
ContextMenu, ContextMenu,
MenuItem, MenuItem,
ToggleMenuItem, ToggleMenuItem,
Menu, Menu,
Text,
} from "@vector-im/compound-web"; } from "@vector-im/compound-web";
import { useObservableEagerState } from "observable-hooks"; import { useObservableEagerState } from "observable-hooks";
@@ -53,6 +51,7 @@ import { type LocalUserMediaViewModel } from "../state/media/LocalUserMediaViewM
import { type RemoteUserMediaViewModel } from "../state/media/RemoteUserMediaViewModel"; import { type RemoteUserMediaViewModel } from "../state/media/RemoteUserMediaViewModel";
import { type UserMediaViewModel } from "../state/media/UserMediaViewModel"; import { type UserMediaViewModel } from "../state/media/UserMediaViewModel";
import { type RingingMediaViewModel } from "../state/media/RingingMediaViewModel"; import { type RingingMediaViewModel } from "../state/media/RingingMediaViewModel";
import { RingingStatus } from "./RingingStatus";
interface TileProps { interface TileProps {
ref?: Ref<HTMLDivElement>; ref?: Ref<HTMLDivElement>;
@@ -68,16 +67,15 @@ interface TileProps {
interface RingingMediaTileProps extends TileProps { interface RingingMediaTileProps extends TileProps {
vm: RingingMediaViewModel; vm: RingingMediaViewModel;
showStatus: boolean;
} }
const RingingMediaTile: FC<RingingMediaTileProps> = ({ const RingingMediaTile: FC<RingingMediaTileProps> = ({
vm, vm,
showStatus,
className, className,
...props ...props
}) => { }) => {
const { t } = useTranslation();
const pickupState = useBehavior(vm.pickupState$);
return ( return (
<MediaView <MediaView
className={classNames(className, styles.tile)} className={classNames(className, styles.tile)}
@@ -85,14 +83,13 @@ const RingingMediaTile: FC<RingingMediaTileProps> = ({
userId={vm.userId} userId={vm.userId}
unencryptedWarning={false} unencryptedWarning={false}
status={ status={
pickupState === "ringing" showStatus && (
? { <Text as="span" size="sm" weight="medium">
text: t("video_tile.calling"), <RingingStatus vm={vm} />
Icon: </Text>
vm.intent === "video" ? VideoCallSolidIcon : VoiceCallSolidIcon, )
}
: { text: t("video_tile.call_ended"), Icon: EndCallIcon }
} }
avatarStyle="translucent"
videoEnabled={false} videoEnabled={false}
videoFit="cover" videoFit="cover"
mirror={false} mirror={false}
@@ -400,6 +397,7 @@ interface GridTileProps {
style?: ComponentProps<typeof animated.div>["style"]; style?: ComponentProps<typeof animated.div>["style"];
showSpeakingIndicators: boolean; showSpeakingIndicators: boolean;
showNameTags: boolean; showNameTags: boolean;
showRingingStatus: boolean;
focusable: boolean; focusable: boolean;
} }
@@ -407,6 +405,7 @@ export const GridTile: FC<GridTileProps> = ({
ref: theirRef, ref: theirRef,
vm, vm,
showSpeakingIndicators, showSpeakingIndicators,
showRingingStatus,
onOpenProfile, onOpenProfile,
...props ...props
}) => { }) => {
@@ -423,6 +422,7 @@ export const GridTile: FC<GridTileProps> = ({
vm={media} vm={media}
displayName={displayName} displayName={displayName}
mxcAvatarUrl={mxcAvatarUrl} mxcAvatarUrl={mxcAvatarUrl}
showStatus={showRingingStatus}
{...props} {...props}
/> />
); );

View File

@@ -54,11 +54,12 @@ Please see LICENSE in the repository root for full details.
left: 50%; left: 50%;
transform: translate(-50%, -50%); transform: translate(-50%, -50%);
pointer-events: none; pointer-events: none;
opacity: 100%;
transition: opacity 0.2s;
} }
.translucent { .avatar[data-style="translucent"] {
opacity: 50%; opacity: 50%;
mix-blend-mode: multiply;
} }
/* CSS makes us put a condition here, even though all we want to do is /* CSS makes us put a condition here, even though all we want to do is
@@ -121,18 +122,18 @@ unconditionally select the container so we can use cqmin units */
.status { .status {
grid-area: status; grid-area: status;
color: var(--cpd-color-text-primary); color: var(--cpd-color-text-primary);
display: flex;
flex-wrap: none;
align-items: center;
gap: 3px;
user-select: none; user-select: none;
overflow: hidden; overflow: hidden;
margin-block-start: calc(var(--cpd-space-3x) - var(--fg-inset)); margin-block-start: calc(var(--cpd-space-3x) - var(--fg-inset));
margin-inline-start: calc(var(--cpd-space-4x) - var(--fg-inset)); margin-inline-start: calc(var(--cpd-space-4x) - var(--fg-inset));
}
.status svg { svg {
color: var(--cpd-color-icon-tertiary); color: var(--cpd-color-icon-tertiary);
vertical-align: text-bottom;
margin-inline-end: 3px;
block-size: 1.2em;
inline-size: 1.2em;
}
} }
.reactions { .reactions {

View File

@@ -7,13 +7,7 @@ Please see LICENSE in the repository root for full details.
import { type TrackReferenceOrPlaceholder } from "@livekit/components-core"; import { type TrackReferenceOrPlaceholder } from "@livekit/components-core";
import { animated } from "@react-spring/web"; import { animated } from "@react-spring/web";
import { import { type FC, type ComponentProps, type ReactNode } from "react";
type FC,
type ComponentProps,
type ReactNode,
type ComponentType,
type SVGAttributes,
} from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import classNames from "classnames"; import classNames from "classnames";
import { VideoTrack } from "@livekit/components-react"; import { VideoTrack } from "@livekit/components-react";
@@ -43,11 +37,12 @@ interface Props extends ComponentProps<typeof animated.div> {
userId: string; userId: string;
videoEnabled: boolean; videoEnabled: boolean;
unencryptedWarning: boolean; unencryptedWarning: boolean;
status?: { text: string; Icon: ComponentType<SVGAttributes<SVGElement>> }; status?: ReactNode;
showNameTags: boolean; showNameTags: boolean;
nameTagLeadingIcon?: ReactNode; nameTagLeadingIcon?: ReactNode;
displayName: string; displayName: string;
mxcAvatarUrl: string | undefined; mxcAvatarUrl: string | undefined;
avatarStyle?: "solid" | "translucent";
focusable: boolean; focusable: boolean;
primaryButton?: ReactNode; primaryButton?: ReactNode;
raisedHandTime?: Date; raisedHandTime?: Date;
@@ -77,6 +72,7 @@ export const MediaView: FC<Props> = ({
nameTagLeadingIcon, nameTagLeadingIcon,
displayName, displayName,
mxcAvatarUrl, mxcAvatarUrl,
avatarStyle = "solid",
focusable, focusable,
primaryButton, primaryButton,
status, status,
@@ -130,11 +126,8 @@ export const MediaView: FC<Props> = ({
name={displayName} name={displayName}
size={avatarSize} size={avatarSize}
src={mxcAvatarUrl} src={mxcAvatarUrl}
className={classNames(styles.avatar, { data-style={avatarStyle}
// When the avatar is overlaid with a status, make it translucent className={styles.avatar}
// for readability
[styles.translucent]: status,
})}
style={{ display: video && videoEnabled ? "none" : "initial" }} style={{ display: video && videoEnabled ? "none" : "initial" }}
/> />
{video?.publication !== undefined && ( {video?.publication !== undefined && (
@@ -180,14 +173,7 @@ export const MediaView: FC<Props> = ({
/> />
</> </>
)} )}
{status && ( {status && <div className={styles.status}>{status}</div>}
<div className={styles.status}>
<status.Icon width={16} height={16} aria-hidden />
<Text as="span" size="sm" weight="medium">
{status.text}
</Text>
</div>
)}
{/* TODO: Bring this back once encryption status is less broken */} {/* TODO: Bring this back once encryption status is less broken */}
{/*encryptionStatus !== EncryptionStatus.Okay && ( {/*encryptionStatus !== EncryptionStatus.Okay && (
<div className={styles.status}> <div className={styles.status}>

View File

@@ -0,0 +1,41 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { type FC } from "react";
import {
VideoCallSolidIcon,
VoiceCallSolidIcon,
EndCallIcon,
} from "@vector-im/compound-design-tokens/assets/web/icons";
import { useTranslation } from "react-i18next";
import { type RingingMediaViewModel } from "../state/media/RingingMediaViewModel";
import { useBehavior } from "../useBehavior";
interface Props {
vm: RingingMediaViewModel;
}
export const RingingStatus: FC<Props> = ({ vm }) => {
const { t } = useTranslation();
const pickupState = useBehavior(vm.pickupState$);
const Icon =
pickupState === "ringing"
? vm.intent === "video"
? VideoCallSolidIcon
: VoiceCallSolidIcon
: EndCallIcon;
return (
<>
<Icon aria-hidden />
{pickupState === "ringing"
? t("video_tile.calling")
: t("video_tile.call_ended")}
</>
);
};

View File

@@ -65,6 +65,7 @@ test("SpotlightTile is accessible", async () => {
onToggleExpanded={toggleExpanded} onToggleExpanded={toggleExpanded}
showIndicators showIndicators
showNameTags showNameTags
showRingingStatus
focusable={true} focusable={true}
/>, />,
); );
@@ -107,6 +108,7 @@ test("Screen share volume UI is shown when screen share has audio", async () =>
onToggleExpanded={toggleExpanded} onToggleExpanded={toggleExpanded}
showIndicators showIndicators
showNameTags showNameTags
showRingingStatus
focusable focusable
/> />
</TooltipProvider>, </TooltipProvider>,
@@ -137,6 +139,7 @@ test("Screen share volume UI is hidden when screen share has no audio", async ()
onToggleExpanded={toggleExpanded} onToggleExpanded={toggleExpanded}
showIndicators showIndicators
showNameTags showNameTags
showRingingStatus
focusable focusable
/>, />,
); );
@@ -172,6 +175,7 @@ test("SpotlightTile displays ringing media", async () => {
onToggleExpanded={toggleExpanded} onToggleExpanded={toggleExpanded}
showIndicators showIndicators
showNameTags showNameTags
showRingingStatus
focusable={true} focusable={true}
/>, />,
); );

View File

@@ -24,9 +24,6 @@ import {
VolumeOnIcon, VolumeOnIcon,
VolumeOffSolidIcon, VolumeOffSolidIcon,
VolumeOnSolidIcon, VolumeOnSolidIcon,
VideoCallSolidIcon,
VoiceCallSolidIcon,
EndCallIcon,
} from "@vector-im/compound-design-tokens/assets/web/icons"; } from "@vector-im/compound-design-tokens/assets/web/icons";
import { animated } from "@react-spring/web"; import { animated } from "@react-spring/web";
import { type Observable, map } from "rxjs"; import { type Observable, map } from "rxjs";
@@ -34,7 +31,7 @@ import { useObservableRef } from "observable-hooks";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import classNames from "classnames"; import classNames from "classnames";
import { type TrackReferenceOrPlaceholder } from "@livekit/components-core"; import { type TrackReferenceOrPlaceholder } from "@livekit/components-core";
import { Menu, MenuItem } from "@vector-im/compound-web"; import { Menu, MenuItem, Text } from "@vector-im/compound-web";
import FullScreenMaximiseIcon from "../icons/FullScreenMaximise.svg?react"; import FullScreenMaximiseIcon from "../icons/FullScreenMaximise.svg?react";
import FullScreenMinimiseIcon from "../icons/FullScreenMinimise.svg?react"; import FullScreenMinimiseIcon from "../icons/FullScreenMinimise.svg?react";
@@ -56,6 +53,7 @@ import { type MediaViewModel } from "../state/media/MediaViewModel";
import { Slider } from "../Slider"; import { Slider } from "../Slider";
import { platform } from "../Platform"; import { platform } from "../Platform";
import { type RingingMediaViewModel } from "../state/media/RingingMediaViewModel"; import { type RingingMediaViewModel } from "../state/media/RingingMediaViewModel";
import { RingingStatus } from "./RingingStatus";
interface SpotlightItemBaseProps { interface SpotlightItemBaseProps {
ref?: Ref<HTMLDivElement>; ref?: Ref<HTMLDivElement>;
@@ -204,28 +202,26 @@ const SpotlightMemberMediaItem: FC<SpotlightMemberMediaItemProps> = ({
interface SpotlightRingingMediaItemProps extends SpotlightItemBaseProps { interface SpotlightRingingMediaItemProps extends SpotlightItemBaseProps {
vm: RingingMediaViewModel; vm: RingingMediaViewModel;
showStatus: boolean;
} }
const SpotlightRingingMediaItem: FC<SpotlightRingingMediaItemProps> = ({ const SpotlightRingingMediaItem: FC<SpotlightRingingMediaItemProps> = ({
vm, vm,
showStatus,
...props ...props
}) => { }) => {
const { t } = useTranslation();
const pickupState = useBehavior(vm.pickupState$);
return ( return (
<MediaView <MediaView
video={undefined} video={undefined}
unencryptedWarning={false} unencryptedWarning={false}
status={ status={
pickupState === "ringing" showStatus && (
? { <Text as="span" size="md" weight="medium">
text: t("video_tile.calling"), <RingingStatus vm={vm} />
Icon: </Text>
vm.intent === "video" ? VideoCallSolidIcon : VoiceCallSolidIcon, )
}
: { text: t("video_tile.call_ended"), Icon: EndCallIcon }
} }
avatarStyle="translucent"
videoEnabled={false} videoEnabled={false}
videoFit="cover" videoFit="cover"
mirror={false} mirror={false}
@@ -246,6 +242,7 @@ interface SpotlightItemProps {
*/ */
targetHeight: number; targetHeight: number;
showNameTags: boolean; showNameTags: boolean;
showRingingStatus: boolean;
focusable: boolean; focusable: boolean;
intersectionObserver$: Observable<IntersectionObserver>; intersectionObserver$: Observable<IntersectionObserver>;
/** /**
@@ -261,6 +258,7 @@ const SpotlightItem: FC<SpotlightItemProps> = ({
targetWidth, targetWidth,
targetHeight, targetHeight,
showNameTags, showNameTags,
showRingingStatus,
focusable, focusable,
intersectionObserver$, intersectionObserver$,
snap, snap,
@@ -302,7 +300,11 @@ const SpotlightItem: FC<SpotlightItemProps> = ({
}; };
return vm.type === "ringing" ? ( return vm.type === "ringing" ? (
<SpotlightRingingMediaItem vm={vm} {...baseProps} /> <SpotlightRingingMediaItem
vm={vm}
showStatus={showRingingStatus}
{...baseProps}
/>
) : ( ) : (
<SpotlightMemberMediaItem vm={vm} {...baseProps} /> <SpotlightMemberMediaItem vm={vm} {...baseProps} />
); );
@@ -386,6 +388,7 @@ interface Props {
targetHeight: number; targetHeight: number;
showIndicators: boolean; showIndicators: boolean;
showNameTags: boolean; showNameTags: boolean;
showRingingStatus: boolean;
focusable: boolean; focusable: boolean;
className?: string; className?: string;
style?: ComponentProps<typeof animated.div>["style"]; style?: ComponentProps<typeof animated.div>["style"];
@@ -400,6 +403,7 @@ export const SpotlightTile: FC<Props> = ({
targetHeight, targetHeight,
showIndicators, showIndicators,
showNameTags, showNameTags,
showRingingStatus,
focusable = true, focusable = true,
className, className,
style, style,
@@ -510,6 +514,7 @@ export const SpotlightTile: FC<Props> = ({
vm={vm} vm={vm}
targetWidth={targetWidth} targetWidth={targetWidth}
targetHeight={targetHeight} targetHeight={targetHeight}
showRingingStatus={showRingingStatus}
showNameTags={showNameTags} showNameTags={showNameTags}
focusable={focusable} focusable={focusable}
intersectionObserver$={intersectionObserver$} intersectionObserver$={intersectionObserver$}

View File

@@ -113,7 +113,7 @@ interface Props<S extends string> {
muted?: boolean; muted?: boolean;
} }
interface UseAudioContext<S extends string> { export interface UseAudioContext<S extends string> {
playSound(soundName: S, volumeOverwrite?: number): Promise<void>; playSound(soundName: S, volumeOverwrite?: number): Promise<void>;
playSoundLooping(soundName: S, delayS?: number): () => Promise<void>; playSoundLooping(soundName: S, delayS?: number): () => Promise<void>;
/** /**