From a456e47796e400c96e28e300682b66116911961c Mon Sep 17 00:00:00 2001 From: Robin Date: Mon, 22 Jun 2026 12:55:37 +0200 Subject: [PATCH] Show ringing status even on spotlight tiles (except in app bar mode) --- src/room/InCallView.tsx | 10 +++++++++- src/state/CallViewModel/CallViewModel.ts | 6 ++++++ src/tile/GridTile.test.tsx | 2 ++ src/tile/GridTile.tsx | 14 +++++++++++--- src/tile/MediaView.module.css | 5 +++-- src/tile/MediaView.tsx | 9 ++++----- src/tile/SpotlightTile.test.tsx | 13 +++++++++++-- src/tile/SpotlightTile.tsx | 24 ++++++++++++++++++++++-- 8 files changed, 68 insertions(+), 15 deletions(-) diff --git a/src/room/InCallView.tsx b/src/room/InCallView.tsx index d395c1841..ca71ff726 100644 --- a/src/room/InCallView.tsx +++ b/src/room/InCallView.tsx @@ -333,7 +333,11 @@ export const InCallView: FC = ({ ); useAppBarHidden(!showHeader); - useAppBarSubtitle(ringingVm && ); + useAppBarSubtitle( + ringingVm && vm.ringingStatusLocation === "app_bar" && ( + + ), + ); let header: ReactNode = null; switch (headerStyle) { @@ -428,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} /> ) : ( @@ -452,6 +458,7 @@ export const InCallView: FC = ({ targetHeight={targetHeight} showIndicators={showSpotlightIndicators} showNameTags={showNameTags} + showRingingStatus={showRingingStatus} focusable={!contentObscured} className={classNames(className, styles.tile)} style={style} @@ -486,6 +493,7 @@ export const InCallView: FC = ({ targetHeight={gridBounds.height} showIndicators={false} showNameTags={showNameTags} + showRingingStatus={vm.ringingStatusLocation === "tile"} focusable={!contentObscured} aria-hidden={contentObscured} /> diff --git a/src/state/CallViewModel/CallViewModel.ts b/src/state/CallViewModel/CallViewModel.ts index 661d30790..505b29e1e 100644 --- a/src/state/CallViewModel/CallViewModel.ts +++ b/src/state/CallViewModel/CallViewModel.ts @@ -232,6 +232,10 @@ export interface CallViewModel { * View model for info relating to ringing, timing out, calling back, etc. */ 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 @@ -1701,6 +1705,8 @@ export function createCallViewModel$( return { autoLeave$: autoLeave$, 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 9e9a4172c..8e6096fb5 100644 --- a/src/tile/GridTile.tsx +++ b/src/tile/GridTile.tsx @@ -67,10 +67,12 @@ interface TileProps { interface RingingMediaTileProps extends TileProps { vm: RingingMediaViewModel; + showStatus: boolean; } const RingingMediaTile: FC = ({ vm, + showStatus, className, ...props }) => { @@ -81,10 +83,13 @@ const RingingMediaTile: FC = ({ userId={vm.userId} unencryptedWarning={false} status={ - - - + showStatus && ( + + + + ) } + avatarStyle="translucent" videoEnabled={false} videoFit="cover" mirror={false} @@ -392,6 +397,7 @@ interface GridTileProps { style?: ComponentProps["style"]; showSpeakingIndicators: boolean; showNameTags: boolean; + showRingingStatus: boolean; focusable: boolean; } @@ -399,6 +405,7 @@ export const GridTile: FC = ({ ref: theirRef, vm, showSpeakingIndicators, + showRingingStatus, onOpenProfile, ...props }) => { @@ -415,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 51d76c0d8..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 diff --git a/src/tile/MediaView.tsx b/src/tile/MediaView.tsx index ccd75c4f5..f860f41eb 100644 --- a/src/tile/MediaView.tsx +++ b/src/tile/MediaView.tsx @@ -42,6 +42,7 @@ interface Props extends ComponentProps { nameTagLeadingIcon?: ReactNode; displayName: string; mxcAvatarUrl: string | undefined; + avatarStyle?: "solid" | "translucent"; focusable: boolean; primaryButton?: ReactNode; raisedHandTime?: Date; @@ -71,6 +72,7 @@ export const MediaView: FC = ({ nameTagLeadingIcon, displayName, mxcAvatarUrl, + avatarStyle = "solid", focusable, primaryButton, status, @@ -124,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 && ( diff --git a/src/tile/SpotlightTile.test.tsx b/src/tile/SpotlightTile.test.tsx index 75c1de780..69bdacf76 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 { isInaccessible, render, screen } from "@testing-library/react"; +import { act, 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"; @@ -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,11 +175,17 @@ test("SpotlightTile displays ringing media", async () => { onToggleExpanded={toggleExpanded} showIndicators showNameTags + showRingingStatus focusable={true} />, ); expect(await axe(container)).toHaveNoViolations(); - // Alice should be in the spotlight + // Alice should be in the spotlight with the right status 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 944d3b995..7f6b446a3 100644 --- a/src/tile/SpotlightTile.tsx +++ b/src/tile/SpotlightTile.tsx @@ -31,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"; @@ -53,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; @@ -201,16 +202,26 @@ const SpotlightMemberMediaItem: FC = ({ interface SpotlightRingingMediaItemProps extends SpotlightItemBaseProps { vm: RingingMediaViewModel; + showStatus: boolean; } const SpotlightRingingMediaItem: FC = ({ vm, + showStatus, ...props }) => { return ( + + + ) + } + avatarStyle="translucent" videoEnabled={false} videoFit="cover" mirror={false} @@ -231,6 +242,7 @@ interface SpotlightItemProps { */ targetHeight: number; showNameTags: boolean; + showRingingStatus: boolean; focusable: boolean; intersectionObserver$: Observable; /** @@ -246,6 +258,7 @@ const SpotlightItem: FC = ({ targetWidth, targetHeight, showNameTags, + showRingingStatus, focusable, intersectionObserver$, snap, @@ -287,7 +300,11 @@ const SpotlightItem: FC = ({ }; return vm.type === "ringing" ? ( - + ) : ( ); @@ -371,6 +388,7 @@ interface Props { targetHeight: number; showIndicators: boolean; showNameTags: boolean; + showRingingStatus: boolean; focusable: boolean; className?: string; style?: ComponentProps["style"]; @@ -385,6 +403,7 @@ export const SpotlightTile: FC = ({ targetHeight, showIndicators, showNameTags, + showRingingStatus, focusable = true, className, style, @@ -495,6 +514,7 @@ export const SpotlightTile: FC = ({ vm={vm} targetWidth={targetWidth} targetHeight={targetHeight} + showRingingStatus={showRingingStatus} showNameTags={showNameTags} focusable={focusable} intersectionObserver$={intersectionObserver$}