From f23b215efa4e3bc1023040aeba93af5cc8044f8b Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Sat, 19 Sep 2026 17:13:14 -0400 Subject: [PATCH] fix(calls): release a push-to-talk hold after 5 minutes (#136) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA --- src/app/hooks/useCallHotkeys.ts | 38 ++++++++++++-- src/app/utils/pttHoldWatchdog.test.ts | 74 +++++++++++++++++++++++++++ src/app/utils/pttHoldWatchdog.ts | 40 +++++++++++++++ 3 files changed, 148 insertions(+), 4 deletions(-) create mode 100644 src/app/utils/pttHoldWatchdog.test.ts create mode 100644 src/app/utils/pttHoldWatchdog.ts diff --git a/src/app/hooks/useCallHotkeys.ts b/src/app/hooks/useCallHotkeys.ts index 302c6548f..55a17157f 100644 --- a/src/app/hooks/useCallHotkeys.ts +++ b/src/app/hooks/useCallHotkeys.ts @@ -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; diff --git a/src/app/utils/pttHoldWatchdog.test.ts b/src/app/utils/pttHoldWatchdog.test.ts new file mode 100644 index 000000000..adcfa03f7 --- /dev/null +++ b/src/app/utils/pttHoldWatchdog.test.ts @@ -0,0 +1,74 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { createPttHoldWatchdog } from './pttHoldWatchdog'; + +const fake = () => { + let now = 0; + const timers = new Map void }>(); + let seq = 0; + return { + timers: { + setTimeout: (fn: () => void, ms: number) => { + seq += 1; + timers.set(seq, { at: now + ms, fn }); + return seq; + }, + clearTimeout: (h: unknown) => { + timers.delete(h as number); + }, + }, + advance(ms: number) { + now += ms; + [...timers.entries()] + .filter(([, t]) => t.at <= now) + .forEach(([id, t]) => { + timers.delete(id); + t.fn(); + }); + }, + }; +}; + +test('a hold past the limit expires once; a release before it never does', () => { + const clock = fake(); + let expired = 0; + const w = createPttHoldWatchdog( + () => { + expired += 1; + }, + 1000, + clock.timers, + ); + w.arm(); + clock.advance(999); + assert.equal(expired, 0); + clock.advance(1); + assert.equal(expired, 1); + assert.equal(w.armed(), false); + + w.arm(); + clock.advance(500); + w.disarm(); + clock.advance(2000); + assert.equal(expired, 1); +}); + +test('re-pressing restarts the clock', () => { + const clock = fake(); + let expired = 0; + const w = createPttHoldWatchdog( + () => { + expired += 1; + }, + 1000, + clock.timers, + ); + w.arm(); + clock.advance(800); + w.disarm(); + w.arm(); + clock.advance(800); + assert.equal(expired, 0); + clock.advance(200); + assert.equal(expired, 1); +}); diff --git a/src/app/utils/pttHoldWatchdog.ts b/src/app/utils/pttHoldWatchdog.ts new file mode 100644 index 000000000..bcb10ac13 --- /dev/null +++ b/src/app/utils/pttHoldWatchdog.ts @@ -0,0 +1,40 @@ +/** + * [Gitea #136] Elbow-on-the-keyboard guard for push-to-talk: a hold longer + * than `maxMs` is released as if the key came up. `arm()` on press, `disarm()` + * on release (a genuine release + re-press restarts the clock). Timers are + * injectable for tests. + */ +export const PTT_MAX_HOLD_MS = 5 * 60 * 1000; + +type Timers = { + setTimeout: (fn: () => void, ms: number) => unknown; + clearTimeout: (handle: unknown) => void; +}; + +const realTimers: Timers = { + setTimeout: (fn, ms) => setTimeout(fn, ms), + clearTimeout: (h) => clearTimeout(h as ReturnType), +}; + +export const createPttHoldWatchdog = ( + onExpire: () => void, + maxMs = PTT_MAX_HOLD_MS, + timers: Timers = realTimers, +) => { + let handle: unknown; + const disarm = () => { + if (handle !== undefined) timers.clearTimeout(handle); + handle = undefined; + }; + return { + arm: () => { + disarm(); + handle = timers.setTimeout(() => { + handle = undefined; + onExpire(); + }, maxMs); + }, + disarm, + armed: () => handle !== undefined, + }; +};