From 2fd5daf1043e2e52f02b638ab471949c59ea858e Mon Sep 17 00:00:00 2001 From: Hampus Date: Sun, 6 Sep 2026 21:29:06 +0200 Subject: [PATCH] fix(app): keep the client active while the user is typing (#2557) --- fluxer_app/src/features/ui/state/Idle.test.ts | 99 +++++++++++++++++++ fluxer_app/src/features/ui/state/Idle.ts | 40 ++++---- .../window/hooks/useWindowEventListeners.ts | 2 + 3 files changed, 120 insertions(+), 21 deletions(-) create mode 100644 fluxer_app/src/features/ui/state/Idle.test.ts diff --git a/fluxer_app/src/features/ui/state/Idle.test.ts b/fluxer_app/src/features/ui/state/Idle.test.ts new file mode 100644 index 000000000..55a8aaa15 --- /dev/null +++ b/fluxer_app/src/features/ui/state/Idle.test.ts @@ -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) { + vi.resetModules(); + if (getSystemIdleTimeMs) { + Object.defineProperty(window, 'electron', {value: {getSystemIdleTimeMs}, configurable: true, writable: true}); + } else { + Reflect.deleteProperty(window as unknown as Record, '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, '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); + }); +}); diff --git a/fluxer_app/src/features/ui/state/Idle.ts b/fluxer_app/src/features/ui/state/Idle.ts index 75b596458..449662780 100644 --- a/fluxer_app/src/features/ui/state/Idle.ts +++ b/fluxer_app/src/features/ui/state/Idle.ts @@ -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): Promise { - 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 { diff --git a/fluxer_app/src/features/window/hooks/useWindowEventListeners.ts b/fluxer_app/src/features/window/hooks/useWindowEventListeners.ts index 0303e9251..e893fec59 100644 --- a/fluxer_app/src/features/window/hooks/useWindowEventListeners.ts +++ b/fluxer_app/src/features/window/hooks/useWindowEventListeners.ts @@ -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);