Merge pull request #3885 from element-hq/toger5/bottom-bar-storybook

[Corrected merge target] Footer component -> Storybook
This commit is contained in:
Timo
2026-04-16 20:26:32 +08:00
committed by GitHub
23 changed files with 1019 additions and 381 deletions

View File

@@ -49,7 +49,6 @@ import { LazyEventEmitter } from "../LazyEventEmitter";
import { MatrixRTCTransportMissingError } from "../utils/errors";
import { ProcessorProvider } from "../livekit/TrackProcessorContext";
import { MediaDevicesContext } from "../MediaDevicesContext";
import { HeaderStyle } from "../UrlParams";
import { constant } from "../state/Behavior";
import { type MuteStates } from "../state/MuteStates.ts";
@@ -173,7 +172,6 @@ function createGroupCallView(
confineToRoom={false}
preload={false}
skipLobby={false}
header={HeaderStyle.Standard}
rtcSession={rtcSession.asMockedSession()}
muteStates={muteState}
widget={widget}

View File

@@ -93,7 +93,6 @@ interface Props {
confineToRoom: boolean;
preload: UrlParams["preload"];
skipLobby: UrlParams["skipLobby"];
header: HeaderStyle;
rtcSession: MatrixRTCSession;
joined: boolean;
setJoined: (value: boolean) => void;
@@ -107,7 +106,6 @@ export const GroupCallView: FC<Props> = ({
confineToRoom,
preload,
skipLobby,
header,
rtcSession,
joined,
setJoined,
@@ -182,6 +180,7 @@ export const GroupCallView: FC<Props> = ({
perParticipantE2EE,
returnToLobby,
password: passwordFromUrl,
header,
} = useUrlParams();
const e2eeSystem = useRoomEncryptionSystem(room.roomId);
@@ -437,7 +436,7 @@ export const GroupCallView: FC<Props> = ({
muteStates={muteStates}
onEnter={() => setJoined(true)}
confineToRoom={confineToRoom}
hideHeader={header === HeaderStyle.None}
hideHeader={header !== HeaderStyle.Standard}
participantCount={participantCount}
onShareClick={onShareClick}
/>
@@ -463,7 +462,6 @@ export const GroupCallView: FC<Props> = ({
rtcSession={rtcSession as MatrixRTCSession}
matrixRoom={room}
onLeft={onLeft}
header={header}
muteStates={muteStates}
e2eeSystem={e2eeSystem}
//otelGroupCallMembership={otelGroupCallMembership}

View File

@@ -31,144 +31,12 @@ Please see LICENSE in the repository root for full details.
background: none;
}
.footer {
position: sticky;
inset-block-end: 0;
z-index: var(--call-view-header-footer-layer);
display: grid;
grid-template-columns: 1fr auto 1fr;
grid-template-areas: ". buttons layout";
align-items: center;
gap: var(--cpd-space-3x);
padding: var(--cpd-space-10x) var(--cpd-space-6x);
background: linear-gradient(
180deg,
rgba(0, 0, 0, 0) 0%,
var(--cpd-color-bg-canvas-default) 100%
);
}
.footer.hidden {
display: none;
}
.footer.overlay {
/* Note that the footer is still position: sticky in this case so that certain
tiles can move up out of the way of the footer when visible. */
opacity: 1;
transition: opacity 0.15s;
}
.footer.overlay.hidden {
display: grid;
opacity: 0;
pointer-events: none;
/* Switch to position: absolute so the footer takes up no space in the layout
when hidden. */
position: absolute;
inset-block-end: 0;
inset-inline: 0;
}
.footer.overlay:has(:focus-visible) {
opacity: 1;
pointer-events: initial;
}
.settingsLogoContainer {
display: flex;
gap: var(--cpd-space-4x);
flex-direction: row;
flex-wrap: nowrap;
}
.logo {
justify-self: start;
display: flex;
align-items: center;
gap: var(--cpd-space-2x);
padding-inline-start: var(--cpd-space-1x);
}
.buttons {
grid-area: buttons;
justify-self: center;
display: flex;
gap: var(--cpd-space-3x);
}
.layout {
grid-area: layout;
justify-self: end;
}
/*First hide the logo*/
@media (max-width: 750px) {
.logo {
display: none;
}
}
/*
With the logo hidden >500px is enough space to show overflow, buttons, layout.
Once we exceed 500 we hide everything except the buttons.
*/
@media (max-width: 500px) {
.footer {
grid-template-areas: "buttons buttons buttons";
}
/*.settingsLogoContainer {
display: none;
}*/
.layout {
display: none !important;
}
}
@media (max-height: 800px) {
.footer {
padding-block: var(--cpd-space-8x);
}
}
@media (max-height: 400px) {
.footer {
padding-block: var(--cpd-space-4x);
}
}
@media (max-width: 370px) {
.shareScreen {
display: none;
}
/* PIP custom css */
@media (max-height: 400px) {
.shareScreen {
display: flex;
}
.footer {
padding-block-start: var(--cpd-space-3x);
padding-block-end: var(--cpd-space-2x);
}
}
}
@media (max-width: 320px) {
.invite,
.raiseHand {
.invite {
display: none;
}
}
@media (min-width: 800px) {
.buttons {
gap: var(--cpd-space-4x);
}
}
.fixedGrid {
position: absolute;
inline-size: 100%;

View File

@@ -17,7 +17,7 @@ import {
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";
import { BrowserRouter, MemoryRouter } from "react-router-dom";
import { TooltipProvider } from "@vector-im/compound-web";
import { RoomContext, useLocalParticipant } from "@livekit/components-react";
import userEvent from "@testing-library/user-event";
@@ -34,13 +34,15 @@ import {
} from "../utils/test";
import { E2eeType } from "../e2ee/e2eeType";
import { getBasicCallViewModelEnvironment } from "../utils/test-viewmodel";
import { type CallViewModelOptions } from "../state/CallViewModel/CallViewModel";
import { alice, local } from "../utils/test-fixtures";
import { ReactionsSenderProvider } from "../reactions/useReactionsSender";
import { useRoomEncryptionSystem } from "../e2ee/sharedKeyManagement";
import { LivekitRoomAudioRenderer } from "../livekit/MatrixAudioRenderer";
import { MediaDevicesContext } from "../MediaDevicesContext";
import { HeaderStyle } from "../UrlParams";
import { type MediaDevices as ECMediaDevices } from "../state/MediaDevices";
import { constant } from "../state/Behavior";
import { AppBar } from "../AppBar";
import { initializeWidget } from "../widget";
initializeWidget();
@@ -97,6 +99,11 @@ beforeEach(() => {
});
interface CreateInCallViewArgs {
mediaDevices?: ECMediaDevices;
callViewModelOptions?: Partial<CallViewModelOptions>;
/** If set, uses a MemoryRouter with this as the initial entry instead of BrowserRouter */
initialRoute?: string;
/** If true, wraps the rendered tree in an AppBar provider */
withAppBar?: boolean;
}
function createInCallView(args: CreateInCallViewArgs = {}): RenderResult & {
rtcSession: MockRTCSession;
@@ -115,47 +122,59 @@ function createInCallView(args: CreateInCallViewArgs = {}): RenderResult & {
[local, alice],
undefined,
mediaDevices,
{},
args.callViewModelOptions,
);
rtcSession.joined = true;
const room = rtcSession.room;
const client = room.client;
const Router = args.initialRoute
? ({ children }: { children: React.ReactNode }): React.ReactNode => (
<MemoryRouter initialEntries={[args.initialRoute!]}>
{children}
</MemoryRouter>
)
: BrowserRouter;
const inCallView = (
<InCallView
client={client}
rtcSession={rtcSession.asMockedSession()}
muteStates={muteState}
vm={vm}
matrixInfo={{
userId: "",
displayName: "",
avatarUrl: "",
roomId: "",
roomName: "",
roomAlias: null,
roomAvatar: null,
e2eeSystem: {
kind: E2eeType.NONE,
},
}}
matrixRoom={room}
onShareClick={null}
/>
);
const content = args.withAppBar ? <AppBar>{inCallView}</AppBar> : inCallView;
const renderResult = render(
<BrowserRouter>
<Router>
<MediaDevicesContext value={mediaDevices}>
<ReactionsSenderProvider
vm={vm}
rtcSession={rtcSession.asMockedSession()}
>
<TooltipProvider>
<RoomContext value={livekitRoom}>
<InCallView
client={client}
header={HeaderStyle.Standard}
rtcSession={rtcSession.asMockedSession()}
muteStates={muteState}
vm={vm}
matrixInfo={{
userId: "",
displayName: "",
avatarUrl: "",
roomId: "",
roomName: "",
roomAlias: null,
roomAvatar: null,
e2eeSystem: {
kind: E2eeType.NONE,
},
}}
matrixRoom={room}
onShareClick={null}
/>
</RoomContext>
<RoomContext value={livekitRoom}>{content}</RoomContext>
</TooltipProvider>
</ReactionsSenderProvider>
</MediaDevicesContext>
</BrowserRouter>,
</Router>,
);
return {
...renderResult,
@@ -170,6 +189,57 @@ describe("InCallView", () => {
expect(container).toMatchSnapshot();
});
});
describe("settings button with AppBar header", () => {
it("mobile landscape, is accessible when showHeader is false", () => {
// windowSize with height <= 600 results in "flat" windowMode,
// which means showHeader$ emits false.
const { getAllByRole } = createInCallView({
initialRoute: "/?header=app_bar",
withAppBar: true,
callViewModelOptions: {
// Set windowMode$ to "flat" (height <= 600)
windowSize$: constant({ width: 1000, height: 500 }),
},
});
// When showHeader is false, hideSettingsButton is false,
// so the settings button is visible in the footer.
const settingsBtn = getAllByRole("button", { name: "Settings" });
// here we check for two settings buttons because there are two buttons in the bottom bar. One for the
// the narrow layout and another one for the wide layout.
// Their visibility uses @media css queries, which cannot be tested in JSDOM,
// but we can at least check that both buttons are rendered and have the correct classes.
expect(settingsBtn.length).toBe(2);
expect(settingsBtn[0]).toHaveAttribute(
"data-testid",
"settings-bottom-left",
);
expect(settingsBtn[0]).toBeVisible();
});
it("mobile portrait, is accessible when showHeader is true", () => {
// windowSize with height > 600 and width > 600 results in "normal" windowMode,
// which means showHeader$ emits true.
const { getAllByRole } = createInCallView({
initialRoute: "/?header=app_bar",
withAppBar: true,
callViewModelOptions: {
// Set windowMode$ to "normal" (height >= 600)
windowSize$: constant({ width: 1000, height: 800 }),
},
});
// When showHeader is true and headerStyle is AppBar,
// hideSettingsButton is true in the footer, but the settings
// button is rendered in the AppBar via useAppBarSecondaryButton.
const settingsBtns = getAllByRole("button", { name: "Settings" });
expect(settingsBtns.length).toBe(1);
expect(settingsBtns[0]).toHaveAttribute(
"data-testid",
"settings-app-bar",
);
expect(settingsBtns[0]).toBeVisible();
});
});
describe("audioOutputSwitcher", () => {
it("is visible and can be clicked", async () => {
const user = userEvent.setup();
@@ -183,9 +253,10 @@ describe("InCallView", () => {
["earpiece-id", { type: "earpiece" }],
]),
);
const selected$ = new BehaviorSubject<
{ id: string; virtualEarpiece: boolean } | undefined
>({ id: "speaker-id", virtualEarpiece: false });
const selected$ = new BehaviorSubject({
id: "speaker-id",
virtualEarpiece: false,
});
const mediaDevices = mockMediaDevices({
audioOutput: {
@@ -197,8 +268,7 @@ describe("InCallView", () => {
const { getByRole } = createInCallView({ mediaDevices });
// The button should be visible. When current output is "speaker",
// the switcher targets "earpiece", so the tooltip label is "Handset".
const audioOutputBtn = getByRole("button", { name: "Handset" });
const audioOutputBtn = getByRole("button", { name: "Loudspeaker" });
expect(audioOutputBtn).toBeVisible();
await user.click(audioOutputBtn);

View File

@@ -5,7 +5,6 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { IconButton, Tooltip } from "@vector-im/compound-web";
import { type MatrixClient, type Room as MatrixRoom } from "matrix-js-sdk";
import {
type FC,
@@ -25,22 +24,8 @@ import classNames from "classnames";
import { BehaviorSubject, map } from "rxjs";
import { useObservable } from "observable-hooks";
import { logger as rootLogger } from "matrix-js-sdk/lib/logger";
import {
VoiceCallSolidIcon,
VolumeOnSolidIcon,
} from "@vector-im/compound-design-tokens/assets/web/icons";
import { useTranslation } from "react-i18next";
import LogoMark from "../icons/LogoMark.svg?react";
import LogoType from "../icons/LogoType.svg?react";
import {
EndCallButton,
MicButton,
VideoButton,
ShareScreenButton,
ReactionToggleButton,
SettingsIconButton,
} from "../button";
import { Header, LeftNav, RightNav, RoomHeaderInfo } from "../Header";
import { HeaderStyle, useUrlParams } from "../UrlParams";
import { useCallViewKeyboardShortcuts } from "../useCallViewKeyboardShortcuts";
@@ -55,7 +40,6 @@ import { useMergedRefs } from "../useMergedRefs";
import { type MuteStates } from "../state/MuteStates";
import { type MatrixInfo } from "./VideoPreview";
import { InviteButton } from "../button/InviteButton";
import { LayoutToggle } from "./LayoutToggle";
import {
type CallViewModel,
createCallViewModel$,
@@ -106,6 +90,8 @@ import { useTrackProcessorObservable$ } from "../livekit/TrackProcessorContext.t
import { type Layout } from "../state/layout-types.ts";
import { ObservableScope } from "../state/ObservableScope.ts";
import { useLatest } from "../useLatest.ts";
import { CallFooter } from "../components/CallFooter.tsx";
import { SettingsIconButton } from "../button/Button.tsx";
const logger = rootLogger.getChild("[InCallView]");
@@ -185,7 +171,6 @@ export interface InCallViewProps {
rtcSession: MatrixRTCSession;
matrixRoom: MatrixRoom;
muteStates: MuteStates;
header: HeaderStyle;
onShareClick: (() => void) | null;
}
@@ -195,8 +180,6 @@ export const InCallView: FC<InCallViewProps> = ({
matrixInfo,
matrixRoom,
muteStates,
header: headerStyle,
onShareClick,
}) => {
const { t } = useTranslation();
@@ -220,7 +203,7 @@ export const InCallView: FC<InCallViewProps> = ({
// Merge the refs so they can attach to the same element
const containerRef = useMergedRefs(containerRef1, containerRef2);
const { showControls } = useUrlParams();
const { showControls, header: headerStyle } = useUrlParams();
const muteAllAudio = useBehavior(muteAllAudio$);
@@ -378,7 +361,11 @@ export const InCallView: FC<InCallViewProps> = ({
let header: ReactNode = null;
if (showHeader) {
switch (headerStyle) {
case "none":
case HeaderStyle.AppBar: {
// dont build a header here. The AppBar will take care of it.
break;
}
case HeaderStyle.None:
// Cosmetic header to fill out space while still affecting the bounds of
// the grid
header = (
@@ -388,7 +375,7 @@ export const InCallView: FC<InCallViewProps> = ({
/>
);
break;
case "standard":
case HeaderStyle.Standard:
header = (
<Header
className={styles.header}
@@ -575,138 +562,46 @@ export const InCallView: FC<InCallViewProps> = ({
matrixRoom.roomId,
);
const buttons: JSX.Element[] = [];
const buttonSize = layout.type === "pip" ? "sm" : "lg";
buttons.push(
<MicButton
size={buttonSize}
key="audio"
enabled={audioEnabled}
onClick={toggleAudio ?? undefined}
disabled={toggleAudio === null}
data-testid="incall_mute"
/>,
<VideoButton
size={buttonSize}
key="video"
enabled={videoEnabled}
onClick={toggleVideo ?? undefined}
disabled={toggleVideo === null}
data-testid="incall_videomute"
/>,
);
if (vm.toggleScreenSharing !== null) {
buttons.push(
<ShareScreenButton
size={buttonSize}
key="share_screen"
className={styles.shareScreen}
enabled={sharingScreen}
onClick={vm.toggleScreenSharing}
data-testid="incall_screenshare"
/>,
);
}
if (supportsReactions) {
buttons.push(
<ReactionToggleButton
size={buttonSize}
vm={vm}
key="raise_hand"
className={styles.raiseHand}
identifier={`${client.getUserId()}:${client.getDeviceId()}`}
/>,
);
}
// In this PR we just move the button ot the bottom bar. We do not yet update its apperance
const audioOutputButton = useMemo(() => {
if (audioOutputSwitcher === null) return null;
const isEarpieceTarget = audioOutputSwitcher.targetOutput === "earpiece";
const Icon = isEarpieceTarget ? VoiceCallSolidIcon : VolumeOnSolidIcon;
const label = isEarpieceTarget
? t("settings.devices.handset")
: t("settings.devices.loudspeaker");
return (
<Tooltip label={label}>
<IconButton
key="audio_output_switcher"
onClick={(e) => {
audioOutputSwitcher.switch();
}}
>
<Icon />
</IconButton>
</Tooltip>
);
}, [t, audioOutputSwitcher]);
if (audioOutputButton) buttons.push(audioOutputButton);
const settingsButtonInAppBar =
headerStyle === HeaderStyle.AppBar && showHeader;
useAppBarSecondaryButton(
<SettingsIconButton key="settings" onClick={openSettings} />,
);
buttons.push(
<EndCallButton
size={buttonSize}
key="end_call"
onClick={function (): void {
vm.hangup();
}}
data-testid="incall_leave"
<SettingsIconButton
key="settings"
onClick={openSettings}
data-testid="settings-app-bar"
/>,
);
const logo = (
<div className={styles.logo}>
<LogoMark width={24} height={24} aria-hidden />
<LogoType
width={80}
height={11}
aria-label={import.meta.env.VITE_PRODUCT_NAME || "Element Call"}
/>
{/* Don't mind this odd placement, it's just a little debug label */}
{debugTileLayout ? `Tiles generation: ${tileStoreGeneration}` : undefined}
</div>
);
// Only hide the settings button if we have an AppBar header and we are showing the header
const footer = (
<div
<CallFooter
ref={footerRef}
className={classNames(styles.footer, {
[styles.overlay]: windowMode === "flat",
[styles.hidden]:
!showFooter || (!showControls && headerStyle === "none"),
})}
>
<div className={styles.settingsLogoContainer}>
{showControls &&
// Settings button is also shown in the app bar if present
headerStyle !== HeaderStyle.AppBar &&
layout.type !== "pip" && (
<SettingsIconButton
kind="secondary"
key="settings"
onClick={openSettings}
/>
)}
{headerStyle !== "none" && logo}
</div>
{showControls && <div className={styles.buttons}>{buttons}</div>}
{showControls && (
<LayoutToggle
className={styles.layout}
layout={gridMode}
setLayout={setGridMode}
/>
)}
</div>
hidden={!showFooter}
hideControls={!showControls}
asOverlay={windowMode === "flat"}
asPip={layout.type === "pip"}
// Hide the logo for both embedded solutions. mobile: HeaderStyle.AppBar and desktop: HeaderStyle.None.
hideLogo={headerStyle !== HeaderStyle.Standard}
layoutMode={gridMode}
setLayoutMode={setGridMode}
audioEnabled={audioEnabled}
toggleAudio={toggleAudio ?? undefined}
videoEnabled={videoEnabled}
toggleVideo={toggleVideo ?? undefined}
sharingScreen={sharingScreen}
toggleScreenSharing={vm.toggleScreenSharing ?? undefined}
reactionIdentifier={`${client.getUserId()}:${client.getDeviceId()}`}
reactionData={supportsReactions ? vm : undefined}
audioOutputSwitcher={audioOutputSwitcher ?? undefined}
// Only pass the openSettings function if the settings button is not in the app bar.
// If there is no fn the button will be hidden in the footer.
openSettings={settingsButtonInAppBar ? undefined : openSettings}
hangup={vm.hangup}
//Debug props
debugTileLayout={debugTileLayout}
tileStoreGeneration={tileStoreGeneration}
/>
);
const allConnections = useBehavior(vm.allConnections$);
return (

View File

@@ -33,7 +33,7 @@ export const LayoutToggle: FC<Props> = ({ layout, setLayout, className }) => {
);
return (
<div className={classNames(styles.toggle, className)}>
<form className={classNames(styles.toggle, className)}>
<Tooltip label={t("layout_spotlight_label")}>
<input
type="radio"
@@ -54,6 +54,6 @@ export const LayoutToggle: FC<Props> = ({ layout, setLayout, className }) => {
/>
</Tooltip>
<GridIcon aria-hidden width={24} height={24} />
</div>
</form>
);
};

View File

@@ -33,12 +33,6 @@ import { Header, LeftNav, RightNav, RoomHeaderInfo } from "../Header";
import { type MatrixInfo, VideoPreview } from "./VideoPreview";
import { type MuteStates } from "../state/MuteStates";
import { InviteButton } from "../button/InviteButton";
import {
EndCallButton,
MicButton,
SettingsIconButton,
VideoButton,
} from "../button/Button";
import { SettingsModal, defaultSettingsTab } from "../settings/SettingsModal";
import { useMediaQuery } from "../useMediaQuery";
import { E2eeType } from "../e2ee/e2eeType";
@@ -52,6 +46,7 @@ import {
import { usePageTitle } from "../usePageTitle";
import { getValue } from "../utils/observable";
import { useBehavior } from "../useBehavior";
import { CallFooter } from "../components/CallFooter";
interface Props {
client: MatrixClient;
@@ -226,23 +221,18 @@ export const LobbyView: FC<Props> = ({
</VideoPreview>
{!recentsButtonInFooter && recentsButton}
</div>
<div className={inCallStyles.footer}>
<CallFooter
audioEnabled={audioEnabled}
videoEnabled={videoEnabled}
toggleAudio={toggleAudio ?? undefined}
toggleVideo={toggleVideo ?? undefined}
openSettings={openSettings}
hangup={!confineToRoom ? onLeaveClick : undefined}
// Logo and header are connected. We will only show the logo in SPA with header.
hideLogo={hideHeader}
>
{recentsButtonInFooter && recentsButton}
<SettingsIconButton kind="secondary" onClick={openSettings} />
<div className={inCallStyles.buttons}>
<MicButton
enabled={audioEnabled}
onClick={toggleAudio ?? undefined}
disabled={toggleAudio === null}
/>
<VideoButton
enabled={videoEnabled}
onClick={toggleVideo ?? undefined}
disabled={toggleVideo === null}
/>
{!confineToRoom && <EndCallButton onClick={onLeaveClick} />}
</div>
</div>
</CallFooter>
</div>
{client && (
<SettingsModal

View File

@@ -132,7 +132,6 @@ export const RoomPage: FC = (): ReactNode => {
confineToRoom={confineToRoom}
preload={preload}
skipLobby={skipLobby || wasInWaitForInviteState.current}
header={header}
muteStates={muteStates}
/>
)

View File

@@ -170,8 +170,9 @@ exports[`InCallView > rendering > renders 1`] = `
>
<button
aria-labelledby="_r_8_"
class="_icon-button_1215g_8"
class="_icon-button_1215g_8 settingsOnlyShowWide"
data-kind="secondary"
data-testid="settings-bottom-left"
role="button"
style="--cpd-icon-button-size: 32px;"
tabindex="0"
@@ -302,10 +303,32 @@ exports[`InCallView > rendering > renders 1`] = `
<div
class="buttons"
>
<button
aria-labelledby="_r_d_"
class="_button_13vu4_8 settingsOnlyShowNarrow _has-icon_13vu4_60 _icon-only_13vu4_53"
data-kind="secondary"
data-size="lg"
data-testid="settings-bottom-center"
role="button"
tabindex="0"
>
<svg
aria-hidden="true"
fill="currentColor"
height="24"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M6 14q-.824 0-1.412-.588A1.93 1.93 0 0 1 4 12q0-.825.588-1.412A1.93 1.93 0 0 1 6 10q.824 0 1.412.588Q8 11.175 8 12t-.588 1.412A1.93 1.93 0 0 1 6 14m6 0q-.825 0-1.412-.588A1.93 1.93 0 0 1 10 12q0-.825.588-1.412A1.93 1.93 0 0 1 12 10q.825 0 1.412.588Q14 11.175 14 12t-.588 1.412A1.93 1.93 0 0 1 12 14m6 0q-.824 0-1.413-.588A1.93 1.93 0 0 1 16 12q0-.825.587-1.412A1.93 1.93 0 0 1 18 10q.824 0 1.413.588Q20 11.175 20 12t-.587 1.412A1.93 1.93 0 0 1 18 14"
/>
</svg>
</button>
<button
aria-checked="false"
aria-disabled="true"
aria-labelledby="_r_d_"
aria-labelledby="_r_i_"
class="_button_13vu4_8 _has-icon_13vu4_60 _icon-only_13vu4_53"
data-kind="secondary"
data-size="lg"
@@ -329,7 +352,7 @@ exports[`InCallView > rendering > renders 1`] = `
<button
aria-checked="false"
aria-disabled="true"
aria-labelledby="_r_i_"
aria-labelledby="_r_n_"
class="_button_13vu4_8 _has-icon_13vu4_60 _icon-only_13vu4_53"
data-kind="secondary"
data-size="lg"
@@ -351,7 +374,7 @@ exports[`InCallView > rendering > renders 1`] = `
</svg>
</button>
<button
aria-labelledby="_r_n_"
aria-labelledby="_r_s_"
class="_button_13vu4_8 endCall _has-icon_13vu4_60 _icon-only_13vu4_53 _destructive_13vu4_110"
data-kind="primary"
data-size="lg"
@@ -373,11 +396,11 @@ exports[`InCallView > rendering > renders 1`] = `
</svg>
</button>
</div>
<div
<form
class="toggle layout"
>
<input
aria-labelledby="_r_s_"
aria-labelledby="_r_11_"
name="layout"
type="radio"
value="spotlight"
@@ -395,7 +418,7 @@ exports[`InCallView > rendering > renders 1`] = `
/>
</svg>
<input
aria-labelledby="_r_11_"
aria-labelledby="_r_16_"
checked=""
name="layout"
type="radio"
@@ -413,7 +436,7 @@ exports[`InCallView > rendering > renders 1`] = `
d="M4 11a.97.97 0 0 1-.712-.287A.97.97 0 0 1 3 10V4q0-.424.288-.712A.97.97 0 0 1 4 3h6q.424 0 .713.288Q11 3.575 11 4v6q0 .424-.287.713A.97.97 0 0 1 10 11zm5-2V5H5v4zm5 12a.97.97 0 0 1-.713-.288A.97.97 0 0 1 13 20v-6q0-.424.287-.713A.97.97 0 0 1 14 13h6q.424 0 .712.287.288.288.288.713v6q0 .424-.288.712A.97.97 0 0 1 20 21zm5-2v-4h-4v4zM4 21a.97.97 0 0 1-.712-.288A.97.97 0 0 1 3 20v-6q0-.424.288-.713A.97.97 0 0 1 4 13h6q.424 0 .713.287.287.288.287.713v6q0 .424-.287.712A.97.97 0 0 1 10 21zm5-2v-4H5v4zm5-8a.97.97 0 0 1-.713-.287A.97.97 0 0 1 13 10V4q0-.424.287-.712A.97.97 0 0 1 14 3h6q.424 0 .712.288Q21 3.575 21 4v6q0 .424-.288.713A.97.97 0 0 1 20 11zm5-2V5h-4v4z"
/>
</svg>
</div>
</form>
</div>
</div>
</div>