diff --git a/src/button/Button.module.css b/src/button/Button.module.css
index 1b53e0dd..dc7573d4 100644
--- a/src/button/Button.module.css
+++ b/src/button/Button.module.css
@@ -8,3 +8,17 @@ Please see LICENSE in the repository root for full details.
.endCall > svg {
color: var(--stopgap-color-on-solid-accent);
}
+
+.rotate > svg {
+ animation: spin 1s linear infinite;
+}
+
+@keyframes spin {
+ from {
+ transform: rotate(0deg);
+ }
+
+ to {
+ transform: rotate(360deg);
+ }
+}
diff --git a/src/button/Button.test.tsx b/src/button/Button.test.tsx
new file mode 100644
index 00000000..c4a03406
--- /dev/null
+++ b/src/button/Button.test.tsx
@@ -0,0 +1,113 @@
+/*
+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 { describe, expect, test, vi } from "vitest";
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { TooltipProvider } from "@vector-im/compound-web";
+
+import { MicButton, VideoButton } from "./Button";
+
+describe("MicButton", () => {
+ test("calls onClick when not busy", async () => {
+ const user = userEvent.setup();
+ const onClick = vi.fn();
+ render(
+
+
+ ,
+ );
+
+ const button = screen.getByRole("switch");
+ await user.click(button);
+
+ expect(onClick).toHaveBeenCalled();
+ });
+
+ test("does not call onClick when busy", async () => {
+ const user = userEvent.setup();
+ const onClick = vi.fn();
+ render(
+
+
+ ,
+ );
+
+ const button = screen.getByRole("switch");
+ expect(button).toHaveAttribute("aria-disabled", "true");
+ expect(button).toHaveAttribute("aria-busy", "true");
+
+ await user.click(button);
+ expect(onClick).not.toHaveBeenCalled();
+ });
+
+ test("does not call onClick when disabled", async () => {
+ const user = userEvent.setup();
+ const onClick = vi.fn();
+ render(
+
+
+ ,
+ );
+
+ const button = screen.getByRole("switch");
+ expect(button).toHaveAttribute("aria-disabled", "true");
+
+ await user.click(button);
+ expect(onClick).not.toHaveBeenCalled();
+ });
+});
+
+describe("VideoButton", () => {
+ test("calls onClick when not busy", async () => {
+ const user = userEvent.setup();
+ const onClick = vi.fn();
+ render(
+
+
+ ,
+ );
+
+ const button = screen.getByRole("switch");
+ await user.click(button);
+
+ expect(onClick).toHaveBeenCalled();
+ });
+
+ test("does not call onClick when busy", async () => {
+ const user = userEvent.setup();
+ const onClick = vi.fn();
+ render(
+
+
+ ,
+ );
+
+ const button = screen.getByRole("switch");
+ expect(button).toHaveAttribute("aria-disabled", "true");
+ expect(button).toHaveAttribute("aria-busy", "true");
+
+ await user.click(button);
+ expect(onClick).not.toHaveBeenCalled();
+ });
+
+ test("does not call onClick when disabled", async () => {
+ const user = userEvent.setup();
+ const onClick = vi.fn();
+ render(
+
+
+ ,
+ );
+
+ const button = screen.getByRole("switch");
+ expect(button).toHaveAttribute("aria-disabled", "true");
+
+ await user.click(button);
+ expect(onClick).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/button/Button.tsx b/src/button/Button.tsx
index 40360ce9..e639e76e 100644
--- a/src/button/Button.tsx
+++ b/src/button/Button.tsx
@@ -16,6 +16,7 @@ import {
import {
MicOnSolidIcon,
MicOffSolidIcon,
+ SpinnerIcon,
VideoCallSolidIcon,
VideoCallOffSolidIcon,
EndCallIcon,
@@ -32,12 +33,13 @@ import { platform } from "../Platform";
interface MicButtonProps extends ComponentPropsWithoutRef<"button"> {
enabled: boolean;
+ busy?: boolean;
size?: "md" | "lg";
}
-export const MicButton: FC = ({ enabled, ...props }) => {
+export const MicButton: FC = ({ enabled, busy, ...props }) => {
const { t } = useTranslation();
- const Icon = enabled ? MicOnSolidIcon : MicOffSolidIcon;
+ const Icon = busy ? SpinnerIcon : enabled ? MicOnSolidIcon : MicOffSolidIcon;
const label = enabled
? t("mute_microphone_button_label")
: t("unmute_microphone_button_label");
@@ -51,6 +53,11 @@ export const MicButton: FC = ({ enabled, ...props }) => {
role="switch"
aria-checked={enabled}
{...props}
+ aria-busy={busy}
+ className={classNames(props.className, {
+ [styles.rotate]: !!busy,
+ })}
+ disabled={props.disabled || busy}
/>
);
@@ -58,12 +65,21 @@ export const MicButton: FC = ({ enabled, ...props }) => {
interface VideoButtonProps extends ComponentPropsWithoutRef<"button"> {
enabled: boolean;
+ busy?: boolean;
size?: "md" | "lg";
}
-export const VideoButton: FC = ({ enabled, ...props }) => {
+export const VideoButton: FC = ({
+ enabled,
+ busy,
+ ...props
+}) => {
const { t } = useTranslation();
- const Icon = enabled ? VideoCallSolidIcon : VideoCallOffSolidIcon;
+ const Icon = busy
+ ? SpinnerIcon
+ : enabled
+ ? VideoCallSolidIcon
+ : VideoCallOffSolidIcon;
const label = enabled
? t("stop_video_button_label")
: t("start_video_button_label");
@@ -77,6 +93,11 @@ export const VideoButton: FC = ({ enabled, ...props }) => {
role="switch"
aria-checked={enabled}
{...props}
+ aria-busy={busy}
+ className={classNames(props.className, {
+ [styles.rotate]: !!busy,
+ })}
+ disabled={props.disabled || busy}
/>
);
diff --git a/src/components/CallFooter.stories.tsx b/src/components/CallFooter.stories.tsx
index 7006d809..a6b509fa 100644
--- a/src/components/CallFooter.stories.tsx
+++ b/src/components/CallFooter.stories.tsx
@@ -80,7 +80,9 @@ export const Default: Story = {
showLogo: false,
layoutMode: "grid",
audioEnabled: true,
+ audioBusy: false,
videoEnabled: true,
+ videoBusy: false,
setLayoutMode: fn(),
openSettings: fn(),
toggleAudio: fn(),
@@ -152,6 +154,26 @@ export const WithAudioAndVideoOptions: Story = {
selectedVideo: "1",
},
};
+
+export const AudioBusy: Story = {
+ ...Default,
+ args: {
+ ...Default.args,
+ audioEnabled: true,
+ audioBusy: true,
+ videoEnabled: true,
+ },
+};
+
+export const VideoBusy: Story = {
+ ...Default,
+ args: {
+ ...Default.args,
+ audioEnabled: true,
+ videoEnabled: true,
+ videoBusy: true,
+ },
+};
export const WithLogo: Story = {
...Default,
args: {
diff --git a/src/components/CallFooter.tsx b/src/components/CallFooter.tsx
index 7dd68d88..f952601d 100644
--- a/src/components/CallFooter.tsx
+++ b/src/components/CallFooter.tsx
@@ -72,7 +72,9 @@ export interface FooterActions {
// we do not use any ? optional properties so that the vm type is including all fields.
export interface FooterState {
audioEnabled: boolean;
+ audioBusy: boolean;
videoEnabled: boolean;
+ videoBusy: boolean;
videoBlurEnabled: boolean;
showFooter: boolean;
@@ -122,7 +124,9 @@ export const CallFooter: FC = ({ ref, children, vm }) => {
const setLayoutMode = useBehavior(vm.setLayoutMode$);
const openSettings = useBehavior(vm.openSettings$);
const audioEnabled = useBehavior(vm.audioEnabled$);
+ const audioBusy = useBehavior(vm.audioBusy$);
const videoEnabled = useBehavior(vm.videoEnabled$);
+ const videoBusy = useBehavior(vm.videoBusy$);
const toggleAudio = useBehavior(vm.toggleAudio$);
const toggleVideo = useBehavior(vm.toggleVideo$);
const sharingScreen = useBehavior(vm.sharingScreen$);
@@ -167,6 +171,7 @@ export const CallFooter: FC = ({ ref, children, vm }) => {
key="audio"
iconsAndLabels="audio"
enabled={audioEnabled ?? false}
+ busy={audioBusy ?? false}
onMuteClick={toggleAudio}
data-testid="incall_mute"
options={audioOptions}
@@ -180,8 +185,9 @@ export const CallFooter: FC = ({ ref, children, vm }) => {
size={buttonSize}
key="audio"
enabled={audioEnabled ?? false}
+ busy={audioBusy ?? false}
onClick={toggleAudio}
- disabled={toggleAudio === undefined}
+ disabled={(audioBusy ?? false) || toggleAudio === undefined}
data-testid="incall_mute"
/>,
);
@@ -194,6 +200,7 @@ export const CallFooter: FC = ({ ref, children, vm }) => {
key="video"
iconsAndLabels="video"
enabled={videoEnabled ?? false}
+ busy={videoBusy ?? false}
onMuteClick={toggleVideo}
options={videoOptions}
selectedOption={selectedVideo}
@@ -208,8 +215,9 @@ export const CallFooter: FC = ({ ref, children, vm }) => {
size={buttonSize}
key="video"
enabled={videoEnabled ?? false}
+ busy={videoBusy ?? false}
onClick={toggleVideo}
- disabled={toggleVideo === undefined}
+ disabled={(videoBusy ?? false) || toggleVideo === undefined}
data-testid="incall_videomute"
/>,
);
diff --git a/src/components/CallFooterViewModel.tsx b/src/components/CallFooterViewModel.tsx
index ec4d4800..bffef9b5 100644
--- a/src/components/CallFooterViewModel.tsx
+++ b/src/components/CallFooterViewModel.tsx
@@ -32,14 +32,21 @@ function buildMuteBehaviors(
muteStates: MuteStates,
): Pick<
ViewModel,
- "audioEnabled$" | "toggleAudio$" | "videoEnabled$" | "toggleVideo$"
+ | "audioEnabled$"
+ | "audioBusy$"
+ | "toggleAudio$"
+ | "videoEnabled$"
+ | "videoBusy$"
+ | "toggleVideo$"
> {
return {
audioEnabled$: muteStates.audio.enabled$,
+ audioBusy$: muteStates.audio.syncing$,
toggleAudio$: scope.behavior(
muteStates.audio.toggle$.pipe(map((t) => t ?? undefined)),
),
videoEnabled$: muteStates.video.enabled$,
+ videoBusy$: muteStates.video.syncing$,
toggleVideo$: scope.behavior(
muteStates.video.toggle$.pipe(map((t) => t ?? undefined)),
),
@@ -252,7 +259,9 @@ export function createLobbyFooterViewModel(
setLayoutMode: undefined,
toggleScreenSharing: undefined,
audioEnabled: undefined,
+ audioBusy: false,
videoEnabled: undefined,
+ videoBusy: false,
layoutMode: undefined,
sharingScreen: false,
audioOutputSwitcher: undefined,
diff --git a/src/components/MediaMuteAndSwitchButton.test.tsx b/src/components/MediaMuteAndSwitchButton.test.tsx
index ac6540e0..d9bcee1e 100644
--- a/src/components/MediaMuteAndSwitchButton.test.tsx
+++ b/src/components/MediaMuteAndSwitchButton.test.tsx
@@ -93,6 +93,48 @@ describe("MediaMuteAndSwitchButton", () => {
expect(onMute).toHaveBeenCalled();
});
+ test("disables mute button while busy", async () => {
+ const user = userEvent.setup();
+ const onMute = vi.fn();
+ const { getByRole } = renderComponent(
+ ,
+ );
+
+ const muteButton = getByRole("switch", { name: "Mute microphone" });
+ expect(muteButton).toHaveAttribute("aria-disabled", "true");
+ expect(muteButton).toHaveAttribute("aria-busy", "true");
+
+ await user.click(muteButton);
+ expect(onMute).not.toHaveBeenCalled();
+ });
+
+ test("disables video button while busy", async () => {
+ const user = userEvent.setup();
+ const onMute = vi.fn();
+ const { getByRole } = renderComponent(
+ ,
+ );
+
+ const videoButton = getByRole("switch", { name: "Stop video" });
+ expect(videoButton).toHaveAttribute("aria-disabled", "true");
+ expect(videoButton).toHaveAttribute("aria-busy", "true");
+
+ await user.click(videoButton);
+ expect(onMute).not.toHaveBeenCalled();
+ });
+
test("requests device names when opened", async () => {
const user = userEvent.setup();
const requestDeviceNames = vi.fn();
diff --git a/src/components/MediaMuteAndSwitchButton.tsx b/src/components/MediaMuteAndSwitchButton.tsx
index e2309e1a..bd220330 100644
--- a/src/components/MediaMuteAndSwitchButton.tsx
+++ b/src/components/MediaMuteAndSwitchButton.tsx
@@ -40,6 +40,8 @@ export interface MediaMuteAndSwitchButtonProps {
enabled?: boolean;
/** Callback if the mute button is clicked */
onMuteClick?: () => void;
+ /** True while mute/unmute operation is syncing. */
+ busy?: boolean;
iconsAndLabels: "video" | "audio";
/** The options available for the media device selector modal */
options?: MenuOptions[];
@@ -59,6 +61,7 @@ const BLUR_ID = "blur";
export const MediaMuteAndSwitchButton: FC = ({
title,
enabled,
+ busy,
onMuteClick,
iconsAndLabels,
options,
@@ -69,6 +72,7 @@ export const MediaMuteAndSwitchButton: FC = ({
}) => {
const [plannedSelection, setPlannedSelection] = useState(null);
const [menuOpen, setMenuOpen] = useState(false);
+ const isBusy = busy ?? false;
const { t } = useTranslation();
const devices = useMediaDevices();
@@ -83,12 +87,13 @@ export const MediaMuteAndSwitchButton: FC = ({
button = (
{
onMuteClick?.();
e.preventDefault();
e.stopPropagation();
}}
- disabled={onMuteClick === undefined}
+ disabled={isBusy || onMuteClick === undefined}
data-testid="incall_videomute"
/>
);
@@ -106,12 +111,13 @@ export const MediaMuteAndSwitchButton: FC = ({
button = (
{
onMuteClick?.();
e.preventDefault();
e.stopPropagation();
}}
- disabled={onMuteClick === undefined}
+ disabled={isBusy || onMuteClick === undefined}
data-testid="incall_mute"
/>
);
diff --git a/src/components/__snapshots__/MediaMuteAndSwitchButton.test.tsx.snap b/src/components/__snapshots__/MediaMuteAndSwitchButton.test.tsx.snap
index ed8d2931..8fe77ef1 100644
--- a/src/components/__snapshots__/MediaMuteAndSwitchButton.test.tsx.snap
+++ b/src/components/__snapshots__/MediaMuteAndSwitchButton.test.tsx.snap
@@ -6,6 +6,7 @@ exports[`MediaMuteAndSwitchButton > renders 1`] = `
class="container"
>