Merge pull request #4013 from element-hq/valere/fix_repeated_click_to_unmute

feat(mute): add syncing state and disable toggle during async mute
This commit is contained in:
Valere Fedronic
2026-06-05 13:32:48 +02:00
committed by Valere
parent c021fc1548
commit 1bf2a0917b
15 changed files with 386 additions and 34 deletions
+14
View File
@@ -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);
}
}
+113
View File
@@ -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(
<TooltipProvider>
<MicButton enabled={true} onClick={onClick} />
</TooltipProvider>,
);
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(
<TooltipProvider>
<MicButton enabled={true} busy={true} onClick={onClick} />
</TooltipProvider>,
);
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(
<TooltipProvider>
<MicButton enabled={true} disabled={true} onClick={onClick} />
</TooltipProvider>,
);
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(
<TooltipProvider>
<VideoButton enabled={true} onClick={onClick} />
</TooltipProvider>,
);
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(
<TooltipProvider>
<VideoButton enabled={true} busy={true} onClick={onClick} />
</TooltipProvider>,
);
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(
<TooltipProvider>
<VideoButton enabled={true} disabled={true} onClick={onClick} />
</TooltipProvider>,
);
const button = screen.getByRole("switch");
expect(button).toHaveAttribute("aria-disabled", "true");
await user.click(button);
expect(onClick).not.toHaveBeenCalled();
});
});
+25 -4
View File
@@ -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<MicButtonProps> = ({ enabled, ...props }) => {
export const MicButton: FC<MicButtonProps> = ({ 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<MicButtonProps> = ({ enabled, ...props }) => {
role="switch"
aria-checked={enabled}
{...props}
aria-busy={busy}
className={classNames(props.className, {
[styles.rotate]: !!busy,
})}
disabled={props.disabled || busy}
/>
</Tooltip>
);
@@ -58,12 +65,21 @@ export const MicButton: FC<MicButtonProps> = ({ enabled, ...props }) => {
interface VideoButtonProps extends ComponentPropsWithoutRef<"button"> {
enabled: boolean;
busy?: boolean;
size?: "md" | "lg";
}
export const VideoButton: FC<VideoButtonProps> = ({ enabled, ...props }) => {
export const VideoButton: FC<VideoButtonProps> = ({
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<VideoButtonProps> = ({ enabled, ...props }) => {
role="switch"
aria-checked={enabled}
{...props}
aria-busy={busy}
className={classNames(props.className, {
[styles.rotate]: !!busy,
})}
disabled={props.disabled || busy}
/>
</Tooltip>
);