From 8ace4b6321d44411272651b376448642b7228648 Mon Sep 17 00:00:00 2001 From: Evan Hahn <69474926+EvanHahn-Signal@users.noreply.github.com> Date: Thu, 17 Dec 2020 17:12:15 -0600 Subject: [PATCH] Improve window activity detection, improving notification delivery --- js/notifications.js | 7 + main.js | 9 + preload.js | 11 ++ ts/background.ts | 43 ----- ts/services/ActiveWindowService.ts | 97 +++++++++++ .../services/ActiveWindowService_test.ts | 164 ++++++++++++++++++ ts/window.d.ts | 5 +- 7 files changed, 290 insertions(+), 46 deletions(-) create mode 100644 ts/services/ActiveWindowService.ts create mode 100644 ts/test-electron/services/ActiveWindowService_test.ts diff --git a/js/notifications.js b/js/notifications.js index 72c3dc0501..0b2796925b 100644 --- a/js/notifications.js +++ b/js/notifications.js @@ -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; diff --git a/main.js b/main.js index 5f3488c57b..595b166f45 100644 --- a/main.js +++ b/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') { diff --git a/preload.js b/preload.js index 13bf0bb3a5..813ebae78d 100644 --- a/preload.js +++ b/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 diff --git a/ts/background.ts b/ts/background.ts index 41bd0dddd4..3509db31cb 100644 --- a/ts/background.ts +++ b/ts/background.ts @@ -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 = []; - 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'); diff --git a/ts/services/ActiveWindowService.ts b/ts/services/ActiveWindowService.ts new file mode 100644 index 0000000000..93f989ec90 --- /dev/null +++ b/ts/services/ActiveWindowService.ts @@ -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(); + } + } +} diff --git a/ts/test-electron/services/ActiveWindowService_test.ts b/ts/test-electron/services/ActiveWindowService_test.ts new file mode 100644 index 0000000000..d73b312ca7 --- /dev/null +++ b/ts/test-electron/services/ActiveWindowService_test.ts @@ -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); + }); +}); diff --git a/ts/window.d.ts b/ts/window.d.ts index 05c878cc7f..e4cb77fdba 100644 --- a/ts/window.d.ts +++ b/ts/window.d.ts @@ -151,8 +151,7 @@ declare global { preloadedImages: Array; 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;