From e11c04ac872ecb3246f2e67288843e7e53f1a759 Mon Sep 17 00:00:00 2001 From: Robin Date: Thu, 18 Jun 2026 16:45:28 +0200 Subject: [PATCH] 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.) --- src/AppBar.module.css | 120 +++++++++++++++++- src/AppBar.tsx | 91 ++++++++----- src/__snapshots__/AppBar.test.tsx.snap | 57 ++++----- src/components/CallFooterViewModel.tsx | 4 +- src/room/InCallView.test.tsx | 48 +------ src/room/InCallView.tsx | 48 ++----- src/room/LobbyView.test.tsx | 16 +-- src/room/RingingAudioRenderer.tsx | 72 +++++++++++ .../__snapshots__/LobbyView.test.tsx.snap | 114 ++++++++--------- src/state/CallViewModel/CallViewModel.test.ts | 17 ++- src/state/CallViewModel/CallViewModel.ts | 16 +-- src/tile/GridTile.tsx | 18 +-- src/tile/MediaView.module.css | 18 ++- src/tile/MediaView.tsx | 19 +-- src/tile/RingingStatus.tsx | 41 ++++++ src/tile/SpotlightTile.test.tsx | 9 +- src/tile/SpotlightTile.tsx | 15 --- src/useAudioContext.tsx | 2 +- 18 files changed, 415 insertions(+), 310 deletions(-) create mode 100644 src/room/RingingAudioRenderer.tsx create mode 100644 src/tile/RingingStatus.tsx diff --git a/src/AppBar.module.css b/src/AppBar.module.css index 13e3b759f..95381428a 100644 --- a/src/AppBar.module.css +++ b/src/AppBar.module.css @@ -1,6 +1,12 @@ .bar { flex-shrink: 0; 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 */ @@ -9,8 +15,7 @@ position: absolute; inset-inline: 0; /* Extend the gradient beyond the bottom of the header for readability */ - inset-block: -24px; - z-index: var(--call-view-header-footer-layer); + inset-block: 0 -16px; background: linear-gradient( 0deg, 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 { + flex-shrink: 0; position: sticky; inset-inline: 0; inset-block-start: 0; - block-size: 64px; - z-index: var(--call-view-header-footer-layer); + display: grid; + 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 { - fill: var(--cpd-color-icon-primary); +.bar:has(.subtitle) > header { + 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: center; + } +} + +.title, +.subtitle { margin: 0; overflow: hidden; text-overflow: ellipsis; 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; + } + } +} diff --git a/src/AppBar.tsx b/src/AppBar.tsx index 490acf065..18314dd32 100644 --- a/src/AppBar.tsx +++ b/src/AppBar.tsx @@ -16,7 +16,8 @@ import { type MouseEvent, type ReactNode, } 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 { ArrowLeftIcon, ChevronLeftIcon, @@ -25,12 +26,12 @@ import { import { useTranslation } from "react-i18next"; import { logger } from "matrix-js-sdk/lib/logger"; -import { Header, LeftNav, RightNav } from "./Header"; import { platform } from "./Platform"; import styles from "./AppBar.module.css"; interface AppBarContext { setTitle: (value: string) => void; + setSubtitle: (value: ReactNode) => void; setSecondaryButton: (value: ReactNode) => void; setPrimaryButtonIconKind: (value: "back" | "minimise") => void; setHidden: (value: boolean) => void; @@ -54,6 +55,7 @@ export const AppBar: FC = ({ children }) => { }, []); const [title, setTitle] = useState(""); + const [subtitle, setSubtitle] = useState(undefined); const [hidden, setHidden] = useState(false); const [secondaryButton, setSecondaryButton] = useState( null, @@ -65,53 +67,64 @@ export const AppBar: FC = ({ children }) => { const context = useMemo( () => ({ setTitle, + setSubtitle, setSecondaryButton, setHidden, setPrimaryButtonIconKind, }), - [setTitle, setHidden, setSecondaryButton, setPrimaryButtonIconKind], + [ + setTitle, + setSubtitle, + setHidden, + setSecondaryButton, + setPrimaryButtonIconKind, + ], ); const BackIcon = platform === "android" ? ArrowLeftIcon : ChevronLeftIcon; return ( <> - {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..f56c12b8c 100644 --- a/src/__snapshots__/AppBar.test.tsx.snap +++ b/src/__snapshots__/AppBar.test.tsx.snap @@ -4,43 +4,36 @@ exports[`AppBar > renders 1`] = `
-
- + + +
+ 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..d395c1841 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,7 @@ export const InCallView: FC = ({ ); useAppBarHidden(!showHeader); + useAppBarSubtitle(ringingVm && ); let header: ReactNode = null; switch (headerStyle) { @@ -626,6 +597,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.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`] = `
-
- + + +
+ @@ -242,43 +235,36 @@ exports[`LobbyView > renders with AppBar ios 1`] = `
-
- + + +
+ diff --git a/src/state/CallViewModel/CallViewModel.test.ts b/src/state/CallViewModel/CallViewModel.test.ts index b99236676..f14f487f5 100644 --- a/src/state/CallViewModel/CallViewModel.test.ts +++ b/src/state/CallViewModel/CallViewModel.test.ts @@ -1420,10 +1420,13 @@ describe.each([ }, }); - // Should ring for 30ms and then time out - expectObservable(vm.ringingIntent$).toBe("(ab) 26ms a", { + expectObservable(vm.ringingVm$).toBe("(ab)", { a: null, - b: "audio", + b: expect.objectContaining({ + type: "ringing", + userId: alice.userId, + intent: "audio", + }), }); // Layout should show placeholder media for the participant we're // ringing the entire time (even once timed out) @@ -1463,9 +1466,13 @@ describe.each([ }); // Should ring until Alice joins - expectObservable(vm.ringingIntent$).toBe("(ab) 17ms a", { + expectObservable(vm.ringingVm$).toBe("(ab) 17ms a", { a: null, - b: "audio", + b: expect.objectContaining({ + type: "ringing", + userId: alice.userId, + intent: "audio", + }), }); // Layout should show placeholder media for the participant we're // ringing the entire time diff --git a/src/state/CallViewModel/CallViewModel.ts b/src/state/CallViewModel/CallViewModel.ts index a12b59578..2df759168 100644 --- a/src/state/CallViewModel/CallViewModel.ts +++ b/src/state/CallViewModel/CallViewModel.ts @@ -39,12 +39,10 @@ import { throttleTime, timer, takeUntil, - concat, } from "rxjs"; import { logger as rootLogger } from "matrix-js-sdk/lib/logger"; import { MembershipManagerEvent, - type RTCCallIntent, type LivekitTransportConfig, type MatrixRTCSession, } from "matrix-js-sdk/lib/matrixrtc"; @@ -232,9 +230,9 @@ export interface CallViewModel { // lifecycle autoLeave$: Observable; /** - * 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; + ringingVm$: Behavior; /** 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 * - by ending the scope @@ -1717,15 +1715,7 @@ export function createCallViewModel$( return { autoLeave$: autoLeave$, - ringingIntent$: scope.behavior( - ringAttempts$.pipe( - switchMap(({ intent, outcome$ }) => - // Hold the intent as the value until the ring attempt completes - concat(of(intent), NEVER.pipe(takeUntil(outcome$)), of(null)), - ), - startWith(null), - ), - ), + ringingVm$: ringingMedia$, leave$: leave$, hangup: (): void => userHangup$.next(), join: localMembership.requestJoinAndPublish, diff --git a/src/tile/GridTile.tsx b/src/tile/GridTile.tsx index d974921b1..9e9a4172c 100644 --- a/src/tile/GridTile.tsx +++ b/src/tile/GridTile.tsx @@ -29,15 +29,13 @@ import { UserProfileIcon, VolumeOffSolidIcon, SwitchCameraSolidIcon, - VideoCallSolidIcon, - VoiceCallSolidIcon, - EndCallIcon, } from "@vector-im/compound-design-tokens/assets/web/icons"; import { ContextMenu, MenuItem, ToggleMenuItem, Menu, + Text, } from "@vector-im/compound-web"; 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 UserMediaViewModel } from "../state/media/UserMediaViewModel"; import { type RingingMediaViewModel } from "../state/media/RingingMediaViewModel"; +import { RingingStatus } from "./RingingStatus"; interface TileProps { ref?: Ref; @@ -75,9 +74,6 @@ const RingingMediaTile: FC = ({ className, ...props }) => { - const { t } = useTranslation(); - const pickupState = useBehavior(vm.pickupState$); - return ( = ({ userId={vm.userId} unencryptedWarning={false} status={ - pickupState === "ringing" - ? { - text: t("video_tile.calling"), - Icon: - vm.intent === "video" ? VideoCallSolidIcon : VoiceCallSolidIcon, - } - : { text: t("video_tile.call_ended"), Icon: EndCallIcon } + + + } videoEnabled={false} videoFit="cover" diff --git a/src/tile/MediaView.module.css b/src/tile/MediaView.module.css index 240f14d19..734326352 100644 --- a/src/tile/MediaView.module.css +++ b/src/tile/MediaView.module.css @@ -121,18 +121,22 @@ unconditionally select the container so we can use cqmin units */ .status { grid-area: status; color: var(--cpd-color-text-primary); - display: flex; - flex-wrap: none; - align-items: center; - gap: 3px; user-select: none; overflow: hidden; margin-block-start: calc(var(--cpd-space-3x) - var(--fg-inset)); margin-inline-start: calc(var(--cpd-space-4x) - var(--fg-inset)); -} -.status svg { - color: var(--cpd-color-icon-tertiary); + span { + vertical-align: center; + } + + svg { + color: var(--cpd-color-icon-tertiary); + vertical-align: center; + margin-inline-end: 3px; + block-size: 1.2em; + inline-size: 1.2em; + } } .reactions { diff --git a/src/tile/MediaView.tsx b/src/tile/MediaView.tsx index 6ff97f7a9..ccd75c4f5 100644 --- a/src/tile/MediaView.tsx +++ b/src/tile/MediaView.tsx @@ -7,13 +7,7 @@ Please see LICENSE in the repository root for full details. import { type TrackReferenceOrPlaceholder } from "@livekit/components-core"; import { animated } from "@react-spring/web"; -import { - type FC, - type ComponentProps, - type ReactNode, - type ComponentType, - type SVGAttributes, -} from "react"; +import { type FC, type ComponentProps, type ReactNode } from "react"; import { useTranslation } from "react-i18next"; import classNames from "classnames"; import { VideoTrack } from "@livekit/components-react"; @@ -43,7 +37,7 @@ interface Props extends ComponentProps { userId: string; videoEnabled: boolean; unencryptedWarning: boolean; - status?: { text: string; Icon: ComponentType> }; + status?: ReactNode; showNameTags: boolean; nameTagLeadingIcon?: ReactNode; displayName: string; @@ -180,14 +174,7 @@ export const MediaView: FC = ({ /> )} - {status && ( -
- - - {status.text} - -
- )} + {status &&
{status}
} {/* TODO: Bring this back once encryption status is less broken */} {/*encryptionStatus !== EncryptionStatus.Okay && (
diff --git a/src/tile/RingingStatus.tsx b/src/tile/RingingStatus.tsx new file mode 100644 index 000000000..384d3c65b --- /dev/null +++ b/src/tile/RingingStatus.tsx @@ -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 = ({ vm }) => { + const { t } = useTranslation(); + const pickupState = useBehavior(vm.pickupState$); + const Icon = + pickupState === "ringing" + ? vm.intent === "video" + ? VideoCallSolidIcon + : VoiceCallSolidIcon + : EndCallIcon; + + return ( + <> + + {pickupState === "ringing" + ? t("video_tile.calling") + : t("video_tile.call_ended")} + + ); +}; diff --git a/src/tile/SpotlightTile.test.tsx b/src/tile/SpotlightTile.test.tsx index 2d56e6b2f..75c1de780 100644 --- a/src/tile/SpotlightTile.test.tsx +++ b/src/tile/SpotlightTile.test.tsx @@ -6,7 +6,7 @@ Please see LICENSE in the repository root for full details. */ import { test, expect, vi } from "vitest"; -import { act, isInaccessible, render, screen } from "@testing-library/react"; +import { isInaccessible, render, screen } from "@testing-library/react"; import { axe } from "vitest-axe"; import userEvent from "@testing-library/user-event"; import { TooltipProvider } from "@vector-im/compound-web"; @@ -177,11 +177,6 @@ test("SpotlightTile displays ringing media", async () => { ); expect(await axe(container)).toHaveNoViolations(); - // Alice should be in the spotlight with the right status + // Alice should be in the spotlight screen.getByText("Alice"); - screen.getByText("Calling…"); - - // Now we time out ringing to Alice - act(() => pickupState$.next("timeout")); - screen.getByText("Call ended"); }); diff --git a/src/tile/SpotlightTile.tsx b/src/tile/SpotlightTile.tsx index 4612e9610..944d3b995 100644 --- a/src/tile/SpotlightTile.tsx +++ b/src/tile/SpotlightTile.tsx @@ -24,9 +24,6 @@ import { VolumeOnIcon, VolumeOffSolidIcon, VolumeOnSolidIcon, - VideoCallSolidIcon, - VoiceCallSolidIcon, - EndCallIcon, } from "@vector-im/compound-design-tokens/assets/web/icons"; import { animated } from "@react-spring/web"; import { type Observable, map } from "rxjs"; @@ -210,22 +207,10 @@ const SpotlightRingingMediaItem: FC = ({ vm, ...props }) => { - const { t } = useTranslation(); - const pickupState = useBehavior(vm.pickupState$); - return ( { muted?: boolean; } -interface UseAudioContext { +export interface UseAudioContext { playSound(soundName: S, volumeOverwrite?: number): Promise; playSoundLooping(soundName: S, delayS?: number): () => Promise; /**