diff --git a/src/AppBar.module.css b/src/AppBar.module.css index 13e3b759f..b36a26239 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: text-bottom; + } +} + +.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.test.tsx b/src/AppBar.test.tsx index a2cce6832..29380b97f 100644 --- a/src/AppBar.test.tsx +++ b/src/AppBar.test.tsx @@ -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. */ +import { type FC, type ReactNode } from "react"; import { render } from "@testing-library/react"; import { describe, expect, it } from "vitest"; import { TooltipProvider } from "@vector-im/compound-web"; -import { AppBar } from "./AppBar"; +import { AppBar, useAppBarSubtitle, useAppBarTitle } from "./AppBar"; + +const content =

This is the content.

; + +function snapshotAppBar(content: ReactNode): void { + const { container } = render( + + {content} + , + ); + expect(container).toMatchSnapshot(); +} describe("AppBar", () => { - it("renders", () => { - const { container } = render( - - -

This is the content.

-
-
, - ); - expect(container).toMatchSnapshot(); + it("renders", () => snapshotAppBar(content)); + + it("renders with title and subtitle", () => { + const TestComponent: FC = () => { + useAppBarTitle("Title"); + useAppBarSubtitle("Subtitle"); + return content; + }; + snapshotAppBar(); }); }); 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..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`] = `
-
- + + +
+ @@ -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 2b901fdc9..505b29e1e 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"; @@ -231,9 +229,13 @@ 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; + /** + * 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). * THIS DOES NOT LEAVE THE CALL YET. The only way to leave the call (send the hangup event) is * - by ending the scope @@ -1702,15 +1704,9 @@ 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$, + ringingStatusLocation: + urlParams.header === HeaderStyle.AppBar ? "app_bar" : "tile", leave$: leave$, hangup: (): void => userHangup$.next(), join: localMembership.requestJoinAndPublish, diff --git a/src/tile/GridTile.test.tsx b/src/tile/GridTile.test.tsx index c8ddcb32d..a44fcdfe4 100644 --- a/src/tile/GridTile.test.tsx +++ b/src/tile/GridTile.test.tsx @@ -77,6 +77,7 @@ test("GridTile is accessible", async () => { targetHeight={200} showSpeakingIndicators showNameTags + showRingingStatus focusable /> , @@ -108,6 +109,7 @@ test("GridTile displays ringing media", async () => { targetHeight={200} showSpeakingIndicators showNameTags + showRingingStatus focusable /> , diff --git a/src/tile/GridTile.tsx b/src/tile/GridTile.tsx index d974921b1..8e6096fb5 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; @@ -68,16 +67,15 @@ interface TileProps { interface RingingMediaTileProps extends TileProps { vm: RingingMediaViewModel; + showStatus: boolean; } const RingingMediaTile: FC = ({ vm, + showStatus, 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 } + showStatus && ( + + + + ) } + avatarStyle="translucent" videoEnabled={false} videoFit="cover" mirror={false} @@ -400,6 +397,7 @@ interface GridTileProps { style?: ComponentProps["style"]; showSpeakingIndicators: boolean; showNameTags: boolean; + showRingingStatus: boolean; focusable: boolean; } @@ -407,6 +405,7 @@ export const GridTile: FC = ({ ref: theirRef, vm, showSpeakingIndicators, + showRingingStatus, onOpenProfile, ...props }) => { @@ -423,6 +422,7 @@ export const GridTile: FC = ({ vm={media} displayName={displayName} mxcAvatarUrl={mxcAvatarUrl} + showStatus={showRingingStatus} {...props} /> ); diff --git a/src/tile/MediaView.module.css b/src/tile/MediaView.module.css index 240f14d19..5e7b3b28d 100644 --- a/src/tile/MediaView.module.css +++ b/src/tile/MediaView.module.css @@ -54,11 +54,12 @@ Please see LICENSE in the repository root for full details. left: 50%; transform: translate(-50%, -50%); pointer-events: none; + opacity: 100%; + transition: opacity 0.2s; } -.translucent { +.avatar[data-style="translucent"] { opacity: 50%; - mix-blend-mode: multiply; } /* 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 { 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); + svg { + color: var(--cpd-color-icon-tertiary); + vertical-align: text-bottom; + 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..f860f41eb 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,11 +37,12 @@ interface Props extends ComponentProps { userId: string; videoEnabled: boolean; unencryptedWarning: boolean; - status?: { text: string; Icon: ComponentType> }; + status?: ReactNode; showNameTags: boolean; nameTagLeadingIcon?: ReactNode; displayName: string; mxcAvatarUrl: string | undefined; + avatarStyle?: "solid" | "translucent"; focusable: boolean; primaryButton?: ReactNode; raisedHandTime?: Date; @@ -77,6 +72,7 @@ export const MediaView: FC = ({ nameTagLeadingIcon, displayName, mxcAvatarUrl, + avatarStyle = "solid", focusable, primaryButton, status, @@ -130,11 +126,8 @@ export const MediaView: FC = ({ name={displayName} size={avatarSize} src={mxcAvatarUrl} - className={classNames(styles.avatar, { - // When the avatar is overlaid with a status, make it translucent - // for readability - [styles.translucent]: status, - })} + data-style={avatarStyle} + className={styles.avatar} style={{ display: video && videoEnabled ? "none" : "initial" }} /> {video?.publication !== undefined && ( @@ -180,14 +173,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..69bdacf76 100644 --- a/src/tile/SpotlightTile.test.tsx +++ b/src/tile/SpotlightTile.test.tsx @@ -65,6 +65,7 @@ test("SpotlightTile is accessible", async () => { onToggleExpanded={toggleExpanded} showIndicators showNameTags + showRingingStatus focusable={true} />, ); @@ -107,6 +108,7 @@ test("Screen share volume UI is shown when screen share has audio", async () => onToggleExpanded={toggleExpanded} showIndicators showNameTags + showRingingStatus focusable /> , @@ -137,6 +139,7 @@ test("Screen share volume UI is hidden when screen share has no audio", async () onToggleExpanded={toggleExpanded} showIndicators showNameTags + showRingingStatus focusable />, ); @@ -172,6 +175,7 @@ test("SpotlightTile displays ringing media", async () => { onToggleExpanded={toggleExpanded} showIndicators showNameTags + showRingingStatus focusable={true} />, ); diff --git a/src/tile/SpotlightTile.tsx b/src/tile/SpotlightTile.tsx index 4612e9610..7f6b446a3 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"; @@ -34,7 +31,7 @@ import { useObservableRef } from "observable-hooks"; import { useTranslation } from "react-i18next"; import classNames from "classnames"; 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 FullScreenMinimiseIcon from "../icons/FullScreenMinimise.svg?react"; @@ -56,6 +53,7 @@ import { type MediaViewModel } from "../state/media/MediaViewModel"; import { Slider } from "../Slider"; import { platform } from "../Platform"; import { type RingingMediaViewModel } from "../state/media/RingingMediaViewModel"; +import { RingingStatus } from "./RingingStatus"; interface SpotlightItemBaseProps { ref?: Ref; @@ -204,28 +202,26 @@ const SpotlightMemberMediaItem: FC = ({ interface SpotlightRingingMediaItemProps extends SpotlightItemBaseProps { vm: RingingMediaViewModel; + showStatus: boolean; } const SpotlightRingingMediaItem: FC = ({ vm, + showStatus, ...props }) => { - const { t } = useTranslation(); - const pickupState = useBehavior(vm.pickupState$); - return ( + + + ) } + avatarStyle="translucent" videoEnabled={false} videoFit="cover" mirror={false} @@ -246,6 +242,7 @@ interface SpotlightItemProps { */ targetHeight: number; showNameTags: boolean; + showRingingStatus: boolean; focusable: boolean; intersectionObserver$: Observable; /** @@ -261,6 +258,7 @@ const SpotlightItem: FC = ({ targetWidth, targetHeight, showNameTags, + showRingingStatus, focusable, intersectionObserver$, snap, @@ -302,7 +300,11 @@ const SpotlightItem: FC = ({ }; return vm.type === "ringing" ? ( - + ) : ( ); @@ -386,6 +388,7 @@ interface Props { targetHeight: number; showIndicators: boolean; showNameTags: boolean; + showRingingStatus: boolean; focusable: boolean; className?: string; style?: ComponentProps["style"]; @@ -400,6 +403,7 @@ export const SpotlightTile: FC = ({ targetHeight, showIndicators, showNameTags, + showRingingStatus, focusable = true, className, style, @@ -510,6 +514,7 @@ export const SpotlightTile: FC = ({ vm={vm} targetWidth={targetWidth} targetHeight={targetHeight} + showRingingStatus={showRingingStatus} showNameTags={showNameTags} focusable={focusable} intersectionObserver$={intersectionObserver$} diff --git a/src/useAudioContext.tsx b/src/useAudioContext.tsx index 4a7c031ca..0bed082d1 100644 --- a/src/useAudioContext.tsx +++ b/src/useAudioContext.tsx @@ -113,7 +113,7 @@ interface Props { muted?: boolean; } -interface UseAudioContext { +export interface UseAudioContext { playSound(soundName: S, volumeOverwrite?: number): Promise; playSoundLooping(soundName: S, delayS?: number): () => Promise; /**