diff --git a/src/browser/components/MattermostView.jsx b/src/browser/components/MattermostView.jsx index f55ef258..16ad0689 100644 --- a/src/browser/components/MattermostView.jsx +++ b/src/browser/components/MattermostView.jsx @@ -45,6 +45,7 @@ export default class MattermostView extends React.Component { this.goForward = this.goForward.bind(this); this.getSrc = this.getSrc.bind(this); this.handleDeepLink = this.handleDeepLink.bind(this); + this.handleUserActivityUpdate = this.handleUserActivityUpdate.bind(this); this.webviewRef = React.createRef(); } @@ -186,6 +187,14 @@ export default class MattermostView extends React.Component { break; } }); + + // start listening for user status updates from main + ipcRenderer.on('user-activity-update', this.handleUserActivityUpdate); + } + + componentWillUnmount() { + // stop listening for user status updates from main + ipcRenderer.removeListener('user-activity-update', this.handleUserActivityUpdate); } reload() { @@ -253,6 +262,11 @@ export default class MattermostView extends React.Component { ); } + handleUserActivityUpdate(event, status) { + // pass user activity update to the webview + this.webviewRef.current.send('user-activity-update', status); + } + render() { const errorView = this.state.errorInfo ? ( { + webappConnector.emit('user-activity-update', {userIsActive, manual: isSystemEvent}); +}); + // mattermost-webapp is SPA. So cache is not cleared due to no navigation. // We needed to manually clear cache to free memory in long-term-use. // http://seenaburns.com/debugging-electron-memory-usage/ diff --git a/src/main.js b/src/main.js index 7982f35b..330a0463 100644 --- a/src/main.js +++ b/src/main.js @@ -32,6 +32,7 @@ import AppStateManager from './main/AppStateManager'; import initCookieManager from './main/cookieManager'; import {shouldBeHiddenOnStartup} from './main/utils'; import SpellChecker from './main/SpellChecker'; +import UserActivityMonitor from './main/UserActivityMonitor'; // pull out required electron components like this // as not all components can be referenced before the app is ready @@ -51,6 +52,7 @@ const argv = parseArgv(process.argv.slice(1)); const hideOnStartup = shouldBeHiddenOnStartup(argv); const loginCallbackMap = new Map(); const certificateStore = CertificateStore.load(path.resolve(app.getPath('userData'), 'certificate.json')); +const userActivityMonitor = new UserActivityMonitor(); // Keep a global reference of the window object, if you don't, the window will // be closed automatically when the JavaScript object is garbage collected. @@ -418,6 +420,14 @@ function initializeAfterAppReady() { config.setRegistryConfigData(registryConfig.data); mainWindow.registryConfigData = registryConfig.data; + // listen for status updates and pass on to renderer + userActivityMonitor.on('status', (status) => { + mainWindow.webContents.send('user-activity-update', status); + }); + + // start monitoring user activity (needs to be started after the app is ready) + userActivityMonitor.startMonitoring(); + if (shouldShowTrayIcon) { // set up tray icon trayIcon = new Tray(trayImages.normal); diff --git a/src/main/UserActivityMonitor.js b/src/main/UserActivityMonitor.js new file mode 100644 index 00000000..acfe4241 --- /dev/null +++ b/src/main/UserActivityMonitor.js @@ -0,0 +1,158 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import EventEmitter from 'events'; + +import electron from 'electron'; + +const {app} = electron; + +/** + * Monitors system idle time, listens for system events and fires status updates as needed + */ +export default class UserActivityMonitor extends EventEmitter { + constructor() { + super(); + + this.isActive = true; + this.forceInactive = false; + this.idleTime = 0; + this.lastStatusUpdate = 0; + this.systemIdleTimeIntervalID = -1; + + this.config = { + internalUpdateFrequencyMs: 1 * 1000, // eslint-disable-line no-magic-numbers + statusUpdateThresholdMs: 60 * 1000, // eslint-disable-line no-magic-numbers + activityTimeoutSec: 5 * 60, // eslint-disable-line no-magic-numbers + }; + + // NOTE: binding needed to prevent error; fat arrow class methods don't work in current setup + // Error: "Error: async hook stack has become corrupted (actual: #, expected: #)" + this.handleSystemGoingAway = this.handleSystemGoingAway.bind(this); + this.handleSystemComingBack = this.handleSystemComingBack.bind(this); + } + + get userIsActive() { + return this.forceInactive ? false : this.isActive; + } + + get userIdleTime() { + return this.idleTime; + } + + /** + * Begin monitoring system events and idle time at defined frequency + * + * @param {Object} config - overide internal configuration defaults + * @param {nunber} config.internalUpdateFrequencyMs + * @param {nunber} config.statusUpdateThresholdMs + * @param {nunber} config.activityTimeoutSec + * @emits {error} emitted when method is called before the app is ready + * @emits {error} emitted when this method has previously been called but not subsequently stopped + */ + startMonitoring(config = {}) { + if (!app.isReady()) { + this.emit('error', new Error('UserActivityMonitor.startMonitoring can only be called after app is ready')); + return; + } + + if (this.systemIdleTimeIntervalID >= 0) { + this.emit('error', new Error('User activity monitoring is already in progress')); + return; + } + + this.config = Object.assign({}, this.config, config); + + // NOTE: electron.powerMonitor cannot be referenced until the app is ready + electron.powerMonitor.on('suspend', this.handleSystemGoingAway); + electron.powerMonitor.on('resume', this.handleSystemComingBack); + electron.powerMonitor.on('shutdown', this.handleSystemGoingAway); + electron.powerMonitor.on('lock-screen', this.handleSystemGoingAway); + electron.powerMonitor.on('unlock-screen', this.handleSystemComingBack); + + this.systemIdleTimeIntervalID = setInterval(() => { + try { + electron.powerMonitor.querySystemIdleTime((idleTime) => { + this.updateIdleTime(idleTime); + }); + } catch (err) { + console.log('Error getting system idle time:', err); + } + }, this.config.internalUpdateFrequencyMs); + } + + /** + * Stop monitoring system events and idle time + */ + stopMonitoring() { + electron.powerMonitor.off('suspend', this.handleSystemGoingAway); + electron.powerMonitor.off('resume', this.handleSystemComingBack); + electron.powerMonitor.off('shutdown', this.handleSystemGoingAway); + electron.powerMonitor.off('lock-screen', this.handleSystemGoingAway); + electron.powerMonitor.off('unlock-screen', this.handleSystemComingBack); + + clearInterval(this.systemIdleTimeIntervalID); + } + + /** + * Updates internal idle time properties and conditionally triggers updates to user activity status + * + * @param {integer} idleTime + * @private + */ + updateIdleTime(idleTime) { + this.idleTime = idleTime; + + if (this.idleTime > this.config.activityTimeoutSec) { + this.updateUserActivityStatus(false); + } else if (!this.forceInactive && this.idleTime < this.config.activityTimeoutSec) { + this.updateUserActivityStatus(true); + } + } + + /** + * Updates user activity status if changed and triggers a status update + * + * @param {boolean} isActive + * @param {boolean} isSystemEvent – indicates whether the update was triggered by a system event (log in/out, screesaver on/off etc) + * @private + */ + updateUserActivityStatus(isActive = false, isSystemEvent = false) { + const now = Date.now(); + if (isActive !== this.isActive) { + this.isActive = isActive; + this.sendStatusUpdate(now, isSystemEvent); + } else if (now - this.lastStatusUpdate > this.config.statusUpdateThresholdMs) { + this.sendStatusUpdate(now, isSystemEvent); + } + } + + /** + * Sends an update with user activity status and current system idle time + * + * @emits {status} emitted at regular, definable intervals providing an update on user active status and idle time + * @private + */ + sendStatusUpdate(now = Date.now(), isSystemEvent = false) { + this.lastStatusUpdate = now; + this.emit('status', { + userIsActive: this.isActive, + idleTime: this.idleTime, + isSystemEvent, + }); + } + + /** + * System event handlers + * + * @private + */ + handleSystemGoingAway() { + this.forceInactive = true; + this.updateUserActivityStatus(false, true); + } + handleSystemComingBack() { + this.forceInactive = false; + this.updateUserActivityStatus(true, true); + } +} diff --git a/test/specs/main/user_activity_monitor_test.js b/test/specs/main/user_activity_monitor_test.js new file mode 100644 index 00000000..56e144d3 --- /dev/null +++ b/test/specs/main/user_activity_monitor_test.js @@ -0,0 +1,89 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. +import assert from 'assert'; + +import UserActivityMonitor from '../../../src/main/UserActivityMonitor'; + +describe('UserActivityMonitor', () => { + describe('updateIdleTime', () => { + it('should set idle time to provided value', () => { + const userActivityMonitor = new UserActivityMonitor(); + const idleTime = Math.round(Date.now() / 1000); + userActivityMonitor.updateIdleTime(idleTime); + assert.equal(userActivityMonitor.userIdleTime, idleTime); + }); + }); + + describe('updateUserActivityStatus', () => { + let userActivityMonitor; + + beforeEach(() => { + userActivityMonitor = new UserActivityMonitor(); + }); + + it('should set user status to active', () => { + userActivityMonitor.updateUserActivityStatus(true); + assert.equal(userActivityMonitor.userIsActive, true); + }); + it('should set user status to inactive', () => { + userActivityMonitor.updateUserActivityStatus(false); + assert.equal(userActivityMonitor.userIsActive, false); + }); + }); + + describe('handleSystemGoingAway', () => { + it('should set user status to inactive and forceInactive to true', () => { + const userActivityMonitor = new UserActivityMonitor(); + userActivityMonitor.isActive = true; + userActivityMonitor.forceInactive = false; + userActivityMonitor.handleSystemGoingAway(); + assert.equal(!userActivityMonitor.userIsActive && userActivityMonitor.forceInactive, true); + }); + }); + + describe('handleSystemComingBack', () => { + it('should set user status to active and forceInactive to false', () => { + const userActivityMonitor = new UserActivityMonitor(); + userActivityMonitor.isActive = false; + userActivityMonitor.forceInactive = true; + userActivityMonitor.handleSystemComingBack(); + assert.equal(userActivityMonitor.userIsActive && !userActivityMonitor.forceInactive, true); + }); + }); + + describe('sendStatusUpdate', () => { + let userActivityMonitor; + + beforeEach(() => { + userActivityMonitor = new UserActivityMonitor(); + }); + + it('should emit a non-system triggered status event indicating a user is active', () => { + userActivityMonitor.on('status', ({userIsActive, isSystemEvent}) => { + assert.equal(userIsActive && !isSystemEvent, true); + }); + userActivityMonitor.updateUserActivityStatus(true, false); + }); + + it('should emit a non-system triggered status event indicating a user is inactive', () => { + userActivityMonitor.on('status', ({userIsActive, isSystemEvent}) => { + assert.equal(!userIsActive && !isSystemEvent, true); + }); + userActivityMonitor.updateUserActivityStatus(false, false); + }); + + it('should emit a system triggered status event indicating a user is active', () => { + userActivityMonitor.on('status', ({userIsActive, isSystemEvent}) => { + assert.equal(userIsActive && isSystemEvent, true); + }); + userActivityMonitor.updateUserActivityStatus(true, true); + }); + + it('should emit a system triggered status event indicating a user is inactive', () => { + userActivityMonitor.on('status', ({userIsActive, isSystemEvent}) => { + assert.equal(!userIsActive && isSystemEvent, true); + }); + userActivityMonitor.updateUserActivityStatus(false, true); + }); + }); +});