Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 45 additions & 4 deletions client/src/components/ControlBar.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,6 @@ function makeProps(overrides: Partial<ControlBarProps> = {}): ControlBarProps {
pipActive: false,
onTogglePip: vi.fn(),
onCopyLink: vi.fn(),
onScreenshot: vi.fn(),
onLeave: vi.fn(),
micGain: 1,
onMicGainChange: vi.fn(),
Expand Down Expand Up @@ -260,9 +259,51 @@ describe('ControlBar', () => {
});
});

// A11y baseline (#164): toggles expose pressed state, menu triggers expose
// popup/expanded state, and local state flips are narrated via a polite
// live region — the contract screen readers depend on.
describe('leave pill and overflow menu', () => {
it('keeps a dedicated Leave pill visible and usable', () => {
const props = renderBar();

const leave = btn('Leave call');
expect(leave).toBeVisible();
fireEvent.click(leave);
expect(props.onLeave).toHaveBeenCalledTimes(1);
});

it('groups overflow actions and has no screenshot entry', () => {
const props = renderBar({ pipSupported: true, pipActive: false, soundEnabled: true });

fireEvent.click(btn('More options'));
const menu = screen.getByRole('menu');

expect(within(menu).queryByText('Take screenshot')).not.toBeInTheDocument();
expect(within(menu).queryByText(/screenshot/i)).not.toBeInTheDocument();

expect(within(menu).getByRole('menuitem', { name: /copy meeting link/i })).toBeInTheDocument();
expect(within(menu).getByRole('menuitem', { name: /open mini player/i })).toBeInTheDocument();
expect(within(menu).getByRole('menuitem', { name: /sound effects/i })).toHaveTextContent('On');
expect(within(menu).getByRole('menuitem', { name: /leave call/i })).toBeInTheDocument();

const items = within(menu).getAllByRole('menuitem').map((el) => el.textContent);
expect(items).toEqual([
'Copy meeting link',
'Open mini player',
'Sound effectsOn',
'Leave call',
]);

fireEvent.click(within(menu).getByRole('menuitem', { name: /copy meeting link/i }));
expect(props.onCopyLink).toHaveBeenCalledTimes(1);
});

it('keeps Leave in the overflow menu as a secondary path', () => {
const props = renderBar();

fireEvent.click(btn('More options'));
fireEvent.click(within(screen.getByRole('menu')).getByRole('menuitem', { name: /leave call/i }));
expect(props.onLeave).toHaveBeenCalledTimes(1);
});
});

