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:
@@ -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