From 038bde8b626da6cc4e925c029d6df0d2fe9f26a9 Mon Sep 17 00:00:00 2001 From: root Date: Thu, 14 May 2026 11:07:10 -0400 Subject: [PATCH] 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 --- src/app/features/call/CallControls.tsx | 95 ++++++++++++++++++- src/app/features/call/Controls.tsx | 2 +- src/app/features/settings/general/General.tsx | 75 +++++++++++++++ src/app/hooks/useCallEmbed.ts | 12 ++- src/app/plugins/call/CallControl.ts | 8 ++ src/app/plugins/call/CallEmbed.ts | 4 +- src/app/state/callPreferences.ts | 7 +- src/app/state/hooks/callPreferences.ts | 4 + src/app/state/settings.ts | 10 ++ 9 files changed, 207 insertions(+), 10 deletions(-) diff --git a/src/app/features/call/CallControls.tsx b/src/app/features/call/CallControls.tsx index 72edc57f3..199858c37 100644 --- a/src/app/features/call/CallControls.tsx +++ b/src/app/features/call/CallControls.tsx @@ -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(); + const [shareConfirm, setShareConfirm] = useState(false); + const [pttMode] = useSetting(settingsAtom, 'pttMode'); + const [pttKey] = useSetting(settingsAtom, 'pttKey'); + const [pttActive, setPttActive] = useState(false); const handleOpenMenu: MouseEventHandler = (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 ( + {pttMode && ( + + + {pttActive ? '● LIVE' : `PTT — Hold ${pttKeyLabel}`} + + + )} + {shareConfirm && ( + + Share your screen? + Your screen will be visible to all participants in this call. + + + + + + )} callEmbed.control.toggleVideo()} /> callEmbed.control.toggleScreenshare()} + onToggle={() => screenshare + ? callEmbed.control.toggleScreenshare() + : setShareConfirm(true) + } /> diff --git a/src/app/features/call/Controls.tsx b/src/app/features/call/Controls.tsx index 143a80226..5a8c1e0f8 100644 --- a/src/app/features/call/Controls.tsx +++ b/src/app/features/call/Controls.tsx @@ -53,7 +53,7 @@ export function SoundButton({ enabled, onToggle }: SoundButtonProps) { delay={500} tooltip={ - {enabled ? 'Turn Off Sound' : 'Turn On Sound'} + {enabled ? 'Deafen' : 'Undeafen'} } > diff --git a/src/app/features/settings/general/General.tsx b/src/app/features/settings/general/General.tsx index 5b5254efe..2c8c62891 100644 --- a/src/app/features/settings/general/General.tsx +++ b/src/app/features/settings/general/General.tsx @@ -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 ( + + Calls + + } + /> + + + } + /> + + + } + /> + {pttMode && ( + + + {listeningForKey ? 'Press a key…' : keyLabel(pttKey)} + + + } + /> + )} + + + ); +} + + function ChatBgGrid() { const [chatBackground, setChatBackground] = useSetting(settingsAtom, 'chatBackground'); const theme = useTheme(); @@ -1110,6 +1184,7 @@ export function General({ requestClose }: GeneralProps) { + diff --git a/src/app/hooks/useCallEmbed.ts b/src/app/hooks/useCallEmbed.ts index 4a354d57e..0421718ee 100644 --- a/src/app/hooks/useCallEmbed.ts +++ b/src/app/hooks/useCallEmbed.ts @@ -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(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; diff --git a/src/app/plugins/call/CallControl.ts b/src/app/plugins/call/CallControl.ts index f4162d73b..e5da3f193 100644 --- a/src/app/plugins/call/CallControl.ts +++ b/src/app/plugins/call/CallControl.ts @@ -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, diff --git a/src/app/plugins/call/CallEmbed.ts b/src/app/plugins/call/CallEmbed.ts index 870466769..7902cbd4c 100644 --- a/src/app/plugins/call/CallEmbed.ts +++ b/src/app/plugins/call/CallEmbed.ts @@ -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( diff --git a/src/app/state/callPreferences.ts b/src/app/state/callPreferences.ts index a8e82067f..cec393d48 100644 --- a/src/app/state/callPreferences.ts +++ b/src/app/state/callPreferences.ts @@ -28,10 +28,13 @@ export const makeCallPreferencesAtom = (userId: string): CallPreferencesAtom => storeKey, (key) => { const v = getLocalStorageItem(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 }); } ); diff --git a/src/app/state/hooks/callPreferences.ts b/src/app/state/hooks/callPreferences.ts index 829ed4b43..0dd379e29 100644 --- a/src/app/state/hooks/callPreferences.ts +++ b/src/app/state/hooks/callPreferences.ts @@ -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(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, diff --git a/src/app/state/settings.ts b/src/app/state/settings.ts index f8c2bfc98..8c24b9c56 100644 --- a/src/app/state/settings.ts +++ b/src/app/state/settings.ts @@ -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 = () => {