From 23ee156f2f242b4a411dfa742219e2cf7283b8d8 Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Sat, 12 Sep 2026 19:46:05 -0400 Subject: [PATCH] fix(calls): PTT, deafen hotkey and AFK auto-mute live for the whole call MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit They were wired inside CallControls, which only renders while the call room is selected, so navigating away (PiP) or opening the in-call chat on mobile silently disabled all three — AFK auto-mute exactly when it mattered. Move them into useCallHotkeys + useAfkAutoMute mounted from the embed-lifetime CallUtils, gated on joined; CallControls keeps only the PTT chip (pttActiveAtom). Also: window blur/focus release the mic only while a PTT key is actually held, so a deliberate hands-free unmute survives a click into the iframe (#27); iframe-side listeners re-bind on the iframe load event so they survive an EC reload (#60). The #23 modifier/interactive guards are preserved and unit-tested. Fixes #9 Fixes #27 Fixes #60 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA --- src/app/components/CallEmbedProvider.tsx | 12 +- src/app/features/call/CallControls.tsx | 166 +----------------- src/app/hooks/useCallHotkeys.test.ts | 35 ++++ src/app/hooks/useCallHotkeys.ts | 214 +++++++++++++++++++++++ 4 files changed, 266 insertions(+), 161 deletions(-) create mode 100644 src/app/hooks/useCallHotkeys.test.ts create mode 100644 src/app/hooks/useCallHotkeys.ts diff --git a/src/app/components/CallEmbedProvider.tsx b/src/app/components/CallEmbedProvider.tsx index 56d73365b..9bf7ad5fa 100644 --- a/src/app/components/CallEmbedProvider.tsx +++ b/src/app/components/CallEmbedProvider.tsx @@ -45,6 +45,8 @@ import { useMatrixClient } from '../hooks/useMatrixClient'; import { previewRingtone, startRingtone, unlockRingtoneAudio } from '../utils/ringtones'; import { useCallMembersChange, useCallSession } from '../hooks/useCall'; import { useCallJoinLeaveSounds } from '../hooks/useCallJoinLeaveSounds'; +import { useCallHotkeys } from '../hooks/useCallHotkeys'; +import { useAfkAutoMute } from '../hooks/useAfkAutoMute'; import { useCallQuality } from '../hooks/useCallQuality'; import { useRemoteAllMuted } from '../hooks/useCallSpeakers'; import { useRoomAvatar, useRoomName } from '../hooks/useRoomMeta'; @@ -609,9 +611,15 @@ function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps) ); } -function CallUtils({ embed }: { embed: CallEmbed }) { +function CallUtils({ embed, joined }: { embed: CallEmbed; joined: boolean }) { const setCallEmbed = useSetAtom(callEmbedAtom); + // [Gitea #9] PTT/deafen hotkeys and AFK auto-mute are bound here, for the + // embed's whole lifetime, rather than in CallControls (which only renders + // while the call room is selected) — so they keep working in PiP and behind + // the mobile in-call chat. Both are gated on `joined`. + useCallHotkeys(embed, joined); + useAfkAutoMute(joined ? embed : undefined); useCallMemberSoundSync(embed); useCallJoinLeaveSounds(embed); useCallThemeSync(embed); @@ -1146,7 +1154,7 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) { return ( - {callEmbed && } + {callEmbed && } {children} diff --git a/src/app/features/call/CallControls.tsx b/src/app/features/call/CallControls.tsx index 7adabafb8..2a917162e 100644 --- a/src/app/features/call/CallControls.tsx +++ b/src/app/features/call/CallControls.tsx @@ -1,5 +1,5 @@ import React, { MouseEventHandler, useCallback, useEffect, useRef, useState } from 'react'; -import { useSetAtom } from 'jotai'; +import { useAtomValue, useSetAtom } from 'jotai'; import { Box, Button, @@ -39,7 +39,7 @@ import { ScreenSize, useScreenSize } from '../../hooks/useScreenSize'; import { stopPropagation } from '../../utils/keyboard'; import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback'; import { useCallEmbedRef } from '../../hooks/useCallEmbed'; -import { useAfkAutoMute } from '../../hooks/useAfkAutoMute'; +import { pttActiveAtom } from '../../hooks/useCallHotkeys'; import { CallSoundboard } from './CallSoundboard'; import { useStateEvent } from '../../hooks/useStateEvent'; import { StateEvent } from '../../../types/matrix/room'; @@ -88,8 +88,6 @@ export function CallControls({ callEmbed }: CallControlsProps) { const { microphone, video, sound, screenshare, spotlight, screenshareAudioMuted } = useCallControlState(callEmbed.control); - useAfkAutoMute(callEmbed); - const [cords, setCords] = useState(); const [shareConfirm, setShareConfirm] = useState(false); useEffect(() => { @@ -102,8 +100,12 @@ export function CallControls({ callEmbed }: CallControlsProps) { }, [shareConfirm]); const [pttMode] = useSetting(settingsAtom, 'pttMode'); const [pttKey] = useSetting(settingsAtom, 'pttKey'); - const [deafenKey] = useSetting(settingsAtom, 'deafenKey'); const [soundboardEnabled] = useSetting(settingsAtom, 'soundboardEnabled'); + // [Gitea #9] PTT/deafen key handling and AFK auto-mute live in useCallHotkeys + // / useAfkAutoMute, mounted from CallEmbedProvider for the embed's lifetime + // (this component only renders while the call room is selected). Only the + // visual PTT chip remains here. + const pttActive = useAtomValue(pttActiveAtom); // [P5-31] Hard room publish policy — hide controls the server will refuse so // users don't click dead buttons. Absent/true = allowed. @@ -116,28 +118,6 @@ export function CallControls({ callEmbed }: CallControlsProps) { const showCamera = cameraAllowed || video; const showScreenshare = screenshareAllowed || screenshare; const showVideoGroup = showCamera || showScreenshare || !!document.fullscreenEnabled; - const [pttActive, setPttActive] = useState(false); - - // Track microphone via ref so the PTT effect doesn't need it as a dep (avoids listener churn) - const microphoneRef = useRef(microphone); - useEffect(() => { - microphoneRef.current = microphone; - }, [microphone]); - - // Handle PTT mode toggle mid-call — save/restore mic state (I-4) - const pttModeRef = useRef(pttMode); - const micBeforePTTRef = useRef(null); - useEffect(() => { - if (pttMode && !pttModeRef.current) { - micBeforePTTRef.current = microphoneRef.current; - callEmbed.control.setMicrophone(false); - } else if (!pttMode && pttModeRef.current) { - callEmbed.control.setMicrophone(micBeforePTTRef.current ?? true); - micBeforePTTRef.current = null; - } - pttModeRef.current = pttMode; - }, [pttMode, callEmbed]); - const handleOpenMenu: MouseEventHandler = (evt) => { setCords(evt.currentTarget.getBoundingClientRect()); }; @@ -163,138 +143,6 @@ export function CallControls({ callEmbed }: CallControlsProps) { ); const handleVideoToggle = useCallback(() => callEmbed.control.toggleVideo(), [callEmbed]); - const pttActiveRef = useRef(false); - - useEffect(() => { - if (!pttMode) return; - const iframeWindow = callEmbed.iframe.contentWindow; - - const onKeyDown = (e: KeyboardEvent) => { - if (e.code !== pttKey || e.repeat) return; - // [Gitea #23] Ignore the PTT key with Ctrl/Alt/Meta held so it doesn't - // hijack OS/app chords (e.g. Cmd+Space) that happen to share the code. - // Shift is allowed through — Shift+Space is a harmless combo for PTT. - if (e.ctrlKey || e.altKey || e.metaKey) return; - const target = e.target as HTMLElement; - // BUG-7: use ownerDocument.body so isEditable works inside the EC iframe - const isEditable = (el: HTMLElement): boolean => { - const tag = el.tagName; - if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return true; - let node: HTMLElement | null = el; - while (node && node !== el.ownerDocument.body) { - if (node.contentEditable === 'true') return true; - if (node.contentEditable === 'false') return false; - node = node.parentElement; - } - return false; - }; - if (isEditable(target)) return; - // [Gitea #23] Don't swallow Space on a focused button/link/etc — PTT still - // engages the mic, but the key's default action (activating the control) - // is left alone so keyboard users can still Tab+Space the call buttons. - const isInteractive = (el: HTMLElement): boolean => { - const tag = el.tagName; - if (tag === 'BUTTON' || tag === 'A' || tag === 'SELECT') return true; - let node: HTMLElement | null = el; - while (node && node !== el.ownerDocument.body) { - const role = node.getAttribute('role'); - if (role === 'button' || role === 'link' || role === 'menuitem' || role === 'tab') { - return true; - } - node = node.parentElement; - } - return false; - }; - if (!isInteractive(target)) e.preventDefault(); - // C-M5: mark PTT active BEFORE unmuting so the mic echo (onMediaState) - // doesn't treat this transient unmute as a user-initiated undeafen. - callEmbed.control.pttActive = true; - if (!microphoneRef.current) callEmbed.control.setMicrophone(true); - pttActiveRef.current = true; - setPttActive(true); - }; - const onKeyUp = (e: KeyboardEvent) => { - if (e.code !== pttKey) return; - callEmbed.control.pttActive = false; - callEmbed.control.setMicrophone(false); - pttActiveRef.current = false; - setPttActive(false); - }; - const onBlur = () => { - callEmbed.control.pttActive = false; - callEmbed.control.setMicrophone(false); - pttActiveRef.current = false; - setPttActive(false); - }; - const onFocus = () => { - callEmbed.control.pttActive = false; - callEmbed.control.setMicrophone(false); - pttActiveRef.current = false; - setPttActive(false); - }; - window.addEventListener('keydown', onKeyDown); - window.addEventListener('keyup', onKeyUp); - window.addEventListener('blur', onBlur); - window.addEventListener('focus', onFocus); - // BUG-9: also wire iframe blur/focus so stuck-mic release works when focus moves to iframe - iframeWindow?.addEventListener('keydown', onKeyDown); - iframeWindow?.addEventListener('keyup', onKeyUp); - iframeWindow?.addEventListener('blur', onBlur); - iframeWindow?.addEventListener('focus', onFocus); - return () => { - window.removeEventListener('keydown', onKeyDown); - window.removeEventListener('keyup', onKeyUp); - window.removeEventListener('blur', onBlur); - window.removeEventListener('focus', onFocus); - iframeWindow?.removeEventListener('keydown', onKeyDown); - iframeWindow?.removeEventListener('keyup', onKeyUp); - iframeWindow?.removeEventListener('blur', onBlur); - iframeWindow?.removeEventListener('focus', onFocus); - // BUG-8: if callEmbed changes while PTT is active, release mic on cleanup - if (pttActiveRef.current) { - callEmbed.control.pttActive = false; - callEmbed.control.setMicrophone(false); - pttActiveRef.current = false; - setPttActive(false); - } - }; - // microphone intentionally read via microphoneRef — excluded from deps to avoid listener churn - }, [pttMode, pttKey, callEmbed]); - - useEffect(() => { - const isEditable = (el: HTMLElement): boolean => { - const tag = el.tagName; - if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return true; - let node: HTMLElement | null = el; - while (node && node !== el.ownerDocument.body) { - if (node.contentEditable === 'true') return true; - if (node.contentEditable === 'false') return false; - node = node.parentElement; - } - return false; - }; - const onKeyDown = (e: KeyboardEvent) => { - if (e.code !== deafenKey) return; - if (e.repeat) return; - // [Gitea #23] Ignore the deafen key with any modifier held — with the - // default 'KeyM', Ctrl+M / Alt+M / Cmd+M are common OS/app chords that - // shouldn't also toggle deafen (and previously got preventDefault()ed). - if (e.ctrlKey || e.altKey || e.metaKey || e.shiftKey) return; - if (isEditable(e.target as HTMLElement)) return; - e.preventDefault(); - callEmbed.control.toggleSound(); - }; - // C-L4: also bind the EC iframe window so the deafen key works when focus is - // inside the iframe (mirrors the PTT binding above). - const iframeWindow = callEmbed.iframe.contentWindow; - window.addEventListener('keydown', onKeyDown); - iframeWindow?.addEventListener('keydown', onKeyDown); - return () => { - window.removeEventListener('keydown', onKeyDown); - iframeWindow?.removeEventListener('keydown', onKeyDown); - }; - }, [callEmbed, deafenKey]); - const [hangupState, hangup] = useAsyncCallback( useCallback(() => callEmbed.hangup(), [callEmbed]), ); diff --git a/src/app/hooks/useCallHotkeys.test.ts b/src/app/hooks/useCallHotkeys.test.ts new file mode 100644 index 000000000..b1f944185 --- /dev/null +++ b/src/app/hooks/useCallHotkeys.test.ts @@ -0,0 +1,35 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { isDeafenKeyDown, isPttKeyDown } from './useCallHotkeys'; + +const key = ( + code: string, + mods: Partial> = {}, +) => ({ + code, + repeat: false, + ctrlKey: false, + altKey: false, + metaKey: false, + shiftKey: false, + ...mods, +}); + +test('isPttKeyDown: matches the bare key and Shift, ignores repeats and Ctrl/Alt/Meta chords (#23)', () => { + assert.equal(isPttKeyDown(key('Space'), 'Space'), true); + assert.equal(isPttKeyDown(key('Space', { shiftKey: true }), 'Space'), true); + assert.equal(isPttKeyDown(key('KeyV'), 'Space'), false); + assert.equal(isPttKeyDown(key('Space', { repeat: true }), 'Space'), false); + assert.equal(isPttKeyDown(key('Space', { ctrlKey: true }), 'Space'), false); + assert.equal(isPttKeyDown(key('Space', { altKey: true }), 'Space'), false); + assert.equal(isPttKeyDown(key('Space', { metaKey: true }), 'Space'), false); +}); + +test('isDeafenKeyDown: matches only the bare key — any modifier (incl. Shift) is ignored (#23)', () => { + assert.equal(isDeafenKeyDown(key('KeyM'), 'KeyM'), true); + assert.equal(isDeafenKeyDown(key('KeyN'), 'KeyM'), false); + assert.equal(isDeafenKeyDown(key('KeyM', { repeat: true }), 'KeyM'), false); + assert.equal(isDeafenKeyDown(key('KeyM', { shiftKey: true }), 'KeyM'), false); + assert.equal(isDeafenKeyDown(key('KeyM', { ctrlKey: true }), 'KeyM'), false); + assert.equal(isDeafenKeyDown(key('KeyM', { metaKey: true }), 'KeyM'), false); +}); diff --git a/src/app/hooks/useCallHotkeys.ts b/src/app/hooks/useCallHotkeys.ts new file mode 100644 index 000000000..318bfc83d --- /dev/null +++ b/src/app/hooks/useCallHotkeys.ts @@ -0,0 +1,214 @@ +import { useEffect, useRef } from 'react'; +import { atom, useSetAtom } from 'jotai'; +import { CallEmbed, useCallControlState } from '../plugins/call'; +import { useSetting } from '../state/hooks/settings'; +import { settingsAtom } from '../state/settings'; + +/** + * True while a push-to-talk key is held. Written by useCallHotkeys (mounted for + * the lifetime of the embed) and read by the PTT chip in CallControls, which + * only renders while the call room is selected. + */ +export const pttActiveAtom = atom(false); + +type KeyLike = { + code: string; + repeat: boolean; + ctrlKey: boolean; + altKey: boolean; + metaKey: boolean; + shiftKey: boolean; +}; + +/** + * Whether a keydown should engage push-to-talk for `pttKey`. + * [Gitea #23] Ignore the PTT key with Ctrl/Alt/Meta held so it doesn't hijack + * OS/app chords (e.g. Cmd+Space) that happen to share the code. Shift is + * allowed through — Shift+Space is a harmless combo for PTT. + */ +export const isPttKeyDown = (e: KeyLike, pttKey: string): boolean => + e.code === pttKey && !e.repeat && !e.ctrlKey && !e.altKey && !e.metaKey; + +/** + * Whether a keydown should toggle deafen for `deafenKey`. + * [Gitea #23] Ignore the deafen key with any modifier held — with the default + * 'KeyM', Ctrl+M / Alt+M / Cmd+M are common OS/app chords that shouldn't also + * toggle deafen (and previously got preventDefault()ed). + */ +export const isDeafenKeyDown = (e: KeyLike, deafenKey: string): boolean => + e.code === deafenKey && !e.repeat && !e.ctrlKey && !e.altKey && !e.metaKey && !e.shiftKey; + +// BUG-7: use ownerDocument.body so isEditable works inside the EC iframe +const isEditable = (el: HTMLElement): boolean => { + const tag = el.tagName; + if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return true; + let node: HTMLElement | null = el; + while (node && node !== el.ownerDocument.body) { + if (node.contentEditable === 'true') return true; + if (node.contentEditable === 'false') return false; + node = node.parentElement; + } + return false; +}; + +// [Gitea #23] Don't swallow Space on a focused button/link/etc — PTT still +// engages the mic, but the key's default action (activating the control) +// is left alone so keyboard users can still Tab+Space the call buttons. +const isInteractive = (el: HTMLElement): boolean => { + const tag = el.tagName; + if (tag === 'BUTTON' || tag === 'A' || tag === 'SELECT') return true; + let node: HTMLElement | null = el; + while (node && node !== el.ownerDocument.body) { + const role = node.getAttribute('role'); + if (role === 'button' || role === 'link' || role === 'menuitem' || role === 'tab') { + return true; + } + node = node.parentElement; + } + return false; +}; + +/** + * Bind listeners to the EC iframe's window and keep them bound across document + * reloads. [Gitea #60] Listeners registered on a contentWindow are discarded + * when that window navigates to a new document (crash recovery, in-widget + * navigation), so re-run `bind` from the iframe's `load` event and detach the + * previous set. Returns a cleanup that detaches everything. + */ +const bindIframeWindow = ( + iframe: HTMLIFrameElement, + bind: (win: Window) => () => void, +): (() => void) => { + let unbind: (() => void) | undefined; + const attach = () => { + unbind?.(); + const win = iframe.contentWindow; + unbind = win ? bind(win) : undefined; + }; + attach(); + iframe.addEventListener('load', attach); + return () => { + iframe.removeEventListener('load', attach); + unbind?.(); + unbind = undefined; + }; +}; + +/** + * Push-to-talk and deafen hotkeys for the active call. + * + * [Gitea #9] Mounted from CallUtils (CallEmbedProvider) so the bindings live as + * long as the embed — not only while the call room is the selected room. Before + * this lived in CallControls, so navigating away (PiP) or opening the in-call + * chat on mobile silently dropped PTT and the deafen key. Gated on `joined` so + * nothing is sent over the widget transport before EC's handler mounts. + */ +export function useCallHotkeys(callEmbed: CallEmbed | undefined, joined: boolean): void { + const embed = joined ? callEmbed : undefined; + const [pttMode] = useSetting(settingsAtom, 'pttMode'); + const [pttKey] = useSetting(settingsAtom, 'pttKey'); + const [deafenKey] = useSetting(settingsAtom, 'deafenKey'); + const { microphone } = useCallControlState(embed?.control); + const setPttActive = useSetAtom(pttActiveAtom); + + // Track microphone via ref so the PTT effect doesn't need it as a dep (avoids listener churn) + const microphoneRef = useRef(microphone); + useEffect(() => { + microphoneRef.current = microphone; + }, [microphone]); + + // Handle PTT mode toggle mid-call — save/restore mic state (I-4) + const pttModeRef = useRef(pttMode); + const micBeforePTTRef = useRef(null); + useEffect(() => { + if (embed) { + if (pttMode && !pttModeRef.current) { + micBeforePTTRef.current = microphoneRef.current; + embed.control.setMicrophone(false); + } else if (!pttMode && pttModeRef.current) { + embed.control.setMicrophone(micBeforePTTRef.current ?? true); + micBeforePTTRef.current = null; + } + } + pttModeRef.current = pttMode; + }, [pttMode, embed]); + + const pttActiveRef = useRef(false); + + useEffect(() => { + if (!embed || !pttMode) return undefined; + + const release = () => { + embed.control.pttActive = false; + embed.control.setMicrophone(false); + pttActiveRef.current = false; + setPttActive(false); + }; + const onKeyDown = (e: KeyboardEvent) => { + if (!isPttKeyDown(e, pttKey)) return; + const target = e.target as HTMLElement; + if (isEditable(target)) return; + if (!isInteractive(target)) e.preventDefault(); + // C-M5: mark PTT active BEFORE unmuting so the mic echo (onMediaState) + // doesn't treat this transient unmute as a user-initiated undeafen. + embed.control.pttActive = true; + if (!microphoneRef.current) embed.control.setMicrophone(true); + pttActiveRef.current = true; + setPttActive(true); + }; + const onKeyUp = (e: KeyboardEvent) => { + if (e.code !== pttKey) return; + release(); + }; + // BUG-9: release a held key when focus leaves/enters so the mic can't stick + // open after the keyup lands elsewhere. [Gitea #27] Only when a PTT hold is + // actually in progress — the mic button is still usable in PTT mode, and a + // deliberate hands-free unmute must survive a click into the iframe/alt-tab. + const onFocusChange = () => { + if (pttActiveRef.current) release(); + }; + const bind = (win: Window) => { + win.addEventListener('keydown', onKeyDown); + win.addEventListener('keyup', onKeyUp); + win.addEventListener('blur', onFocusChange); + win.addEventListener('focus', onFocusChange); + return () => { + win.removeEventListener('keydown', onKeyDown); + win.removeEventListener('keyup', onKeyUp); + win.removeEventListener('blur', onFocusChange); + win.removeEventListener('focus', onFocusChange); + }; + }; + const unbindHost = bind(window); + // BUG-9: also wire iframe key/blur/focus so PTT works with focus in the iframe + const unbindIframe = bindIframeWindow(embed.iframe, bind); + return () => { + unbindHost(); + unbindIframe(); + // BUG-8: if the embed changes while PTT is active, release mic on cleanup + if (pttActiveRef.current) release(); + }; + // microphone intentionally read via microphoneRef — excluded from deps to avoid listener churn + }, [pttMode, pttKey, embed, setPttActive]); + + useEffect(() => { + if (!embed) return undefined; + const onKeyDown = (e: KeyboardEvent) => { + if (!isDeafenKeyDown(e, deafenKey)) return; + if (isEditable(e.target as HTMLElement)) return; + e.preventDefault(); + embed.control.toggleSound(); + }; + window.addEventListener('keydown', onKeyDown); + // C-L4: also bind the EC iframe window so the deafen key works when focus is + // inside the iframe (mirrors the PTT binding above). + const unbindIframe = bindIframeWindow(embed.iframe, (win) => { + win.addEventListener('keydown', onKeyDown); + return () => win.removeEventListener('keydown', onKeyDown); + }); + return () => { + window.removeEventListener('keydown', onKeyDown); + unbindIframe(); + }; + }, [embed, deafenKey]); +}