Files
Desktop/ts/services/ActiveWindowService.std.ts

165 lines
5.0 KiB
TypeScript

// Copyright 2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { SECOND } from '../util/durations/constants.std.js';
import { throttle } from '../util/throttle.std.js';
// Idle timer - you're active for ACTIVE_TIMEOUT after one of these events
const ACTIVE_TIMEOUT = 15 * SECOND;
// Some events (scrolling) should cause us to be considered active for a short period of
// time, even if the window is unfocused
const ACTIVE_AFTER_NON_FOCUSING_EVENT_TIMEOUT = 1 * SECOND;
const LISTENER_THROTTLE_TIME = 5 * SECOND;
const ACTIVE_EVENTS = [
'click',
'keydown',
'mousedown',
'mousemove',
// 'scroll', // this is triggered by Timeline re-renders, can't use
'touchstart',
'wheel',
];
// A wheel move can scroll the timeline but, unlike the other events, it does not focus
// the window. We still want to consider the window "active" for a short period after that
// user-initiated scroll (e.g. to mark messages read)
const NON_FOCUSING_ACTIVE_EVENTS = ['wheel'];
class ActiveWindowService {
// This starting value might be wrong but we should get an update from the main process
// soon. We'd rather report that the window is inactive so we can show notifications.
#isInitialized = false;
#isFocused = false;
#activeCallbacks: Array<() => void> = [];
#changeCallbacks: Array<(isActive: boolean) => void> = [];
#lastActiveEventAt = -Infinity;
#lastActiveNonFocusingEventAt = -Infinity;
#callActiveCallbacks: () => void;
constructor() {
this.#callActiveCallbacks = throttle(() => {
this.#activeCallbacks.forEach(callback => callback());
}, LISTENER_THROTTLE_TIME);
}
// These types aren't perfectly accurate, but they make this class easier to test.
initialize(document: EventTarget, ipc: NodeJS.EventEmitter): void {
if (this.#isInitialized) {
throw new Error(
'Active window service should not be initialized multiple times'
);
}
this.#isInitialized = true;
this.#lastActiveEventAt = Date.now();
const onActiveEvent = this.#onActiveEvent.bind(this);
ACTIVE_EVENTS.forEach((eventName: string) => {
document.addEventListener(eventName, onActiveEvent, true);
});
// We don't know for sure that we'll get the right data over IPC so we use `unknown`.
ipc.on('set-window-focus', (_event: unknown, isFocused: unknown) => {
this.#setWindowFocus(Boolean(isFocused));
});
}
isActive(): boolean {
if (this.#isFocused) {
return Date.now() < this.#lastActiveEventAt + ACTIVE_TIMEOUT;
}
return (
Date.now() <
this.#lastActiveNonFocusingEventAt +
ACTIVE_AFTER_NON_FOCUSING_EVENT_TIMEOUT
);
}
registerForActive(callback: () => void): void {
this.#activeCallbacks.push(callback);
}
unregisterForActive(callback: () => void): void {
this.#activeCallbacks = this.#activeCallbacks.filter(
item => item !== callback
);
}
registerForChange(callback: (isActive: boolean) => void): void {
this.#changeCallbacks.push(callback);
}
unregisterForChange(callback: (isActive: boolean) => void): void {
this.#changeCallbacks = this.#changeCallbacks.filter(
item => item !== callback
);
}
#onActiveEvent(e: Event): void {
this.#updateState(() => {
this.#lastActiveEventAt = Date.now();
if (NON_FOCUSING_ACTIVE_EVENTS.includes(e.type)) {
this.#lastActiveNonFocusingEventAt = Date.now();
}
});
}
#setWindowFocus(isFocused: boolean): void {
this.#updateState(() => {
this.#isFocused = isFocused;
});
}
#updateState(fn: () => void): void {
const wasActiveBefore = this.isActive();
fn();
const isActiveNow = this.isActive();
if (!wasActiveBefore && isActiveNow) {
this.#callActiveCallbacks();
}
if (wasActiveBefore !== isActiveNow) {
for (const callback of this.#changeCallbacks) {
callback(isActiveNow);
}
}
}
}
export type ActiveWindowServiceType = {
isActive(): boolean;
registerForActive(callback: () => void): void;
unregisterForActive(callback: () => void): void;
registerForChange(callback: (isActive: boolean) => void): void;
unregisterForChange(callback: (isActive: boolean) => void): void;
};
export function getActiveWindowService(
document: EventTarget,
ipc: NodeJS.EventEmitter
): ActiveWindowServiceType {
const activeWindowService = new ActiveWindowService();
activeWindowService.initialize(document, ipc);
return {
isActive(): boolean {
return activeWindowService.isActive();
},
registerForActive(callback: () => void): void {
return activeWindowService.registerForActive(callback);
},
unregisterForActive(callback: () => void): void {
return activeWindowService.unregisterForActive(callback);
},
registerForChange(callback: (isActive: boolean) => void): void {
return activeWindowService.registerForChange(callback);
},
unregisterForChange(callback: (isActive: boolean) => void): void {
return activeWindowService.unregisterForChange(callback);
},
};
}