fix(calls): release a push-to-talk hold after 5 minutes (#136)

Elbow-on-the-keyboard guard: a PTT hold longer than 5 min (fixed, not a
setting) is released exactly like a keyup — pttActive off, mic muted — with a
toast 'Push to talk released after 5 minutes — press the key again to keep
talking.' A fresh press re-engages normally. One watchdog shared by the DOM
path and the desktop-global hotkey path; keydown auto-repeat no longer
restarts anything; non-PTT mode, deafen and the mic button are untouched.
Helper unit-tested with fake timers; verified headless with Playwright's clock:
held → still open at +4 min → released + toast at +5 min → re-press works.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
This commit is contained in:
2026-09-19 17:13:14 -04:00
co-authored by Claude Opus 5
parent 821760131c
commit f23b215efa
3 changed files with 148 additions and 4 deletions
+34 -4
View File
@@ -4,6 +4,8 @@ import { CallEmbed, useCallControlState } from '../plugins/call';
import { useSetting } from '../state/hooks/settings';
import { settingsAtom } from '../state/settings';
import { invokeTauri, isTauri, useTauriEvent } from './useTauri';
import { createPttHoldWatchdog } from '../utils/pttHoldWatchdog';
import { toastQueueAtom } from '../state/toast';
/** Payload of the desktop's `lotus-global-hotkey` DOM event (cinny-desktop #2). */
type GlobalHotkeyEvent = {
@@ -171,11 +173,38 @@ export function useCallHotkeys(callEmbed: CallEmbed | undefined, joined: boolean
}, [pttMode, embed]);
const pttActiveRef = useRef(false);
const setToast = useSetAtom(toastQueueAtom);
const embedRef = useRef(embed);
useEffect(() => {
embedRef.current = embed;
}, [embed]);
// [Gitea #136] Elbow-on-the-keyboard guard, shared by the DOM and the
// desktop-global PTT paths: a hold longer than 5 min is released like a keyup.
const holdWatchdog = useRef(
createPttHoldWatchdog(() => {
const current = embedRef.current;
if (!current || !pttActiveRef.current) return;
current.control.pttActive = false;
current.control.setMicrophone(false);
pttActiveRef.current = false;
setPttActive(false);
setToast({
id: `ptt-stuck-${Date.now()}`,
displayName: 'Lotus Chat',
body: 'Push to talk released after 5 minutes — press the key again to keep talking.',
roomName: 'Voice call',
roomId: current.roomId,
});
}),
);
useEffect(() => () => holdWatchdog.current.disarm(), []);
useEffect(() => {
if (!embed || !pttMode) return undefined;
const release = () => {
holdWatchdog.current.disarm();
embed.control.pttActive = false;
embed.control.setMicrophone(false);
pttActiveRef.current = false;
@@ -186,12 +215,15 @@ export function useCallHotkeys(callEmbed: CallEmbed | undefined, joined: boolean
const target = e.target as HTMLElement;
if (isEditable(target)) return;
if (!isInteractive(target)) e.preventDefault();
// Key auto-repeat re-fires keydown while held; don't restart the clock.
if (pttActiveRef.current) return;
// 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);
holdWatchdog.current.arm();
};
const onKeyUp = (e: KeyboardEvent) => {
if (e.code !== pttKey) return;
@@ -234,10 +266,6 @@ export function useCallHotkeys(callEmbed: CallEmbed | undefined, joined: boolean
// has focus the DOM handlers above/below own the key — with their
// editable-field and interactive-element checks — so global events are
// ignored then and nothing double-fires.
const embedRef = useRef(embed);
useEffect(() => {
embedRef.current = embed;
}, [embed]);
useEffect(() => {
if (!isTauri() || !embed || !globalCallHotkeys) return undefined;
const bindings = [
@@ -265,7 +293,9 @@ export function useCallHotkeys(callEmbed: CallEmbed | undefined, joined: boolean
if (!microphoneRef.current) current.control.setMicrophone(true);
pttActiveRef.current = true;
setPttActive(true);
holdWatchdog.current.arm();
} else if (pttActiveRef.current) {
holdWatchdog.current.disarm();
current.control.pttActive = false;
current.control.setMicrophone(false);
pttActiveRef.current = false;