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