Improve window activity detection, improving notification delivery
This commit is contained in:
parent
be9721c72d
commit
8ace4b6321
7 changed files with 290 additions and 46 deletions
|
@ -89,12 +89,19 @@
|
|||
});
|
||||
|
||||
if (status.type !== 'ok') {
|
||||
window.log.info(
|
||||
`Not updating notifications; notification status is ${status.type}. ${
|
||||
status.shouldClearNotifications ? 'Also clearing notifications' : ''
|
||||
}`
|
||||
);
|
||||
|
||||
if (status.shouldClearNotifications) {
|
||||
this.notificationData = null;
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
window.log.info('Showing a notification');
|
||||
|
||||
let notificationTitle;
|
||||
let notificationMessage;
|
||||
|
|
9
main.js
9
main.js
|
@ -373,6 +373,15 @@ async function createWindow() {
|
|||
mainWindow.on('resize', debouncedCaptureStats);
|
||||
mainWindow.on('move', debouncedCaptureStats);
|
||||
|
||||
const setWindowFocus = () => {
|
||||
mainWindow.webContents.send('set-window-focus', mainWindow.isFocused());
|
||||
};
|
||||
mainWindow.on('focus', setWindowFocus);
|
||||
mainWindow.on('blur', setWindowFocus);
|
||||
mainWindow.once('ready-to-show', setWindowFocus);
|
||||
// This is a fallback in case we drop an event for some reason.
|
||||
setInterval(setWindowFocus, 10000);
|
||||
|
||||
if (config.environment === 'test') {
|
||||
mainWindow.loadURL(prepareURL([__dirname, 'test', 'index.html']));
|
||||
} else if (config.environment === 'test-lib') {
|
||||
|
|
11
preload.js
11
preload.js
|
@ -383,6 +383,7 @@ try {
|
|||
const { autoOrientImage } = require('./js/modules/auto_orient_image');
|
||||
const { imageToBlurHash } = require('./ts/util/imageToBlurHash');
|
||||
const { isGroupCallingEnabled } = require('./ts/util/isGroupCallingEnabled');
|
||||
const { ActiveWindowService } = require('./ts/services/ActiveWindowService');
|
||||
|
||||
window.autoOrientImage = autoOrientImage;
|
||||
window.dataURLToBlobSync = require('blueimp-canvas-to-blob');
|
||||
|
@ -395,6 +396,16 @@ try {
|
|||
window.getGuid = require('uuid/v4');
|
||||
window.isGroupCallingEnabled = isGroupCallingEnabled;
|
||||
|
||||
const activeWindowService = new ActiveWindowService();
|
||||
activeWindowService.initialize(window.document, ipc);
|
||||
window.isActive = activeWindowService.isActive.bind(activeWindowService);
|
||||
window.registerForActive = activeWindowService.registerForActive.bind(
|
||||
activeWindowService
|
||||
);
|
||||
window.unregisterForActive = activeWindowService.unregisterForActive.bind(
|
||||
activeWindowService
|
||||
);
|
||||
|
||||
window.isValidGuid = maybeGuid =>
|
||||
/^[0-9A-F]{8}-[0-9A-F]{4}-4[0-9A-F]{3}-[89AB][0-9A-F]{3}-[0-9A-F]{12}$/i.test(
|
||||
maybeGuid
|
||||
|
|
|
@ -88,49 +88,6 @@ type WhatIsThis = import('./window.d').WhatIsThis;
|
|||
false
|
||||
);
|
||||
|
||||
// Idle timer - you're active for ACTIVE_TIMEOUT after one of these events
|
||||
const ACTIVE_TIMEOUT = 15 * 1000;
|
||||
const ACTIVE_EVENTS = [
|
||||
'click',
|
||||
'keydown',
|
||||
'mousedown',
|
||||
'mousemove',
|
||||
// 'scroll', // this is triggered by Timeline re-renders, can't use
|
||||
'touchstart',
|
||||
'wheel',
|
||||
];
|
||||
|
||||
const LISTENER_DEBOUNCE = 5 * 1000;
|
||||
let activeHandlers: Array<WhatIsThis> = [];
|
||||
let activeTimestamp = Date.now();
|
||||
|
||||
window.addEventListener('blur', () => {
|
||||
// Force inactivity
|
||||
activeTimestamp = Date.now() - ACTIVE_TIMEOUT;
|
||||
});
|
||||
|
||||
window.resetActiveTimer = _.throttle(() => {
|
||||
const previouslyActive = window.isActive();
|
||||
activeTimestamp = Date.now();
|
||||
|
||||
if (!previouslyActive) {
|
||||
activeHandlers.forEach(handler => handler());
|
||||
}
|
||||
}, LISTENER_DEBOUNCE);
|
||||
|
||||
ACTIVE_EVENTS.forEach(name => {
|
||||
document.addEventListener(name, window.resetActiveTimer, true);
|
||||
});
|
||||
|
||||
window.isActive = () => {
|
||||
const now = Date.now();
|
||||
return now <= activeTimestamp + ACTIVE_TIMEOUT;
|
||||
};
|
||||
window.registerForActive = handler => activeHandlers.push(handler);
|
||||
window.unregisterForActive = handler => {
|
||||
activeHandlers = activeHandlers.filter(item => item !== handler);
|
||||
};
|
||||
|
||||
// Keyboard/mouse mode
|
||||
let interactionMode: 'mouse' | 'keyboard' = 'mouse';
|
||||
$(document.body).addClass('mouse-mode');
|
||||
|
|
97
ts/services/ActiveWindowService.ts
Normal file
97
ts/services/ActiveWindowService.ts
Normal file
|
@ -0,0 +1,97 @@
|
|||
// Copyright 2020 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import { throttle } from 'lodash';
|
||||
|
||||
// Idle timer - you're active for ACTIVE_TIMEOUT after one of these events
|
||||
const ACTIVE_TIMEOUT = 15 * 1000;
|
||||
const LISTENER_THROTTLE_TIME = 5 * 1000;
|
||||
const ACTIVE_EVENTS = [
|
||||
'click',
|
||||
'keydown',
|
||||
'mousedown',
|
||||
'mousemove',
|
||||
// 'scroll', // this is triggered by Timeline re-renders, can't use
|
||||
'touchstart',
|
||||
'wheel',
|
||||
];
|
||||
|
||||
export 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.
|
||||
private isInitialized = false;
|
||||
|
||||
private isFocused = false;
|
||||
|
||||
private activeCallbacks: Array<() => void> = [];
|
||||
|
||||
private lastActiveEventAt = -Infinity;
|
||||
|
||||
private 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 {
|
||||
return (
|
||||
this.isFocused && Date.now() < this.lastActiveEventAt + ACTIVE_TIMEOUT
|
||||
);
|
||||
}
|
||||
|
||||
registerForActive(callback: () => void): void {
|
||||
this.activeCallbacks.push(callback);
|
||||
}
|
||||
|
||||
unregisterForActive(callback: () => void): void {
|
||||
this.activeCallbacks = this.activeCallbacks.filter(
|
||||
item => item !== callback
|
||||
);
|
||||
}
|
||||
|
||||
private onActiveEvent(): void {
|
||||
this.updateState(() => {
|
||||
this.lastActiveEventAt = Date.now();
|
||||
});
|
||||
}
|
||||
|
||||
private setWindowFocus(isFocused: boolean): void {
|
||||
this.updateState(() => {
|
||||
this.isFocused = isFocused;
|
||||
});
|
||||
}
|
||||
|
||||
private updateState(fn: () => void): void {
|
||||
const wasActiveBefore = this.isActive();
|
||||
fn();
|
||||
const isActiveNow = this.isActive();
|
||||
|
||||
if (!wasActiveBefore && isActiveNow) {
|
||||
this.callActiveCallbacks();
|
||||
}
|
||||
}
|
||||
}
|
164
ts/test-electron/services/ActiveWindowService_test.ts
Normal file
164
ts/test-electron/services/ActiveWindowService_test.ts
Normal file
|
@ -0,0 +1,164 @@
|
|||
// Copyright 2020 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import { assert } from 'chai';
|
||||
import * as sinon from 'sinon';
|
||||
import { EventEmitter } from 'events';
|
||||
|
||||
import { ActiveWindowService } from '../../services/ActiveWindowService';
|
||||
|
||||
describe('ActiveWindowService', () => {
|
||||
const fakeIpcEvent = {};
|
||||
|
||||
beforeEach(function beforeEach() {
|
||||
this.clock = sinon.useFakeTimers({ now: 1000 });
|
||||
});
|
||||
|
||||
afterEach(function afterEach() {
|
||||
this.clock.restore();
|
||||
});
|
||||
|
||||
function createFakeDocument() {
|
||||
return document.createElement('div');
|
||||
}
|
||||
|
||||
it('is inactive at the start', () => {
|
||||
const service = new ActiveWindowService();
|
||||
service.initialize(createFakeDocument(), new EventEmitter());
|
||||
|
||||
assert.isFalse(service.isActive());
|
||||
});
|
||||
|
||||
it('becomes active after focusing', () => {
|
||||
const fakeIpc = new EventEmitter();
|
||||
const service = new ActiveWindowService();
|
||||
service.initialize(createFakeDocument(), fakeIpc);
|
||||
|
||||
fakeIpc.emit('set-window-focus', fakeIpcEvent, true);
|
||||
|
||||
assert.isTrue(service.isActive());
|
||||
});
|
||||
|
||||
it('becomes inactive after 15 seconds without interaction', function test() {
|
||||
const fakeIpc = new EventEmitter();
|
||||
const service = new ActiveWindowService();
|
||||
service.initialize(createFakeDocument(), fakeIpc);
|
||||
|
||||
fakeIpc.emit('set-window-focus', fakeIpcEvent, true);
|
||||
|
||||
this.clock.tick(5000);
|
||||
assert.isTrue(service.isActive());
|
||||
|
||||
this.clock.tick(9999);
|
||||
assert.isTrue(service.isActive());
|
||||
|
||||
this.clock.tick(1);
|
||||
assert.isFalse(service.isActive());
|
||||
});
|
||||
|
||||
['click', 'keydown', 'mousedown', 'mousemove', 'touchstart', 'wheel'].forEach(
|
||||
(eventName: string) => {
|
||||
it(`is inactive even in the face of ${eventName} events if unfocused`, function test() {
|
||||
const fakeDocument = createFakeDocument();
|
||||
const fakeIpc = new EventEmitter();
|
||||
const service = new ActiveWindowService();
|
||||
service.initialize(fakeDocument, fakeIpc);
|
||||
|
||||
fakeIpc.emit('set-window-focus', fakeIpcEvent, false);
|
||||
|
||||
fakeDocument.dispatchEvent(new Event(eventName));
|
||||
assert.isFalse(service.isActive());
|
||||
});
|
||||
|
||||
it(`stays active if focused and receiving ${eventName} events`, function test() {
|
||||
const fakeDocument = createFakeDocument();
|
||||
const fakeIpc = new EventEmitter();
|
||||
const service = new ActiveWindowService();
|
||||
service.initialize(fakeDocument, fakeIpc);
|
||||
|
||||
fakeIpc.emit('set-window-focus', fakeIpcEvent, true);
|
||||
|
||||
fakeDocument.dispatchEvent(new Event(eventName));
|
||||
assert.isTrue(service.isActive());
|
||||
|
||||
this.clock.tick(8000);
|
||||
fakeDocument.dispatchEvent(new Event(eventName));
|
||||
assert.isTrue(service.isActive());
|
||||
|
||||
this.clock.tick(8000);
|
||||
fakeDocument.dispatchEvent(new Event(eventName));
|
||||
assert.isTrue(service.isActive());
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
it('calls callbacks when going from unfocused to focused', () => {
|
||||
const fakeIpc = new EventEmitter();
|
||||
const service = new ActiveWindowService();
|
||||
service.initialize(createFakeDocument(), fakeIpc);
|
||||
|
||||
const callback = sinon.stub();
|
||||
service.registerForActive(callback);
|
||||
|
||||
fakeIpc.emit('set-window-focus', fakeIpcEvent, true);
|
||||
|
||||
sinon.assert.calledOnce(callback);
|
||||
});
|
||||
|
||||
it('calls callbacks when receiving a click event after being focused', function test() {
|
||||
const fakeDocument = createFakeDocument();
|
||||
const fakeIpc = new EventEmitter();
|
||||
const service = new ActiveWindowService();
|
||||
service.initialize(fakeDocument, fakeIpc);
|
||||
|
||||
fakeIpc.emit('set-window-focus', fakeIpcEvent, true);
|
||||
|
||||
this.clock.tick(20000);
|
||||
|
||||
const callback = sinon.stub();
|
||||
service.registerForActive(callback);
|
||||
|
||||
fakeDocument.dispatchEvent(new Event('click'));
|
||||
|
||||
sinon.assert.calledOnce(callback);
|
||||
});
|
||||
|
||||
it('only calls callbacks every 5 seconds; it is throttled', function test() {
|
||||
const fakeIpc = new EventEmitter();
|
||||
const service = new ActiveWindowService();
|
||||
service.initialize(createFakeDocument(), fakeIpc);
|
||||
|
||||
const callback = sinon.stub();
|
||||
service.registerForActive(callback);
|
||||
|
||||
fakeIpc.emit('set-window-focus', fakeIpcEvent, true);
|
||||
fakeIpc.emit('set-window-focus', fakeIpcEvent, false);
|
||||
fakeIpc.emit('set-window-focus', fakeIpcEvent, true);
|
||||
fakeIpc.emit('set-window-focus', fakeIpcEvent, false);
|
||||
fakeIpc.emit('set-window-focus', fakeIpcEvent, true);
|
||||
fakeIpc.emit('set-window-focus', fakeIpcEvent, false);
|
||||
|
||||
sinon.assert.calledOnce(callback);
|
||||
|
||||
this.clock.tick(15000);
|
||||
|
||||
fakeIpc.emit('set-window-focus', fakeIpcEvent, true);
|
||||
|
||||
sinon.assert.calledTwice(callback);
|
||||
});
|
||||
|
||||
it('can remove callbacks', () => {
|
||||
const fakeDocument = createFakeDocument();
|
||||
const fakeIpc = new EventEmitter();
|
||||
const service = new ActiveWindowService();
|
||||
service.initialize(fakeDocument, fakeIpc);
|
||||
|
||||
const callback = sinon.stub();
|
||||
service.registerForActive(callback);
|
||||
service.unregisterForActive(callback);
|
||||
|
||||
fakeIpc.emit('set-window-focus', fakeIpcEvent, true);
|
||||
|
||||
sinon.assert.notCalled(callback);
|
||||
});
|
||||
});
|
5
ts/window.d.ts
vendored
5
ts/window.d.ts
vendored
|
@ -151,8 +151,7 @@ declare global {
|
|||
preloadedImages: Array<WhatIsThis>;
|
||||
reduxActions: ReduxActions;
|
||||
reduxStore: WhatIsThis;
|
||||
registerForActive: (handler: WhatIsThis) => void;
|
||||
resetActiveTimer: () => void;
|
||||
registerForActive: (handler: () => void) => void;
|
||||
restart: () => void;
|
||||
setImmediate: typeof setImmediate;
|
||||
showWindow: () => void;
|
||||
|
@ -187,7 +186,7 @@ declare global {
|
|||
};
|
||||
systemTheme: WhatIsThis;
|
||||
textsecure: TextSecureType;
|
||||
unregisterForActive: (handler: WhatIsThis) => void;
|
||||
unregisterForActive: (handler: () => void) => void;
|
||||
updateTrayIcon: (count: number) => void;
|
||||
|
||||
Backbone: typeof Backbone;
|
||||
|
|
Loading…
Add table
Reference in a new issue