Show ringing status even on spotlight tiles (except in app bar mode)

This commit is contained in:
Robin
2026-06-22 12:55:37 +02:00
parent 27abf816f5
commit a456e47796
8 changed files with 68 additions and 15 deletions

View File

@@ -333,7 +333,11 @@ export const InCallView: FC<InCallViewProps> = ({
);
useAppBarHidden(!showHeader);
useAppBarSubtitle(ringingVm && <RingingStatus vm={ringingVm} />);
useAppBarSubtitle(
ringingVm && vm.ringingStatusLocation === "app_bar" && (
<RingingStatus vm={ringingVm} />
),
);
let header: ReactNode = null;
switch (headerStyle) {
@@ -428,6 +432,7 @@ export const InCallView: FC<InCallViewProps> = ({
);
const showSpeakingIndicators = useBehavior(vm.showSpeakingIndicators$);
const showNameTags = useBehavior(vm.showNameTags$);
const showRingingStatus = vm.ringingStatusLocation === "tile";
return model instanceof GridTileViewModel ? (
<GridTile
@@ -440,6 +445,7 @@ export const InCallView: FC<InCallViewProps> = ({
style={style}
showSpeakingIndicators={showSpeakingIndicators}
showNameTags={showNameTags}
showRingingStatus={showRingingStatus}
focusable={!contentObscured}
/>
) : (
@@ -452,6 +458,7 @@ export const InCallView: FC<InCallViewProps> = ({
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<InCallViewProps> = ({
targetHeight={gridBounds.height}
showIndicators={false}
showNameTags={showNameTags}
showRingingStatus={vm.ringingStatusLocation === "tile"}
focusable={!contentObscured}
aria-hidden={contentObscured}
/>

View File

@@ -232,6 +232,10 @@ export interface CallViewModel {
* View model for info relating to ringing, timing out, calling back, etc.
*/
ringingVm$: Behavior<RingingMediaViewModel | null>;
/**
* 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,

View File

@@ -77,6 +77,7 @@ test("GridTile is accessible", async () => {
targetHeight={200}
showSpeakingIndicators
showNameTags
showRingingStatus
focusable
/>
</ReactionsSenderProvider>,
@@ -108,6 +109,7 @@ test("GridTile displays ringing media", async () => {
targetHeight={200}
showSpeakingIndicators
showNameTags
showRingingStatus
focusable
/>
</ReactionsSenderProvider>,

View File

@@ -67,10 +67,12 @@ interface TileProps {
interface RingingMediaTileProps extends TileProps {
vm: RingingMediaViewModel;
showStatus: boolean;
}
const RingingMediaTile: FC<RingingMediaTileProps> = ({
vm,
showStatus,
className,
...props
}) => {
@@ -81,10 +83,13 @@ const RingingMediaTile: FC<RingingMediaTileProps> = ({
userId={vm.userId}
unencryptedWarning={false}
status={
<Text as="span" size="sm" weight="medium">
<RingingStatus vm={vm} />
</Text>
showStatus && (
<Text as="span" size="sm" weight="medium">
<RingingStatus vm={vm} />
</Text>
)
}
avatarStyle="translucent"
videoEnabled={false}
videoFit="cover"
mirror={false}
@@ -392,6 +397,7 @@ interface GridTileProps {
style?: ComponentProps<typeof animated.div>["style"];
showSpeakingIndicators: boolean;
showNameTags: boolean;
showRingingStatus: boolean;
focusable: boolean;
}
@@ -399,6 +405,7 @@ export const GridTile: FC<GridTileProps> = ({
ref: theirRef,
vm,
showSpeakingIndicators,
showRingingStatus,
onOpenProfile,
...props
}) => {
@@ -415,6 +422,7 @@ export const GridTile: FC<GridTileProps> = ({
vm={media}
displayName={displayName}
mxcAvatarUrl={mxcAvatarUrl}
showStatus={showRingingStatus}
{...props}
/>
);

View File

@@ -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

View File

@@ -42,6 +42,7 @@ interface Props extends ComponentProps<typeof animated.div> {
nameTagLeadingIcon?: ReactNode;
displayName: string;
mxcAvatarUrl: string | undefined;
avatarStyle?: "solid" | "translucent";
focusable: boolean;
primaryButton?: ReactNode;
raisedHandTime?: Date;
@@ -71,6 +72,7 @@ export const MediaView: FC<Props> = ({
nameTagLeadingIcon,
displayName,
mxcAvatarUrl,
avatarStyle = "solid",
focusable,
primaryButton,
status,
@@ -124,11 +126,8 @@ export const MediaView: FC<Props> = ({
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 && (

View File

@@ -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
/>
</TooltipProvider>,
@@ -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");
});

View File

@@ -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<HTMLDivElement>;
@@ -201,16 +202,26 @@ const SpotlightMemberMediaItem: FC<SpotlightMemberMediaItemProps> = ({
interface SpotlightRingingMediaItemProps extends SpotlightItemBaseProps {
vm: RingingMediaViewModel;
showStatus: boolean;
}
const SpotlightRingingMediaItem: FC<SpotlightRingingMediaItemProps> = ({
vm,
showStatus,
...props
}) => {
return (
<MediaView
video={undefined}
unencryptedWarning={false}
status={
showStatus && (
<Text as="span" size="md" weight="medium">
<RingingStatus vm={vm} />
</Text>
)
}
avatarStyle="translucent"
videoEnabled={false}
videoFit="cover"
mirror={false}
@@ -231,6 +242,7 @@ interface SpotlightItemProps {
*/
targetHeight: number;
showNameTags: boolean;
showRingingStatus: boolean;
focusable: boolean;
intersectionObserver$: Observable<IntersectionObserver>;
/**
@@ -246,6 +258,7 @@ const SpotlightItem: FC<SpotlightItemProps> = ({
targetWidth,
targetHeight,
showNameTags,
showRingingStatus,
focusable,
intersectionObserver$,
snap,
@@ -287,7 +300,11 @@ const SpotlightItem: FC<SpotlightItemProps> = ({
};
return vm.type === "ringing" ? (
<SpotlightRingingMediaItem vm={vm} {...baseProps} />
<SpotlightRingingMediaItem
vm={vm}
showStatus={showRingingStatus}
{...baseProps}
/>
) : (
<SpotlightMemberMediaItem vm={vm} {...baseProps} />
);
@@ -371,6 +388,7 @@ interface Props {
targetHeight: number;
showIndicators: boolean;
showNameTags: boolean;
showRingingStatus: boolean;
focusable: boolean;
className?: string;
style?: ComponentProps<typeof animated.div>["style"];
@@ -385,6 +403,7 @@ export const SpotlightTile: FC<Props> = ({
targetHeight,
showIndicators,
showNameTags,
showRingingStatus,
focusable = true,
className,
style,
@@ -495,6 +514,7 @@ export const SpotlightTile: FC<Props> = ({
vm={vm}
targetWidth={targetWidth}
targetHeight={targetHeight}
showRingingStatus={showRingingStatus}
showNameTags={showNameTags}
focusable={focusable}
intersectionObserver$={intersectionObserver$}