{children}
>
@@ -132,6 +145,20 @@ export function useAppBarTitle(title: string): void {
}, [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"
* It is an error to call this hook from multiple sites in the same component tree.
diff --git a/src/__snapshots__/AppBar.test.tsx.snap b/src/__snapshots__/AppBar.test.tsx.snap
index 0df187671..482189481 100644
--- a/src/__snapshots__/AppBar.test.tsx.snap
+++ b/src/__snapshots__/AppBar.test.tsx.snap
@@ -4,43 +4,89 @@ exports[`AppBar > renders 1`] = `
-
-
+
+
+
+
+
+
+
+
+ This is the content.
+
+
+`;
+
+exports[`AppBar > renders with title and subtitle 1`] = `
+
+
+
+
+
+
+
+
+
+ Title
+
+
+ Subtitle
+
+
diff --git a/src/components/CallFooterViewModel.tsx b/src/components/CallFooterViewModel.tsx
index bffef9b55..2f64cc83b 100644
--- a/src/components/CallFooterViewModel.tsx
+++ b/src/components/CallFooterViewModel.tsx
@@ -173,9 +173,7 @@ export function createCallFooterViewModel(
callModel.setSettingsOpen$,
]).pipe(
map(([isPip, showHeader, setSettingsOpen]) =>
- !isPip &&
- !(headerStyle === HeaderStyle.AppBar && showHeader) &&
- showControls
+ !isPip && headerStyle !== HeaderStyle.AppBar && showControls
? (): void => setSettingsOpen(true)
: undefined,
),
diff --git a/src/room/InCallView.test.tsx b/src/room/InCallView.test.tsx
index c2d8a7295..94b152e43 100644
--- a/src/room/InCallView.test.tsx
+++ b/src/room/InCallView.test.tsx
@@ -7,7 +7,6 @@ Please see LICENSE in the repository root for full details.
*/
import {
- afterEach,
beforeEach,
describe,
expect,
@@ -15,12 +14,7 @@ import {
type MockedFunction,
vi,
} from "vitest";
-import {
- render,
- type RenderResult,
- getByRole,
- screen,
-} from "@testing-library/react";
+import { render, type RenderResult } from "@testing-library/react";
import { type LocalParticipant } from "livekit-client";
import { BehaviorSubject, of } from "rxjs";
import { BrowserRouter } from "react-router-dom";
@@ -50,7 +44,6 @@ import { useRoomEncryptionSystem } from "../e2ee/sharedKeyManagement";
import { LivekitRoomAudioRenderer } from "../livekit/MatrixAudioRenderer";
import { MediaDevicesContext } from "../MediaDevicesContext";
import { type MediaDevices as ECMediaDevices } from "../state/MediaDevices";
-import { constant } from "../state/Behavior";
import { AppBar } from "../AppBar";
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", () => {
it("is visible and can be clicked", async () => {
const user = userEvent.setup();
diff --git a/src/room/InCallView.tsx b/src/room/InCallView.tsx
index 7d7c6d645..ca71ff726 100644
--- a/src/room/InCallView.tsx
+++ b/src/room/InCallView.tsx
@@ -44,7 +44,6 @@ import {
createCallViewModel$,
} from "../state/CallViewModel/CallViewModel.ts";
import { Grid, type TileProps } from "../grid/Grid";
-import { useInitial } from "../useInitial";
import { SpotlightTile } from "../tile/SpotlightTile";
import { type EncryptionSystem } from "../e2ee/sharedKeyManagement";
import { E2eeType } from "../e2ee/e2eeType";
@@ -69,22 +68,23 @@ import { LivekitRoomAudioRenderer } from "../livekit/MatrixAudioRenderer.tsx";
import { muteAllAudio$ } from "../state/MuteAllAudioModel.ts";
import { useMediaDevices } from "../MediaDevicesContext.ts";
import { EarpieceOverlay } from "./EarpieceOverlay.tsx";
-import { useAppBarHidden, useAppBarSecondaryButton } from "../AppBar.tsx";
+import {
+ useAppBarHidden,
+ useAppBarSecondaryButton,
+ useAppBarSubtitle,
+} from "../AppBar.tsx";
import { useBehavior } from "../useBehavior.ts";
import { Toast } from "../Toast.tsx";
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 { type Layout } from "../state/layout-types.ts";
import { ObservableScope } from "../state/ObservableScope.ts";
-import { useLatest } from "../useLatest.ts";
import { CallFooter, type FooterSnapshot } from "../components/CallFooter.tsx";
import { SettingsIconButton } from "../button/Button.tsx";
import { createCallFooterViewModel } from "../components/CallFooterViewModel.tsx";
import { type ViewModel } from "../state/ViewModel.ts";
+import { RingingStatus } from "../tile/RingingStatus.tsx";
+import { RingingAudioRenderer } from "./RingingAudioRenderer.tsx";
declare module "react" {
interface CSSProperties {
@@ -240,20 +240,6 @@ export const InCallView: FC = ({
const { showControls, header: headerStyle } = useUrlParams();
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 toggleVideo = useBehavior(muteStates.video.toggle$);
const setAudioEnabled = useBehavior(muteStates.audio.setEnabled$);
@@ -266,7 +252,7 @@ export const InCallView: FC = ({
() => void toggleRaisedHand(),
);
- const ringingIntent = useBehavior(vm.ringingIntent$);
+ const ringingVm = useBehavior(vm.ringingVm$);
const audioParticipants = useBehavior(vm.livekitRoomItems$);
const participantCount = useBehavior(vm.participantCount$);
const reconnecting = useBehavior(vm.reconnecting$);
@@ -286,22 +272,6 @@ export const InCallView: FC = ({
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
s, so we listen
// for `pointerup` instead. Scrolls end in `pointercancel`, not `pointerup`,
// so this still only fires for taps.
@@ -363,6 +333,11 @@ export const InCallView: FC = ({
);
useAppBarHidden(!showHeader);
+ useAppBarSubtitle(
+ ringingVm && vm.ringingStatusLocation === "app_bar" && (
+
+ ),
+ );
let header: ReactNode = null;
switch (headerStyle) {
@@ -457,6 +432,7 @@ export const InCallView: FC = ({
);
const showSpeakingIndicators = useBehavior(vm.showSpeakingIndicators$);
const showNameTags = useBehavior(vm.showNameTags$);
+ const showRingingStatus = vm.ringingStatusLocation === "tile";
return model instanceof GridTileViewModel ? (
= ({
style={style}
showSpeakingIndicators={showSpeakingIndicators}
showNameTags={showNameTags}
+ showRingingStatus={showRingingStatus}
focusable={!contentObscured}
/>
) : (
@@ -481,6 +458,7 @@ export const InCallView: FC = ({
targetHeight={targetHeight}
showIndicators={showSpotlightIndicators}
showNameTags={showNameTags}
+ showRingingStatus={showRingingStatus}
focusable={!contentObscured}
className={classNames(className, styles.tile)}
style={style}
@@ -515,6 +493,7 @@ export const InCallView: FC = ({
targetHeight={gridBounds.height}
showIndicators={false}
showNameTags={showNameTags}
+ showRingingStatus={vm.ringingStatusLocation === "tile"}
focusable={!contentObscured}
aria-hidden={contentObscured}
/>
@@ -626,6 +605,7 @@ export const InCallView: FC = ({
{renderContent()}
+
{reconnectingToast}
{earpieceOverlay}
diff --git a/src/room/LobbyView.test.tsx b/src/room/LobbyView.test.tsx
index 4131529cd..e2bb06ccb 100644
--- a/src/room/LobbyView.test.tsx
+++ b/src/room/LobbyView.test.tsx
@@ -130,16 +130,14 @@ describe("LobbyView", () => {
});
it("renders with AppBar android", async () => {
- const { container } = renderLobbyView(
+ const { container, getByRole } = renderLobbyView(
{
waitingForInvite: true,
},
true,
"android",
);
- expect(
- container.getElementsByClassName(headerStyles.header).length,
- ).toBeTruthy();
+ getByRole("banner");
// Check that the primary button uses ArrowLeftIcon (the back/return icon),
// not the default CollapseIcon
const { container: iconContainer } = render();
@@ -147,7 +145,7 @@ describe("LobbyView", () => {
.querySelector("path")!
.getAttribute("d");
const primaryButtonSvgPath = container
- .querySelector(".leftNav button")
+ .querySelector(".primaryButton")
?.querySelector("path")
?.getAttribute("d");
expect(primaryButtonSvgPath).toBe(expectedSvgPath);
@@ -156,16 +154,14 @@ describe("LobbyView", () => {
});
it("renders with AppBar ios", async () => {
- const { container } = renderLobbyView(
+ const { container, getByRole } = renderLobbyView(
{
waitingForInvite: true,
},
true,
"ios",
);
- expect(
- container.getElementsByClassName(headerStyles.header).length,
- ).toBeTruthy();
+ getByRole("banner");
// Check that the primary button uses ArrowLeftIcon (the back/return icon),
// not the default CollapseIcon
const { container: iconContainer } = render();
@@ -173,7 +169,7 @@ describe("LobbyView", () => {
.querySelector("path")!
.getAttribute("d");
const primaryButtonSvgPath = container
- .querySelector(".leftNav button")
+ .querySelector(".primaryButton")
?.querySelector("path")
?.getAttribute("d");
expect(primaryButtonSvgPath).toBe(expectedSvgPath);
diff --git a/src/room/RingingAudioRenderer.test.tsx b/src/room/RingingAudioRenderer.test.tsx
new file mode 100644
index 000000000..4d95ffacb
--- /dev/null
+++ b/src/room/RingingAudioRenderer.test.tsx
@@ -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).mockResolvedValue({
+ sound: new ArrayBuffer(0),
+ });
+ const endSoundLooping = vi.fn().mockReturnValue(Promise.resolve());
+ const playSoundLooping = vi.fn().mockReturnValue(endSoundLooping);
+ (useAudioContext as MockedFunction).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();
+ 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();
+});
diff --git a/src/room/RingingAudioRenderer.tsx b/src/room/RingingAudioRenderer.tsx
new file mode 100644
index 000000000..c0fe45d5b
--- /dev/null
+++ b/src/room/RingingAudioRenderer.tsx
@@ -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 = ({
+ 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 && ;
+};
+
+interface ActiveRingingAudioRendererProps {
+ vm: RingingMediaViewModel;
+ audio: UseAudioContext<"ringtone"> | null;
+}
+
+const ActiveRingingAudioRenderer: FC = ({
+ 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;
+};
diff --git a/src/room/__snapshots__/LobbyView.test.tsx.snap b/src/room/__snapshots__/LobbyView.test.tsx.snap
index e19c3477f..ac89651b9 100644
--- a/src/room/__snapshots__/LobbyView.test.tsx.snap
+++ b/src/room/__snapshots__/LobbyView.test.tsx.snap
@@ -4,43 +4,36 @@ exports[`LobbyView > renders with AppBar android 1`] = `