mirror of
https://github.com/vector-im/element-call.git
synced 2026-07-18 18:59:23 +00:00
Move ringing status indicator to header on mobile
On mobile, the ringing status indicator is supposed to display in the header rather than on a tile. The exact layout differs between Android and iOS. To get it right I had to refactor AppBar to use CSS grid templates. (Also, I changed my mind about the exact ringing data I needed out of CallViewModel - sorry. A little move of the ringtone audio renderer into its own component was necessary to accommodate that.)
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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<InCallViewProps> = ({
|
||||
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<InCallViewProps> = ({
|
||||
() => 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<InCallViewProps> = ({
|
||||
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
|
||||
// for `pointerup` instead. Scrolls end in `pointercancel`, not `pointerup`,
|
||||
// so this still only fires for taps.
|
||||
@@ -363,6 +333,7 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
);
|
||||
|
||||
useAppBarHidden(!showHeader);
|
||||
useAppBarSubtitle(ringingVm && <RingingStatus vm={ringingVm} />);
|
||||
|
||||
let header: ReactNode = null;
|
||||
switch (headerStyle) {
|
||||
@@ -626,6 +597,7 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
{renderContent()}
|
||||
<CallEventAudioRenderer vm={vm} muted={muteAllAudio} />
|
||||
<ReactionsAudioRenderer vm={vm} muted={muteAllAudio} />
|
||||
<RingingAudioRenderer vm={ringingVm} muted={muteAllAudio} />
|
||||
{reconnectingToast}
|
||||
{earpieceOverlay}
|
||||
<ReactionsOverlay vm={vm} />
|
||||
|
||||
@@ -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(<ArrowLeftIcon />);
|
||||
@@ -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(<ChevronLeftIcon />);
|
||||
@@ -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);
|
||||
|
||||
72
src/room/RingingAudioRenderer.tsx
Normal file
72
src/room/RingingAudioRenderer.tsx
Normal 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;
|
||||
};
|
||||
@@ -4,43 +4,36 @@ exports[`LobbyView > renders with AppBar android 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="bar"
|
||||
style="display: block;"
|
||||
>
|
||||
<header
|
||||
class="header"
|
||||
>
|
||||
<div
|
||||
class="nav leftNav"
|
||||
<header>
|
||||
<button
|
||||
aria-labelledby="_r_36_"
|
||||
class="_icon-button_1215g_8 primaryButton"
|
||||
data-kind="primary"
|
||||
role="button"
|
||||
style="--cpd-icon-button-size: 32px;"
|
||||
tabindex="0"
|
||||
>
|
||||
<button
|
||||
aria-labelledby="_r_36_"
|
||||
class="_icon-button_1215g_8"
|
||||
data-kind="primary"
|
||||
role="button"
|
||||
style="--cpd-icon-button-size: 32px;"
|
||||
tabindex="0"
|
||||
<div
|
||||
class="_indicator-icon_147l5_17"
|
||||
style="--cpd-icon-button-size: 100%;"
|
||||
>
|
||||
<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"
|
||||
>
|
||||
<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.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>
|
||||
<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
|
||||
class="nav rightNav"
|
||||
class="secondaryButton"
|
||||
/>
|
||||
</header>
|
||||
</div>
|
||||
@@ -242,43 +235,36 @@ exports[`LobbyView > renders with AppBar ios 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="bar"
|
||||
style="display: block;"
|
||||
>
|
||||
<header
|
||||
class="header"
|
||||
>
|
||||
<div
|
||||
class="nav leftNav"
|
||||
<header>
|
||||
<button
|
||||
aria-labelledby="_r_4a_"
|
||||
class="_icon-button_1215g_8 primaryButton"
|
||||
data-kind="primary"
|
||||
role="button"
|
||||
style="--cpd-icon-button-size: 32px;"
|
||||
tabindex="0"
|
||||
>
|
||||
<button
|
||||
aria-labelledby="_r_4a_"
|
||||
class="_icon-button_1215g_8"
|
||||
data-kind="primary"
|
||||
role="button"
|
||||
style="--cpd-icon-button-size: 32px;"
|
||||
tabindex="0"
|
||||
<div
|
||||
class="_indicator-icon_147l5_17"
|
||||
style="--cpd-icon-button-size: 100%;"
|
||||
>
|
||||
<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"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
fill="currentColor"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
width="1em"
|
||||
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>
|
||||
<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
|
||||
class="nav rightNav"
|
||||
class="secondaryButton"
|
||||
/>
|
||||
</header>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user