diff --git a/.eslintignore b/.eslintignore index fc7b1dc5bb..59050f2c01 100644 --- a/.eslintignore +++ b/.eslintignore @@ -36,6 +36,7 @@ ts/**/*.js !js/logging.js !js/models/conversations.js !js/models/messages.js +!js/notifications.js !js/views/attachment_view.js !js/views/backbone_wrapper_view.js !js/views/conversation_search_view.js diff --git a/js/database.js b/js/database.js index 6bade25f87..2348217008 100644 --- a/js/database.js +++ b/js/database.js @@ -1,6 +1,7 @@ -/* global Whisper: false */ -/* global Backbone: false */ /* global _: false */ +/* global Backbone: false */ + +/* global Whisper: false */ // eslint-disable-next-line func-names (function() { diff --git a/js/modules/os.js b/js/modules/os.js deleted file mode 100644 index 245172f684..0000000000 --- a/js/modules/os.js +++ /dev/null @@ -1,7 +0,0 @@ -/* eslint-env node */ - -exports.isMacOS = () => process.platform === 'darwin'; - -exports.isLinux = () => process.platform === 'linux'; - -exports.isWindows = () => process.platform === 'win32'; diff --git a/js/modules/types/settings.js b/js/modules/types/settings.js deleted file mode 100644 index 85fbd7addf..0000000000 --- a/js/modules/types/settings.js +++ /dev/null @@ -1,3 +0,0 @@ -const OS = require('../os'); - -exports.isAudioNotificationSupported = () => !OS.isLinux(); diff --git a/js/notifications.js b/js/notifications.js index 95029fe50e..91091d39f5 100644 --- a/js/notifications.js +++ b/js/notifications.js @@ -1,9 +1,21 @@ +/* global Backbone: false */ + +/* global ConversationController: false */ +/* global drawAttention: false */ +/* global i18n: false */ +/* global isFocused: false */ +/* global Signal: false */ +/* global storage: false */ +/* global Whisper: false */ + +// eslint-disable-next-line func-names (function() { 'use strict'; - window.Whisper = window.Whisper || {}; - const { Settings } = window.Signal.Types; - var SETTINGS = { + window.Whisper = window.Whisper || {}; + const { Settings } = Signal.Types; + + const SettingNames = { OFF: 'off', COUNT: 'count', NAME: 'name', @@ -11,78 +23,68 @@ }; Whisper.Notifications = new (Backbone.Collection.extend({ - initialize: function() { + initialize() { this.isEnabled = false; this.on('add', this.update); this.on('remove', this.onRemove); }, - onClick: function(conversationId) { - var conversation = ConversationController.get(conversationId); + onClick(conversationId) { + const conversation = ConversationController.get(conversationId); this.trigger('click', conversation); }, - update: function() { + update() { const { isEnabled } = this; - const isFocused = window.isFocused(); + const isAppFocused = isFocused(); const isAudioNotificationEnabled = storage.get('audio-notification') || false; const isAudioNotificationSupported = Settings.isAudioNotificationSupported(); - const shouldPlayNotificationSound = - isAudioNotificationSupported && isAudioNotificationEnabled; const numNotifications = this.length; - console.log('Update notifications:', { - isFocused, + const userSetting = this.getUserSetting(); + + const status = Signal.Notifications.getStatus({ + isAppFocused, + isAudioNotificationEnabled, + isAudioNotificationSupported, isEnabled, numNotifications, - shouldPlayNotificationSound, + userSetting, }); - if (!isEnabled) { + console.log('Update notifications:', status); + + if (status.type !== 'ok') { + if (status.shouldClearNotifications) { + this.clear(); + } + return; } - const hasNotifications = numNotifications > 0; - if (!hasNotifications) { - return; - } - - const isNotificationOmitted = isFocused; - if (isNotificationOmitted) { - this.clear(); - return; - } - - var setting = storage.get('notification-setting') || 'message'; - if (setting === SETTINGS.OFF) { - return; - } - - window.drawAttention(); - - var title; - var message; - var iconUrl; + let title; + let message; + let iconUrl; // NOTE: i18n has more complex rules for pluralization than just // distinguishing between zero (0) and other (non-zero), // e.g. Russian: // http://docs.translatehouse.org/projects/localization-guide/en/latest/l10n/pluralforms.html - var newMessageCount = [ + const newMessageCount = [ numNotifications, numNotifications === 1 ? i18n('newMessage') : i18n('newMessages'), ].join(' '); - var last = this.last(); - switch (this.getSetting()) { - case SETTINGS.COUNT: + const last = this.last(); + switch (userSetting) { + case SettingNames.COUNT: title = 'Signal'; message = newMessageCount; break; - case SETTINGS.NAME: + case SettingNames.NAME: title = newMessageCount; - message = 'Most recent from ' + last.get('title'); + message = `Most recent from ${last.get('title')}`; iconUrl = last.get('iconUrl'); break; - case SETTINGS.MESSAGE: + case SettingNames.MESSAGE: if (numNotifications === 1) { title = last.get('title'); } else { @@ -91,52 +93,43 @@ message = last.get('message'); iconUrl = last.get('iconUrl'); break; + default: + console.log(`Error: Unknown user setting: '${userSetting}'`); + break; } - if (window.config.polyfillNotifications) { - window.nodeNotifier.notify({ - title: title, - message: message, - sound: false, - }); - window.nodeNotifier.on('click', function(notifierObject, options) { - last.get('conversationId'); - }); - } else { - var notification = new Notification(title, { - body: message, - icon: iconUrl, - tag: 'signal', - silent: !shouldPlayNotificationSound, - }); + drawAttention(); - notification.onclick = this.onClick.bind( - this, - last.get('conversationId') - ); - } + const notification = new Notification(title, { + body: message, + icon: iconUrl, + tag: 'signal', + silent: !status.shouldPlayNotificationSound, + }); + + notification.onclick = () => this.onClick(last.get('conversationId')); // We don't want to notify the user about these same messages again this.clear(); }, - getSetting: function() { - return storage.get('notification-setting') || SETTINGS.MESSAGE; + getUserSetting() { + return storage.get('notification-setting') || SettingNames.MESSAGE; }, - onRemove: function() { - console.log('remove notification'); + onRemove() { + console.log('Remove notification'); }, - clear: function() { - console.log('remove all notifications'); + clear() { + console.log('Remove all notifications'); this.reset([]); }, - enable: function() { + enable() { const needUpdate = !this.isEnabled; this.isEnabled = true; if (needUpdate) { this.update(); } }, - disable: function() { + disable() { this.isEnabled = false; }, }))(); diff --git a/main.js b/main.js index 51b556afff..93e46cee47 100644 --- a/main.js +++ b/main.js @@ -4,7 +4,6 @@ const os = require('os'); const _ = require('lodash'); const electron = require('electron'); -const semver = require('semver'); const { BrowserWindow, app, Menu, shell, ipcMain: ipc } = electron; @@ -99,17 +98,6 @@ const loadLocale = require('./app/locale').load; let logger; let locale; -const WINDOWS_8 = '8.0.0'; -const osRelease = os.release(); -const polyfillNotifications = - os.platform() === 'win32' && semver.lt(osRelease, WINDOWS_8); -console.log( - 'OS Release:', - osRelease, - '- notifications polyfill?', - polyfillNotifications -); - function prepareURL(pathSegments) { return url.format({ pathname: path.join.apply(null, pathSegments), @@ -127,7 +115,6 @@ function prepareURL(pathSegments) { node_version: process.versions.node, hostname: os.hostname(), appInstance: process.env.NODE_APP_INSTANCE, - polyfillNotifications: polyfillNotifications ? true : undefined, // for stringify() proxyUrl: process.env.HTTPS_PROXY || process.env.https_proxy, importMode: importMode ? true : undefined, // for stringify() }, diff --git a/package.json b/package.json index fd447c78fa..e4d07bc3b4 100644 --- a/package.json +++ b/package.json @@ -67,7 +67,6 @@ "mkdirp": "^0.5.1", "moment": "^2.21.0", "node-fetch": "https://github.com/scottnonnenberg/node-fetch.git#3e5f51e08c647ee5f20c43b15cf2d352d61c36b4", - "node-notifier": "^5.1.2", "os-locale": "^2.1.0", "pify": "^3.0.0", "proxy-agent": "^2.1.0", @@ -92,6 +91,8 @@ "@types/qs": "^6.5.1", "@types/react": "^16.3.1", "@types/react-dom": "^16.0.4", + "@types/semver": "^5.5.0", + "@types/sinon": "^4.3.1", "arraybuffer-loader": "^1.0.3", "asar": "^0.14.0", "bower": "^1.8.2", diff --git a/preload.js b/preload.js index c8cb16df72..412456a887 100644 --- a/preload.js +++ b/preload.js @@ -101,7 +101,6 @@ window.loadImage = require('blueimp-load-image'); window.nodeBuffer = Buffer; window.nodeFetch = require('node-fetch'); -window.nodeNotifier = require('node-notifier'); window.ProxyAgent = require('proxy-agent'); // Note: when modifying this file, consider whether our React Components or Backbone Views @@ -200,7 +199,8 @@ window.Signal.Migrations.Migrations0DatabaseWithAttachmentData = require('./js/m window.Signal.Migrations.Migrations1DatabaseWithoutAttachmentData = require('./js/modules/migrations/migrations_1_database_without_attachment_data'); window.Signal.Migrations.upgradeMessageSchema = upgradeMessageSchema; -window.Signal.OS = require('./js/modules/os'); +window.Signal.Notifications = require('./ts/notifications'); +window.Signal.OS = require('./ts/OS'); window.Signal.Settings = require('./js/modules/settings'); window.Signal.Startup = require('./js/modules/startup'); @@ -211,7 +211,7 @@ window.Signal.Types.Errors = require('./js/modules/types/errors'); window.Signal.Types.Message = Message; window.Signal.Types.MIME = require('./ts/types/MIME'); -window.Signal.Types.Settings = require('./js/modules/types/settings'); +window.Signal.Types.Settings = require('./ts/types/Settings'); window.Signal.Util = require('./ts/util'); window.Signal.Views = {}; diff --git a/test/modules/types/settings_test.js b/test/modules/types/settings_test.js deleted file mode 100644 index 59f1953484..0000000000 --- a/test/modules/types/settings_test.js +++ /dev/null @@ -1,52 +0,0 @@ -const sinon = require('sinon'); -const { assert } = require('chai'); - -const Settings = require('../../../js/modules/types/settings'); - -describe('Settings', () => { - const sandbox = sinon.createSandbox(); - - describe('isAudioNotificationSupported', () => { - context('on macOS', () => { - beforeEach(() => { - sandbox.stub(process, 'platform').value('darwin'); - }); - - afterEach(() => { - sandbox.restore(); - }); - - it('should return true', () => { - assert.isTrue(Settings.isAudioNotificationSupported()); - }); - }); - - context('on Windows', () => { - beforeEach(() => { - sandbox.stub(process, 'platform').value('win32'); - }); - - afterEach(() => { - sandbox.restore(); - }); - - it('should return true', () => { - assert.isTrue(Settings.isAudioNotificationSupported()); - }); - }); - - context('on Linux', () => { - beforeEach(() => { - sandbox.stub(process, 'platform').value('linux'); - }); - - afterEach(() => { - sandbox.restore(); - }); - - it('should return false', () => { - assert.isFalse(Settings.isAudioNotificationSupported()); - }); - }); - }); -}); diff --git a/ts/OS.ts b/ts/OS.ts new file mode 100644 index 0000000000..efc71bf9e6 --- /dev/null +++ b/ts/OS.ts @@ -0,0 +1,15 @@ +import is from '@sindresorhus/is'; +import os from 'os'; +import semver from 'semver'; + +export const isMacOS = () => process.platform === 'darwin'; +export const isLinux = () => process.platform === 'linux'; +export const isWindows = (minVersion?: string) => { + const isPlatformValid = process.platform === 'win32'; + const osRelease = os.release(); + const isVersionValid = is.undefined(minVersion) + ? true + : semver.gte(osRelease, minVersion); + + return isPlatformValid && isVersionValid; +}; diff --git a/ts/notifications/getStatus.ts b/ts/notifications/getStatus.ts new file mode 100644 index 0000000000..7c693435c4 --- /dev/null +++ b/ts/notifications/getStatus.ts @@ -0,0 +1,66 @@ +interface Environment { + isAppFocused: boolean; + isAudioNotificationEnabled: boolean; + isAudioNotificationSupported: boolean; + isEnabled: boolean; + numNotifications: number; + userSetting: UserSetting; +} + +interface Status { + shouldClearNotifications: boolean; + shouldPlayNotificationSound: boolean; + shouldShowNotifications: boolean; + type: Type; +} + +type UserSetting = 'off' | 'count' | 'name' | 'message'; + +type Type = + | 'ok' + | 'disabled' + | 'appIsFocused' + | 'noNotifications' + | 'userSetting'; + +export const getStatus = ({ + isAppFocused, + isAudioNotificationEnabled, + isAudioNotificationSupported, + isEnabled, + numNotifications, + userSetting, +}: Environment): Status => { + const type = ((): Type => { + if (!isEnabled) { + return 'disabled'; + } + + const hasNotifications = numNotifications > 0; + if (!hasNotifications) { + return 'noNotifications'; + } + + if (isAppFocused) { + return 'appIsFocused'; + } + + if (userSetting === 'off') { + return 'userSetting'; + } + + return 'ok'; + })(); + + const shouldPlayNotificationSound = + isAudioNotificationSupported && isAudioNotificationEnabled; + const shouldShowNotifications = type === 'ok'; + const shouldClearNotifications = type === 'appIsFocused'; + + return { + shouldClearNotifications, + shouldPlayNotificationSound, + shouldShowNotifications, + type, + }; +}; diff --git a/ts/notifications/index.ts b/ts/notifications/index.ts new file mode 100644 index 0000000000..30cb2f1e9b --- /dev/null +++ b/ts/notifications/index.ts @@ -0,0 +1,3 @@ +import { getStatus } from './getStatus'; + +export { getStatus }; diff --git a/ts/test/types/Settings_test.ts b/ts/test/types/Settings_test.ts new file mode 100644 index 0000000000..ebd7fd10d7 --- /dev/null +++ b/ts/test/types/Settings_test.ts @@ -0,0 +1,71 @@ +import os from 'os'; +import sinon from 'sinon'; +import { assert } from 'chai'; + +import * as Settings from '../../../ts/types/Settings'; + +describe('Settings', () => { + const sandbox = sinon.createSandbox(); + + describe('isAudioNotificationSupported', () => { + context('on macOS', () => { + beforeEach(() => { + sandbox.stub(process, 'platform').value('darwin'); + }); + + afterEach(() => { + sandbox.restore(); + }); + + it('should return true', () => { + assert.isTrue(Settings.isAudioNotificationSupported()); + }); + }); + + context('on Windows', () => { + context('version 7', () => { + beforeEach(() => { + sandbox.stub(process, 'platform').value('win32'); + sandbox.stub(os, 'release').returns('7.0.0'); + }); + + afterEach(() => { + sandbox.restore(); + }); + + it('should return false', () => { + assert.isFalse(Settings.isAudioNotificationSupported()); + }); + }); + + context('version 8+', () => { + beforeEach(() => { + sandbox.stub(process, 'platform').value('win32'); + sandbox.stub(os, 'release').returns('8.0.0'); + }); + + afterEach(() => { + sandbox.restore(); + }); + + it('should return true', () => { + assert.isTrue(Settings.isAudioNotificationSupported()); + }); + }); + }); + + context('on Linux', () => { + beforeEach(() => { + sandbox.stub(process, 'platform').value('linux'); + }); + + afterEach(() => { + sandbox.restore(); + }); + + it('should return false', () => { + assert.isFalse(Settings.isAudioNotificationSupported()); + }); + }); + }); +}); diff --git a/ts/types/Settings.ts b/ts/types/Settings.ts new file mode 100644 index 0000000000..08eea3106e --- /dev/null +++ b/ts/types/Settings.ts @@ -0,0 +1,6 @@ +import * as OS from '../OS'; + +const MIN_WINDOWS_VERSION = '8.0.0'; + +export const isAudioNotificationSupported = () => + OS.isWindows(MIN_WINDOWS_VERSION) || OS.isMacOS(); diff --git a/yarn.lock b/yarn.lock index af4b0221d8..47540d5bbb 100644 --- a/yarn.lock +++ b/yarn.lock @@ -83,6 +83,14 @@ version "16.3.1" resolved "https://registry.yarnpkg.com/@types/react/-/react-16.3.1.tgz#6f6aaffaf7dba502ff5ca15e4aa18caee9b04995" +"@types/semver@^5.5.0": + version "5.5.0" + resolved "https://registry.yarnpkg.com/@types/semver/-/semver-5.5.0.tgz#146c2a29ee7d3bae4bf2fcb274636e264c813c45" + +"@types/sinon@^4.3.1": + version "4.3.1" + resolved "https://registry.yarnpkg.com/@types/sinon/-/sinon-4.3.1.tgz#32458f9b166cd44c23844eee4937814276f35199" + "@vxna/mini-html-webpack-template@^0.1.6": version "0.1.6" resolved "https://registry.yarnpkg.com/@vxna/mini-html-webpack-template/-/mini-html-webpack-template-0.1.6.tgz#64225d564da5fe610b6445523c245572923c00b8" @@ -3789,10 +3797,6 @@ growl@1.10.3: version "1.10.3" resolved "https://registry.yarnpkg.com/growl/-/growl-1.10.3.tgz#1926ba90cf3edfe2adb4927f5880bc22c66c790f" -growly@^1.3.0: - version "1.3.0" - resolved "https://registry.yarnpkg.com/growly/-/growly-1.3.0.tgz#f10748cbe76af964b7c96c93c6bcc28af120c081" - grunt-cli@^1.2.0, grunt-cli@~1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/grunt-cli/-/grunt-cli-1.2.0.tgz#562b119ebb069ddb464ace2845501be97b35b6a8" @@ -5882,15 +5886,6 @@ node-libs-browser@^2.0.0: util "^0.10.3" vm-browserify "0.0.4" -node-notifier@^5.1.2: - version "5.1.2" - resolved "https://registry.yarnpkg.com/node-notifier/-/node-notifier-5.1.2.tgz#2fa9e12605fa10009d44549d6fcd8a63dde0e4ff" - dependencies: - growly "^1.3.0" - semver "^5.3.0" - shellwords "^0.1.0" - which "^1.2.12" - node-pre-gyp@^0.6.39: version "0.6.39" resolved "https://registry.yarnpkg.com/node-pre-gyp/-/node-pre-gyp-0.6.39.tgz#c00e96860b23c0e1420ac7befc5044e1d78d8649" @@ -7997,10 +7992,6 @@ shelljs@0.3.x: version "0.3.0" resolved "https://registry.yarnpkg.com/shelljs/-/shelljs-0.3.0.tgz#3596e6307a781544f591f37da618360f31db57b1" -shellwords@^0.1.0: - version "0.1.1" - resolved "https://registry.yarnpkg.com/shellwords/-/shellwords-0.1.1.tgz#d6b9181c1a48d397324c84871efbcfc73fc0654b" - signal-exit@^3.0.0, signal-exit@^3.0.1, signal-exit@^3.0.2: version "3.0.2" resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.2.tgz#b5fdc08f1287ea1178628e415e25132b73646c6d" @@ -9507,7 +9498,7 @@ which@1, which@^1.2.9, which@~1.2.1: dependencies: isexe "^2.0.0" -which@^1.2.10, which@^1.2.12, which@^1.2.14, which@^1.3.0: +which@^1.2.10, which@^1.2.14, which@^1.3.0: version "1.3.0" resolved "https://registry.yarnpkg.com/which/-/which-1.3.0.tgz#ff04bdfc010ee547d780bec38e1ac1c2777d253a" dependencies: