From 47edeea60157b420da146af5ca49a79986bc5096 Mon Sep 17 00:00:00 2001 From: Claudio Costa Date: Mon, 7 Nov 2022 09:40:13 +0100 Subject: [PATCH] [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 --- src/common/communication.ts | 14 +- src/common/utils/constants.ts | 5 + src/common/utils/util.test.js | 27 ++ src/common/utils/util.ts | 12 + src/main/preload/callsWidget.js | 75 ++++++ src/main/preload/mattermost.js | 36 +++ src/main/windows/callsWidgetWindow.test.js | 286 +++++++++++++++++++++ src/main/windows/callsWidgetWindow.ts | 213 +++++++++++++++ src/main/windows/windowManager.test.js | 185 +++++++++++++ src/main/windows/windowManager.ts | 75 +++++- src/types/calls.ts | 30 +++ webpack.config.main.js | 1 + 12 files changed, 953 insertions(+), 6 deletions(-) create mode 100644 src/main/preload/callsWidget.js create mode 100644 src/main/windows/callsWidgetWindow.test.js create mode 100644 src/main/windows/callsWidgetWindow.ts create mode 100644 src/types/calls.ts diff --git a/src/common/communication.ts b/src/common/communication.ts index 7b43341d..7fbaf786 100644 --- a/src/common/communication.ts +++ b/src/common/communication.ts @@ -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'; diff --git a/src/common/utils/constants.ts b/src/common/utils/constants.ts index ddc98c64..4ea1e899 100644 --- a/src/common/utils/constants.ts +++ b/src/common/utils/constants.ts @@ -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; diff --git a/src/common/utils/util.test.js b/src/common/utils/util.test.js index c8fd5567..f59f584f 100644 --- a/src/common/utils/util.test.js +++ b/src/common/utils/util.test.js @@ -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); + }); + }); }); diff --git a/src/common/utils/util.ts b/src/common/utils/util.ts index 3aa4fdff..37a1a088 100644 --- a/src/common/utils/util.ts +++ b/src/common/utils/util.ts @@ -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, }; diff --git a/src/main/preload/callsWidget.js b/src/main/preload/callsWidget.js new file mode 100644 index 00000000..14b15d32 --- /dev/null +++ b/src/main/preload/callsWidget.js @@ -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, + ); +}); diff --git a/src/main/preload/mattermost.js b/src/main/preload/mattermost.js index a72e3536..32ec4d6a 100644 --- a/src/main/preload/mattermost.js +++ b/src/main/preload/mattermost.js @@ -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', () => { diff --git a/src/main/windows/callsWidgetWindow.test.js b/src/main/windows/callsWidgetWindow.test.js new file mode 100644 index 00000000..e8c32c50 --- /dev/null +++ b/src/main/windows/callsWidgetWindow.test.js @@ -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); + }); + }); +}); diff --git a/src/main/windows/callsWidgetWindow.ts b/src/main/windows/callsWidgetWindow.ts new file mode 100644 index 00000000..620bd1b2 --- /dev/null +++ b/src/main/windows/callsWidgetWindow.ts @@ -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); + } +} + diff --git a/src/main/windows/windowManager.test.js b/src/main/windows/windowManager.test.js index b16cd1d4..1574a997 100644 --- a/src/main/windows/windowManager.test.js +++ b/src/main/windows/windowManager.test.js @@ -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'); + }); + }); }); diff --git a/src/main/windows/windowManager.ts b/src/main/windows/windowManager.ts index 70d4c816..1bb67737 100644 --- a/src/main/windows/windowManager.ts +++ b/src/main/windows/windowManager.ts @@ -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); + } }); } diff --git a/src/types/calls.ts b/src/types/calls.ts new file mode 100644 index 00000000..592597ac --- /dev/null +++ b/src/types/calls.ts @@ -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; +} diff --git a/webpack.config.main.js b/webpack.config.main.js index 96627a3f..84f0a40e 100644 --- a/webpack.config.main.js +++ b/webpack.config.main.js @@ -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")',