describe('accessibility', () => {
function renderRerenderable(overrides: Partial<ControlBarProps> = {}) {
const view = render(
Expand Down
18 changes: 4 additions & 14 deletions client/src/components/ControlBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@ import {
MoreVert as MoreVertIcon,
PanTool as PanToolIcon,
PeopleAlt as PeopleAltIcon,
PhotoCamera as PhotoCameraIcon,
PictureInPictureAlt as PipIcon,
PresentToAll as PresentIcon,
CancelPresentation as StopPresentIcon,
Expand Down Expand Up @@ -77,7 +76,7 @@ export interface ControlBarProps {
layoutMode?: LayoutMode; onLayoutChange: (mode: LayoutMode) => void;
soundEnabled: boolean; onToggleSound: () => void;
pipSupported: boolean; pipActive: boolean; onTogglePip: () => void;
onCopyLink: () => void; onScreenshot: () => void; onLeave: () => void;
onCopyLink: () => void; onLeave: () => void;
micGain?: number; onMicGainChange: (value: number) => void;
outputVolume?: number; onOutputVolumeChange: (value: number) => void;
showPinToggle?: boolean; pinned?: boolean; onTogglePin?: () => void;
Expand Down Expand Up @@ -138,7 +137,6 @@ export default function ControlBar({
soundEnabled, onToggleSound,
pipSupported, pipActive, onTogglePip,
onCopyLink,
onScreenshot,
onLeave,
micGain = 1, onMicGainChange,
outputVolume = 1, onOutputVolumeChange,
Expand Down Expand Up @@ -459,26 +457,18 @@ export default function ControlBar({
{isMobile && <Divider />}
<MenuItem onClick={() => { closeMore(); onCopyLink(); }}>
<ListItemIcon><ContentCopyIcon fontSize="small" /></ListItemIcon>
<ListItemText>Copy joining link</ListItemText>
<ListItemText>Copy meeting link</ListItemText>
</MenuItem>
{onScreenshot && (
<MenuItem onClick={() => { closeMore(); onScreenshot(); }}>
<ListItemIcon><PhotoCameraIcon fontSize="small" /></ListItemIcon>
<ListItemText>Take screenshot</ListItemText>
</MenuItem>
)}
{pipSupported && (
<MenuItem onClick={() => { closeMore(); onTogglePip(); }}>
<ListItemIcon><PipIcon fontSize="small" /></ListItemIcon>
<ListItemText>{pipActive ? 'Close mini player' : 'Open mini player'}</ListItemText>
</MenuItem>
)}
<Divider />
<MenuItem onClick={() => { onToggleSound(); }}>
<ListItemIcon>{soundEnabled ? <VolumeUpIcon fontSize="small" /> : <VolumeOffIcon fontSize="small" />}</ListItemIcon>
<ListItemText>Sound effects</ListItemText>
<Box component="span" sx={{ ml: 2, color: 'text.secondary', fontSize: 13 }}>
{soundEnabled ? 'On' : 'Off'}
</Box>
<ListItemText primary="Sound effects" secondary={soundEnabled ? 'On' : 'Off'} />
</MenuItem>
<Divider />
<MenuItem onClick={() => { closeMore(); onLeave(); }} sx={{ color: 'error.main' }}>
Expand Down
25 changes: 24 additions & 1 deletion client/src/hooks/useScreenShare.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -76,8 +76,31 @@ describe('useScreenShare', () => {
localScreenStream: null,
}));
expect(result.current.shares).toEqual([]);
// hasScreen still reflects the sharing intent.
// Sharing requested, picker/produce still pending — no presentation stage.
expect(result.current.hasScreen).toBe(false);
});

it('keeps hasScreen false when sharing is requested but no stream exists yet', () => {
const { result, rerender } = renderHook((props: ScreenShareOptions) => useScreenShare(props), {
initialProps: {
...baseProps,
isScreenSharing: true,
localScreenStream: null,
} as ScreenShareOptions,
});

expect(result.current.hasScreen).toBe(false);
expect(result.current.pinnedShare).toBeNull();

const local = stream('local');
rerender({
...baseProps,
isScreenSharing: true,
localScreenStream: local,
localScreenSurface: 'window',
});
expect(result.current.hasScreen).toBe(true);
expect(result.current.pinnedShare?.stream).toBe(local);
});

it('prefers a remote share over the local one when nothing is pinned', () => {
Expand Down
8 changes: 6 additions & 2 deletions client/src/hooks/useScreenShare.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,8 @@ export interface ScreenShareEntry {
// the first remote share, then the first share, then null.
// This replaces the previous setState-in-effect that reset
// `pinnedShareKey` whenever the list changed.
// • hasScreen — whether the presentation layout should take the stage.
// • hasScreen — whether a real screen MediaStream is on stage (local
// share only counts once localScreenStream exists).
// • showScreenAnyway — opt-in reveal past the local "infinity mirror" guard,
// reset whenever the local share stops so a fresh share
// re-arms the guard.
Expand Down Expand Up @@ -89,7 +90,10 @@ export function useScreenShare({
?? shares[0]
?? null;

const hasScreen = isScreenSharing || remoteScreenEntries.length > 0;
// Intent (`isScreenSharing`) is not enough: clicking Share can flip that
// true before getDisplayMedia returns a stream. Presentation/black stage
// only belongs on a live local or remote MediaStream.
const hasScreen = shares.length > 0;

// Opt-in reveal past the local "infinity mirror" guard. Keyed to the *specific*
// local stream the user revealed, so the guard re-arms automatically: stopping
Expand Down
27 changes: 1 addition & 26 deletions client/src/pages/RoomPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,6 @@ import type {
TranscriptState,
} from '@a-meet/contracts';
import { playSound, isSoundEnabled, toggleSound } from '../services/sounds';
import { copyMeetingScreenshot, downloadMeetingScreenshot } from '../utils/capture-screenshot';
import { appLogger } from '../utils/logger';
import { downloadTranscript, mergeTranscriptEntries } from '../utils/transcript';

Expand Down Expand Up @@ -585,35 +584,12 @@ export default function RoomPage() {
pushNote({ kind: 'event', variant: 'info', text: "Couldn't open the mini player" }),
);
};
// Capture the current meeting view (camera tiles + any on-stage share) and
// copy it to the clipboard as a PNG. Falls back to a file download when the
// browser can't write images to the clipboard (e.g. Firefox).
async function handleScreenshot() {
const tiles = cameraTiles().map(({ key, stream, name, videoOn, audioOn, mirror }) =>
({ key, stream, name, videoOn, audioOn, mirror }));
// Prefix the share key so it can't collide with the 'local' camera tile.
const share = pinnedShare
? { key: `share-${pinnedShare.key}`, stream: pinnedShare.stream, name: pinnedShare.name }
: null;
try {
await copyMeetingScreenshot({ tiles, share });
playSound('toggleOn');
pushNote({ kind: 'event', variant: 'info', text: 'Screenshot copied to clipboard' });
} catch {
try {
await downloadMeetingScreenshot({ tiles, share }, `a-meet-${roomId}`);
pushNote({ kind: 'event', variant: 'info', text: 'Screenshot saved' });
} catch {
pushNote({ kind: 'event', variant: 'info', text: "Couldn't capture a screenshot" });
}
}
}

async function handleCopyLink() {
const link = `${window.location.origin}/lobby/${roomId}`;
try {
await navigator.clipboard.writeText(link);
pushNote({ kind: 'event', variant: 'info', text: 'Joining link copied' });
pushNote({ kind: 'event', variant: 'info', text: 'Meeting link copied' });
} catch {
pushNote({ kind: 'event', variant: 'info', text: 'Press the link button to copy' });
}
Expand Down Expand Up @@ -1416,7 +1392,6 @@ export default function RoomPage() {
soundEnabled={soundEnabled} onToggleSound={handleToggleSound}
pipSupported={pipSupported} pipActive={pipActive} onTogglePip={handleTogglePip}
onCopyLink={handleCopyLink}
onScreenshot={handleScreenshot}
onLeave={handleLeave}
micGain={micGain} onMicGainChange={setMicGain}
outputVolume={outputVolume} onOutputVolumeChange={setOutputVolume}
Expand Down
74 changes: 0 additions & 74 deletions client/src/utils/capture-screenshot.test.js

This file was deleted.

Loading
Loading