mirror of
https://github.com/finos/SymphonyElectron.git
synced 2025-02-25 18:55:29 -06:00
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:
parent
858adb4cf3
commit
698036d7af
@ -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', () => {
|
||||
|
@ -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;
|
||||
}
|
||||
|
40
src/app/notifications/electron-notification.ts
Normal file
40
src/app/notifications/electron-notification.ts
Normal 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);
|
||||
}
|
||||
}
|
78
src/app/notifications/notification-helper.ts
Normal file
78
src/app/notifications/notification-helper.ts
Normal 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;
|
@ -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;
|
||||
|
@ -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
|
||||
*/
|
||||
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
@ -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) {
|
||||
|
Loading…
Reference in New Issue
Block a user