fix(app): keep the client active while the user is typing (#2557)

This commit is contained in:
Hampus authored and GitHub committed 2026-09-06 21:29:06 +02:00
1 parent fbf0f6adfe
commit 2fd5daf104
3 files changed
+120 -21

No files matched your search

@@ -0,0 +1,99 @@
// @vitest-environment happy-dom
// SPDX-License-Identifier: AGPL-3.0-or-later
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
const IDLE_DURATION_MS = 600_000;
const IDLE_CHECK_INTERVAL_MS = 30_000;
vi.mock('@app/features/platform/types/Env', () => ({
IS_DEV: false,
IS_PROD: true,
MODE: 'test',
}));
vi.mock('@app/features/presence/state/LocalPresence', () => ({
default: {updatePresence: vi.fn()},
}));
async function loadIdle(getSystemIdleTimeMs?: () => Promise<number>) {
vi.resetModules();
if (getSystemIdleTimeMs) {
Object.defineProperty(window, 'electron', {value: {getSystemIdleTimeMs}, configurable: true, writable: true});
} else {
Reflect.deleteProperty(window as unknown as Record<string, unknown>, 'electron');
}
const {default: Idle} = await import('@app/features/ui/state/Idle');
return Idle;
}
describe('Idle', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
Reflect.deleteProperty(window as unknown as Record<string, unknown>, 'electron');
vi.useRealTimers();
});
it('goes idle once nothing has reported activity for the idle duration', async () => {
const Idle = await loadIdle();
await vi.advanceTimersByTimeAsync(IDLE_DURATION_MS - IDLE_CHECK_INTERVAL_MS);
expect(Idle.isIdle()).toBe(false);
await vi.advanceTimersByTimeAsync(IDLE_CHECK_INTERVAL_MS);
expect(Idle.isIdle()).toBe(true);
});
it('stays active while the app keeps seeing input', async () => {
const Idle = await loadIdle();
for (let elapsed = 0; elapsed < IDLE_DURATION_MS * 2; elapsed += IDLE_CHECK_INTERVAL_MS) {
Idle.recordActivity();
await vi.advanceTimersByTimeAsync(IDLE_CHECK_INTERVAL_MS);
}
expect(Idle.isIdle()).toBe(false);
});
it('stays active while the app sees input even when the system idle clock keeps climbing', async () => {
const startedAt = Date.now();
const Idle = await loadIdle(async () => Date.now() - startedAt);
for (let elapsed = 0; elapsed < IDLE_DURATION_MS * 2; elapsed += IDLE_CHECK_INTERVAL_MS) {
Idle.recordActivity();
await vi.advanceTimersByTimeAsync(IDLE_CHECK_INTERVAL_MS);
}
expect(Idle.isIdle()).toBe(false);
});
it('leaves idle as soon as the app sees input again', async () => {
const startedAt = Date.now();
const Idle = await loadIdle(async () => Date.now() - startedAt);
await vi.advanceTimersByTimeAsync(IDLE_DURATION_MS);
expect(Idle.isIdle()).toBe(true);
Idle.recordActivity();
expect(Idle.isIdle()).toBe(false);
await vi.advanceTimersByTimeAsync(IDLE_CHECK_INTERVAL_MS);
expect(Idle.isIdle()).toBe(false);
});
it('stays active while the system reports input the app never sees', async () => {
const Idle = await loadIdle(async () => 0);
await vi.advanceTimersByTimeAsync(IDLE_DURATION_MS * 2);
expect(Idle.isIdle()).toBe(false);
});
it('goes idle when neither the app nor the system reports activity', async () => {
const startedAt = Date.now();
const Idle = await loadIdle(async () => Date.now() - startedAt);
await vi.advanceTimersByTimeAsync(IDLE_DURATION_MS);
expect(Idle.isIdle()).toBe(true);
});
it('falls back to app activity when the system idle clock is unreadable', async () => {
const Idle = await loadIdle(async () => Number.NaN);
await vi.advanceTimersByTimeAsync(IDLE_DURATION_MS);
expect(Idle.isIdle()).toBe(true);
Idle.recordActivity();
await vi.advanceTimersByTimeAsync(IDLE_CHECK_INTERVAL_MS);
expect(Idle.isIdle()).toBe(false);
});
});
+19 -21
View File
@@ -30,11 +30,11 @@ function normalizeIdleTimeMs(value: number): number | null {
class Idle {
idle = false;
private lastActivityTime = Date.now();
private lastLocalActivityTime = Date.now();
private lastSystemActivityTime = 0;
private checkInterval: NodeJS.Timeout | null = null;
private systemIdleCheckInFlight = false;
private lastSystemIdleFailureAt = 0;
private activityVersion = 0;
constructor() {
makeAutoObservable(this, {}, {autoBind: true});
@@ -56,16 +56,15 @@ class Idle {
}
recordActivity(): void {
this.lastActivityTime = Date.now();
this.activityVersion++;
this.lastLocalActivityTime = Date.now();
if (this.idle) {
this.applyIdleState(false);
}
}
markBackground(): void {
this.lastActivityTime = 0;
this.activityVersion++;
this.lastLocalActivityTime = 0;
this.lastSystemActivityTime = 0;
this.applyIdleState(true);
}
@@ -74,16 +73,24 @@ class Idle {
}
getIdleSince(): number {
return this.idle ? this.lastActivityTime : 0;
return this.idle ? this.getLastActivityTime() : 0;
}
getInactiveDurationMs(now = Date.now()): number {
return Math.max(0, now - this.lastActivityTime);
return Math.max(0, now - this.getLastActivityTime());
}
private getLastActivityTime(): number {
return Math.max(this.lastLocalActivityTime, this.lastSystemActivityTime);
}
private updateIdleState(): void {
const desktopIdleApi = getDesktopIdleApi();
if (desktopIdleApi && Date.now() - this.lastSystemIdleFailureAt >= SYSTEM_IDLE_RETRY_DELAY_MS) {
if (
desktopIdleApi &&
!this.systemIdleCheckInFlight &&
Date.now() - this.lastSystemIdleFailureAt >= SYSTEM_IDLE_RETRY_DELAY_MS
) {
void this.updateIdleStateFromSystem(desktopIdleApi);
return;
}
@@ -96,29 +103,20 @@ class Idle {
}
private async updateIdleStateFromSystem(desktopIdleApi: Required<DesktopIdleApi>): Promise<void> {
if (this.systemIdleCheckInFlight) return;
this.systemIdleCheckInFlight = true;
const activityVersion = this.activityVersion;
const requestedAt = Date.now();
try {
const idleTimeMs = normalizeIdleTimeMs(await desktopIdleApi.getSystemIdleTimeMs());
if (idleTimeMs === null) {
this.lastSystemIdleFailureAt = Date.now();
this.updateIdleStateFromLocalActivity();
return;
} else {
this.lastSystemActivityTime = Math.max(this.lastSystemActivityTime, Date.now() - idleTimeMs);
}
if (activityVersion !== this.activityVersion && this.lastActivityTime >= requestedAt) {
return;
}
const now = Date.now();
this.lastActivityTime = Math.max(0, now - idleTimeMs);
this.applyIdleState(idleTimeMs >= IDLE_DURATION_MS);
} catch {
this.lastSystemIdleFailureAt = Date.now();
this.updateIdleStateFromLocalActivity();
} finally {
this.systemIdleCheckInFlight = false;
}
this.updateIdleStateFromLocalActivity();
}
private applyIdleState(idle: boolean): void {
@@ -43,6 +43,7 @@ export function useWindowEventListeners({preventDocumentScroll}: WindowEventList
document.addEventListener('visibilitychange', handleVisibilityChange);
window.addEventListener('mousedown', handleImmediateActivity);
window.addEventListener('keydown', handleUserActivity);
window.addEventListener('input', handleUserActivity);
window.addEventListener('resize', handleResize);
window.addEventListener('touchstart', handleImmediateActivity);
document.addEventListener('touchstart', preventPinchZoom, {passive: false});
@@ -58,6 +59,7 @@ export function useWindowEventListeners({preventDocumentScroll}: WindowEventList
document.removeEventListener('visibilitychange', handleVisibilityChange);
window.removeEventListener('mousedown', handleImmediateActivity);
window.removeEventListener('keydown', handleUserActivity);
window.removeEventListener('input', handleUserActivity);
window.removeEventListener('resize', handleResize);
window.removeEventListener('touchstart', handleImmediateActivity);
document.removeEventListener('touchstart', preventPinchZoom);