feat: SDA-2614 (Implement native electron notifications) (#1135)

* feat: SDA-2614 - Implement native electron notifications

* feat: SDA-2614 - Refactor and fix unit tests

* feat: SDA-2614 - Rename extraData to notificationData
This commit is contained in:
Kiran Niranjan 2020-12-09 11:45:29 +05:30 committed by GitHub
parent 858adb4cf3
commit 698036d7af
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 230 additions and 8 deletions

View File

@ -107,6 +107,15 @@ jest.mock('../src/app/download-handler', () => {
};
});
jest.mock('../src/app/notifications/notification-helper', () => {
return {
notificationHelper: {
showNotification: jest.fn(),
closeNotification: jest.fn(),
},
};
});
jest.mock('../src/common/i18n');
describe('main api handler', () => {

View File

@ -1,6 +1,6 @@
import { BrowserWindow, ipcMain } from 'electron';
import { apiCmds, apiName, IApiArgs } from '../common/api-interface';
import { apiCmds, apiName, IApiArgs, INotificationData } from '../common/api-interface';
import { LocaleType } from '../common/i18n';
import { logger } from '../common/logger';
import { activityDetection } from './activity-detection';
@ -9,6 +9,7 @@ import appStateHandler from './app-state-handler';
import { CloudConfigDataTypes, config, ICloudConfig } from './config-handler';
import { downloadHandler } from './download-handler';
import { memoryMonitor } from './memory-monitor';
import notificationHelper from './notifications/notification-helper';
import { protocolHandler } from './protocol-handler';
import { finalizeLogExports, registerLogRetriever } from './reports-handler';
import { screenSnippet } from './screen-snippet-handler';
@ -208,6 +209,17 @@ ipcMain.on(apiName.symphonyApi, async (event: Electron.IpcMainEvent, arg: IApiAr
logger.info('window-handler: isMana: ' + windowHandler.isMana);
}
break;
case apiCmds.showNotification:
if (typeof arg.notificationOpts === 'object') {
const opts = arg.notificationOpts as INotificationData;
notificationHelper.showNotification(opts);
}
break;
case apiCmds.closeNotification:
if (typeof arg.notificationId === 'number') {
await notificationHelper.closeNotification(arg.notificationId);
}
break;
default:
break;
}

View File

@ -0,0 +1,40 @@
import { Notification, NotificationConstructorOptions } from 'electron';
import { ElectronNotificationData, INotificationData, NotificationActions } from '../../common/api-interface';
export class ElectronNotification extends Notification {
private callback: (
actionType: NotificationActions,
data: INotificationData,
notificationData?: ElectronNotificationData,
) => void;
private options: INotificationData;
constructor(options: INotificationData, callback) {
super(options as NotificationConstructorOptions);
this.callback = callback;
this.options = options;
this.once('click', this.onClick);
this.once('reply', this.onReply);
}
/**
* Notification on click handler
* @param _event
* @private
*/
private onClick(_event: Event) {
this.callback(NotificationActions.notificationClicked, this.options);
}
/**
* Notification reply handler
* @param _event
* @param reply
* @private
*/
private onReply(_event: Event, reply: string) {
this.callback(NotificationActions.notificationReply, this.options, reply);
}
}

View File

@ -0,0 +1,78 @@
import { ElectronNotificationData, INotificationData, NotificationActions } from '../../common/api-interface';
import { isWindowsOS } from '../../common/env';
import { notification } from '../../renderer/notification';
import { windowHandler } from '../window-handler';
import { windowExists } from '../window-utils';
import { ElectronNotification } from './electron-notification';
class NotificationHelper {
private electronNotification: Map<number, ElectronNotification>;
constructor() {
this.electronNotification = new Map<number, ElectronNotification>();
}
/**
* Displays Electron/HTML notification based on the
* isElectronNotification flag
*
* @param options {INotificationData}
*/
public showNotification(options: INotificationData) {
if (options.isElectronNotification) {
// MacOS: Electron notification only supports static image path
options.icon = this.getIcon(options);
const electronToast = new ElectronNotification(options, this.notificationCallback);
this.electronNotification.set(options.id, electronToast);
electronToast.show();
return;
}
notification.showNotification(options, this.notificationCallback);
}
/**
* Closes a specific notification by id
*
* @param id {number} - unique id assigned to a specific notification
*/
public async closeNotification(id: number) {
if (this.electronNotification.has(id)) {
const electronNotification = this.electronNotification.get(id);
if (electronNotification) {
electronNotification.close();
}
return;
}
await notification.hideNotification(id);
}
/**
* Sends the notification actions event to the web client
*
* @param event {NotificationActions}
* @param data {ElectronNotificationData}
* @param notificationData {ElectronNotificationData}
*/
public notificationCallback(
event: NotificationActions,
data: ElectronNotificationData,
notificationData: ElectronNotificationData,
) {
const mainWindow = windowHandler.getMainWindow();
if (mainWindow && windowExists(mainWindow) && mainWindow.webContents) {
mainWindow.webContents.send('notification-actions', { event, data, notificationData });
}
}
/**
* Return the correct icon based on platform
* @param options
* @private
*/
private getIcon(options: INotificationData): string | undefined {
return isWindowsOS ? options.icon : undefined;
}
}
const notificationHelper = new NotificationHelper();
export default notificationHelper;

View File

@ -44,6 +44,7 @@ export enum apiCmds {
clearDownloadedItems = 'clear-downloaded-items',
restartApp = 'restart-app',
setIsMana = 'set-is-mana',
showNotification = 'show-notification',
}
export enum apiName {
@ -80,6 +81,8 @@ export interface IApiArgs {
logs: ILogs;
cloudConfig: object;
isMana: boolean;
notificationOpts: object;
notificationId: number;
}
export type WindowTypes = 'screen-picker' | 'screen-sharing-indicator' | 'notification-settings';
@ -126,7 +129,7 @@ export interface INotificationData {
title: string;
body: string;
image: string;
icon: string;
icon?: string;
flash: boolean;
color: string;
tag: string;
@ -135,11 +138,14 @@ export interface INotificationData {
displayTime: number;
isExternal: boolean;
theme: Theme;
isElectronNotification?: boolean;
callback?: () => void;
}
export enum NotificationActions {
notificationClicked = 'notification-clicked',
notificationClosed = 'notification-closed',
notificationReply = 'notification-reply',
}
/**
@ -204,3 +210,7 @@ export interface IRestartFloaterData {
windowName: string;
bounds: Electron.Rectangle;
}
export type Reply = string;
export type ElectronNotificationData = Reply | object;
export type NotificationActionCallback = (event: NotificationActions, data: INotificationData) => void;

View File

@ -53,6 +53,13 @@
<hr>
<p>Notifications:<p>
<button id='notf'>show notification</button>
<div>
<p>
<button id='closeNotification'>close notification</button>
<input type='number' id='notificationTag' value='Notification Tag to close'/>
</p>
</div>
<p id='reply'>Reply content will display here</p>
<p>
<label for='title'>title:</label>
<input type='text' id='title' value='Notification Demo'/>
@ -77,6 +84,10 @@
<label for='isExternal'>external:</label>
<input type='checkbox' id='isExternal'/>
</p>
<p>
<label for='isNativeNotification'>Native Electron Notification:</label>
<input type='checkbox' id='isNativeNotification'/>
</p>
<p>Change theme:<p>
<select id="select-theme" name="theme">
<option value="">select</option>
@ -267,6 +278,7 @@
});
var notfEl = document.getElementById('notf');
var closeNotificationEl = document.getElementById('closeNotification');
var num = 0;
// note: notification will close when clicked
@ -280,6 +292,7 @@
var color = document.getElementById('color').value;
var tag = document.getElementById('tag').value;
var company = document.getElementById('company').value;
var isNativeNotification = document.getElementById('isNativeNotification').checked;
num++;
@ -298,6 +311,8 @@
hello: `Notification with id ${num} clicked')`
},
tag: tag,
isElectronNotification: isNativeNotification,
hasReply: true,
method: 'notification',
};
@ -323,7 +338,9 @@
};
ssfNotificationHandler.addEventListener('error', onerror);
} else if (window.manaSSF) {
const callback = () => { console.log('notification clicked') };
const callback = (event, data) => {
document.getElementById('reply').innerText = data.notificationData;
};
window.manaSSF.showNotification(notf, callback);
} else {
window.postMessage({ method: 'notification', data: notf }, '*');
@ -331,6 +348,13 @@
});
closeNotificationEl.addEventListener('click', () => {
if (window.manaSSF) {
const id = document.getElementById('notificationTag').value;
window.manaSSF.closeNotification(parseInt(id));
}
})
/**
* Badge Count
*/

View File

@ -1,4 +1,4 @@
import { contextBridge, ipcRenderer, remote, webFrame } from 'electron';
import { contextBridge, ipcRenderer, webFrame } from 'electron';
import * as React from 'react';
import * as ReactDOM from 'react-dom';
import { apiCmds, apiName } from '../common/api-interface';
@ -22,7 +22,6 @@ const minMemoryFetchInterval = 4 * 60 * 60 * 1000;
const maxMemoryFetchInterval = 12 * 60 * 60 * 1000;
const snackBar = new SnackBar();
const banner = new MessageBanner();
const notification = remote.require('../renderer/notification').notification;
/**
* creates API exposed from electron.
@ -83,8 +82,8 @@ if (ssfWindow.ssf) {
registerRestartFloater: ssfWindow.ssf.registerRestartFloater,
setCloudConfig: ssfWindow.ssf.setCloudConfig,
checkMediaPermission: ssfWindow.ssf.checkMediaPermission,
showNotification: notification.showNotification,
closeNotification: notification.hideNotification,
showNotification: ssfWindow.ssf.showNotification,
closeNotification: ssfWindow.ssf.closeNotification,
restartApp: ssfWindow.ssf.restartApp,
});
}

View File

@ -11,6 +11,7 @@ import {
ICPUUsage,
ILogMsg,
IMediaPermission,
INotificationData,
IRestartFloaterData,
IScreenSharingIndicator,
IScreenSharingIndicatorOptions,
@ -18,6 +19,7 @@ import {
IVersionInfo,
KeyCodes,
LogLevel,
NotificationActionCallback,
} from '../common/api-interface';
import { i18n, LocaleType } from '../common/i18n-preload';
import { throttle } from '../common/utils';
@ -51,6 +53,8 @@ const local: ILocalObject = {
ipcRenderer,
};
const notificationActionCallbacks = new Map<number, NotificationActionCallback>();
// Throttle func
const throttledSetBadgeCount = throttle((count) => {
local.ipcRenderer.send(apiName.symphonyApi, {
@ -217,7 +221,7 @@ export class SSFApi {
containerIdentifier: appName,
containerVer: appVer,
buildNumber,
apiVer: '2.0.0',
apiVer: '3.0.0',
cpuArch,
// Only need to bump if there are any breaking changes.
searchApiVer: searchAPIVersion,
@ -590,6 +594,39 @@ export class SSFApi {
});
}
/**
* Displays a notification from the main process
* @param notificationOpts {INotificationData}
* @param notificationCallback {NotificationActionCallback}
*/
public showNotification(notificationOpts: INotificationData, notificationCallback: NotificationActionCallback): void {
// Store callbacks based on notification id so,
// we can use this to trigger on notification action
if (typeof notificationOpts.id === 'number') {
notificationActionCallbacks.set(notificationOpts.id, notificationCallback);
}
// ipc does not support sending Functions, Promises, Symbols, WeakMaps,
// or WeakSets will throw an exception
if (notificationOpts.callback) {
delete notificationOpts.callback;
}
ipcRenderer.send(apiName.symphonyApi, {
cmd: apiCmds.showNotification,
notificationOpts,
});
}
/**
* Closes a specific notification based on id
* @param notificationId {number} Id of a notification
*/
public closeNotification(notificationId: number): void {
ipcRenderer.send(apiName.symphonyApi, {
cmd: apiCmds.closeNotification,
notificationId,
});
}
}
/**
@ -770,6 +807,19 @@ local.ipcRenderer.on('restart-floater', (_event, { windowName, bounds }: IRestar
}
});
/**
* An event triggered by the main process on notification actions
* @param {INotificationData}
*/
local.ipcRenderer.on('notification-actions', (_event, args) => {
const callback = notificationActionCallbacks.get(args.data.id);
const data = args.data;
data.notificationData = args.notificationData;
if (args && callback) {
callback(args.event, data);
}
});
// Invoked whenever the app is reloaded/navigated
const sanitize = (): void => {
if (window.name === apiName.mainWindowName) {