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:
@@ -4,6 +4,8 @@ import { CallEmbed, useCallControlState } from '../plugins/call';
|
|||||||
import { useSetting } from '../state/hooks/settings';
|
import { useSetting } from '../state/hooks/settings';
|
||||||
import { settingsAtom } from '../state/settings';
|
import { settingsAtom } from '../state/settings';
|
||||||
import { invokeTauri, isTauri, useTauriEvent } from './useTauri';
|
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). */
|
/** Payload of the desktop's `lotus-global-hotkey` DOM event (cinny-desktop #2). */
|
||||||
type GlobalHotkeyEvent = {
|
type GlobalHotkeyEvent = {
|
||||||
@@ -171,11 +173,38 @@ export function useCallHotkeys(callEmbed: CallEmbed | undefined, joined: boolean
|
|||||||
}, [pttMode, embed]);
|
}, [pttMode, embed]);
|
||||||
|
|
||||||
const pttActiveRef = useRef(false);
|
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(() => {
|
useEffect(() => {
|
||||||
if (!embed || !pttMode) return undefined;
|
if (!embed || !pttMode) return undefined;
|
||||||
|
|
||||||
const release = () => {
|
const release = () => {
|
||||||
|
holdWatchdog.current.disarm();
|
||||||
embed.control.pttActive = false;
|
embed.control.pttActive = false;
|
||||||
embed.control.setMicrophone(false);
|
embed.control.setMicrophone(false);
|
||||||
pttActiveRef.current = false;
|
pttActiveRef.current = false;
|
||||||
@@ -186,12 +215,15 @@ export function useCallHotkeys(callEmbed: CallEmbed | undefined, joined: boolean
|
|||||||
const target = e.target as HTMLElement;
|
const target = e.target as HTMLElement;
|
||||||
if (isEditable(target)) return;
|
if (isEditable(target)) return;
|
||||||
if (!isInteractive(target)) e.preventDefault();
|
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)
|
// C-M5: mark PTT active BEFORE unmuting so the mic echo (onMediaState)
|
||||||
// doesn't treat this transient unmute as a user-initiated undeafen.
|
// doesn't treat this transient unmute as a user-initiated undeafen.
|
||||||
embed.control.pttActive = true;
|
embed.control.pttActive = true;
|
||||||
if (!microphoneRef.current) embed.control.setMicrophone(true);
|
if (!microphoneRef.current) embed.control.setMicrophone(true);
|
||||||
pttActiveRef.current = true;
|
pttActiveRef.current = true;
|
||||||
setPttActive(true);
|
setPttActive(true);
|
||||||
|
holdWatchdog.current.arm();
|
||||||
};
|
};
|
||||||
const onKeyUp = (e: KeyboardEvent) => {
|
const onKeyUp = (e: KeyboardEvent) => {
|
||||||
if (e.code !== pttKey) return;
|
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
|
// has focus the DOM handlers above/below own the key — with their
|
||||||
// editable-field and interactive-element checks — so global events are
|
// editable-field and interactive-element checks — so global events are
|
||||||
// ignored then and nothing double-fires.
|
// ignored then and nothing double-fires.
|
||||||
const embedRef = useRef(embed);
|
|
||||||
useEffect(() => {
|
|
||||||
embedRef.current = embed;
|
|
||||||
}, [embed]);
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isTauri() || !embed || !globalCallHotkeys) return undefined;
|
if (!isTauri() || !embed || !globalCallHotkeys) return undefined;
|
||||||
const bindings = [
|
const bindings = [
|
||||||
@@ -265,7 +293,9 @@ export function useCallHotkeys(callEmbed: CallEmbed | undefined, joined: boolean
|
|||||||
if (!microphoneRef.current) current.control.setMicrophone(true);
|
if (!microphoneRef.current) current.control.setMicrophone(true);
|
||||||
pttActiveRef.current = true;
|
pttActiveRef.current = true;
|
||||||
setPttActive(true);
|
setPttActive(true);
|
||||||
|
holdWatchdog.current.arm();
|
||||||
} else if (pttActiveRef.current) {
|
} else if (pttActiveRef.current) {
|
||||||
|
holdWatchdog.current.disarm();
|
||||||
current.control.pttActive = false;
|
current.control.pttActive = false;
|
||||||
current.control.setMicrophone(false);
|
current.control.setMicrophone(false);
|
||||||
pttActiveRef.current = false;
|
pttActiveRef.current = false;
|
||||||
|
|||||||
@@ -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<number, { at: number; fn: () => 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);
|
||||||
|
});
|
||||||
@@ -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<typeof setTimeout>),
|
||||||
|
};
|
||||||
|
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user