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
+9 -1
View File
@@ -333,7 +333,11 @@ export const InCallView: FC<InCallViewProps> = ({
); );
useAppBarHidden(!showHeader); useAppBarHidden(!showHeader);
useAppBarSubtitle(ringingVm && <RingingStatus vm={ringingVm} />); useAppBarSubtitle(
ringingVm && vm.ringingStatusLocation === "app_bar" && (
<RingingStatus vm={ringingVm} />
),
);
let header: ReactNode = null; let header: ReactNode = null;
switch (headerStyle) { switch (headerStyle) {
@@ -428,6 +432,7 @@ export const InCallView: FC<InCallViewProps> = ({
); );
const showSpeakingIndicators = useBehavior(vm.showSpeakingIndicators$); const showSpeakingIndicators = useBehavior(vm.showSpeakingIndicators$);
const showNameTags = useBehavior(vm.showNameTags$); const showNameTags = useBehavior(vm.showNameTags$);
const showRingingStatus = vm.ringingStatusLocation === "tile";
return model instanceof GridTileViewModel ? ( return model instanceof GridTileViewModel ? (
<GridTile <GridTile
@@ -440,6 +445,7 @@ export const InCallView: FC<InCallViewProps> = ({
style={style} style={style}
showSpeakingIndicators={showSpeakingIndicators} showSpeakingIndicators={showSpeakingIndicators}
showNameTags={showNameTags} showNameTags={showNameTags}
showRingingStatus={showRingingStatus}
focusable={!contentObscured} focusable={!contentObscured}
/> />
) : ( ) : (
@@ -452,6 +458,7 @@ export const InCallView: FC<InCallViewProps> = ({
targetHeight={targetHeight} targetHeight={targetHeight}
showIndicators={showSpotlightIndicators} showIndicators={showSpotlightIndicators}
showNameTags={showNameTags} showNameTags={showNameTags}
showRingingStatus={showRingingStatus}
focusable={!contentObscured} focusable={!contentObscured}
className={classNames(className, styles.tile)} className={classNames(className, styles.tile)}
style={style} style={style}
@@ -486,6 +493,7 @@ export const InCallView: FC<InCallViewProps> = ({
targetHeight={gridBounds.height} targetHeight={gridBounds.height}
showIndicators={false} showIndicators={false}
showNameTags={showNameTags} showNameTags={showNameTags}
showRingingStatus={vm.ringingStatusLocation === "tile"}
focusable={!contentObscured} focusable={!contentObscured}
aria-hidden={contentObscured} aria-hidden={contentObscured}
/> />
+6
View File
@@ -232,6 +232,10 @@ export interface CallViewModel {
* View model for info relating to ringing, timing out, calling back, etc. * View model for info relating to ringing, timing out, calling back, etc.
*/ */
ringingVm$: Behavior<RingingMediaViewModel | null>; 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). /** 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 * THIS DOES NOT LEAVE THE CALL YET. The only way to leave the call (send the hangup event) is
* - by ending the scope * - by ending the scope
@@ -1701,6 +1705,8 @@ export function createCallViewModel$(
return { return {
autoLeave$: autoLeave$, autoLeave$: autoLeave$,
ringingVm$: ringingMedia$, ringingVm$: ringingMedia$,
ringingStatusLocation:
urlParams.header === HeaderStyle.AppBar ? "app_bar" : "tile",
leave$: leave$, leave$: leave$,
hangup: (): void => userHangup$.next(), hangup: (): void => userHangup$.next(),
join: localMembership.requestJoinAndPublish, join: localMembership.requestJoinAndPublish,
+2
View File
@@ -77,6 +77,7 @@ test("GridTile is accessible", async () => {
targetHeight={200} targetHeight={200}
showSpeakingIndicators showSpeakingIndicators
showNameTags showNameTags
showRingingStatus
focusable focusable
/> />
</ReactionsSenderProvider>, </ReactionsSenderProvider>,
@@ -108,6 +109,7 @@ test("GridTile displays ringing media", async () => {
targetHeight={200} targetHeight={200}
showSpeakingIndicators showSpeakingIndicators
showNameTags showNameTags
showRingingStatus
focusable focusable
/> />
</ReactionsSenderProvider>, </ReactionsSenderProvider>,
+11 -3
View File
@@ -67,10 +67,12 @@ interface TileProps {
interface RingingMediaTileProps extends TileProps { interface RingingMediaTileProps extends TileProps {
vm: RingingMediaViewModel; vm: RingingMediaViewModel;
showStatus: boolean;
} }
const RingingMediaTile: FC<RingingMediaTileProps> = ({ const RingingMediaTile: FC<RingingMediaTileProps> = ({
vm, vm,
showStatus,
className, className,
...props ...props
}) => { }) => {
@@ -81,10 +83,13 @@ const RingingMediaTile: FC<RingingMediaTileProps> = ({
userId={vm.userId} userId={vm.userId}
unencryptedWarning={false} unencryptedWarning={false}
status={ status={
<Text as="span" size="sm" weight="medium"> showStatus && (
<RingingStatus vm={vm} /> <Text as="span" size="sm" weight="medium">
</Text> <RingingStatus vm={vm} />
</Text>
)
} }
avatarStyle="translucent"
videoEnabled={false} videoEnabled={false}
videoFit="cover" videoFit="cover"
mirror={false} mirror={false}
@@ -392,6 +397,7 @@ interface GridTileProps {
style?: ComponentProps<typeof animated.div>["style"]; style?: ComponentProps<typeof animated.div>["style"];
showSpeakingIndicators: boolean; showSpeakingIndicators: boolean;
showNameTags: boolean; showNameTags: boolean;
showRingingStatus: boolean;
focusable: boolean; focusable: boolean;
} }
@@ -399,6 +405,7 @@ export const GridTile: FC<GridTileProps> = ({
ref: theirRef, ref: theirRef,
vm, vm,
showSpeakingIndicators, showSpeakingIndicators,
showRingingStatus,
onOpenProfile, onOpenProfile,
...props ...props
}) => { }) => {
@@ -415,6 +422,7 @@ export const GridTile: FC<GridTileProps> = ({
vm={media} vm={media}
displayName={displayName} displayName={displayName}
mxcAvatarUrl={mxcAvatarUrl} mxcAvatarUrl={mxcAvatarUrl}
showStatus={showRingingStatus}
{...props} {...props}
/> />
); );
+3 -2
View File
@@ -54,11 +54,12 @@ Please see LICENSE in the repository root for full details.
left: 50%; left: 50%;
transform: translate(-50%, -50%); transform: translate(-50%, -50%);
pointer-events: none; pointer-events: none;
opacity: 100%;
transition: opacity 0.2s;
} }
.translucent { .avatar[data-style="translucent"] {
opacity: 50%; opacity: 50%;
mix-blend-mode: multiply;
} }
/* CSS makes us put a condition here, even though all we want to do is /* CSS makes us put a condition here, even though all we want to do is
+4 -5
View File
@@ -42,6 +42,7 @@ interface Props extends ComponentProps<typeof animated.div> {
nameTagLeadingIcon?: ReactNode; nameTagLeadingIcon?: ReactNode;
displayName: string; displayName: string;
mxcAvatarUrl: string | undefined; mxcAvatarUrl: string | undefined;
avatarStyle?: "solid" | "translucent";
focusable: boolean; focusable: boolean;
primaryButton?: ReactNode; primaryButton?: ReactNode;
raisedHandTime?: Date; raisedHandTime?: Date;
@@ -71,6 +72,7 @@ export const MediaView: FC<Props> = ({
nameTagLeadingIcon, nameTagLeadingIcon,
displayName, displayName,
mxcAvatarUrl, mxcAvatarUrl,
avatarStyle = "solid",
focusable, focusable,
primaryButton, primaryButton,
status, status,
@@ -124,11 +126,8 @@ export const MediaView: FC<Props> = ({
name={displayName} name={displayName}
size={avatarSize} size={avatarSize}
src={mxcAvatarUrl} src={mxcAvatarUrl}
className={classNames(styles.avatar, { data-style={avatarStyle}
// When the avatar is overlaid with a status, make it translucent className={styles.avatar}
// for readability
[styles.translucent]: status,
})}
style={{ display: video && videoEnabled ? "none" : "initial" }} style={{ display: video && videoEnabled ? "none" : "initial" }}
/> />
{video?.publication !== undefined && ( {video?.publication !== undefined && (
+11 -2
View File
@@ -6,7 +6,7 @@ Please see LICENSE in the repository root for full details.
*/ */
import { test, expect, vi } from "vitest"; 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 { axe } from "vitest-axe";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
import { TooltipProvider } from "@vector-im/compound-web"; import { TooltipProvider } from "@vector-im/compound-web";
@@ -65,6 +65,7 @@ test("SpotlightTile is accessible", async () => {
onToggleExpanded={toggleExpanded} onToggleExpanded={toggleExpanded}
showIndicators showIndicators
showNameTags showNameTags
showRingingStatus
focusable={true} focusable={true}
/>, />,
); );
@@ -107,6 +108,7 @@ test("Screen share volume UI is shown when screen share has audio", async () =>
onToggleExpanded={toggleExpanded} onToggleExpanded={toggleExpanded}
showIndicators showIndicators
showNameTags showNameTags
showRingingStatus
focusable focusable
/> />
</TooltipProvider>, </TooltipProvider>,
@@ -137,6 +139,7 @@ test("Screen share volume UI is hidden when screen share has no audio", async ()
onToggleExpanded={toggleExpanded} onToggleExpanded={toggleExpanded}
showIndicators showIndicators
showNameTags showNameTags
showRingingStatus
focusable focusable
/>, />,
); );
@@ -172,11 +175,17 @@ test("SpotlightTile displays ringing media", async () => {
onToggleExpanded={toggleExpanded} onToggleExpanded={toggleExpanded}
showIndicators showIndicators
showNameTags showNameTags
showRingingStatus
focusable={true} focusable={true}
/>, />,
); );
expect(await axe(container)).toHaveNoViolations(); 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("Alice");
screen.getByText("Calling…");
// Now we time out ringing to Alice
act(() => pickupState$.next("timeout"));
screen.getByText("Call ended");
}); });
+22 -2
View File
@@ -31,7 +31,7 @@ import { useObservableRef } from "observable-hooks";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import classNames from "classnames"; import classNames from "classnames";
import { type TrackReferenceOrPlaceholder } from "@livekit/components-core"; 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 FullScreenMaximiseIcon from "../icons/FullScreenMaximise.svg?react";
import FullScreenMinimiseIcon from "../icons/FullScreenMinimise.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 { Slider } from "../Slider";
import { platform } from "../Platform"; import { platform } from "../Platform";
import { type RingingMediaViewModel } from "../state/media/RingingMediaViewModel"; import { type RingingMediaViewModel } from "../state/media/RingingMediaViewModel";
import { RingingStatus } from "./RingingStatus";
interface SpotlightItemBaseProps { interface SpotlightItemBaseProps {
ref?: Ref<HTMLDivElement>; ref?: Ref<HTMLDivElement>;
@@ -201,16 +202,26 @@ const SpotlightMemberMediaItem: FC<SpotlightMemberMediaItemProps> = ({
interface SpotlightRingingMediaItemProps extends SpotlightItemBaseProps { interface SpotlightRingingMediaItemProps extends SpotlightItemBaseProps {
vm: RingingMediaViewModel; vm: RingingMediaViewModel;
showStatus: boolean;
} }
const SpotlightRingingMediaItem: FC<SpotlightRingingMediaItemProps> = ({ const SpotlightRingingMediaItem: FC<SpotlightRingingMediaItemProps> = ({
vm, vm,
showStatus,
...props ...props
}) => { }) => {
return ( return (
<MediaView <MediaView
video={undefined} video={undefined}
unencryptedWarning={false} unencryptedWarning={false}
status={
showStatus && (
<Text as="span" size="md" weight="medium">
<RingingStatus vm={vm} />
</Text>
)
}
avatarStyle="translucent"
videoEnabled={false} videoEnabled={false}
videoFit="cover" videoFit="cover"
mirror={false} mirror={false}
@@ -231,6 +242,7 @@ interface SpotlightItemProps {
*/ */
targetHeight: number; targetHeight: number;
showNameTags: boolean; showNameTags: boolean;
showRingingStatus: boolean;
focusable: boolean; focusable: boolean;
intersectionObserver$: Observable<IntersectionObserver>; intersectionObserver$: Observable<IntersectionObserver>;
/** /**
@@ -246,6 +258,7 @@ const SpotlightItem: FC<SpotlightItemProps> = ({
targetWidth, targetWidth,
targetHeight, targetHeight,
showNameTags, showNameTags,
showRingingStatus,
focusable, focusable,
intersectionObserver$, intersectionObserver$,
snap, snap,
@@ -287,7 +300,11 @@ const SpotlightItem: FC<SpotlightItemProps> = ({
}; };
return vm.type === "ringing" ? ( return vm.type === "ringing" ? (
<SpotlightRingingMediaItem vm={vm} {...baseProps} /> <SpotlightRingingMediaItem
vm={vm}
showStatus={showRingingStatus}
{...baseProps}
/>
) : ( ) : (
<SpotlightMemberMediaItem vm={vm} {...baseProps} /> <SpotlightMemberMediaItem vm={vm} {...baseProps} />
); );
@@ -371,6 +388,7 @@ interface Props {
targetHeight: number; targetHeight: number;
showIndicators: boolean; showIndicators: boolean;
showNameTags: boolean; showNameTags: boolean;
showRingingStatus: boolean;
focusable: boolean; focusable: boolean;
className?: string; className?: string;
style?: ComponentProps<typeof animated.div>["style"]; style?: ComponentProps<typeof animated.div>["style"];
@@ -385,6 +403,7 @@ export const SpotlightTile: FC<Props> = ({
targetHeight, targetHeight,
showIndicators, showIndicators,
showNameTags, showNameTags,
showRingingStatus,
focusable = true, focusable = true,
className, className,
style, style,
@@ -495,6 +514,7 @@ export const SpotlightTile: FC<Props> = ({
vm={vm} vm={vm}
targetWidth={targetWidth} targetWidth={targetWidth}
targetHeight={targetHeight} targetHeight={targetHeight}
showRingingStatus={showRingingStatus}
showNameTags={showNameTags} showNameTags={showNameTags}
focusable={focusable} focusable={focusable}
intersectionObserver$={intersectionObserver$} intersectionObserver$={intersectionObserver$}