mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-18 05:25:40 +09:00
fix(app): keep the client active while the user is typing (#2557)
This commit is contained in:
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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user