[MM-46993] Implement CallsWidgetWindow (#2265)

* Initial implementation of CallsWidgetWindow

* Refactor + implement widget resizing logic

* Add tests

* Enable screen sharing

* Channel link

* Add more tests

* Move constants to common file

* Extract boundsDiff into util

* Set background color on initialization

* Fix channel link

* Support installations under a subpath

* Fix path, caching issues and pass title

* [MM-48142] Fix remaining call state issues in main window (#2349)

* Update widget URL to new format

* Slightly bump widget dimensions to account for border

* Fix call state on parent window
This commit is contained in:
Claudio Costa 2022-11-07 09:40:13 +01:00 committed by GitHub
parent c319038704
commit 47edeea601
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 953 additions and 6 deletions

View file

@ -122,9 +122,6 @@ export const UPDATE_PATHS = 'update-paths';
export const UPDATE_URL_VIEW_WIDTH = 'update-url-view-width';
export const DISPATCH_GET_DESKTOP_SOURCES = 'dispatch-get-desktop-sources';
export const DESKTOP_SOURCES_RESULT = 'desktop-sources-result';
export const RELOAD_CURRENT_VIEW = 'reload-current-view';
export const PING_DOMAIN = 'ping-domain';
@ -136,6 +133,17 @@ export const GET_AVAILABLE_LANGUAGES = 'get-available-languages';
export const VIEW_FINISHED_RESIZING = 'view-finished-resizing';
// Calls
export const DISPATCH_GET_DESKTOP_SOURCES = 'dispatch-get-desktop-sources';
export const DESKTOP_SOURCES_RESULT = 'desktop-sources-result';
export const DESKTOP_SOURCES_MODAL_REQUEST = 'desktop-sources-modal-request';
export const CALLS_JOIN_CALL = 'calls-join-call';
export const CALLS_LEAVE_CALL = 'calls-leave-call';
export const CALLS_WIDGET_RESIZE = 'calls-widget-resize';
export const CALLS_WIDGET_SHARE_SCREEN = 'calls-widget-share-screen';
export const CALLS_WIDGET_CHANNEL_LINK_CLICK = 'calls-widget-channel-link-click';
export const CALLS_JOINED_CALL = 'calls-joined-call';
export const REQUEST_CLEAR_DOWNLOADS_DROPDOWN = 'request-clear-downloads-dropdown';
export const CLOSE_DOWNLOADS_DROPDOWN = 'close-downloads-dropdown';
export const OPEN_DOWNLOADS_DROPDOWN = 'open-downloads-dropdown';

View file

@ -25,6 +25,11 @@ export const DEFAULT_WINDOW_HEIGHT = 800;
export const MINIMUM_WINDOW_WIDTH = 700;
export const MINIMUM_WINDOW_HEIGHT = 240;
// Calls
export const MINIMUM_CALLS_WIDGET_WIDTH = 284;
export const MINIMUM_CALLS_WIDGET_HEIGHT = 90;
export const CALLS_PLUGIN_ID = 'com.mattermost.calls';
export const DOWNLOADS_DROPDOWN_HEIGHT = 360;
export const DOWNLOADS_DROPDOWN_WIDTH = 280;
export const DOWNLOADS_DROPDOWN_PADDING = 24;

View file

@ -88,4 +88,31 @@ describe('common/utils/util', () => {
expect(Utils.isVersionGreaterThanOrEqualTo(a, b)).toEqual(true);
});
});
describe('boundsDiff', () => {
it('diff', () => {
const base = {
x: 0,
y: 0,
width: 400,
height: 200,
};
const actual = {
x: 100,
y: -100,
width: 600,
height: 100,
};
const diff = {
x: -100,
y: 100,
width: -200,
height: 100,
};
expect(Utils.boundsDiff(base, actual)).toEqual(diff);
});
});
});

View file

