SDA-4220 (Implement API for call notification) (#1891)

* SDA-4420 - Implement API for call notification

Signed-off-by: Kiran Niranjan <kiran.niranjan@symphony.com>

* SDA-4420 - Add unit tests

Signed-off-by: Kiran Niranjan <kiran.niranjan@symphony.com>

* SDA-4420 - Fix caller name style

Signed-off-by: Kiran Niranjan <kiran.niranjan@symphony.com>

* SDA-4420 - Change to logger

Signed-off-by: Kiran Niranjan <kiran.niranjan@symphony.com>

* SDA-4420 - update call toast position based on notification setting change

Signed-off-by: Kiran Niranjan <kiran.niranjan@symphony.com>

---------

Signed-off-by: Kiran Niranjan <kiran.niranjan@symphony.com>
This commit is contained in:
Kiran Niranjan 2023-07-16 11:04:39 +05:30 committed by GitHub
parent d5f2ef1178
commit dcbe7a0b74
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
20 changed files with 2014 additions and 334 deletions

View File

@ -270,6 +270,22 @@ export const session = {
},
};
export const screen = {
getAllDisplays: jest.fn(),
getPrimaryDisplay: jest.fn(() => {
return {
workArea: {
x: '',
y: '',
},
workAreaSize: {
width: '',
height: '',
},
};
}),
};
export const remote = {
app,
getCurrentWindow,

View File

@ -0,0 +1,157 @@
import { shallow } from 'enzyme';
import * as React from 'react';
import CallNotificationComp from '../src/renderer/components/call-notification';
import { Themes } from '../src/renderer/components/notification-settings';
import { ipcRenderer } from './__mocks__/electron';
const IPC_RENDERER_NOTIFICATION_DATA_CHANNEL = 'call-notification-data';
describe('Call toast notification component', () => {
const defaultProps = {
title: 'Incoming call',
};
const spy = jest.spyOn(CallNotificationComp.prototype, 'setState');
let wrapper;
beforeEach(() => {
wrapper = shallow(React.createElement(CallNotificationComp));
});
it('should render correctly', () => {
ipcRenderer.send(IPC_RENDERER_NOTIFICATION_DATA_CHANNEL, defaultProps);
expect(spy).toBeCalledWith(defaultProps);
const container = wrapper.find('.title');
expect(container.text()).toBe(defaultProps.title);
});
it('should close the call notification when the reject button is clicked', async () => {
const spy = jest.spyOn(ipcRenderer, 'send');
const rejectButton = wrapper.find(
'[data-testid="CALL_NOTIFICATION_REJECT_BUTTON"]',
);
expect(rejectButton).toBeTruthy();
rejectButton.simulate('click', { stopPropagation: jest.fn() });
expect(spy).toBeCalledWith('call-notification-on-reject', 0);
});
it('should close the call notification when the accept button is clicked', async () => {
const spy = jest.spyOn(ipcRenderer, 'send');
const rejectButton = wrapper.find(
'[data-testid="CALL_NOTIFICATION_ACCEPT_BUTTON"]',
);
expect(rejectButton).toBeTruthy();
rejectButton.simulate('click', { stopPropagation: jest.fn() });
expect(spy).toBeCalledWith('call-notification-on-reject', 0);
expect(spy).toBeCalledWith('call-notification-on-accept', 0);
});
it('should click on the notification when the user clicks on main container', async () => {
const spy = jest.spyOn(ipcRenderer, 'send');
const notificationContainer = wrapper.find('.container');
expect(notificationContainer).toBeTruthy();
notificationContainer.simulate('click', { stopPropagation: jest.fn() });
expect(spy).toBeCalledWith('call-notification-clicked', 0);
});
it('should render Symphony logo with IM chat type if no image provided', () => {
const icon = '';
const profilePlaceHolderText = 'LS';
const callType = 'IM';
ipcRenderer.send(IPC_RENDERER_NOTIFICATION_DATA_CHANNEL, {
...defaultProps,
icon,
profilePlaceHolderText,
callType,
});
const defaultLogoContainer = wrapper.find('.thumbnail');
expect(defaultLogoContainer).toBeTruthy();
const imageContainer = wrapper.find('.profilePlaceHolderText');
expect(imageContainer.exists()).toBeTruthy();
const imClass = wrapper.find('.profilePlaceHolderContainer');
expect(imClass.exists()).toBeTruthy();
});
it('should render correct button text', () => {
const acceptButtonText = 'Answer';
const rejectButtonText = 'Decline';
ipcRenderer.send(IPC_RENDERER_NOTIFICATION_DATA_CHANNEL, {
...defaultProps,
acceptButtonText,
rejectButtonText,
});
const acceptButton = wrapper.find(
'[data-testid="CALL_NOTIFICATION_ACCEPT_BUTTON"]',
);
expect(acceptButton.text()).toBe('Answer');
const rejectButton = wrapper.find(
'[data-testid="CALL_NOTIFICATION_REJECT_BUTTON"]',
);
expect(rejectButton.text()).toBe('Decline');
});
it('should render default primary text as unknown when empty', () => {
const icon = '';
const profilePlaceHolderText = 'LS';
const callType = 'IM';
ipcRenderer.send(IPC_RENDERER_NOTIFICATION_DATA_CHANNEL, {
...defaultProps,
icon,
profilePlaceHolderText,
callType,
});
const callerName = wrapper.find('.caller-name');
expect(callerName.text()).toBe('unknown');
});
it('should render Symphony logo with ROOM chat type if no image provided', () => {
const icon = '';
const profilePlaceHolderText = 'LS';
const callType = 'ROOM';
ipcRenderer.send(IPC_RENDERER_NOTIFICATION_DATA_CHANNEL, {
...defaultProps,
icon,
profilePlaceHolderText,
callType,
});
const defaultLogoContainer = wrapper.find('.thumbnail');
expect(defaultLogoContainer).toBeTruthy();
const imageContainer = wrapper.find('.profilePlaceHolderText');
expect(imageContainer.exists()).toBeTruthy();
const imClass = wrapper.find('.roomPlaceHolderContainer');
expect(imClass.exists()).toBeTruthy();
});
it('should render Symphony logo if Symphony default image provided', () => {
const icon = './default.png';
ipcRenderer.send(IPC_RENDERER_NOTIFICATION_DATA_CHANNEL, {
...defaultProps,
icon,
});
const defaultLogoContainer = wrapper.find('.default-logo');
expect(defaultLogoContainer).toBeTruthy();
const imageContainer = wrapper.find('.profile-picture');
expect(imageContainer.exists()).toBeFalsy();
});
it('should flash in a custom way when theme is set', () => {
const flash = true;
const theme = Themes.DARK;
ipcRenderer.send(IPC_RENDERER_NOTIFICATION_DATA_CHANNEL, {
...defaultProps,
flash,
theme,
});
const flashingNotification = wrapper.find(`.${theme}-flashing`);
expect(flashingNotification.exists()).toBeTruthy();
});
it('should display ext badge when external', () => {
let externalBadge = wrapper.find('.ext-badge-container');
expect(externalBadge.exists()).toBeFalsy();
const isExternal = true;
ipcRenderer.send(IPC_RENDERER_NOTIFICATION_DATA_CHANNEL, {
...defaultProps,
isExternal,
});
externalBadge = wrapper.find('.ext-badge-container');
expect(externalBadge.exists()).toBeTruthy();
});
});

View File

@ -1,4 +1,5 @@
import { activityDetection } from '../src/app/activity-detection';
import * as c9PipeHandler from '../src/app/c9-pipe-handler';
import { downloadHandler } from '../src/app/download-handler';
import '../src/app/main-api-handler';
import { protocolHandler } from '../src/app/protocol-handler';
@ -7,7 +8,6 @@ import * as windowActions from '../src/app/window-actions';
import { windowHandler } from '../src/app/window-handler';
import * as utils from '../src/app/window-utils';
import { apiCmds, apiName } from '../src/common/api-interface';
import * as c9PipeHandler from '../src/app/c9-pipe-handler';
import { logger } from '../src/common/logger';
import { BrowserWindow, ipcMain } from './__mocks__/electron';

View File

@ -13,6 +13,7 @@ import {
apiName,
IApiArgs,
IAuthResponse,
ICallNotificationData,
INotificationData,
} from '../common/api-interface';
import { i18n, LocaleType } from '../common/i18n';
@ -50,6 +51,7 @@ import {
} from './window-utils';
import { getCommandLineArgs } from '../common/utils';
import callNotificationHelper from '../renderer/call-notification-helper';
import { autoUpdate, AutoUpdateTrigger } from './auto-update-handler';
import { presenceStatus } from './presence-status-handler';
import { presenceStatusStore } from './stores/index';
@ -338,6 +340,17 @@ ipcMain.on(
await notificationHelper.closeNotification(arg.notificationId);
}
break;
case apiCmds.showCallNotification:
if (typeof arg.notificationOpts === 'object') {
const opts = arg.notificationOpts as ICallNotificationData;
callNotificationHelper.showNotification(opts);
}
break;
case apiCmds.closeCallNotification:
if (typeof arg.notificationId === 'number') {
await callNotificationHelper.closeNotification(arg.notificationId);
}
break;
/**
* This gets called from mana, when user logs out
*/

View File

@ -0,0 +1,247 @@
import { ipcMain } from 'electron';
import {
apiName,
ElectronNotificationData,
ICallNotificationData,
NotificationActions,
} from '../../common/api-interface';
import { isDevEnv, isMac } from '../../common/env';
import { logger } from '../../common/logger';
import { notification } from '../../renderer/notification';
import {
AUX_CLICK,
ICustomBrowserWindow,
IS_NODE_INTEGRATION_ENABLED,
IS_SAND_BOXED,
} from '../window-handler';
import { createComponentWindow, windowExists } from '../window-utils';
const CALL_NOTIFICATION_WIDTH = 264;
const CALL_NOTIFICATION_HEIGHT = 290;
class CallNotification {
private callNotificationWindow: ICustomBrowserWindow | undefined;
private notificationCallbacks: Map<
number,
(event: NotificationActions, data: ElectronNotificationData) => void
> = new Map();
constructor() {
ipcMain.on('call-notification-clicked', (_event, windowId) => {
this.notificationClicked(windowId);
});
ipcMain.on('call-notification-on-accept', (_event, windowId) => {
this.onCallNotificationOnAccept(windowId);
});
ipcMain.on('call-notification-on-reject', (_event, windowId) => {
this.onCallNotificationOnReject(windowId);
});
ipcMain.on('notification-settings-update', async (_event) => {
setTimeout(() => {
const { x, y } = notification.getCallNotificationPosition();
if (
this.callNotificationWindow &&
windowExists(this.callNotificationWindow)
) {
try {
this.callNotificationWindow.setPosition(
parseInt(String(x), 10),
parseInt(String(y), 10),
);
} catch (err) {
logger.info(
`Failed to set window position. x: ${x} y: ${y}. Contact the developers for more details`,
);
}
}
}, 500);
});
}
public createCallNotificationWindow = (
callNotificationData: ICallNotificationData,
callback,
) => {
if (
this.callNotificationWindow &&
windowExists(this.callNotificationWindow) &&
this.callNotificationWindow.notificationData?.id
) {
this.callNotificationWindow.notificationData = callNotificationData;
this.callNotificationWindow.winName = apiName.notificationWindowName;
this.notificationCallbacks.set(callNotificationData.id, callback);
this.callNotificationWindow.webContents.send(
'call-notification-data',
callNotificationData,
);
return;
}
// Set stream id as winKey to link stream to the window
this.callNotificationWindow = createComponentWindow(
'call-notification',
this.getCallNotificationOpts(),
false,
) as ICustomBrowserWindow;
this.callNotificationWindow.notificationData = callNotificationData;
this.callNotificationWindow.winName = apiName.notificationWindowName;
this.notificationCallbacks.set(callNotificationData.id, callback);
this.callNotificationWindow.setVisibleOnAllWorkspaces(true);
this.callNotificationWindow.setSkipTaskbar(true);
this.callNotificationWindow.setAlwaysOnTop(true, 'screen-saver');
const { x, y } = notification.getCallNotificationPosition();
try {
this.callNotificationWindow.setPosition(
parseInt(String(x), 10),
parseInt(String(y), 10),
);
} catch (err) {
logger.info(
`Failed to set window position. x: ${x} y: ${y}. Contact the developers for more details`,
);
}
this.callNotificationWindow.webContents.once('did-finish-load', () => {
if (
!this.callNotificationWindow ||
!windowExists(this.callNotificationWindow)
) {
return;
}
this.callNotificationWindow.webContents.setZoomFactor(1);
this.callNotificationWindow.webContents.setVisualZoomLevelLimits(1, 1);
this.callNotificationWindow.webContents.send(
'call-notification-data',
callNotificationData,
);
this.callNotificationWindow.showInactive();
});
this.callNotificationWindow.once('closed', () => {
this.callNotificationWindow = undefined;
});
};
/**
* Handles call notification click
*
* @param clientId {number}
*/
public notificationClicked(clientId): void {
const browserWindow = this.callNotificationWindow;
if (
browserWindow &&
windowExists(browserWindow) &&
browserWindow.notificationData
) {
const data = browserWindow.notificationData;
const callback = this.notificationCallbacks.get(clientId);
if (typeof callback === 'function') {
callback(NotificationActions.notificationClicked, data);
}
this.closeNotification(clientId);
}
}
/**
* Handles call notification success action which updates client
* @param clientId {number}
*/
public onCallNotificationOnAccept(clientId: number): void {
const browserWindow = this.callNotificationWindow;
if (
browserWindow &&
windowExists(browserWindow) &&
browserWindow.notificationData
) {
const data = browserWindow.notificationData;
const callback = this.notificationCallbacks.get(clientId);
if (typeof callback === 'function') {
callback(NotificationActions.notificationAccept, data);
}
this.closeNotification(clientId);
}
}
/**
* Handles call notification success action which updates client
* @param clientId {number}
*/
public onCallNotificationOnReject(clientId: number): void {
const browserWindow = this.callNotificationWindow;
if (
browserWindow &&
windowExists(browserWindow) &&
browserWindow.notificationData
) {
const data = browserWindow.notificationData;
const callback = this.notificationCallbacks.get(clientId);
if (typeof callback === 'function') {
callback(NotificationActions.notificationReject, data);
}
this.closeNotification(clientId);
}
}
/**
* Close the notification window
*/
public closeNotification(clientId: number): void {
const browserWindow = this.callNotificationWindow;
if (browserWindow && windowExists(browserWindow)) {
if (
browserWindow.notificationData &&
browserWindow.notificationData.id !== clientId
) {
logger.info(
'call-notification',
`notification with the id ${browserWindow.notificationData.id} does match with clientID ${clientId}`,
);
return;
}
browserWindow.close();
logger.info(
'call-notification',
'successfully closed call notification window',
);
}
return;
}
private getCallNotificationOpts =
(): Electron.BrowserWindowConstructorOptions => {
const callNotificationOpts: Electron.BrowserWindowConstructorOptions = {
width: CALL_NOTIFICATION_WIDTH,
height: CALL_NOTIFICATION_HEIGHT,
alwaysOnTop: true,
skipTaskbar: true,
resizable: false,
show: false,
frame: false,
transparent: true,
fullscreenable: false,
acceptFirstMouse: true,
modal: false,
focusable: true,
autoHideMenuBar: true,
minimizable: false,
maximizable: false,
title: 'Call Notification - Symphony',
webPreferences: {
sandbox: IS_SAND_BOXED,
nodeIntegration: IS_NODE_INTEGRATION_ENABLED,
devTools: isDevEnv,
disableBlinkFeatures: AUX_CLICK,
},
};
if (isMac) {
callNotificationOpts.type = 'panel';
}
return callNotificationOpts;
};
}
const callNotification = new CallNotification();
export { callNotification };

View File

@ -19,7 +19,13 @@ import * as fs from 'fs';
import * as path from 'path';
import { format, parse } from 'url';
import { apiName, Themes, WindowTypes } from '../common/api-interface';
import {
apiName,
ICallNotificationData,
INotificationData,
Themes,
WindowTypes,
} from '../common/api-interface';
import { isDevEnv, isLinux, isMac, isWindowsOS } from '../common/env';
import { i18n, LocaleType } from '../common/i18n';
import { ScreenShotAnnotation } from '../common/ipcEvent';
@ -99,7 +105,7 @@ export interface ICustomBrowserWindowConstructorOpts
export interface ICustomBrowserWindow extends Electron.BrowserWindow {
winName: string;
notificationData?: object;
notificationData?: INotificationData | ICallNotificationData;
origin?: string;
}

View File

@ -33,6 +33,7 @@ export enum apiCmds {
getMediaSource = 'get-media-source',
notification = 'notification',
closeNotification = 'close-notification',
closeCallNotification = 'close-call-notification',
memoryInfo = 'memory-info',
swiftSearch = 'swift-search',
getConfigUrl = 'get-config-url',
@ -47,6 +48,7 @@ export enum apiCmds {
restartApp = 'restart-app',
setIsMana = 'set-is-mana',
showNotification = 'show-notification',
showCallNotification = 'show-call-notification',
closeAllWrapperWindows = 'close-all-windows',
setZoomLevel = 'set-zoom-level',
aboutAppClipBoardData = 'about-app-clip-board-data',
@ -241,6 +243,7 @@ export enum KeyCodes {
}
type Theme = '' | 'light' | 'dark';
type CallType = 'IM' | 'ROOM' | 'OTHER';
/**
* Notification
@ -267,11 +270,37 @@ export interface INotificationData {
hasMention?: boolean;
}
/**
* Notification
*/
export interface ICallNotificationData {
id: number;
title: string;
image: string;
icon?: string;
color: string;
company: string;
isExternal: boolean;
theme: Theme;
primaryText: string;
callback?: () => void;
secondaryText?: string;
companyIconUrl?: string;
profilePlaceHolderText: string;
actionIconUrl?: string;
callType: CallType;
shouldDisplayBadge: boolean;
acceptButtonText: string;
rejectButtonText: string;
}
export enum NotificationActions {
notificationClicked = 'notification-clicked',
notificationClosed = 'notification-closed',
notificationReply = 'notification-reply',
notificationIgnore = 'notification-ignore',
notificationAccept = 'notification-accept',
notificationReject = 'notification-reject',
}
/**

View File

@ -1,10 +1,71 @@
<html>
<head>
<style>
body {
font-family: 'Segoe UI', 'Helvetica Neue', 'Verdana', 'Arial',
sans-serif;
}
button {
padding: 6px 6px;
margin-bottom: 6px;
background-color: #3f51b5;
color: #fff;
border: none;
border-radius: 4px;
cursor: pointer;
}
.input-container {
position: relative;
padding: 6px 0;
}
input[type='text'] {
height: 20px;
width: 280px;
border: 1px solid #c0c0c0;
border-radius: 4px;
box-sizing: border-box;
padding: 16px;
color: grey;
}
.label {
position: absolute;
top: 0;
bottom: 0;
left: 16px;
display: flex;
align-items: center;
pointer-events: none;
}
input[type='text'],
.label .text {
font-family: 'Segoe UI';
font-size: 16px;
}
.label .text {
transition: all 0.15s ease-out;
transform: translate(0, -100%);
color: #3f51b5;
background-color: #f8f8f8;
font-size: 12px;
}
input[type='text']:focus {
outline: none;
border: 2px solid blue;
color: black;
}
table {
border-collapse: collapse;
border-spacing: 0;
}
table,
th,
td {
@ -27,13 +88,15 @@
border-radius: 50%;
display: inline-block;
}
.origin-reminder {
background: lightyellow;
padding: 20px;
margin: 40px;
padding: 10px;
margin: 20px;
border: 2px solid black;
display: block;
}
.origin-reminder-tt {
font-weight: normal;
background: yellow;
@ -61,148 +124,338 @@
<u>Make sure to comment it out again before you commit.</u>
</p>
</div>
<hr />
<p>Notifications:</p>
<p>
<button id="notf">show notification</button>
</p>
<!--Toast Notification-->
<div
style="
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 16px;
padding: 8px;
"
>
<div
style="
background-color: #f8f8f8;
padding: 16px;
border: 1px solid #e0e0e0;
border-radius: 4px;
"
>
<h3>Notifications:</h3>
<div>
<button id="notf">show notification</button>
</div>
<div>
<div style="padding-top: 10px">
<button id="closeNotification">close notification</button>
<input
type="number"
id="notificationTag"
value="Notification Tag to close"
/>
</div>
</div>
<p id="reply">Reply content will display here</p>
<div class="input-container">
<label class="label" for="title">
<div class="text">Title</div>
</label>
<input type="text" id="title" value="Callam Davis" />
</div>
<div class="input-container">
<label class="label" for="body">
<div class="text">body</div>
</label>
<input
type="text"
id="body"
value="Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum."
/>
</div>
<div class="input-container">
<label class="label" for="image">
<div class="text">image url</div>
</label>
<input
type="text"
id="image"
value="https://i.pinimg.com/originals/ea/11/fb/ea11fb152820f63d536e8396c89f81e9.jpg"
/>
</div>
<div class="input-container">
<label class="label" for="company">
<div class="text">company</div>
</label>
<input type="text" id="company" value="Symphony" />
</div>
<div class="input-container">
<label class="label" for="color">
<div class="text">Color</div>
</label>
<input type="text" id="color" value="" />
</div>
<div class="input-container">
<label class="label" for="tag">
<div class="text">Tag</div>
</label>
<input type="text" id="tag" value="" />
</div>
<div class="input-container">
<label for="flash">Flash</label>
<input type="checkbox" id="flash" />
</div>
<div class="input-container">
<label for="isExternal">External</label>
<input type="checkbox" id="isExternal" />
</div>
<div class="input-container">
<label for="isUpdated">Is updated</label>
<input type="checkbox" id="isUpdated" />
</div>
<div class="input-container">
<label for="hasMention">Has mention</label>
<input type="checkbox" id="hasMention" />
</div>
<div class="input-container">
<label for="isNativeNotification"
>Native Electron Notification:</label
>
<input type="checkbox" id="isNativeNotification" />
</div>
<div class="input-container">
<label for="sticky">Sticky</label>
<input type="checkbox" id="sticky" />
</div>
<div style="padding-bottom: 10px">
<label for="select-theme">Change theme</label>
<select id="select-theme" name="theme">
<option value="">select</option>
<option selected="selected" value="dark">dark</option>
<option value="light">light</option>
</select>
</div>
<div>
<p>
<button id="closeNotification">close notification</button>
<input
type="number"
id="notificationTag"
value="Notification Tag to close"
/>
</p>
<button id="open-config-win">Open configure window</button>
</div>
<!--Call Notification-->
<div
style="
background-color: #f8f8f8;
padding: 16px;
border: 1px solid #e0e0e0;
border-radius: 4px;
"
>
<h3>Call Notification Input</h3>
<div style="padding-bottom: 10px">
<button id="call-notf">show call notification</button>
<button id="close-call-notf">close call notification</button>
</div>
<div class="input-container">
<label class="label" for="profilePlaceHolderText">
<div class="text">Profile PlaceHolder Text</div>
</label>
<input type="text" id="profilePlaceHolderText" value="+3" />
</div>
<div class="input-container">
<label class="label" for="primaryText">
<div class="text">Primary Text</div>
</label>
<input type="text" id="primaryText" value="Anna Galbraith" />
</div>
<div class="input-container">
<label class="label" for="secondaryText">
<div class="text">Secondary Text</div>
</label>
<input type="text" id="secondaryText" value="Broker" />
</div>
<div class="input-container">
<label class="label" for="companyIconUrl">
<div class="text">Tertiary Text Icon</div>
</label>
<input
type="text"
id="companyIconUrl"
value="https://creazilla-store.fra1.digitaloceanspaces.com/icons/3431275/whatsapp-icon-md.png"
/>
</div>
<div class="input-container">
<label class="label" for="acceptButtonText">
<div class="text">Accept Button Text</div>
</label>
<input type="text" id="acceptButtonText" value="" />
</div>
<div class="input-container">
<label class="label" for="rejectButtonText">
<div class="text">Reject Button Text</div>
</label>
<input type="text" id="rejectButtonText" value="" />
</div>
<div class="input-container">
<label class="label" for="actionIconUrl">
<div class="text">Action IconUrl</div>
</label>
<input type="text" id="actionIconUrl" value="" />
</div>
<div class="input-container">
<label for="select-call-type">Notification Type</label>
<select id="select-call-type" name="call-type">
<option value="">select</option>
<option selected="selected" value="IM">IM</option>
<option value="ROOM">ROOM</option>
<option value="OTHER">OTHER</option>
</select>
<br />
</div>
<div class="input-container">
<label for="shouldDisplayBadge">shouldDisplayBadge:</label>
<input type="checkbox" id="shouldDisplayBadge" checked />
</div>
</div>
</div>
<p id="reply">Reply content will display here</p>
<p>
<label for="title">title:</label>
<input type="text" id="title" value="Callam Davis" />
</p>
<p>
<label for="body">body:</label>
<input
type="text"
id="body"
value="Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum."
/>
</p>
<p>
<label for="image">image url:</label>
<input
type="text"
id="image"
value="https://i.pinimg.com/originals/ea/11/fb/ea11fb152820f63d536e8396c89f81e9.jpg"
/>
</p>
<p>
<label for="company">company:</label>
<input type="text" id="company" value="Symphony" />
</p>
<p>
<label for="flash">flash:</label>
<input type="checkbox" id="flash" />
</p>
<p>
<label for="isExternal">external:</label>
<input type="checkbox" id="isExternal" />
</p>
<p>
<label for="isUpdated">is updated:</label>
<input type="checkbox" id="isUpdated" />
</p>
<p>
<label for="hasMention">has mention:</label>
<input type="checkbox" id="hasMention" />
</p>
<p>
<label for="isNativeNotification">Native Electron Notification:</label>
<input type="checkbox" id="isNativeNotification" />
</p>
<p>Change theme:</p>
<p>
<select id="select-theme" name="theme">
<option value="">select</option>
<option selected="selected" value="dark">dark</option>
<option value="light">light</option>
</select>
<br />
</p>
<div
style="
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
padding: 8px;
"
>
<div
style="
background-color: #f8f8f8;
padding: 16px;
border: 1px solid #e0e0e0;
border-radius: 4px;
"
>
<textarea id="text-val" rows="4">
Writes some thing to the file</textarea
>
<div style="padding: 10px 0px">
<button id="download-file1" value="Download">Download</button>
<button type="button" id="download-file2" value="Download">
Download 2
</button>
</div>
<div id="footer" style="padding: 10px 0px" class="hidden">
<div id="download-manager-footer" class="download-bar">
Downloaded File
</div>
</div>
<button type="button" id="open-download-item">Open Latest Item</button>
<button
type="button"
id="show-download-item"
value="Show Latest Item in Finder"
>
Show Latest Item in Finder
</button>
<button
type="button"
id="close-download-manager"
value="Close Download Manager"
>
Close Download Manager
</button>
</div>
<p>
<label for="sticky">sticky:</label>
<input type="checkbox" id="sticky" />
</p>
<p>
<label for="color">color:</label>
<input type="text" id="color" value="" />
</p>
<p>
<label for="tag">tag:</label>
<input type="text" id="tag" value="" />
</p>
<button id="open-config-win">Open configure window</button>
<br />
<hr />
<textarea id="text-val" rows="4">Writes some thing to the file</textarea>
<br />
<input type="button" id="download-file1" value="Download" />
<input type="button" id="download-file2" value="Download" />
<div id="footer" class="hidden">
<div id="download-manager-footer" class="download-bar"></div>
<div
style="
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
padding: 8px;
"
>
<div
style="
background-color: #f8f8f8;
padding: 16px;
border: 1px solid #e0e0e0;
border-radius: 4px;
"
>
<div>Activity Detection:</div>
<div style="padding: 10px">
<button id="activity-detection">Init activity</button>
<span id="activity-status" class="green-dot"></span>
</div>
</div>
</div>
</div>
<div
style="
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
padding: 8px;
"
>
<div
style="
background-color: #f8f8f8;
padding: 16px;
border: 1px solid #e0e0e0;
border-radius: 4px;
"
>
<p>Change language:</p>
<p>
<select id="locale-select" name="locale">
<option value="en-US">en-US</option>
<option value="ja-JP">ja-JP</option>
<option value="fr-FR">fr-FR</option>
</select>
<button id="changeLocale">Submit</button>
<br />
</p>
</div>
</div>
<div
style="
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
padding: 8px;
"
>
<div
style="
background-color: #f8f8f8;
padding: 16px;
border: 1px solid #e0e0e0;
border-radius: 4px;
"
>
<p>Badge Count:</p>
<p>
<button id="inc-badge">increment badge count</button>
<br />
<button id="clear-badge">clear badge count</button>
<br />
</p>
</div>
</div>
<div
style="
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
padding: 8px;
"
>
<div
style="
background-color: #f8f8f8;
padding: 16px;
border: 1px solid #e0e0e0;
border-radius: 4px;
"
>
<p>Screen Snippet:</p>
<button id="snippet">get snippet</button>
<p>snippet output:</p>
<image id="snippet-img" />
<button id="cancel-snippet">cancel snippet</button>
</div>
</div>
<input type="button" id="open-download-item" value="Open Latest Item" />
<input
type="button"
id="show-download-item"
value="Show Latest Item in Finder"
/>
<input
type="button"
id="close-download-manager"
value="Close Download Manager"
/>
<br />
<hr />
<p>Activity Detection:</p>
<p>
<button id="activity-detection">Init activity</button>
<span id="activity-status" class="green-dot"></span>
</p>
<br />
<hr />
<p>Change language:</p>
<p>
<select id="locale-select" name="locale">
<option value="en-US">en-US</option>
<option value="ja-JP">ja-JP</option>
<option value="fr-FR">fr-FR</option>
</select>
<button id="changeLocale">Submit</button>
<br />
</p>
<hr />
<p>Badge Count:</p>
<p>
<button id="inc-badge">increment badge count</button>
<br />
<button id="clear-badge">clear badge count</button>
<br />
</p>
<hr />
<p>Screen Snippet:</p>
<button id="snippet">get snippet</button>
<p>snippet output:</p>
<image id="snippet-img" />
<button id="cancel-snippet">cancel snippet</button>
<hr />
<p>Logs:</p>
Filename <input type="text" id="log-filename" value="test_log.log" />
<p>Contents</p>
@ -293,6 +546,14 @@
<br />
</body>
<script>
const inputs = document.querySelectorAll('input');
inputs.forEach((input) => {
input.addEventListener('input', () => {
input.setAttribute('value', input.value);
});
});
window.name = 'main';
const apiCmds = {
@ -440,6 +701,97 @@
}
});
var callNotifEl = document.getElementById('call-notf');
var closeCallNotifEl = document.getElementById('close-call-notf');
// note: notification will close when clicked
callNotifEl.addEventListener('click', function () {
var title = document.getElementById('title').value;
var body = document.getElementById('body').value;
var imageUrl = document.getElementById('image').value;
var shouldFlash = document.getElementById('flash').checked;
var isExternal = document.getElementById('isExternal').checked;
var color = document.getElementById('color').value;
var company = document.getElementById('company').value;
num++;
var theme = document.getElementById('select-theme').value;
var notf = {
id: num,
title,
image: imageUrl,
icon: imageUrl,
color: color,
flash: shouldFlash,
isExternal: isExternal,
theme: theme,
data: {
hello: `Notification with id ${num} clicked')`,
},
method: 'notification',
primaryText: document.getElementById('primaryText').value,
secondaryText: document.getElementById('secondaryText').value,
company: company,
companyIconUrl: document.getElementById('companyIconUrl').value,
profilePlaceHolderText: document.getElementById(
'profilePlaceHolderText',
).value,
actionIconUrl: document.getElementById('actionIconUrl').value,
callType: document.getElementById('select-call-type').value,
shouldDisplayBadge:
document.getElementById('shouldDisplayBadge').checked,
acceptButtonText: document.getElementById('acceptButtonText').value,
rejectButtonText: document.getElementById('rejectButtonText').value,
};
if (window.ssf) {
console.log(notf);
const ssfNotificationHandler = new window.ssf.Notification(
notf.title,
notf,
);
const onclick = (event) => {
event.target.close();
if (!process.env.NODE_ENV) {
alert('notification clicked: ' + event.target.data.hello);
}
};
ssfNotificationHandler.addEventListener('click', onclick);
const onclose = () => {
if (!process.env.NODE_ENV) {
alert('notification closed');
}
};
ssfNotificationHandler.addEventListener('close', onclose);
const onerror = (event) => {
alert('error=' + event.result);
};
ssfNotificationHandler.addEventListener('error', onerror);
} else if (window.manaSSF) {
const callback = (event, data) => {
if (event === 'notification-reply') {
document.getElementById('reply').innerText = data.notificationData;
}
};
window.manaSSF.showCallNotification(notf, callback);
} else {
window.postMessage({ method: 'notification', data: notf }, '*');
}
});
closeNotificationEl.addEventListener('click', () => {
if (window.manaSSF) {
const id = document.getElementById('notificationTag').value;
window.manaSSF.closeNotification(parseInt(id));
}
});
closeCallNotifEl.addEventListener('click', () => {
if (window.manaSSF) {
window.manaSSF.closeCallNotification(num);
}
});
/**
* Badge Count
*/

View File

@ -0,0 +1,5 @@
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8.14883 7.63617L8.16856 7.62531L8.18402 7.61734L9.53575 8.78152C9.38859 8.96221 9.2077 9.16592 8.98784 9.38577L8.81947 9.55415C8.50878 9.86484 8.23285 9.85183 8.09941 9.78895C7.29391 9.40937 6.06773 8.67546 4.69585 7.30359C3.32398 5.93171 2.59007 4.70553 2.2105 3.90003C2.14761 3.76659 2.13461 3.49066 2.44529 3.17997L2.61367 3.0116C2.83352 2.79174 3.03723 2.61085 3.21792 2.46369L4.3821 3.81542L4.37395 3.83124L4.36327 3.85061C4.35222 3.87071 4.32457 3.92103 4.29602 3.98249C4.24027 4.10251 4.1372 4.35431 4.1396 4.68546C4.14257 5.09581 4.29293 5.45671 4.47009 5.75134C4.65177 6.05347 4.91489 6.37431 5.27001 6.72943C5.62513 7.08455 5.94597 7.34767 6.2481 7.52935C6.54273 7.70652 6.90363 7.85687 7.31398 7.85985C7.64513 7.86225 7.89693 7.75917 8.01695 7.70342C8.0784 7.67487 8.12873 7.64722 8.14883 7.63617ZM0.855676 4.54435C1.30373 5.49517 2.13409 6.8648 3.63436 8.36508C5.13464 9.86535 6.50427 10.6957 7.45509 11.1438C8.29271 11.5385 9.23041 11.2543 9.87502 10.6097L10.0434 10.4413C10.6461 9.83866 11.0186 9.31934 11.2305 8.97723C11.4073 8.69192 11.3253 8.34165 11.0812 8.13142L8.78342 6.15245C8.55282 5.95385 8.22429 5.91599 7.95295 6.05596L7.48137 6.29923C7.46833 6.30596 7.45508 6.31328 7.44351 6.31968C7.38962 6.34945 7.36067 6.3631 7.33322 6.3629C7.28523 6.36255 7.18873 6.34386 7.02481 6.24529C6.86134 6.147 6.63584 5.97228 6.3315 5.66794C6.02716 5.3636 5.85244 5.1381 5.75415 4.97463C5.65558 4.81071 5.63689 4.71421 5.63654 4.66622C5.63634 4.63878 5.65023 4.60939 5.68002 4.55548C5.68641 4.54391 5.69349 4.5311 5.70021 4.51807L5.94348 4.04649C6.08345 3.77515 6.04559 3.44662 5.84699 3.21602L3.86802 0.918224C3.65779 0.674129 3.30752 0.592164 3.02221 0.768898C2.6801 0.980817 2.16078 1.35338 1.55812 1.95605L1.38974 2.12442C0.745131 2.76903 0.460968 3.70673 0.855676 4.54435Z" fill="white"/>
<path d="M9.07532 4.04907C9.07532 4.67039 8.57164 5.17407 7.95032 5.17407C7.329 5.17407 6.82532 4.67039 6.82532 4.04907C6.82532 3.42775 7.329 2.92407 7.95032 2.92407C8.57164 2.92407 9.07532 3.42775 9.07532 4.04907Z" fill="white"/>
<path d="M11.3253 1.79907C11.3253 2.42039 10.8216 2.92407 10.2003 2.92407C9.579 2.92407 9.07532 2.42039 9.07532 1.79907C9.07532 1.17775 9.579 0.674072 10.2003 0.674072C10.8216 0.674072 11.3253 1.17775 11.3253 1.79907Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 2.3 KiB

View File

@ -0,0 +1,52 @@
import { callNotification } from '../app/notifications/call-notification';
import { windowHandler } from '../app/window-handler';
import {
ElectronNotificationData,
ICallNotificationData,
NotificationActions,
} from '../common/api-interface';
class CallNotificationHelper {
/**
* Displays HTML call notification
*
* @param options {ICallNotificationData}
*/
public showNotification(options: ICallNotificationData) {
callNotification.createCallNotificationWindow(
options,
this.notificationCallback,
);
}
/**
* Closes a specific notification by id
*
* @param id {number} - unique id assigned to a specific notification
*/
public async closeNotification(id: number) {
await callNotification.closeNotification(id);
}
/**
* Sends the notification actions event to the web client
*
* @param event {NotificationActions}
* @param data {ElectronNotificationData}
*/
public notificationCallback(
event: NotificationActions,
data: ElectronNotificationData,
) {
const mainWebContents = windowHandler.getMainWebContents();
if (mainWebContents && !mainWebContents.isDestroyed()) {
mainWebContents.send('call-notification-actions', {
event,
data,
});
}
}
}
const callNotificationHelper = new CallNotificationHelper();
export default callNotificationHelper;

View File

@ -0,0 +1,377 @@
import classNames from 'classnames';
import { ipcRenderer } from 'electron';
import * as React from 'react';
import {
darkTheme,
getContainerCssClasses,
getThemeColors,
isValidColor,
Theme,
whiteColorRegExp,
} from '../notification-theme';
import { Themes } from './notification-settings';
type CallType = 'IM' | 'ROOM' | 'OTHER';
interface ICallNotificationState {
title: string;
primaryText: string;
secondaryText?: string;
company: string;
companyIconUrl?: string;
profilePlaceHolderText: string;
actionIconUrl?: string;
callType: CallType;
shouldDisplayBadge: boolean;
acceptButtonText?: string;
rejectButtonText?: string;
image: string;
icon: string | undefined;
id: number;
color: string;
flash: boolean;
isExternal: boolean;
theme: Theme;
}
type mouseEventButton =
| React.MouseEvent<HTMLDivElement>
| React.MouseEvent<HTMLButtonElement>;
export default class CallNotification extends React.Component<
{},
ICallNotificationState
> {
private readonly eventHandlers = {
onClick: (data) => (_event: mouseEventButton) => this.click(data),
onAccept: (data) => (event: mouseEventButton) => this.accept(event, data),
onReject: (data) => (event: mouseEventButton) => this.reject(event, data),
};
constructor(props) {
super(props);
this.state = {
title: 'Incoming call',
primaryText: 'unknown',
secondaryText: '',
company: 'Symphony',
companyIconUrl: '',
profilePlaceHolderText: 'S',
callType: 'IM',
shouldDisplayBadge: true,
image: '',
icon: '',
id: 0,
color: '',
flash: false,
isExternal: false,
theme: '',
};
this.updateState = this.updateState.bind(this);
}
/**
* Callback to handle event when a component is mounted
*/
public componentDidMount(): void {
ipcRenderer.on('call-notification-data', this.updateState);
}
/**
* Callback to handle event when a component is unmounted
*/
public componentWillUnmount(): void {
ipcRenderer.removeListener('call-notification-data', this.updateState);
}
/**
* Renders the component
*/
public render(): JSX.Element {
const {
id,
title,
primaryText,
secondaryText,
company,
companyIconUrl,
color,
actionIconUrl,
profilePlaceHolderText,
callType,
acceptButtonText,
rejectButtonText,
shouldDisplayBadge,
isExternal,
theme,
flash,
icon,
} = this.state;
let themeClassName;
if (theme) {
themeClassName = theme;
} else if (darkTheme.includes(color.toLowerCase())) {
themeClassName = 'black-text';
} else {
themeClassName =
color && color.match(whiteColorRegExp) ? Themes.LIGHT : Themes.DARK;
}
const themeColors = getThemeColors(theme, flash, isExternal, false, color);
const customCssClasses = getContainerCssClasses(
theme,
flash,
isExternal,
false,
);
let containerCssClass = `container ${themeClassName} `;
containerCssClass += customCssClasses.join(' ');
const acceptText = acceptButtonText
? acceptButtonText
: callType === 'IM' || callType === 'ROOM'
? 'join'
: 'answer';
const rejectText = rejectButtonText
? rejectButtonText
: callType === 'IM' || callType === 'ROOM'
? 'ignore'
: 'decline';
return (
<div
data-testid='CALL_NOTIFICATION_CONTAINER'
className={containerCssClass}
style={{
backgroundColor: themeColors.notificationBackgroundColor,
borderColor: themeColors.notificationBorderColor,
}}
onClick={this.eventHandlers.onClick(id)}
>
<div className={`title ${themeClassName}`}>{title}</div>
<div className='caller-info-container'>
<div className='logo-container'>
{this.renderImage(
icon,
profilePlaceHolderText,
callType,
shouldDisplayBadge,
)}
</div>
<div className='info-text-container'>
<div className='primary-text-container'>
<div className='caller-name-container'>
<div
data-testid='CALL_NOTIFICATION_NAME'
className={`caller-name ${themeClassName}`}
>
{primaryText}
</div>
{this.renderExtBadge(isExternal)}
</div>
</div>
{secondaryText ? (
<div className='secondary-text-container'>
<div className='caller-details'>
<div className={`caller-role ${themeClassName}`}>
{secondaryText}
</div>
</div>
</div>
) : (
<></>
)}
{company || companyIconUrl ? (
<div className='tertiary-text-container'>
<div className='application-details'>
{company && companyIconUrl && (
<img
className={'company-icon'}
src={companyIconUrl}
alt={'company logo'}
/>
)}
<div className={`company-name ${themeClassName}`}>
{company}
</div>
</div>
</div>
) : (
<></>
)}
</div>
</div>
<div className='actions'>
<button
className={classNames('decline', {
'call-type-other': callType === 'OTHER',
})}
>
<div
data-testid='CALL_NOTIFICATION_REJECT_BUTTON'
className='label'
onClick={this.eventHandlers.onReject(id)}
>
{rejectText}
</div>
</button>
<button
className={classNames('accept', {
'call-type-other': callType === 'OTHER',
})}
>
{actionIconUrl ? (
<img
onError={(event) => {
(event.target as any).src =
'../renderer/assets/call-icon.svg';
}}
className={'action-icon'}
src={actionIconUrl}
/>
) : (
<img
src='../renderer/assets/call-icon.svg'
alt='join call icon'
className='profile-picture-badge'
/>
)}
<div
data-testid='CALL_NOTIFICATION_ACCEPT_BUTTON'
className='label'
onClick={this.eventHandlers.onAccept(id)}
>
{acceptText}
</div>
</button>
</div>
</div>
);
}
private click = (id: number) => {
ipcRenderer.send('call-notification-clicked', id);
};
private accept = (event, id: number) => {
event.stopPropagation();
ipcRenderer.send('call-notification-on-accept', id);
};
private reject = (event, id: number) => {
event.stopPropagation();
ipcRenderer.send('call-notification-on-reject', id);
};
/**
* Sets the component state
*
* @param _event
* @param data {Object}
*/
private updateState(_event, data): void {
const { color } = data;
// FYI: 1.5 sends hex color but without '#', reason why we check and add prefix if necessary.
// Goal is to keep backward compatibility with 1.5 colors (SDA v. 9.2.0)
const isOldColor = /^([A-Fa-f0-9]{6})/.test(color);
data.color = isOldColor ? `#${color}` : isValidColor(color) ? color : '';
data.isInputHidden = true;
// FYI: 1.5 doesn't send current theme. We need to deduce it from the color that is sent.
// Goal is to keep backward compatibility with 1.5 themes (SDA v. 9.2.0)
data.theme =
isOldColor && darkTheme.includes(data.color)
? Themes.DARK
: data.theme
? data.theme
: Themes.LIGHT;
this.setState(data as ICallNotificationState);
}
/**
* Renders image if provided otherwise renders symphony logo
* @param imageUrl
* @param profilePlaceHolderText
* @param callType
* @param shouldDisplayBadge
*/
private renderImage(
imageUrl: string | undefined,
profilePlaceHolderText: string,
callType: CallType,
shouldDisplayBadge: boolean,
): JSX.Element | undefined {
let imgClass = 'default-logo';
let url = '../renderer/assets/notification-symphony-logo.svg';
let alt = 'Symphony logo';
const isDefaultUrl = imageUrl && imageUrl.includes('default.png');
if (imageUrl && !isDefaultUrl) {
imgClass = 'profile-picture';
url = imageUrl;
alt = 'Profile picture';
}
if (!imageUrl) {
const profilePlaceHolderClassName =
callType === 'IM'
? 'profilePlaceHolderContainer'
: 'roomPlaceHolderContainer';
return (
<div className='logo'>
<div className={`thumbnail ${profilePlaceHolderClassName}`}>
<p className={'profilePlaceHolderText'}>{profilePlaceHolderText}</p>
</div>
{this.renderSymphonyBadge(shouldDisplayBadge, callType)}
</div>
);
}
return (
<div className='logo'>
<img className={imgClass} src={url} alt={alt} />
{this.renderSymphonyBadge(shouldDisplayBadge)}
</div>
);
}
/**
* Renders profile picture Symphony badge
* @param hasImageUrl
* @param callType
*/
private renderSymphonyBadge(
hasImageUrl: boolean,
callType: CallType = 'IM',
): JSX.Element | undefined {
const badgePositionClass =
callType === 'IM' ? 'badge-position-im' : 'badge-position-room';
if (hasImageUrl) {
return (
<img
src='../renderer/assets/symphony-badge.svg'
alt=''
className={`profile-picture-badge ${badgePositionClass}`}
/>
);
}
return;
}
/**
* Renders external badge if the content is from external
* @param isExternal
*/
private renderExtBadge(isExternal: boolean): JSX.Element | undefined {
if (!isExternal) {
return;
}
return (
<div className='ext-badge-container'>
<img
src='../renderer/assets/notification-ext-badge.svg'
alt='ext-badge'
/>
</div>
);
}
}

View File

@ -3,39 +3,16 @@ import { ipcRenderer } from 'electron';
import * as React from 'react';
import { i18n } from '../../common/i18n-preload';
import {
darkTheme,
getContainerCssClasses,
getThemeColors,
isValidColor,
Theme,
whiteColorRegExp,
} from '../notification-theme';
import { Themes } from './notification-settings';
const whiteColorRegExp = new RegExp(
/^(?:white|#fff(?:fff)?|rgba?\(\s*255\s*,\s*255\s*,\s*255\s*(?:,\s*1\s*)?\))$/i,
);
const darkTheme = [
'#e23030',
'#b5616a',
'#ab8ead',
'#ebc875',
'#a3be77',
'#58c6ff',
'#ebab58',
];
const Colors = {
dark: {
regularFlashingNotificationBgColor: '#27588e',
notificationBackgroundColor: '#27292c',
notificationBorderColor: '#717681',
mentionBackgroundColor: '#99342c',
mentionBorderColor: '#ff5d50',
},
light: {
regularFlashingNotificationBgColor: '#aad4f8',
notificationBackgroundColor: '#f1f1f3',
notificationBorderColor: 'transparent',
mentionBackgroundColor: '#fcc1b9',
mentionBorderColor: 'transparent',
},
};
type Theme = '' | Themes.DARK | Themes.LIGHT;
interface INotificationState {
title: string;
company: string;
@ -64,8 +41,6 @@ type keyboardEvent = React.KeyboardEvent<HTMLInputElement>;
// Notification container height
const CONTAINER_HEIGHT = 100;
const CONTAINER_HEIGHT_WITH_INPUT = 142;
const LIGHT_THEME = '#EAEBEC';
const DARK_THEME = '#25272B';
export default class NotificationComp extends React.Component<
{},
@ -144,7 +119,9 @@ export default class NotificationComp extends React.Component<
isExternal,
isUpdated,
theme,
hasMention,
containerHeight,
flash,
icon,
} = this.state;
let themeClassName;
@ -156,10 +133,21 @@ export default class NotificationComp extends React.Component<
themeClassName =
color && color.match(whiteColorRegExp) ? Themes.LIGHT : Themes.DARK;
}
const themeColors = this.getThemeColors();
const themeColors = getThemeColors(
theme,
flash,
isExternal,
hasMention,
color,
);
const closeImgFilePath = `../renderer/assets/close-icon-${themeClassName}.svg`;
let containerCssClass = `container ${themeClassName} `;
const customCssClasses = this.getContainerCssClasses();
const customCssClasses = getContainerCssClasses(
theme,
flash,
isExternal,
hasMention,
);
containerCssClass += customCssClasses.join(' ');
return (
<div
@ -470,11 +458,7 @@ export default class NotificationComp extends React.Component<
// FYI: 1.5 sends hex color but without '#', reason why we check and add prefix if necessary.
// Goal is to keep backward compatibility with 1.5 colors (SDA v. 9.2.0)
const isOldColor = /^([A-Fa-f0-9]{6})/.test(color);
data.color = isOldColor
? `#${color}`
: this.isValidColor(color)
? color
: '';
data.color = isOldColor ? `#${color}` : isValidColor(color) ? color : '';
data.isInputHidden = true;
data.containerHeight = CONTAINER_HEIGHT;
// FYI: 1.5 doesn't send current theme. We need to deduce it from the color that is sent.
@ -489,15 +473,6 @@ export default class NotificationComp extends React.Component<
this.setState(data as INotificationState);
}
/**
* Validates the color
* @param color
* @private
*/
private isValidColor(color: string): boolean {
return /^#([A-Fa-f0-9]{6})/.test(color);
}
/**
* Reset data for new notification
* @private
@ -508,65 +483,6 @@ export default class NotificationComp extends React.Component<
}
}
/**
* Returns notification colors based on theme
* @param theme Current theme, can be either light or dark
*/
private getThemeColors(): { [key: string]: string } {
const { theme, flash, isExternal, hasMention, color } = this.state;
const currentColors =
theme === Themes.DARK ? { ...Colors.dark } : { ...Colors.light };
const externalFlashingBackgroundColor =
theme === Themes.DARK ? '#70511f' : '#f6e5a6';
if (flash && theme) {
if (isExternal) {
if (!hasMention) {
currentColors.notificationBorderColor = '#F7CA3B';
currentColors.notificationBackgroundColor =
externalFlashingBackgroundColor;
if (this.isCustomColor(color)) {
currentColors.notificationBorderColor =
this.getThemedCustomBorderColor(theme, color);
currentColors.notificationBackgroundColor = color;
}
} else {
currentColors.notificationBorderColor = '#F7CA3B';
}
} else if (hasMention) {
currentColors.notificationBorderColor =
currentColors.notificationBorderColor;
} else {
// in case of regular message without mention
// FYI: SDA versions prior to 9.2.3 do not support theme color properly, reason why SFE-lite is pushing notification default background color.
// For this reason, to be backward compatible, we check if sent color correspond to 'default' background color. If yes, we should ignore it and not consider it as a custom color.
currentColors.notificationBackgroundColor = this.isCustomColor(color)
? color
: currentColors.regularFlashingNotificationBgColor;
currentColors.notificationBorderColor = this.isCustomColor(color)
? this.getThemedCustomBorderColor(theme, color)
: theme === Themes.DARK
? '#2996fd'
: 'transparent';
}
} else if (!flash) {
if (hasMention) {
currentColors.notificationBackgroundColor =
currentColors.mentionBackgroundColor;
currentColors.notificationBorderColor =
currentColors.mentionBorderColor;
} else if (this.isCustomColor(color)) {
currentColors.notificationBackgroundColor = color;
currentColors.notificationBorderColor = this.getThemedCustomBorderColor(
theme,
color,
);
} else if (isExternal) {
currentColors.notificationBorderColor = '#F7CA3B';
}
}
return currentColors;
}
/**
* Renders reply button
* @param id
@ -613,83 +529,4 @@ export default class NotificationComp extends React.Component<
}
return;
}
/**
* This function aims at providing toast notification css classes
*/
private getContainerCssClasses(): string[] {
const customClasses: string[] = [];
const { flash, theme, hasMention, isExternal } = this.state;
if (flash && theme) {
if (isExternal) {
customClasses.push('external-border');
if (hasMention) {
customClasses.push(`${theme}-ext-mention-flashing`);
} else {
customClasses.push(`${theme}-ext-flashing`);
}
} else if (hasMention) {
customClasses.push(`${theme}-mention-flashing`);
} else {
// In case it's a regular message notification
customClasses.push(`${theme}-flashing`);
}
} else if (isExternal) {
customClasses.push('external-border');
}
return customClasses;
}
/**
* SDA versions prior to 9.2.3 do not support theme color properly, reason why SFE-lite is pushing notification default background color and theme.
* For that reason, we try to identify if provided color is the default one or not.
* @param color color sent through SDABridge
* @returns boolean
*/
private isCustomColor(color: string): boolean {
if (color && color !== LIGHT_THEME && color !== DARK_THEME) {
return true;
}
return false;
}
/**
* Function that allows to increase color brightness
* @param hex hes color
* @param percent percent
* @returns new hex color
*/
private increaseBrightness(hex: string, percent: number) {
// strip the leading # if it's there
hex = hex.replace(/^\s*#|\s*$/g, '');
const r = parseInt(hex.substr(0, 2), 16);
const g = parseInt(hex.substr(2, 2), 16);
const b = parseInt(hex.substr(4, 2), 16);
return (
'#' +
// tslint:disable-next-line: no-bitwise
(0 | ((1 << 8) + r + ((256 - r) * percent) / 100))
.toString(16)
.substr(1) +
// tslint:disable-next-line: no-bitwise
(0 | ((1 << 8) + g + ((256 - g) * percent) / 100))
.toString(16)
.substr(1) +
// tslint:disable-next-line: no-bitwise
(0 | ((1 << 8) + b + ((256 - b) * percent) / 100)).toString(16).substr(1)
);
}
/**
* Returns custom border color
* @param theme current theme
* @param customColor color
* @returns custom border color
*/
private getThemedCustomBorderColor(theme: string, customColor: string) {
return theme === Themes.DARK
? this.increaseBrightness(customColor, 50)
: 'transparent';
}
}

View File

@ -20,7 +20,7 @@ interface ISettings {
differentialHeight: number;
}
interface ICorner {
export interface ICorner {
x: number;
y: number;
}
@ -29,9 +29,11 @@ type startCorner = 'upper-right' | 'upper-left' | 'lower-right' | 'lower-left';
const NEXT_INSERT_POSITION = 100;
const NEXT_INSERT_POSITION_WITH_INPUT = 142;
const NOTIFICATIONS_PADDING_SEPARATION = 12;
const CALL_NOTIFICATION_WIDTH = 264;
const CALL_NOTIFICATION_HEIGHT = 286;
export default class NotificationHandler {
public settings: ISettings;
public callNotificationSettings: ICorner = { x: 0, y: 0 };
public nextInsertPos: ICorner = { x: 0, y: 0 };
private readonly eventHandlers = {
@ -98,6 +100,8 @@ export default class NotificationHandler {
const display = this.externalDisplay || screen.getPrimaryDisplay();
this.settings.corner.x = display.workArea.x;
this.settings.corner.y = display.workArea.y;
this.callNotificationSettings.x = display.workArea.x;
this.callNotificationSettings.y = display.workArea.y;
// update corner x/y based on corner of screen where notification should appear
const workAreaWidth = display.workAreaSize.width;
@ -107,21 +111,42 @@ export default class NotificationHandler {
case 'upper-right':
this.settings.corner.x += workAreaWidth - offSet;
this.settings.corner.y += offSet;
// Call Notification settings
this.callNotificationSettings.x +=
workAreaWidth - offSet - CALL_NOTIFICATION_WIDTH;
this.callNotificationSettings.y +=
workAreaHeight - offSet - CALL_NOTIFICATION_HEIGHT;
break;
case 'lower-right':
this.settings.corner.x += workAreaWidth - offSet;
this.settings.corner.y += workAreaHeight - offSet;
// Call Notification settings
this.callNotificationSettings.x +=
workAreaWidth - offSet - CALL_NOTIFICATION_WIDTH;
this.callNotificationSettings.y += offSet;
break;
case 'lower-left':
this.settings.corner.x += offSet;
this.settings.corner.y += workAreaHeight - offSet;
this.settings.corner.y +=
workAreaHeight - offSet - CALL_NOTIFICATION_HEIGHT;
// Call Notification settings
this.callNotificationSettings.x += offSet;
this.callNotificationSettings.y += offSet;
break;
case 'upper-left':
this.settings.corner.x += offSet;
this.settings.corner.y += offSet;
// Call Notification settings
this.callNotificationSettings.x += offSet;
this.callNotificationSettings.y +=
workAreaHeight - offSet - CALL_NOTIFICATION_HEIGHT;
break;
default:
// no change needed
this.callNotificationSettings.x +=
workAreaWidth - offSet - CALL_NOTIFICATION_WIDTH;
this.callNotificationSettings.y +=
workAreaHeight - offSet - CALL_NOTIFICATION_HEIGHT;
break;
}
this.calculateDimensions();

View File

@ -0,0 +1,187 @@
import { Themes } from './components/notification-settings';
export const whiteColorRegExp = new RegExp(
/^(?:white|#fff(?:fff)?|rgba?\(\s*255\s*,\s*255\s*,\s*255\s*(?:,\s*1\s*)?\))$/i,
);
export const darkTheme = [
'#e23030',
'#b5616a',
'#ab8ead',
'#ebc875',
'#a3be77',
'#58c6ff',
'#ebab58',
];
export const Colors = {
dark: {
regularFlashingNotificationBgColor: '#27588e',
notificationBackgroundColor: '#27292c',
notificationBorderColor: '#717681',
mentionBackgroundColor: '#99342c',
mentionBorderColor: '#ff5d50',
},
light: {
regularFlashingNotificationBgColor: '#aad4f8',
notificationBackgroundColor: '#f1f1f3',
notificationBorderColor: 'transparent',
mentionBackgroundColor: '#fcc1b9',
mentionBorderColor: 'transparent',
},
};
export type Theme = '' | Themes.DARK | Themes.LIGHT;
const LIGHT_THEME = '#EAEBEC';
const DARK_THEME = '#25272B';
/**
* Validates the color
* @param color
* @private
*/
export const isValidColor = (color: string): boolean => {
return /^#([A-Fa-f0-9]{6})/.test(color);
};
/**
* SDA versions prior to 9.2.3 do not support theme color properly, reason why SFE-lite is pushing notification default background color and theme.
* For that reason, we try to identify if provided color is the default one or not.
* @param color color sent through SDABridge
* @returns boolean
*/
export const isCustomColor = (color: string): boolean => {
return !!(color && color !== LIGHT_THEME && color !== DARK_THEME);
};
/**
* This function aims at providing toast notification css classes
*/
export const getContainerCssClasses = (
theme,
flash,
isExternal,
hasMention,
): string[] => {
const customClasses: string[] = [];
if (flash && theme) {
if (isExternal) {
customClasses.push('external-border');
if (hasMention) {
customClasses.push(`${theme}-ext-mention-flashing`);
} else {
customClasses.push(`${theme}-ext-flashing`);
}
} else if (hasMention) {
customClasses.push(`${theme}-mention-flashing`);
} else {
// In case it's a regular message notification
customClasses.push(`${theme}-flashing`);
}
} else if (isExternal) {
customClasses.push('external-border');
}
return customClasses;
};
/**
* Function that allows to increase color brightness
* @param hex hes color
* @param percent percent
* @returns new hex color
*/
export const increaseBrightness = (hex: string, percent: number) => {
// strip the leading # if it's there
hex = hex.replace(/^\s*#|\s*$/g, '');
const r = parseInt(hex.substr(0, 2), 16);
const g = parseInt(hex.substr(2, 2), 16);
const b = parseInt(hex.substr(4, 2), 16);
return (
'#' +
// tslint:disable-next-line: no-bitwise
(0 | ((1 << 8) + r + ((256 - r) * percent) / 100)).toString(16).substr(1) +
// tslint:disable-next-line: no-bitwise
(0 | ((1 << 8) + g + ((256 - g) * percent) / 100)).toString(16).substr(1) +
// tslint:disable-next-line: no-bitwise
(0 | ((1 << 8) + b + ((256 - b) * percent) / 100)).toString(16).substr(1)
);
};
/**
* Returns custom border color
* @param theme current theme
* @param customColor color
* @returns custom border color
*/
export const getThemedCustomBorderColor = (
theme: string,
customColor: string,
) => {
return theme === Themes.DARK
? increaseBrightness(customColor, 50)
: 'transparent';
};
/**
* Returns notification colors based on theme
*/
export const getThemeColors = (
theme,
flash,
isExternal,
hasMention,
color,
): { [key: string]: string } => {
const currentColors =
theme === Themes.DARK ? { ...Colors.dark } : { ...Colors.light };
const externalFlashingBackgroundColor =
theme === Themes.DARK ? '#70511f' : '#f6e5a6';
if (flash && theme) {
if (isExternal) {
if (!hasMention) {
currentColors.notificationBorderColor = '#F7CA3B';
currentColors.notificationBackgroundColor =
externalFlashingBackgroundColor;
if (isCustomColor(color)) {
currentColors.notificationBorderColor = getThemedCustomBorderColor(
theme,
color,
);
currentColors.notificationBackgroundColor = color;
}
} else {
currentColors.notificationBorderColor = '#F7CA3B';
}
} else if (hasMention) {
currentColors.notificationBorderColor =
currentColors.notificationBorderColor;
} else {
// in case of regular message without mention
// FYI: SDA versions prior to 9.2.3 do not support theme color properly, reason why SFE-lite is pushing notification default background color.
// For this reason, to be backward compatible, we check if sent color correspond to 'default' background color. If yes, we should ignore it and not consider it as a custom color.
currentColors.notificationBackgroundColor = isCustomColor(color)
? color
: currentColors.regularFlashingNotificationBgColor;
currentColors.notificationBorderColor = isCustomColor(color)
? getThemedCustomBorderColor(theme, color)
: theme === Themes.DARK
? '#2996fd'
: 'transparent';
}
} else if (!flash) {
if (hasMention) {
currentColors.notificationBackgroundColor =
currentColors.mentionBackgroundColor;
currentColors.notificationBorderColor = currentColors.mentionBorderColor;
} else if (isCustomColor(color)) {
currentColors.notificationBackgroundColor = color;
currentColors.notificationBorderColor = getThemedCustomBorderColor(
theme,
color,
);
} else if (isExternal) {
currentColors.notificationBorderColor = '#F7CA3B';
}
}
return currentColors;
};

View File

@ -21,7 +21,7 @@ import {
} from '../common/api-interface';
import { isMac } from '../common/env';
import { logger } from '../common/logger';
import NotificationHandler from './notification-handler';
import NotificationHandler, { ICorner } from './notification-handler';
const CLEAN_UP_INTERVAL = 60 * 1000; // Closes inactive notification
const animationQueue = new AnimationQueue();
@ -526,6 +526,14 @@ class Notification extends NotificationHandler {
}
}
/**
* Get the call notification insert position
* @return ICorner
*/
public getCallNotificationPosition = (): ICorner => {
return this.callNotificationSettings;
};
/**
* Waits for window to load and resolves
*

View File

@ -5,6 +5,7 @@ import * as ReactDOM from 'react-dom';
import { i18n } from '../common/i18n-preload';
import AboutBox from './components/about-app';
import BasicAuth from './components/basic-auth';
import CallNotification from './components/call-notification';
import NotificationComp from './components/notification-comp';
import NotificationSettings from './components/notification-settings';
import ScreenPicker from './components/screen-picker';
@ -22,6 +23,7 @@ const enum components {
basicAuth = 'basic-auth',
notification = 'notification-comp',
notificationSettings = 'notification-settings',
callNotification = 'call-notification',
welcome = 'welcome',
snippingTool = 'snipping-tool',
titleBar = 'windows-title-bar',
@ -71,6 +73,10 @@ const load = () => {
)();
component = NotificationSettings;
break;
case components.callNotification:
document.title = i18n.t('Call Notification - Symphony')();
component = CallNotification;
break;
case components.welcome:
document.title = i18n.t('WelcomeText', 'Welcome')();
component = Welcome;

View File

@ -82,6 +82,8 @@ if (ssfWindow.ssf) {
checkMediaPermission: ssfWindow.ssf.checkMediaPermission,
showNotification: ssfWindow.ssf.showNotification,
closeNotification: ssfWindow.ssf.closeNotification,
showCallNotification: ssfWindow.ssf.showCallNotification,
closeCallNotification: ssfWindow.ssf.closeCallNotification,
restartApp: ssfWindow.ssf.restartApp,
closeAllWrapperWindows: ssfWindow.ssf.closeAllWrapperWindows,
setZoomLevel: ssfWindow.ssf.setZoomLevel,

View File

@ -15,6 +15,7 @@ import {
ConfigUpdateType,
EPresenceStatusCategory,
IBoundsChange,
ICallNotificationData,
ICloud9Pipe,
ICPUUsage,
ILogMsg,
@ -74,6 +75,11 @@ const notificationActionCallbacks = new Map<
NotificationActionCallback
>();
const callNotificationActionCallbacks = new Map<
number,
NotificationActionCallback
>();
const DEFAULT_THROTTLE = 1000;
// Throttle func
@ -697,6 +703,45 @@ export class SSFApi {
});
}
/**
* Displays a call notification from the main process
* @param notificationOpts {INotificationData}
* @param notificationCallback {NotificationActionCallback}
*/
public showCallNotification(
notificationOpts: ICallNotificationData,
notificationCallback: NotificationActionCallback,
): void {
// Store callbacks based on notification id so,
// we can use this to trigger on notification action
if (typeof notificationOpts.id === 'number') {
callNotificationActionCallbacks.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.showCallNotification,
notificationOpts,
});
}
/**
* Closes a specific call notification based on id
* @param notificationId {number} Id of a notification
*/
public closeCallNotification(notificationId: number): void {
ipcRenderer.send(apiName.symphonyApi, {
cmd: apiCmds.closeCallNotification,
notificationId,
});
}
/**
* Get zoom level
*
@ -1145,6 +1190,19 @@ local.ipcRenderer.on('notification-actions', (_event, args) => {
}
});
/**
* An event triggered by the main process on call notification actions
* @param {ICallNotificationData}
*/
local.ipcRenderer.on('call-notification-actions', (_event, args) => {
const callback = callNotificationActionCallbacks.get(args.data.id);
const data = args.data;
data.notificationData = args.notificationData;
if (args && callback) {
callback(args.event, data);
}
});
/**
* An event triggered by the main process on updating the cloud config
* @param {string[]}

View File

@ -0,0 +1,298 @@
@import 'theme';
@import 'variables';
@import 'notifications-animations';
.black-text {
--text-color: #000000;
--button-bg-color: #52575f;
--logo-bg: url('../renderer/assets/symphony-logo.png');
}
.light {
--text-color-title: @black;
--text-color-primary: @graphite-80;
--text-color-secondary: @graphite-60;
--notification-bg-color: @light-notification-bg-color;
--notification-border-color: @graphite-10;
--profile-place-holder-text: @electricity-ui-50;
}
.dark {
--text-color-title: @vanilla-white;
--text-color-primary: @graphite-05;
--text-color-secondary: @graphite-20;
--notification-bg-color: @dark-notification-bg-color;
--notification-border-color: @graphite-60;
--profile-place-holder-text: @electricity-ui-40;
}
body {
margin: 0;
}
.container {
height: 246px;
padding: 16px 16px 24px 16px;
background-color: var(--notification-bg-color);
flex-direction: column;
align-items: flex-start;
display: flex;
border-radius: 8px;
border: 2px solid var(--yellow-20, #f7ca3b);
}
.title {
font-size: 12px;
font-family: @font-family;
font-style: normal;
font-weight: 600;
line-height: 24px;
color: var(--text-color-title);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 200px;
}
.thumbnail {
width: 78px;
height: 78px;
flex-shrink: 0;
position: relative;
border: 4px solid var(--notification-border-color);
background-color: var(--notification-bg-color);
overflow: hidden;
.profilePlaceHolderText {
color: var(--profile-place-holder-text);
text-align: center;
font-size: 32px;
font-family: @font-family;
font-style: normal;
font-weight: 600;
line-height: 16px;
text-transform: uppercase;
white-space: nowrap;
text-overflow: ellipsis;
}
.profilePlaceHolderClassName {
}
}
.profilePlaceHolderContainer {
border-radius: 70px;
}
.roomPlaceHolderContainer {
border-radius: 4px;
}
.logo-container {
display: flex;
position: relative;
.logo {
display: flex;
flex-direction: column;
align-items: center;
.profile-picture {
width: 86px;
border-radius: 70px;
}
.default-logo {
top: 0;
width: 40px;
border-radius: 5px;
}
.profile-picture-badge {
width: 32px;
position: absolute;
}
.badge-position-im {
right: 0;
bottom: 0;
}
.badge-position-room {
right: -2px;
bottom: -1px;
}
}
}
.caller-name {
color: var(--text-color-primary);
font-family: @font-family;
font-size: 14px;
font-style: normal;
font-weight: 600;
line-height: 24px;
display: flex;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 200px;
}
.ext-badge-container {
width: 27px;
height: 19px;
padding: 0 4px;
align-items: center;
}
.caller-role {
color: var(--text-color-secondary);
font-family: @font-family;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 20px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 200px;
}
.application-details {
display: flex;
justify-content: center;
align-items: center;
gap: 4px;
}
.company-icon {
width: 16px;
}
.company-name {
color: var(--text-color-secondary);
font-family: @font-family;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 20px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 200px;
}
.info-text-container {
display: flex;
padding: 0 4px;
flex-direction: column;
align-items: center;
gap: 4px;
align-self: stretch;
}
.caller-name-container {
display: flex;
}
.caller-info-container {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
gap: 8px;
flex: 1 0 0;
align-self: stretch;
-webkit-user-select: none;
}
.caller-info {
flex: 1;
height: 198px;
}
.caller-info,
text {
align-self: stretch;
display: flex;
flex-direction: column;
align-items: center;
color: var(--text-color);
}
.actions {
display: flex;
justify-content: center;
align-items: flex-start;
gap: 16px;
align-self: stretch;
button {
display: flex;
padding: 4px 12px;
justify-content: center;
width: 72px;
align-items: center;
gap: 8px;
border-radius: 16px;
border-style: none;
cursor: pointer;
.label {
color: @red-05;
text-align: center;
font-size: 12px;
font-family: @font-family;
font-style: normal;
font-weight: 600;
line-height: 16px;
text-transform: uppercase;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}
.call-type-other {
width: 94px;
}
}
.decline {
background-color: @red-50;
}
.action-icon {
width: 12px;
}
.accept {
background-color: @green-50;
}
.light-flashing {
animation: light-flashing 1s infinite !important;
}
.dark-flashing {
animation: dark-flashing 1s infinite !important;
}
.dark-ext-flashing {
animation: dark-ext-flashing 1s infinite !important;
}
.light-ext-flashing {
animation: light-ext-flashing 1s infinite !important;
}
.dark-mention-flashing {
animation: dark-mention-flashing 1s infinite !important;
}
.light-mention-flashing {
animation: light-mention-flashing 1s infinite !important;
}
.dark-ext-mention-flashing {
animation: dark-ext-mention-flashing 1s infinite !important;
}
.light-ext-mention-flashing {
animation: light-ext-mention-flashing 1s infinite !important;
}

View File

@ -25,4 +25,9 @@
@graphite-70: #3a3d43;
@vanilla-white: #fff;
@black: #000000;
@blue: #008eff;
@red-05: #fbeeed;
@red-50: #dd342e;
@green-50: #378535;