feat(call): PTT, deafen label, camera default off, screenshare confirm, noise suppression setting

- Push to Talk: keydown/keyup binds mic to configurable key (default Space)
  with visual PTT indicator and key-binding UI in Settings > General > Calls
- Camera always defaults OFF on join; cameraOnJoin setting for explicit opt-in
- Deafen button tooltip corrected to Deafen/Undeafen instead of Turn Off/On Sound
- Screenshare confirmation dialog before broadcasting to call participants
- Noise suppression toggle wired from settings through CallEmbed URL params
- CallControl.setMicrophone() public method for programmatic mic control
- Calls settings section added to General settings page

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
root
2026-05-14 11:07:10 -04:00
parent 2dfdda5d8c
commit a23851d4a6
9 changed files with 207 additions and 10 deletions
+93 -2
View File
@@ -1,4 +1,4 @@
import React, { MouseEventHandler, useCallback, useRef, useState } from 'react';
import React, { MouseEventHandler, useCallback, useEffect, useRef, useState } from 'react';
import {
Box,
Button,
@@ -26,6 +26,8 @@ import {
VideoButton,
} from './Controls';
import { CallEmbed, useCallControlState } from '../../plugins/call';
import { useSetting } from '../../state/hooks/settings';
import { settingsAtom } from '../../state/settings';
import { useResizeObserver } from '../../hooks/useResizeObserver';
import { stopPropagation } from '../../utils/keyboard';
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
@@ -51,6 +53,10 @@ export function CallControls({ callEmbed }: CallControlsProps) {
);
const [cords, setCords] = useState<RectCords>();
const [shareConfirm, setShareConfirm] = useState(false);
const [pttMode] = useSetting(settingsAtom, 'pttMode');
const [pttKey] = useSetting(settingsAtom, 'pttKey');
const [pttActive, setPttActive] = useState(false);
const handleOpenMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
setCords(evt.currentTarget.getBoundingClientRect());
@@ -71,12 +77,37 @@ export function CallControls({ callEmbed }: CallControlsProps) {
setCords(undefined);
};
useEffect(() => {
if (!pttMode) return;
const onKeyDown = (e: KeyboardEvent) => {
if (e.code === pttKey && !e.repeat && !microphone) {
e.preventDefault();
callEmbed.control.setMicrophone(true);
setPttActive(true);
}
};
const onKeyUp = (e: KeyboardEvent) => {
if (e.code === pttKey) {
callEmbed.control.setMicrophone(false);
setPttActive(false);
}
};
window.addEventListener('keydown', onKeyDown);
window.addEventListener('keyup', onKeyUp);
return () => {
window.removeEventListener('keydown', onKeyDown);
window.removeEventListener('keyup', onKeyUp);
};
}, [pttMode, pttKey, callEmbed, microphone]);
const [hangupState, hangup] = useAsyncCallback(
useCallback(() => callEmbed.hangup(), [callEmbed])
);
const exiting =
hangupState.status === AsyncStatus.Loading || hangupState.status === AsyncStatus.Success;
const pttKeyLabel = pttKey === 'Space' ? 'SPACE' : pttKey.replace('Key', '').replace('Digit', '');
return (
<Box
ref={controlRef}
@@ -84,6 +115,63 @@ export function CallControls({ callEmbed }: CallControlsProps) {
justifyContent="Center"
alignItems="Center"
>
{pttMode && (
<Box
style={{
position: 'absolute',
top: '-2.5rem',
left: '50%',
transform: 'translateX(-50%)',
background: pttActive ? 'rgba(0,255,136,0.18)' : 'rgba(255,107,0,0.12)',
border: `1px solid ${pttActive ? 'rgba(0,255,136,0.55)' : 'rgba(255,107,0,0.35)'}`,
borderRadius: '99px',
padding: '0.2rem 0.9rem',
pointerEvents: 'none',
whiteSpace: 'nowrap',
}}
>
<Text size="T200" style={{ color: pttActive ? '#00FF88' : '#FF6B00', fontWeight: 700, letterSpacing: '0.08em' }}>
{pttActive ? '● LIVE' : `PTT — Hold ${pttKeyLabel}`}
</Text>
</Box>
)}
{shareConfirm && (
<Box
style={{
position: 'absolute',
bottom: '110%',
left: '50%',
transform: 'translateX(-50%)',
background: 'var(--bg-surface)',
border: '1px solid var(--border-color)',
borderRadius: '0.75rem',
padding: '1rem 1.25rem',
zIndex: 100,
minWidth: '260px',
boxShadow: '0 8px 32px rgba(0,0,0,0.35)',
display: 'flex',
flexDirection: 'column',
gap: '0.75rem',
}}
>
<Text size="T300" style={{ fontWeight: 600 }}>Share your screen?</Text>
<Text size="T200" style={{ opacity: 0.75 }}>Your screen will be visible to all participants in this call.</Text>
<Box gap="200">
<Button
size="300" variant="Success" fill="Solid" radii="300"
onClick={() => { callEmbed.control.toggleScreenshare(); setShareConfirm(false); }}
>
<Text size="B300">Share</Text>
</Button>
<Button
size="300" variant="Surface" fill="Soft" radii="300" outlined
onClick={() => setShareConfirm(false)}
>
<Text size="B300">Cancel</Text>
</Button>
</Box>
</Box>
)}
<SequenceCard
className={css.ControlCard}
variant="SurfaceVariant"
@@ -105,7 +193,10 @@ export function CallControls({ callEmbed }: CallControlsProps) {
<VideoButton enabled={video} onToggle={() => callEmbed.control.toggleVideo()} />
<ScreenShareButton
enabled={screenshare}
onToggle={() => callEmbed.control.toggleScreenshare()}
onToggle={() => screenshare
? callEmbed.control.toggleScreenshare()
: setShareConfirm(true)
}
/>
</Box>
</Box>
+1 -1
View File
@@ -53,7 +53,7 @@ export function SoundButton({ enabled, onToggle }: SoundButtonProps) {
delay={500}
tooltip={
<Tooltip>
<Text size="T200">{enabled ? 'Turn Off Sound' : 'Turn On Sound'}</Text>
<Text size="T200">{enabled ? 'Deafen' : 'Undeafen'}</Text>
</Tooltip>
}
>
@@ -805,6 +805,80 @@ function Editor() {
}
function Calls() {
const [cameraOnJoin, setCameraOnJoin] = useSetting(settingsAtom, 'cameraOnJoin');
const [callNoiseSuppression, setCallNoiseSuppression] = useSetting(settingsAtom, 'callNoiseSuppression');
const [pttMode, setPttMode] = useSetting(settingsAtom, 'pttMode');
const [pttKey, setPttKey] = useSetting(settingsAtom, 'pttKey');
const [listeningForKey, setListeningForKey] = useState(false);
const handleKeyBind = () => {
setListeningForKey(true);
const onKey = (e: KeyboardEvent) => {
e.preventDefault();
if (e.code === 'Escape') {
setListeningForKey(false);
} else {
setPttKey(e.code);
setListeningForKey(false);
}
window.removeEventListener('keydown', onKey, true);
};
window.addEventListener('keydown', onKey, true);
};
const keyLabel = (code: string) => code === 'Space' ? 'Space' : code.replace('Key', '').replace('Digit', '');
return (
<Box direction="Column" gap="100">
<Text size="L400">Calls</Text>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile
title="Join with Camera On"
description="Enable camera automatically when joining a voice or video call. Camera is off by default."
after={<Switch variant="Primary" value={cameraOnJoin} onChange={setCameraOnJoin} />}
/>
</SequenceCard>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile
title="Noise Suppression"
description="Apply AI noise suppression to filter background noise during calls (powered by Element Call)."
after={<Switch variant="Primary" value={callNoiseSuppression} onChange={setCallNoiseSuppression} />}
/>
</SequenceCard>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column" gap="400">
<SettingTile
title="Push to Talk"
description="Mute your microphone by default. Hold the PTT key to speak."
after={<Switch variant="Primary" value={pttMode} onChange={setPttMode} />}
/>
{pttMode && (
<SettingTile
title="PTT Key Binding"
description="Press a key to bind it as your push-to-talk key."
after={
<Button
size="300"
variant={listeningForKey ? 'Warning' : 'Secondary'}
fill={listeningForKey ? 'Solid' : 'Soft'}
radii="300"
outlined
onClick={handleKeyBind}
style={{ minWidth: '90px' }}
>
<Text size="B300">
{listeningForKey ? 'Press a key…' : keyLabel(pttKey)}
</Text>
</Button>
}
/>
)}
</SequenceCard>
</Box>
);
}
function ChatBgGrid() {
const [chatBackground, setChatBackground] = useSetting(settingsAtom, 'chatBackground');
const theme = useTheme();
@@ -1110,6 +1184,7 @@ export function General({ requestClose }: GeneralProps) {
<DateAndTime />
<Editor />
<Messages />
<Calls />
</Box>
</PageContent>
</Scroll>
+8 -4
View File
@@ -15,6 +15,8 @@ import { useResizeObserver } from './useResizeObserver';
import { CallControlState } from '../plugins/call/CallControlState';
import { useCallMembersChange, useCallSession } from './useCall';
import { CallPreferences } from '../state/callPreferences';
import { useSetting } from '../state/hooks/settings';
import { settingsAtom } from '../state/settings';
const CallEmbedContext = createContext<CallEmbed | undefined>(undefined);
@@ -42,14 +44,15 @@ export const createCallEmbed = (
dm: boolean,
themeKind: ElementCallThemeKind,
container: HTMLElement,
pref?: CallPreferences
pref?: CallPreferences,
noiseSuppression = true
): CallEmbed => {
const rtcSession = mx.matrixRTC.getRoomSession(room);
const ongoing =
MatrixRTCSession.sessionMembershipsForRoom(room, rtcSession.sessionDescription).length > 0;
const intent = CallEmbed.getIntent(dm, ongoing);
const widget = CallEmbed.getWidget(mx, room, intent, themeKind);
const widget = CallEmbed.getWidget(mx, room, intent, themeKind, noiseSuppression);
const controlState = pref && new CallControlState(pref.microphone, pref.video, pref.sound);
const embed = new CallEmbed(mx, room, widget, container, controlState);
@@ -62,6 +65,7 @@ export const useCallStart = (dm = false) => {
const theme = useTheme();
const setCallEmbed = useSetAtom(callEmbedAtom);
const callEmbedRef = useCallEmbedRef();
const [callNoiseSuppression] = useSetting(settingsAtom, 'callNoiseSuppression');
const startCall = useCallback(
(room: Room, pref?: CallPreferences) => {
@@ -69,11 +73,11 @@ export const useCallStart = (dm = false) => {
if (!container) {
throw new Error('Failed to start call, No embed container element found!');
}
const callEmbed = createCallEmbed(mx, room, dm, theme.kind, container, pref);
const callEmbed = createCallEmbed(mx, room, dm, theme.kind, container, pref, callNoiseSuppression ?? true);
setCallEmbed(callEmbed);
},
[mx, dm, theme, setCallEmbed, callEmbedRef]
[mx, dm, theme, setCallEmbed, callEmbedRef, callNoiseSuppression]
);
return startCall;
+8
View File
@@ -173,6 +173,14 @@ export class CallControl extends EventEmitter implements CallControlState {
this.emitStateUpdate();
}
public setMicrophone(enabled: boolean) {
const payload: ElementMediaStatePayload = {
audio_enabled: enabled,
video_enabled: this.video,
};
return this.setMediaState(payload);
}
public toggleMicrophone() {
const payload: ElementMediaStatePayload = {
audio_enabled: !this.microphone,
+3 -1
View File
@@ -59,7 +59,8 @@ export class CallEmbed {
mx: MatrixClient,
room: Room,
intent: ElementCallIntent,
themeKind: ElementCallThemeKind
themeKind: ElementCallThemeKind,
noiseSuppression = true
): Widget {
const userId = mx.getSafeUserId();
const deviceId = mx.getDeviceId() ?? '';
@@ -81,6 +82,7 @@ export class CallEmbed {
perParticipantE2EE: room.hasEncryptionStateEvent().toString(),
lang: 'en-EN',
theme: themeKind,
noiseSuppression: noiseSuppression.toString(),
});
const widgetUrl = new URL(
+5 -2
View File
@@ -28,10 +28,13 @@ export const makeCallPreferencesAtom = (userId: string): CallPreferencesAtom =>
storeKey,
(key) => {
const v = getLocalStorageItem<CallPreferences>(key, DEFAULT_PREFERENCES);
return v;
// Never restore camera state — always start with camera off for privacy.
// Users can toggle it in the prescreen before joining.
return { ...v, video: false };
},
(key, value) => {
setLocalStorageItem(key, value);
// Don't persist video state — always resets to off on next load.
setLocalStorageItem(key, { ...value, video: false });
}
);
+4
View File
@@ -1,6 +1,8 @@
import { createContext, useCallback, useContext } from 'react';
import { useAtom } from 'jotai';
import { CallPreferences, CallPreferencesAtom } from '../callPreferences';
import { useSetting } from './settings';
import { settingsAtom } from '../settings';
const CallPreferencesAtomContext = createContext<CallPreferencesAtom | null>(null);
export const CallPreferencesProvider = CallPreferencesAtomContext.Provider;
@@ -21,6 +23,7 @@ export const useCallPreferences = (): CallPreferences & {
} => {
const callPrefAtom = useCallPreferencesAtom();
const [pref, setPref] = useAtom(callPrefAtom);
const [cameraOnJoin] = useSetting(settingsAtom, 'cameraOnJoin');
const toggleMicrophone = useCallback(() => {
const microphone = !pref.microphone;
@@ -54,6 +57,7 @@ export const useCallPreferences = (): CallPreferences & {
return {
...pref,
video: cameraOnJoin ? pref.video : false,
toggleMicrophone,
toggleVideo,
toggleSound,
+10
View File
@@ -46,6 +46,11 @@ export interface Settings {
chatBackground: ChatBackground;
perMessageProfiles: boolean;
cameraOnJoin: boolean;
callNoiseSuppression: boolean;
pttMode: boolean;
pttKey: string;
}
const defaultSettings: Settings = {
@@ -84,6 +89,11 @@ const defaultSettings: Settings = {
chatBackground: 'none',
perMessageProfiles: false,
cameraOnJoin: false,
callNoiseSuppression: true,
pttMode: false,
pttKey: 'Space',
};
export const getSettings = () => {