@ -2,6 +2,8 @@
// See LICENSE.txt for license information.
// Copyright (c) 2015-2016 Yuya Ochiai
import {Rectangle} from 'electron';
import {DEVELOPMENT, PRODUCTION} from './constants';
function runMode() {
@ -47,8 +49,18 @@ export function t(s: string) {
return s;
}
function boundsDiff(base: Rectangle, actual: Rectangle) {
return {
x: base.x - actual.x,
y: base.y - actual.y,
width: base.width - actual.width,
height: base.height - actual.height,
};
}
export default {
runMode,
shorten,
isVersionGreaterThanOrEqualTo,
boundsDiff,
};

View file

@ -0,0 +1,75 @@
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
'use strict';
import {ipcRenderer} from 'electron';
import {
CALLS_LEAVE_CALL,
CALLS_JOINED_CALL,
CALLS_WIDGET_RESIZE,
CALLS_WIDGET_SHARE_SCREEN,
CALLS_WIDGET_CHANNEL_LINK_CLICK,
DESKTOP_SOURCES_RESULT,
DESKTOP_SOURCES_MODAL_REQUEST,
DISPATCH_GET_DESKTOP_SOURCES,
} from 'common/communication';
window.addEventListener('message', ({origin, data = {}} = {}) => {
const {type, message = {}} = data;
if (origin !== window.location.origin) {
return;
}
switch (type) {
case 'get-app-version': {
ipcRenderer.invoke('get-app-version').then(({name, version}) => {
window.postMessage(
{
type: 'register-desktop',
message: {
name,
version,
},
},
window.location.origin,
);
});
break;
}
case 'get-desktop-sources': {
ipcRenderer.send(DISPATCH_GET_DESKTOP_SOURCES, 'widget', message);
break;
}
case DESKTOP_SOURCES_MODAL_REQUEST:
case CALLS_WIDGET_CHANNEL_LINK_CLICK:
case CALLS_WIDGET_RESIZE:
case CALLS_JOINED_CALL:
case CALLS_LEAVE_CALL: {
ipcRenderer.send(type, message);
break;
}
}
});
ipcRenderer.on(DESKTOP_SOURCES_RESULT, (event, sources) => {
window.postMessage(
{
type: DESKTOP_SOURCES_RESULT,
message: sources,
},
window.location.origin,
);
});
ipcRenderer.on(CALLS_WIDGET_SHARE_SCREEN, (event, message) => {
window.postMessage(
{
type: CALLS_WIDGET_SHARE_SCREEN,
message,
},
window.location.origin,
);
});

View file

@ -30,6 +30,11 @@ import {
DISPATCH_GET_DESKTOP_SOURCES,
DESKTOP_SOURCES_RESULT,
VIEW_FINISHED_RESIZING,
CALLS_JOIN_CALL,
CALLS_JOINED_CALL,
CALLS_LEAVE_CALL,
DESKTOP_SOURCES_MODAL_REQUEST,
CALLS_WIDGET_SHARE_SCREEN,
CLOSE_DOWNLOADS_DROPDOWN,
} from 'common/communication';
@ -157,6 +162,18 @@ window.addEventListener('message', ({origin, data = {}} = {}) => {
ipcRenderer.send(DISPATCH_GET_DESKTOP_SOURCES, viewName, message);
break;
}
case CALLS_JOIN_CALL: {
ipcRenderer.send(CALLS_JOIN_CALL, viewName, message);
break;
}
case CALLS_WIDGET_SHARE_SCREEN: {
ipcRenderer.send(CALLS_WIDGET_SHARE_SCREEN, viewName, message);
break;
}
case CALLS_LEAVE_CALL: {
ipcRenderer.send(CALLS_LEAVE_CALL, viewName, message);
break;
}
}
});
@ -307,6 +324,25 @@ ipcRenderer.on(DESKTOP_SOURCES_RESULT, (event, sources) => {
);
});
ipcRenderer.on(DESKTOP_SOURCES_MODAL_REQUEST, () => {
window.postMessage(
{
type: DESKTOP_SOURCES_MODAL_REQUEST,
},
window.location.origin,
);
});
ipcRenderer.on(CALLS_JOINED_CALL, (event, message) => {
window.postMessage(
{
type: CALLS_JOINED_CALL,
message,
},
window.location.origin,
);
});
/* eslint-enable no-magic-numbers */
window.addEventListener('resize', () => {

View file

@ -0,0 +1,286 @@
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {EventEmitter} from 'events';
import {BrowserWindow} from 'electron';
import {CALLS_WIDGET_SHARE_SCREEN, CALLS_JOINED_CALL} from 'common/communication';
import {
MINIMUM_CALLS_WIDGET_WIDTH,
MINIMUM_CALLS_WIDGET_HEIGHT,
CALLS_PLUGIN_ID,
} from 'common/utils/constants';
import CallsWidgetWindow from './callsWidgetWindow';
jest.mock('electron', () => ({
BrowserWindow: jest.fn(),
ipcMain: {
on: jest.fn(),
off: jest.fn(),
},
}));
describe('main/windows/callsWidgetWindow', () => {
describe('create CallsWidgetWindow', () => {
const widgetConfig = {
callID: 'test-call-id',
siteURL: 'http://localhost:8065',
title: '',
serverName: 'test-server-name',
channelURL: '/team/channel_id',
};
const mainWindow = {
getBounds: jest.fn(),
};
const mainView = {
view: {
webContents: {
send: jest.fn(),
},
},
};
const baseWindow = new EventEmitter();
baseWindow.loadURL = jest.fn();
baseWindow.focus = jest.fn();
baseWindow.setVisibleOnAllWorkspaces = jest.fn();
baseWindow.setAlwaysOnTop = jest.fn();
baseWindow.setBackgroundColor = jest.fn();
baseWindow.setMenuBarVisibility = jest.fn();
baseWindow.setBounds = jest.fn();
beforeEach(() => {
mainWindow.getBounds.mockImplementation(() => {
return {
x: 0,
y: 0,
width: 1280,
height: 720,
};
});
baseWindow.getBounds = jest.fn(() => {
return {
x: 0,
y: 0,
width: MINIMUM_CALLS_WIDGET_WIDTH,
height: MINIMUM_CALLS_WIDGET_HEIGHT,
};
});
baseWindow.loadURL.mockImplementation(() => ({
catch: jest.fn(),
}));
BrowserWindow.mockImplementation(() => baseWindow);
});
afterEach(() => {
jest.resetAllMocks();
});
it('verify initial configuration', () => {
const widgetWindow = new CallsWidgetWindow(mainWindow, mainView, widgetConfig);
expect(widgetWindow).toBeDefined();
expect(BrowserWindow).toHaveBeenCalledWith(expect.objectContaining({
width: MINIMUM_CALLS_WIDGET_WIDTH,
height: MINIMUM_CALLS_WIDGET_HEIGHT,
minWidth: MINIMUM_CALLS_WIDGET_WIDTH,
minHeight: MINIMUM_CALLS_WIDGET_HEIGHT,
fullscreen: false,
resizable: false,
frame: false,
transparent: true,
show: false,
alwaysOnTop: true,
backgroundColor: '#00ffffff',
}));
});
it('showing window', () => {
baseWindow.show = jest.fn(() => {
baseWindow.emit('show');
});
const widgetWindow = new CallsWidgetWindow(mainWindow, mainView, widgetConfig);
widgetWindow.win.emit('ready-to-show');
expect(widgetWindow.win.show).toHaveBeenCalled();
expect(widgetWindow.win.setAlwaysOnTop).toHaveBeenCalled();
expect(widgetWindow.win.setBounds).toHaveBeenCalledWith({
x: 12,
y: 618,
width: MINIMUM_CALLS_WIDGET_WIDTH,
height: MINIMUM_CALLS_WIDGET_HEIGHT,
});
});
it('loadURL error', () => {
baseWindow.show = jest.fn(() => {
baseWindow.emit('show');
});
baseWindow.loadURL = jest.fn(() => {
return Promise.reject(new Error('failed to load URL'));
});
const widgetWindow = new CallsWidgetWindow(mainWindow, mainView, widgetConfig);
expect(widgetWindow.win.loadURL).toHaveBeenCalled();
});
it('open devTools', () => {
process.env.MM_DEBUG_CALLS_WIDGET = 'true';
baseWindow.show = jest.fn(() => {
baseWindow.emit('show');
});
baseWindow.webContents = {
openDevTools: jest.fn(),
};
const widgetWindow = new CallsWidgetWindow(mainWindow, mainView, widgetConfig);
widgetWindow.win.emit('ready-to-show');
expect(widgetWindow.win.webContents.openDevTools).toHaveBeenCalled();
});
it('closing window', () => {
baseWindow.close = jest.fn(() => {
baseWindow.emit('closed');
});
const widgetWindow = new CallsWidgetWindow(mainWindow, mainView, widgetConfig);
widgetWindow.close();
expect(widgetWindow.win.close).toHaveBeenCalled();
});
it('resize', () => {
baseWindow.show = jest.fn(() => {
baseWindow.emit('show');
});
let winBounds = {
x: 0,
y: 0,
width: MINIMUM_CALLS_WIDGET_WIDTH,
height: MINIMUM_CALLS_WIDGET_HEIGHT,
};
baseWindow.getBounds = jest.fn(() => {
return winBounds;
});
baseWindow.setBounds = jest.fn((bounds) => {
winBounds = bounds;
});
const widgetWindow = new CallsWidgetWindow(mainWindow, mainView, widgetConfig);
widgetWindow.win.emit('ready-to-show');
expect(baseWindow.setBounds).toHaveBeenCalledTimes(2);
widgetWindow.onResize(null, {
element: 'calls-widget-menu',
height: 100,
});
expect(baseWindow.setBounds).toHaveBeenCalledWith({
x: 12,
y: 518,
width: MINIMUM_CALLS_WIDGET_WIDTH,
height: MINIMUM_CALLS_WIDGET_HEIGHT + 100,
});
widgetWindow.onResize(null, {
element: 'calls-widget-audio-menu',
width: 100,
});
expect(baseWindow.setBounds).toHaveBeenCalledWith({
x: 12,
y: 518,
width: MINIMUM_CALLS_WIDGET_WIDTH + 100,
height: MINIMUM_CALLS_WIDGET_HEIGHT + 100,
});
widgetWindow.onResize(null, {
element: 'calls-widget-audio-menu',
width: 0,
});
expect(baseWindow.setBounds).toHaveBeenCalledWith({
x: 12,
y: 518,
width: MINIMUM_CALLS_WIDGET_WIDTH,
height: MINIMUM_CALLS_WIDGET_HEIGHT + 100,
});
widgetWindow.onResize(null, {
element: 'calls-widget-menu',
height: 0,
});
expect(baseWindow.setBounds).toHaveBeenCalledWith({
x: 12,
y: 618,
width: MINIMUM_CALLS_WIDGET_WIDTH,
height: MINIMUM_CALLS_WIDGET_HEIGHT,
});
});
it('getServerName', () => {
const widgetWindow = new CallsWidgetWindow(mainWindow, mainView, widgetConfig);
expect(widgetWindow.getServerName()).toBe('test-server-name');
});
it('getChannelURL', () => {
const widgetWindow = new CallsWidgetWindow(mainWindow, mainView, widgetConfig);
expect(widgetWindow.getChannelURL()).toBe('/team/channel_id');
});
it('getChannelURL', () => {
const widgetWindow = new CallsWidgetWindow(mainWindow, mainView, widgetConfig);
expect(widgetWindow.getCallID()).toBe('test-call-id');
});
it('getWidgetURL', () => {
const config = {
...widgetConfig,
siteURL: 'http://localhost:8065/subpath',
title: 'call test title #/&',
};
const widgetWindow = new CallsWidgetWindow(mainWindow, mainView, config);
const expected = `${config.siteURL}/plugins/${CALLS_PLUGIN_ID}/standalone/widget.html?call_id=${config.callID}&title=call+test+title+%23%2F%26`;
expect(widgetWindow.getWidgetURL()).toBe(expected);
});
it('onShareScreen', () => {
baseWindow.webContents = {
send: jest.fn(),
};
const widgetWindow = new CallsWidgetWindow(mainWindow, mainView, widgetConfig);
const message = {
sourceID: 'test-source-id',
withAudio: false,
};
widgetWindow.onShareScreen(null, '', message);
expect(widgetWindow.win.webContents.send).toHaveBeenCalledWith(CALLS_WIDGET_SHARE_SCREEN, message);
});
it('onJoinedCall', () => {
baseWindow.webContents = {
send: jest.fn(),
};
const widgetWindow = new CallsWidgetWindow(mainWindow, mainView, widgetConfig);
const message = {
callID: 'test-call-id',
};
widgetWindow.onJoinedCall(null, message);
expect(widgetWindow.mainView.view.webContents.send).toHaveBeenCalledWith(CALLS_JOINED_CALL, message);
});
});
});

View file

@ -0,0 +1,213 @@
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import url from 'url';
import {EventEmitter} from 'events';
import {BrowserWindow, Rectangle, ipcMain, IpcMainEvent} from 'electron';
import log from 'electron-log';
import {
CallsWidgetWindowConfig,
CallsWidgetResizeMessage,
CallsWidgetShareScreenMessage,
CallsJoinedCallMessage,
} from 'types/calls';
import {MattermostView} from 'main/views/MattermostView';
import {getLocalPreload} from 'main/utils';
import {
MINIMUM_CALLS_WIDGET_WIDTH,
MINIMUM_CALLS_WIDGET_HEIGHT,
CALLS_PLUGIN_ID,
} from 'common/utils/constants';
import Utils from 'common/utils/util';
import {
CALLS_WIDGET_RESIZE,
CALLS_WIDGET_SHARE_SCREEN,
CALLS_JOINED_CALL,
} from 'common/communication';
type LoadURLOpts = {
extraHeaders: string;
}
export default class CallsWidgetWindow extends EventEmitter {
public win: BrowserWindow;
private main: BrowserWindow;
private mainView: MattermostView;
private config: CallsWidgetWindowConfig;
private boundsErr: Rectangle = {
x: 0,
y: 0,
width: 0,
height: 0,
};
private offsetsMap = {
'calls-widget-menu': {
height: 0,
},
};
constructor(mainWindow: BrowserWindow, mainView: MattermostView, config: CallsWidgetWindowConfig) {
super();
this.config = config;
this.main = mainWindow;
this.mainView = mainView;
this.win = new BrowserWindow({
width: MINIMUM_CALLS_WIDGET_WIDTH,
height: MINIMUM_CALLS_WIDGET_HEIGHT,
minWidth: MINIMUM_CALLS_WIDGET_WIDTH,
minHeight: MINIMUM_CALLS_WIDGET_HEIGHT,
title: 'Calls Widget',
fullscreen: false,
resizable: false,
frame: false,
transparent: true,
show: false,
alwaysOnTop: true,
backgroundColor: '#00ffffff',
webPreferences: {
preload: getLocalPreload('callsWidget.js'),
},
});
this.win.once('ready-to-show', () => this.win.show());
this.win.once('show', this.onShow);
this.win.on('closed', this.onClosed);
ipcMain.on(CALLS_WIDGET_RESIZE, this.onResize);
ipcMain.on(CALLS_WIDGET_SHARE_SCREEN, this.onShareScreen);
ipcMain.on(CALLS_JOINED_CALL, this.onJoinedCall);
this.load();
}
public close() {
log.debug('CallsWidgetWindow.close');
this.win.close();
}
public getServerName() {
return this.config.serverName;
}
public getChannelURL() {
return this.config.channelURL;
}
public getCallID() {
return this.config.callID;
}
private load() {
const opts = {} as LoadURLOpts;
this.win.loadURL(this.getWidgetURL(), opts).catch((reason) => {
log.error(`Calls widget window failed to load: ${reason}`);
});
}
private onClosed = () => {
log.debug('CallsWidgetWindow.onClosed');
this.emit('closed');
this.removeAllListeners('closed');
ipcMain.off(CALLS_WIDGET_RESIZE, this.onResize);
ipcMain.off(CALLS_WIDGET_SHARE_SCREEN, this.onShareScreen);
ipcMain.off(CALLS_JOINED_CALL, this.onJoinedCall);
}
private getWidgetURL() {
const u = new url.URL(this.config.siteURL);
u.pathname += `/plugins/${CALLS_PLUGIN_ID}/standalone/widget.html`;
u.searchParams.append('call_id', this.config.callID);
if (this.config.title) {
u.searchParams.append('title', this.config.title);
}
return u.toString();
}
private onResize = (event: IpcMainEvent, msg: CallsWidgetResizeMessage) => {
log.debug('CallsWidgetWindow.onResize');
const currBounds = this.win.getBounds();
switch (msg.element) {
case 'calls-widget-audio-menu': {
const newBounds = {
x: currBounds.x,
y: currBounds.y,
width: msg.width > 0 ? currBounds.width + msg.width : MINIMUM_CALLS_WIDGET_WIDTH,
height: currBounds.height,
};
this.setBounds(newBounds);
break;
}
case 'calls-widget-menu': {
const hOff = this.offsetsMap[msg.element].height;
const newBounds = {
x: currBounds.x,
y: msg.height === 0 ? currBounds.y + hOff : currBounds.y - (msg.height - hOff),
width: MINIMUM_CALLS_WIDGET_WIDTH,
height: MINIMUM_CALLS_WIDGET_HEIGHT + msg.height,
};
this.setBounds(newBounds);
this.offsetsMap[msg.element].height = msg.height;
break;
}
}
}
private onShareScreen = (ev: IpcMainEvent, viewName: string, message: CallsWidgetShareScreenMessage) => {
this.win.webContents.send(CALLS_WIDGET_SHARE_SCREEN, message);
}
private onJoinedCall = (ev: IpcMainEvent, message: CallsJoinedCallMessage) => {
this.mainView.view.webContents.send(CALLS_JOINED_CALL, message);
}
private setBounds(bounds: Rectangle) {
// NOTE: this hack is needed to fix positioning on certain systems where
// BrowserWindow.setBounds() is not consistent.
bounds.x += this.boundsErr.x;
bounds.y += this.boundsErr.y;
bounds.height += this.boundsErr.height;
bounds.width += this.boundsErr.width;
this.win.setBounds(bounds);
this.boundsErr = Utils.boundsDiff(bounds, this.win.getBounds());
}
private onShow = () => {
log.debug('CallsWidgetWindow.onShow');
this.win.focus();
this.win.setVisibleOnAllWorkspaces(true, {visibleOnFullScreen: true});
this.win.setAlwaysOnTop(true, 'screen-saver');
const bounds = this.win.getBounds();
const mainBounds = this.main.getBounds();
const initialBounds = {
x: mainBounds.x + 12,
y: (mainBounds.y + mainBounds.height) - bounds.height - 12,
width: MINIMUM_CALLS_WIDGET_WIDTH,
height: MINIMUM_CALLS_WIDGET_HEIGHT,
};
this.win.setMenuBarVisibility(false);
if (process.env.MM_DEBUG_CALLS_WIDGET) {
this.win.webContents.openDevTools({mode: 'detach'});
}
this.setBounds(initialBounds);
}
}

View file

@ -16,6 +16,8 @@ import {WindowManager} from './windowManager';
import createMainWindow from './mainWindow';
import {createSettingsWindow} from './settingsWindow';
import CallsWidgetWindow from './callsWidgetWindow';
jest.mock('path', () => ({
resolve: jest.fn(),
join: jest.fn(),
@ -74,6 +76,8 @@ jest.mock('../downloadsManager', () => ({
getDownloads: () => {},
}));
jest.mock('./callsWidgetWindow');
describe('main/windows/windowManager', () => {
describe('handleUpdateConfig', () => {
const windowManager = new WindowManager();
@ -991,4 +995,185 @@ describe('main/windows/windowManager', () => {
expect(view1.isAtRoot).toBe(true);
});
});
describe('createCallsWidgetWindow', () => {
const view = {
name: 'server-1_tab-messaging',
serverInfo: {
remoteInfo: {
siteURL: 'http://server-1.com',
},
},
};
const windowManager = new WindowManager();
windowManager.viewManager = {
views: new Map([
['server-1_tab-messaging', view],
]),
};
it('should create calls widget window', () => {
expect(windowManager.callsWidgetWindow).toBeUndefined();
windowManager.createCallsWidgetWindow(null, 'server-1_tab-messaging', {callID: 'test'});
expect(windowManager.callsWidgetWindow).toBeDefined();
});
it('should not create a new window if call is the same', () => {
const widgetWindow = windowManager.callsWidgetWindow;
expect(widgetWindow).toBeDefined();
widgetWindow.getCallID = jest.fn(() => 'test');
windowManager.createCallsWidgetWindow(null, 'server-1_tab-messaging', {callID: 'test'});
expect(windowManager.callsWidgetWindow).toEqual(widgetWindow);
});
it('should create a new window if switching calls', () => {
const widgetWindow = windowManager.callsWidgetWindow;
expect(widgetWindow).toBeDefined();
widgetWindow.getCallID = jest.fn(() => 'test');
windowManager.createCallsWidgetWindow(null, 'server-1_tab-messaging', {callID: 'test2'});
expect(windowManager.callsWidgetWindow).not.toEqual(widgetWindow);
});
});
describe('handleDesktopSourcesModalRequest', () => {
const windowManager = new WindowManager();
windowManager.switchServer = jest.fn();
windowManager.viewManager = {
showByName: jest.fn(),
getCurrentView: jest.fn(),
};
beforeEach(() => {
CallsWidgetWindow.mockImplementation(() => {
return {
getServerName: () => 'server-1',
};
});
Config.teams = [
{
name: 'server-1',
order: 1,
tabs: [
{
name: 'tab-1',
order: 0,
isOpen: false,
},
{
name: 'tab-2',
order: 2,
isOpen: true,
},
],
}, {
name: 'server-2',
order: 0,
tabs: [
{
name: 'tab-1',
order: 0,
isOpen: false,
},
{
name: 'tab-2',
order: 2,
isOpen: true,
},
],
lastActiveTab: 2,
},
];
const map = Config.teams.reduce((arr, item) => {
item.tabs.forEach((tab) => {
arr.push([`${item.name}_${tab.name}`, {}]);
});
return arr;
}, []);
windowManager.viewManager.views = new Map(map);
});
afterEach(() => {
jest.resetAllMocks();
Config.teams = [];
});
it('should switch server', () => {
windowManager.callsWidgetWindow = new CallsWidgetWindow();
windowManager.handleDesktopSourcesModalRequest();
expect(windowManager.switchServer).toHaveBeenCalledWith('server-1');
});
});
describe('handleCallsWidgetChannelLinkClick', () => {
const windowManager = new WindowManager();
windowManager.switchServer = jest.fn();
windowManager.viewManager = {
showByName: jest.fn(),
getCurrentView: jest.fn(),
};
beforeEach(() => {
CallsWidgetWindow.mockImplementation(() => {
return {
getServerName: () => 'server-2',
};
});
Config.teams = [
{
name: 'server-1',
order: 1,
tabs: [
{
name: 'tab-1',
order: 0,
isOpen: false,
},
{
name: 'tab-2',
order: 2,
isOpen: true,
},
],
}, {
name: 'server-2',
order: 0,
tabs: [
{
name: 'tab-1',
order: 0,
isOpen: false,
},
{
name: 'tab-2',
order: 2,
isOpen: true,
},
],
lastActiveTab: 2,
},
];
const map = Config.teams.reduce((arr, item) => {
item.tabs.forEach((tab) => {
arr.push([`${item.name}_${tab.name}`, {}]);
});
return arr;
}, []);
windowManager.viewManager.views = new Map(map);
});
afterEach(() => {
jest.resetAllMocks();
Config.teams = [];
});
it('should switch server', () => {
windowManager.callsWidgetWindow = new CallsWidgetWindow();
windowManager.handleCallsWidgetChannelLinkClick();
expect(windowManager.switchServer).toHaveBeenCalledWith('server-2');
});
});
});

View file

@ -7,6 +7,10 @@ import path from 'path';
import {app, BrowserWindow, nativeImage, systemPreferences, ipcMain, IpcMainEvent, IpcMainInvokeEvent, desktopCapturer} from 'electron';
import log from 'electron-log';
import {
CallsJoinCallMessage,
} from 'types/calls';
import {
MAXIMIZE_CHANGE,
HISTORY,
@ -27,6 +31,10 @@ import {
DESKTOP_SOURCES_RESULT,
RELOAD_CURRENT_VIEW,
VIEW_FINISHED_RESIZING,
CALLS_JOIN_CALL,
CALLS_LEAVE_CALL,
DESKTOP_SOURCES_MODAL_REQUEST,
CALLS_WIDGET_CHANNEL_LINK_CLICK,
} from 'common/communication';
import urlUtils from 'common/utils/url';
import {SECOND} from 'common/utils/constants';
@ -49,6 +57,8 @@ import downloadsManager from 'main/downloadsManager';
import {createSettingsWindow} from './settingsWindow';
import createMainWindow from './mainWindow';
import CallsWidgetWindow from './callsWidgetWindow';
// singleton module to manage application's windows
export class WindowManager {
@ -57,6 +67,7 @@ export class WindowManager {
mainWindow?: BrowserWindow;
mainWindowReady: boolean;
settingsWindow?: BrowserWindow;
callsWidgetWindow?: CallsWidgetWindow;
viewManager?: ViewManager;
teamDropdown?: TeamDropdownView;
downloadsDropdown?: DownloadsDropdownView;
@ -81,6 +92,10 @@ export class WindowManager {
ipcMain.on(DISPATCH_GET_DESKTOP_SOURCES, this.handleGetDesktopSources);
ipcMain.on(RELOAD_CURRENT_VIEW, this.handleReloadCurrentView);
ipcMain.on(VIEW_FINISHED_RESIZING, this.handleViewFinishedResizing);
ipcMain.on(CALLS_JOIN_CALL, this.createCallsWidgetWindow);
ipcMain.on(CALLS_LEAVE_CALL, () => this.callsWidgetWindow?.close());
ipcMain.on(DESKTOP_SOURCES_MODAL_REQUEST, this.handleDesktopSourcesModalRequest);
ipcMain.on(CALLS_WIDGET_CHANNEL_LINK_CLICK, this.handleCallsWidgetChannelLinkClick);
}
handleUpdateConfig = () => {
@ -89,6 +104,53 @@ export class WindowManager {
}
}
createCallsWidgetWindow = (event: IpcMainEvent, viewName: string, msg: CallsJoinCallMessage) => {
log.debug('WindowManager.createCallsWidgetWindow');
if (this.callsWidgetWindow) {
// trying to join again the call we are already in should not be allowed.
if (this.callsWidgetWindow.getCallID() === msg.callID) {
return;
}
this.callsWidgetWindow.close();
}
const currentView = this.viewManager?.views.get(viewName);
if (!currentView) {
log.error('unable to create calls widget window: currentView is missing');
return;
}
this.callsWidgetWindow = new CallsWidgetWindow(this.mainWindow!, currentView, {
siteURL: currentView.serverInfo.remoteInfo.siteURL!,
callID: msg.callID,
title: msg.title,
serverName: this.currentServerName!,
channelURL: msg.channelURL,
});
this.callsWidgetWindow.on('closed', () => delete this.callsWidgetWindow);
}
handleDesktopSourcesModalRequest = () => {
log.debug('WindowManager.handleDesktopSourcesModalRequest');
if (this.callsWidgetWindow) {
this.switchServer(this.callsWidgetWindow?.getServerName());
this.mainWindow?.focus();
const currentView = this.viewManager?.getCurrentView();
currentView?.view.webContents.send(DESKTOP_SOURCES_MODAL_REQUEST);
}
}
handleCallsWidgetChannelLinkClick = () => {
log.debug('WindowManager.handleCallsWidgetChannelLinkClick');
if (this.callsWidgetWindow) {
this.switchServer(this.callsWidgetWindow.getServerName());
this.mainWindow?.focus();
const currentView = this.viewManager?.getCurrentView();
currentView?.view.webContents.send(BROWSER_HISTORY_PUSH, this.callsWidgetWindow.getChannelURL());
}
}
showSettingsWindow = () => {
log.debug('WindowManager.showSettingsWindow');
@ -743,19 +805,26 @@ export class WindowManager {
handleGetDesktopSources = async (event: IpcMainEvent, viewName: string, opts: Electron.SourcesOptions) => {
log.debug('WindowManager.handleGetDesktopSources', {viewName, opts});
const globalWidget = viewName === 'widget' && this.callsWidgetWindow;
const view = this.viewManager?.views.get(viewName);
if (!view) {
if (!view && !globalWidget) {
return;
}
desktopCapturer.getSources(opts).then((sources) => {
view.view.webContents.send(DESKTOP_SOURCES_RESULT, sources.map((source) => {
const message = sources.map((source) => {
return {
id: source.id,
name: source.name,
thumbnailURL: source.thumbnail.toDataURL(),
};
}));
});
if (view) {
view.view.webContents.send(DESKTOP_SOURCES_RESULT, message);
} else {
this.callsWidgetWindow?.win.webContents.send(DESKTOP_SOURCES_RESULT, message);
}
});
}

30
src/types/calls.ts Normal file
View file

@ -0,0 +1,30 @@
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
export type CallsWidgetWindowConfig = {
siteURL: string;
callID: string;
title: string;
serverName: string;
channelURL: string;
}
export type CallsJoinCallMessage = {
callID: string;
title: string;
channelURL: string;
}
export type CallsWidgetResizeMessage = {
element: string;
width: number;
height: number;
}
export type CallsWidgetShareScreenMessage = {
sourceID: string;
withAudio: boolean;
}
export type CallsJoinedCallMessage = {
callID: string;
}

View file

@ -23,6 +23,7 @@ module.exports = merge(base, {
modalPreload: './src/main/preload/modalPreload.js',
loadingScreenPreload: './src/main/preload/loadingScreenPreload.js',
urlView: './src/main/preload/urlView.js',
callsWidget: './src/main/preload/callsWidget.js',
},
externals: {
'macos-notification-state': 'require("macos-notification-state")',