diff --git a/package-lock.json b/package-lock.json index 41a7b744..45d10c26 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,7 +21,6 @@ "@babel/preset-env": "7.16.11", "@babel/preset-react": "7.16.7", "@babel/register": "7.17.7", - "@bloomberg/record-tuple-polyfill": "^0.0.4", "@electron/fuses": "1.6.0", "@electron/universal": "1.3.1", "@mattermost/compass-icons": "0.1.32", @@ -2108,12 +2107,6 @@ "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", "dev": true }, - "node_modules/@bloomberg/record-tuple-polyfill": { - "version": "0.0.4", - "resolved": "https://registry.npmjs.org/@bloomberg/record-tuple-polyfill/-/record-tuple-polyfill-0.0.4.tgz", - "integrity": "sha512-h0OYmPR3A5Dfbetra/GzxBAzQk8sH7LhRkRUTdagX6nrtlUgJGYCTv4bBK33jsTQw9HDd8PE2x1Ma+iRKEDUsw==", - "dev": true - }, "node_modules/@develar/schema-utils": { "version": "2.6.5", "resolved": "https://registry.npmjs.org/@develar/schema-utils/-/schema-utils-2.6.5.tgz", @@ -34773,12 +34766,6 @@ "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", "dev": true }, - "@bloomberg/record-tuple-polyfill": { - "version": "0.0.4", - "resolved": "https://registry.npmjs.org/@bloomberg/record-tuple-polyfill/-/record-tuple-polyfill-0.0.4.tgz", - "integrity": "sha512-h0OYmPR3A5Dfbetra/GzxBAzQk8sH7LhRkRUTdagX6nrtlUgJGYCTv4bBK33jsTQw9HDd8PE2x1Ma+iRKEDUsw==", - "dev": true - }, "@develar/schema-utils": { "version": "2.6.5", "resolved": "https://registry.npmjs.org/@develar/schema-utils/-/schema-utils-2.6.5.tgz", diff --git a/package.json b/package.json index b34041ff..5694fbeb 100644 --- a/package.json +++ b/package.json @@ -139,7 +139,6 @@ "@babel/preset-env": "7.16.11", "@babel/preset-react": "7.16.7", "@babel/register": "7.17.7", - "@bloomberg/record-tuple-polyfill": "^0.0.4", "@electron/fuses": "1.6.0", "@electron/universal": "1.3.1", "@mattermost/compass-icons": "0.1.32", diff --git a/src/common/tabs/BaseTabView.ts b/src/common/tabs/BaseTabView.ts index e35350af..d5745340 100644 --- a/src/common/tabs/BaseTabView.ts +++ b/src/common/tabs/BaseTabView.ts @@ -2,11 +2,10 @@ // See LICENSE.txt for license information. import {v4 as uuid} from 'uuid'; -import {Tuple as tuple} from '@bloomberg/record-tuple-polyfill'; import {MattermostServer} from 'common/servers/MattermostServer'; -import {getTabViewName, TabType, TabView, TabTuple} from './TabView'; +import {getTabViewName, TabType, TabView} from './TabView'; export default abstract class BaseTabView implements TabView { id: string; @@ -21,9 +20,6 @@ export default abstract class BaseTabView implements TabView { get name(): string { return getTabViewName(this.server.name, this.type); } - get urlTypeTuple(): TabTuple { - return tuple(this.server.url.href, this.type) as TabTuple; - } get url(): URL { throw new Error('Not implemented'); } diff --git a/src/common/tabs/TabView.ts b/src/common/tabs/TabView.ts index 4da85dc4..e83ec067 100644 --- a/src/common/tabs/TabView.ts +++ b/src/common/tabs/TabView.ts @@ -9,7 +9,6 @@ export const TAB_MESSAGING = 'TAB_MESSAGING'; export const TAB_FOCALBOARD = 'TAB_FOCALBOARD'; export const TAB_PLAYBOOKS = 'TAB_PLAYBOOKS'; export type TabType = typeof TAB_MESSAGING | typeof TAB_FOCALBOARD | typeof TAB_PLAYBOOKS; -export type TabTuple = [string, TabType]; export interface TabView { id: string; @@ -20,7 +19,6 @@ export interface TabView { get type(): TabType; get url(): URL; get shouldNotify(): boolean; - get urlTypeTuple(): TabTuple; } export function getDefaultTeamWithTabsFromTeam(team: FullTeam) { diff --git a/src/main/views/MattermostView.test.js b/src/main/views/MattermostView.test.js index e37c06e3..474cff99 100644 --- a/src/main/views/MattermostView.test.js +++ b/src/main/views/MattermostView.test.js @@ -9,6 +9,7 @@ import MessagingTabView from 'common/tabs/MessagingTabView'; import MainWindow from '../windows/mainWindow'; import * as WindowManager from '../windows/windowManager'; +import ContextMenu from '../contextMenu'; import * as appState from '../appState'; import Utils from '../utils'; @@ -24,6 +25,12 @@ jest.mock('electron', () => ({ on: jest.fn(), getTitle: () => 'title', getURL: () => 'http://server-1.com', + clearHistory: jest.fn(), + send: jest.fn(), + canGoBack: jest.fn(), + canGoForward: jest.fn(), + goToOffset: jest.fn(), + canGoToOffset: jest.fn(), }, })), ipcMain: { @@ -42,6 +49,7 @@ jest.mock('../appState', () => ({ updateMentions: jest.fn(), })); jest.mock('./webContentEvents', () => ({ + addWebContentsEventListeners: jest.fn(), removeWebContentsListeners: jest.fn(), })); jest.mock('../contextMenu', () => jest.fn()); @@ -53,7 +61,7 @@ jest.mock('../utils', () => ({ })); const server = new MattermostServer({name: 'server_name', url: 'http://server-1.com'}); -const tabView = new MessagingTabView(server); +const tabView = new MessagingTabView(server, true); describe('main/views/MattermostView', () => { describe('load', () => { @@ -179,6 +187,66 @@ describe('main/views/MattermostView', () => { }); }); + describe('goToOffset', () => { + const window = {on: jest.fn()}; + const mattermostView = new MattermostView(tabView, {}, {}); + mattermostView.reload = jest.fn(); + + afterEach(() => { + MainWindow.get.mockReturnValue(window); + jest.clearAllMocks(); + }); + + it('should only go to offset if it can', () => { + mattermostView.view.webContents.canGoToOffset.mockReturnValue(false); + mattermostView.goToOffset(1); + expect(mattermostView.view.webContents.goToOffset).not.toBeCalled(); + + mattermostView.view.webContents.canGoToOffset.mockReturnValue(true); + mattermostView.goToOffset(1); + expect(mattermostView.view.webContents.goToOffset).toBeCalled(); + }); + + it('should call reload if an error occurs', () => { + mattermostView.view.webContents.canGoToOffset.mockReturnValue(true); + mattermostView.view.webContents.goToOffset.mockImplementation(() => { + throw new Error('hi'); + }); + mattermostView.goToOffset(1); + expect(mattermostView.reload).toBeCalled(); + }); + }); + + describe('onLogin', () => { + const window = {on: jest.fn()}; + const mattermostView = new MattermostView(tabView, {}, {}); + mattermostView.view.webContents.getURL = jest.fn(); + mattermostView.reload = jest.fn(); + + afterEach(() => { + MainWindow.get.mockReturnValue(window); + jest.clearAllMocks(); + }); + + it('should reload view when URL is not on subpath of original server URL', () => { + mattermostView.view.webContents.getURL.mockReturnValue('http://server-2.com/subpath'); + mattermostView.onLogin(true); + expect(mattermostView.reload).toHaveBeenCalled(); + }); + + it('should not reload if URLs are matching', () => { + mattermostView.view.webContents.getURL.mockReturnValue('http://server-1.com'); + mattermostView.onLogin(true); + expect(mattermostView.reload).not.toHaveBeenCalled(); + }); + + it('should not reload if URL is subpath of server URL', () => { + mattermostView.view.webContents.getURL.mockReturnValue('http://server-1.com/subpath'); + mattermostView.onLogin(true); + expect(mattermostView.reload).not.toHaveBeenCalled(); + }); + }); + describe('loadSuccess', () => { const window = {on: jest.fn()}; const mattermostView = new MattermostView(tabView, {}, {}); @@ -208,7 +276,7 @@ describe('main/views/MattermostView', () => { }); describe('show', () => { - const window = {addBrowserView: jest.fn(), removeBrowserView: jest.fn(), on: jest.fn()}; + const window = {addBrowserView: jest.fn(), removeBrowserView: jest.fn(), on: jest.fn(), setTopBrowserView: jest.fn()}; const mattermostView = new MattermostView(tabView, {}, {}); beforeEach(() => { @@ -226,59 +294,99 @@ describe('main/views/MattermostView', () => { it('should add browser view to window and set bounds when request is true and view not currently visible', () => { mattermostView.isVisible = false; - mattermostView.show(true); + mattermostView.show(); expect(window.addBrowserView).toBeCalledWith(mattermostView.view); expect(mattermostView.setBounds).toBeCalled(); expect(mattermostView.isVisible).toBe(true); }); - it('should remove browser view when request is false', () => { - mattermostView.isVisible = true; - mattermostView.show(false); - expect(window.removeBrowserView).toBeCalledWith(mattermostView.view); - expect(mattermostView.isVisible).toBe(false); - }); - it('should do nothing when not toggling', () => { mattermostView.isVisible = true; - mattermostView.show(true); + mattermostView.show(); expect(window.addBrowserView).not.toBeCalled(); - expect(window.removeBrowserView).not.toBeCalled(); - - mattermostView.isVisible = false; - mattermostView.show(false); - expect(window.addBrowserView).not.toBeCalled(); - expect(window.removeBrowserView).not.toBeCalled(); }); it('should focus view if view is ready', () => { mattermostView.status = 1; mattermostView.isVisible = false; - mattermostView.show(true); + mattermostView.show(); expect(mattermostView.focus).toBeCalled(); }); }); + describe('hide', () => { + const window = {addBrowserView: jest.fn(), removeBrowserView: jest.fn(), on: jest.fn(), setTopBrowserView: jest.fn()}; + const mattermostView = new MattermostView(tabView, {}, {}); + + beforeEach(() => { + MainWindow.get.mockReturnValue(window); + }); + + it('should remove browser view', () => { + mattermostView.isVisible = true; + mattermostView.hide(); + expect(window.removeBrowserView).toBeCalledWith(mattermostView.view); + expect(mattermostView.isVisible).toBe(false); + }); + + it('should do nothing when not toggling', () => { + mattermostView.isVisible = false; + mattermostView.hide(); + expect(window.removeBrowserView).not.toBeCalled(); + }); + }); + + describe('updateHistoryButton', () => { + const window = {on: jest.fn()}; + const mattermostView = new MattermostView(tabView, {}, {}); + + beforeEach(() => { + MainWindow.get.mockReturnValue(window); + }); + + it('should erase history and set isAtRoot when navigating to root URL', () => { + mattermostView.atRoot = false; + mattermostView.updateHistoryButton(); + expect(mattermostView.view.webContents.clearHistory).toHaveBeenCalled(); + expect(mattermostView.isAtRoot).toBe(true); + }); + }); + describe('destroy', () => { const window = {removeBrowserView: jest.fn(), on: jest.fn()}; - const mattermostView = new MattermostView(tabView, {}, {}); + const contextMenu = { + dispose: jest.fn(), + }; beforeEach(() => { MainWindow.get.mockReturnValue(window); - mattermostView.view.webContents.destroy = jest.fn(); + ContextMenu.mockReturnValue(contextMenu); }); it('should remove browser view from window', () => { + const mattermostView = new MattermostView(tabView, {}, {}); + mattermostView.view.webContents.destroy = jest.fn(); mattermostView.destroy(); expect(window.removeBrowserView).toBeCalledWith(mattermostView.view); }); it('should clear mentions', () => { + const mattermostView = new MattermostView(tabView, {}, {}); + mattermostView.view.webContents.destroy = jest.fn(); mattermostView.destroy(); expect(appState.updateMentions).toBeCalledWith(mattermostView.tab.name, 0, false); }); + it('should destroy context menu', () => { + const mattermostView = new MattermostView(tabView, {}, {}); + mattermostView.view.webContents.destroy = jest.fn(); + mattermostView.destroy(); + expect(contextMenu.dispose).toBeCalled(); + }); + it('should clear outstanding timeouts', () => { + const mattermostView = new MattermostView(tabView, {}, {}); + mattermostView.view.webContents.destroy = jest.fn(); const spy = jest.spyOn(global, 'clearTimeout'); mattermostView.retryLoad = 999; mattermostView.removeLoading = 1000; diff --git a/src/main/views/MattermostView.ts b/src/main/views/MattermostView.ts index e0631f04..45cad123 100644 --- a/src/main/views/MattermostView.ts +++ b/src/main/views/MattermostView.ts @@ -6,7 +6,6 @@ import {BrowserViewConstructorOptions, Event, Input} from 'electron/main'; import {EventEmitter} from 'events'; -import Util from 'common/utils/util'; import {RELOAD_INTERVAL, MAX_SERVER_RETRIES, SECOND, MAX_LOADING_SCREEN_SECONDS} from 'common/utils/constants'; import urlUtils from 'common/utils/url'; import { @@ -20,21 +19,21 @@ import { LOADSCREEN_END, BROWSER_HISTORY_BUTTON, } from 'common/communication'; -import {MattermostServer} from 'common/servers/MattermostServer'; -import {TabView, TabTuple} from 'common/tabs/TabView'; import {Logger} from 'common/log'; +import {TabView} from 'common/tabs/TabView'; +import {MattermostServer} from 'common/servers/MattermostServer'; import {ServerInfo} from 'main/server/serverInfo'; import MainWindow from 'main/windows/mainWindow'; +import WindowManager from 'main/windows/windowManager'; import ContextMenu from '../contextMenu'; import {getWindowBoundaries, getLocalPreload, composeUserAgent, shouldHaveBackBar} from '../utils'; -import WindowManager from '../windows/windowManager'; import * as appState from '../appState'; import WebContentsEventManager from './webContentEvents'; -export enum Status { +enum Status { LOADING, READY, WAITING_MM, @@ -42,27 +41,23 @@ export enum Status { } const MENTIONS_GROUP = 2; -const log = new Logger('MattermostView'); +const titleParser = /(\((\d+)\) )?(\* )?/g; export class MattermostView extends EventEmitter { tab: TabView; - view: BrowserView; - isVisible: boolean; - isLoggedIn: boolean; - isAtRoot: boolean; - options: BrowserViewConstructorOptions; serverInfo: ServerInfo; + isVisible: boolean; - removeLoading?: number; - - currentFavicon?: string; - hasBeenShown: boolean; - contextMenu: ContextMenu; - - status?: Status; - retryLoad?: NodeJS.Timeout; - maxRetries: number; - + private log: Logger; + private view: BrowserView; + private loggedIn: boolean; + private atRoot: boolean; + private options: BrowserViewConstructorOptions; + private removeLoading?: number; + private contextMenu: ContextMenu; + private status?: Status; + private retryLoad?: NodeJS.Timeout; + private maxRetries: number; private altPressStatus: boolean; constructor(tab: TabView, serverInfo: ServerInfo, options: BrowserViewConstructorOptions) { @@ -81,38 +76,24 @@ export class MattermostView extends EventEmitter { ...options.webPreferences, }; this.isVisible = false; - this.isLoggedIn = false; - this.isAtRoot = true; + this.loggedIn = false; + this.atRoot = true; this.view = new BrowserView(this.options); this.resetLoadingStatus(); - log.verbose(`BrowserView created for server ${this.tab.name}`); - - this.hasBeenShown = false; + this.log = new Logger(this.name, 'MattermostView'); + this.log.verbose('View created'); + this.view.webContents.on('did-finish-load', this.handleDidFinishLoad); + this.view.webContents.on('page-title-updated', this.handleTitleUpdate); + this.view.webContents.on('page-favicon-updated', this.handleFaviconUpdate); + this.view.webContents.on('update-target-url', this.handleUpdateTarget); + this.view.webContents.on('did-navigate', this.handleDidNavigate); if (process.platform !== 'darwin') { this.view.webContents.on('before-input-event', this.handleInputEvents); } - this.view.webContents.on('did-finish-load', () => { - log.debug('did-finish-load', this.tab.name); - - // wait for screen to truly finish loading before sending the message down - const timeout = setInterval(() => { - if (!this.view.webContents) { - return; - } - - if (!this.view.webContents.isLoading()) { - try { - this.view.webContents.send(SET_VIEW_OPTIONS, this.tab.name, this.tab.shouldNotify); - clearTimeout(timeout); - } catch (e) { - log.error('failed to send view options to view', this.tab.name); - } - } - }, 100); - }); + WebContentsEventManager.addWebContentsEventListeners(this.view.webContents); this.contextMenu = new ContextMenu({}, this.view); this.maxRetries = MAX_SERVER_RETRIES; @@ -124,28 +105,79 @@ export class MattermostView extends EventEmitter { }); } - // use the same name as the server - // TODO: we'll need unique identifiers if we have multiple instances of the same server in different tabs (1:N relationships) get name() { return this.tab.name; } - - get urlTypeTuple(): TabTuple { - return this.tab.urlTypeTuple; + get isAtRoot() { + return this.atRoot; + } + get isLoggedIn() { + return this.loggedIn; + } + get currentURL() { + return this.view.webContents.getURL(); + } + get webContentsId() { + return this.view.webContents.id; } updateServerInfo = (srv: MattermostServer) => { + let reload; + if (srv.url.toString() !== this.tab.server.url.toString()) { + reload = () => this.reload(); + } this.tab.server = srv; this.serverInfo = new ServerInfo(srv); this.view.webContents.send(SET_VIEW_OPTIONS, this.tab.name, this.tab.shouldNotify); + reload?.(); } - resetLoadingStatus = () => { - if (this.status !== Status.LOADING) { // if it's already loading, don't touch anything - delete this.retryLoad; - this.status = Status.LOADING; - this.maxRetries = MAX_SERVER_RETRIES; + onLogin = (loggedIn: boolean) => { + if (this.isLoggedIn === loggedIn) { + return; } + + this.loggedIn = loggedIn; + + // If we're logging in from a different tab, force a reload + if (loggedIn && + this.currentURL !== this.tab.url.toString() && + !this.currentURL.startsWith(this.tab.url.toString()) + ) { + this.reload(); + } + } + + goToOffset = (offset: number) => { + if (this.view.webContents.canGoToOffset(offset)) { + try { + this.view.webContents.goToOffset(offset); + this.updateHistoryButton(); + } catch (error) { + this.log.error(error); + this.reload(); + } + } + } + + updateHistoryButton = () => { + if (urlUtils.parseURL(this.currentURL)?.toString() === this.tab.url.toString()) { + this.view.webContents.clearHistory(); + this.atRoot = true; + } else { + this.atRoot = false; + } + this.view.webContents.send(BROWSER_HISTORY_BUTTON, this.view.webContents.canGoBack(), this.view.webContents.canGoForward()); + } + + updateTabView = (tab: TabView) => { + let reload; + if (tab.url.toString() !== this.tab.url.toString()) { + reload = () => this.reload(); + } + this.tab = tab; + this.view.webContents.send(SET_VIEW_OPTIONS, this.name, this.tab.shouldNotify); + reload?.(); } load = (someURL?: URL | string) => { @@ -159,19 +191,19 @@ export class MattermostView extends EventEmitter { if (parsedURL) { loadURL = parsedURL.toString(); } else { - log.error('Cannot parse provided url, using current server url', someURL); + this.log.error('Cannot parse provided url, using current server url', someURL); loadURL = this.tab.url.toString(); } } else { loadURL = this.tab.url.toString(); } - log.verbose(`[${Util.shorten(this.tab.name)}] Loading ${loadURL}`); + this.log.verbose(`Loading ${loadURL}`); const loading = this.view.webContents.loadURL(loadURL, {userAgent: composeUserAgent()}); loading.then(this.loadSuccess(loadURL)).catch((err) => { if (err.code && err.code.startsWith('ERR_CERT')) { - WindowManager.sendToRenderer(LOAD_FAILED, this.tab.name, err.toString(), loadURL.toString()); - this.emit(LOAD_FAILED, this.tab.name, err.toString(), loadURL.toString()); - log.info(`[${Util.shorten(this.tab.name)}] Invalid certificate, stop retrying until the user decides what to do: ${err}.`); + WindowManager.sendToRenderer(LOAD_FAILED, this.name, err.toString(), loadURL.toString()); + this.emit(LOAD_FAILED, this.name, err.toString(), loadURL.toString()); + this.log.info('Invalid certificate, stop retrying until the user decides what to do.', err); this.status = Status.ERROR; return; } @@ -183,85 +215,28 @@ export class MattermostView extends EventEmitter { }); } - retry = (loadURL: string) => { - return () => { - // window was closed while retrying - if (!this.view || !this.view.webContents) { - return; - } - const loading = this.view.webContents.loadURL(loadURL, {userAgent: composeUserAgent()}); - loading.then(this.loadSuccess(loadURL)).catch((err) => { - if (this.maxRetries-- > 0) { - this.loadRetry(loadURL, err); - } else { - WindowManager.sendToRenderer(LOAD_FAILED, this.tab.name, err.toString(), loadURL.toString()); - this.emit(LOAD_FAILED, this.tab.name, err.toString(), loadURL.toString()); - log.info(`[${Util.shorten(this.tab.name)}] Couldn't establish a connection with ${loadURL}: ${err}. Will continue to retry in the background.`); - this.status = Status.ERROR; - this.retryLoad = setTimeout(this.retryInBackground(loadURL), RELOAD_INTERVAL); - } - }); - }; - } - - retryInBackground = (loadURL: string) => { - return () => { - // window was closed while retrying - if (!this.view || !this.view.webContents) { - return; - } - const loading = this.view.webContents.loadURL(loadURL, {userAgent: composeUserAgent()}); - loading.then(this.loadSuccess(loadURL)).catch(() => { - this.retryLoad = setTimeout(this.retryInBackground(loadURL), RELOAD_INTERVAL); - }); - }; - } - - loadRetry = (loadURL: string, err: Error) => { - this.retryLoad = setTimeout(this.retry(loadURL), RELOAD_INTERVAL); - WindowManager.sendToRenderer(LOAD_RETRY, this.tab.name, Date.now() + RELOAD_INTERVAL, err.toString(), loadURL.toString()); - log.info(`[${Util.shorten(this.tab.name)}] failed loading ${loadURL}: ${err}, retrying in ${RELOAD_INTERVAL / SECOND} seconds`); - } - - loadSuccess = (loadURL: string) => { - return () => { - const mainWindow = MainWindow.get(); - if (!mainWindow) { - return; - } - - log.verbose(`[${Util.shorten(this.tab.name)}] finished loading ${loadURL}`); - WindowManager.sendToRenderer(LOAD_SUCCESS, this.tab.name); - this.maxRetries = MAX_SERVER_RETRIES; - if (this.status === Status.LOADING) { - this.updateMentionsFromTitle(this.view.webContents.getTitle()); - this.findUnreadState(null); - } - this.status = Status.WAITING_MM; - this.removeLoading = setTimeout(this.setInitialized, MAX_LOADING_SCREEN_SECONDS, true); - this.emit(LOAD_SUCCESS, this.tab.name, loadURL); - this.setBounds(getWindowBoundaries(mainWindow, shouldHaveBackBar(this.tab.url || '', this.view.webContents.getURL()))); - }; - } - - show = (requestedVisibility?: boolean) => { + show = () => { const mainWindow = MainWindow.get(); if (!mainWindow) { return; } - - this.hasBeenShown = true; - const request = typeof requestedVisibility === 'undefined' ? true : requestedVisibility; - if (request && !this.isVisible) { - mainWindow.addBrowserView(this.view); - this.setBounds(getWindowBoundaries(mainWindow, shouldHaveBackBar(this.tab.url || '', this.view.webContents.getURL()))); - if (this.status === Status.READY) { - this.focus(); - } - } else if (!request && this.isVisible) { - mainWindow.removeBrowserView(this.view); + if (this.isVisible) { + return; + } + this.isVisible = true; + mainWindow.addBrowserView(this.view); + mainWindow.setTopBrowserView(this.view); + this.setBounds(getWindowBoundaries(mainWindow, shouldHaveBackBar(this.tab.url || '', this.currentURL))); + if (this.status === Status.READY) { + this.focus(); + } + } + + hide = () => { + if (this.isVisible) { + this.isVisible = false; + MainWindow.get()?.removeBrowserView(this.view); } - this.isVisible = request; } reload = () => { @@ -269,41 +244,21 @@ export class MattermostView extends EventEmitter { this.load(); } - hide = () => this.show(false); + getBounds = () => { + return this.view.getBounds(); + } openFind = () => { this.view.webContents.sendInputEvent({type: 'keyDown', keyCode: 'F', modifiers: [process.platform === 'darwin' ? 'cmd' : 'ctrl', 'shift']}); } - goToOffset = (offset: number) => { - if (this.view.webContents.canGoToOffset(offset)) { - try { - this.view.webContents.goToOffset(offset); - this.updateHistoryButton(); - } catch (error) { - log.error(error); - this.reload(); - } - } - } - - updateHistoryButton = () => { - if (urlUtils.parseURL(this.view.webContents.getURL())?.toString() === this.tab.url.toString()) { - this.view.webContents.clearHistory(); - this.isAtRoot = true; - } else { - this.isAtRoot = false; - } - this.view.webContents.send(BROWSER_HISTORY_BUTTON, this.view.webContents.canGoBack(), this.view.webContents.canGoForward()); - } - setBounds = (boundaries: Electron.Rectangle) => { this.view.setBounds(boundaries); } destroy = () => { - WebContentsEventManager.removeWebContentsListeners(this.view.webContents.id); - appState.updateMentions(this.tab.name, 0, false); + WebContentsEventManager.removeWebContentsListeners(this.webContentsId); + appState.updateMentions(this.name, 0, false); MainWindow.get()?.removeBrowserView(this.view); // workaround to eliminate zombie processes @@ -319,13 +274,19 @@ export class MattermostView extends EventEmitter { if (this.removeLoading) { clearTimeout(this.removeLoading); } + + this.contextMenu.dispose(); } - focus = () => { - if (this.view.webContents) { - this.view.webContents.focus(); - } else { - log.warn('trying to focus the browserview, but it doesn\'t yet have webcontents.'); + /** + * Status hooks + */ + + resetLoadingStatus = () => { + if (this.status !== Status.LOADING) { // if it's already loading, don't touch anything + delete this.retryLoad; + this.status = Status.LOADING; + this.maxRetries = MAX_SERVER_RETRIES; } } @@ -345,25 +306,41 @@ export class MattermostView extends EventEmitter { this.status = Status.READY; if (timedout) { - log.info(`${this.tab.name} timeout expired will show the browserview`); - this.emit(LOADSCREEN_END, this.tab.name); + this.log.verbose('timeout expired will show the browserview'); + this.emit(LOADSCREEN_END, this.name); } clearTimeout(this.removeLoading); delete this.removeLoading; } - isInitialized = () => { - return this.status === Status.READY; - } - openDevTools = () => { this.view.webContents.openDevTools({mode: 'detach'}); } - getWebContents = () => { - return this.view.webContents; + /** + * WebContents hooks + */ + + sendToRenderer = (channel: string, ...args: any[]) => { + this.view.webContents.send(channel, ...args); } + isDestroyed = () => { + return this.view.webContents.isDestroyed(); + } + + focus = () => { + if (this.view.webContents) { + this.view.webContents.focus(); + } else { + this.log.warn('trying to focus the browserview, but it doesn\'t yet have webcontents.'); + } + } + + /** + * ALT key handling for the 3-dot menu (Windows/Linux) + */ + private registerAltKeyPressed = (input: Input) => { const isAltPressed = input.key === 'Alt' && input.alt === true && input.control === false && input.shift === false && input.meta === false; @@ -380,8 +357,8 @@ export class MattermostView extends EventEmitter { return input.type === 'keyUp' && this.altPressStatus === true; }; - handleInputEvents = (_: Event, input: Input) => { - log.silly('handleInputEvents', {tabName: this.tab.name, input}); + private handleInputEvents = (_: Event, input: Input) => { + this.log.silly('handleInputEvents', input); this.registerAltKeyPressed(input); @@ -390,61 +367,148 @@ export class MattermostView extends EventEmitter { } } - handleDidNavigate = (event: Event, url: string) => { - log.debug('handleDidNavigate', {tabName: this.tab.name, url}); + /** + * Unreads/mentions handlers + */ + + private updateMentionsFromTitle = (title: string) => { + const resultsIterator = title.matchAll(titleParser); + const results = resultsIterator.next(); // we are only interested in the first set + const mentions = (results && results.value && parseInt(results.value[MENTIONS_GROUP], 10)) || 0; + + appState.updateMentions(this.name, mentions); + } + + // if favicon is null, it will affect appState, but won't be memoized + private findUnreadState = (favicon: string | null) => { + try { + this.view.webContents.send(IS_UNREAD, favicon, this.name); + } catch (err: any) { + this.log.error('There was an error trying to request the unread state', err); + } + } + + private handleTitleUpdate = (e: Event, title: string) => { + this.log.debug('handleTitleUpdate', title); + + this.updateMentionsFromTitle(title); + } + + private handleFaviconUpdate = (e: Event, favicons: string[]) => { + this.log.silly('handleFaviconUpdate', favicons); + + // if unread state is stored for that favicon, retrieve value. + // if not, get related info from preload and store it for future changes + this.findUnreadState(favicons[0]); + } + + /** + * Loading/retry logic + */ + + private retry = (loadURL: string) => { + return () => { + // window was closed while retrying + if (!this.view || !this.view.webContents) { + return; + } + const loading = this.view.webContents.loadURL(loadURL, {userAgent: composeUserAgent()}); + loading.then(this.loadSuccess(loadURL)).catch((err) => { + if (this.maxRetries-- > 0) { + this.loadRetry(loadURL, err); + } else { + WindowManager.sendToRenderer(LOAD_FAILED, this.name, err.toString(), loadURL.toString()); + this.emit(LOAD_FAILED, this.name, err.toString(), loadURL.toString()); + this.log.info(`Couldn't establish a connection with ${loadURL}, will continue to retry in the background`, err); + this.status = Status.ERROR; + this.retryLoad = setTimeout(this.retryInBackground(loadURL), RELOAD_INTERVAL); + } + }); + }; + } + + private retryInBackground = (loadURL: string) => { + return () => { + // window was closed while retrying + if (!this.view || !this.view.webContents) { + return; + } + const loading = this.view.webContents.loadURL(loadURL, {userAgent: composeUserAgent()}); + loading.then(this.loadSuccess(loadURL)).catch(() => { + this.retryLoad = setTimeout(this.retryInBackground(loadURL), RELOAD_INTERVAL); + }); + }; + } + + private loadRetry = (loadURL: string, err: Error) => { + this.retryLoad = setTimeout(this.retry(loadURL), RELOAD_INTERVAL); + WindowManager.sendToRenderer(LOAD_RETRY, this.name, Date.now() + RELOAD_INTERVAL, err.toString(), loadURL.toString()); + this.log.info(`failed loading ${loadURL}: ${err}, retrying in ${RELOAD_INTERVAL / SECOND} seconds`); + } + + private loadSuccess = (loadURL: string) => { + return () => { + this.log.verbose(`finished loading ${loadURL}`); + WindowManager.sendToRenderer(LOAD_SUCCESS, this.name); + this.maxRetries = MAX_SERVER_RETRIES; + if (this.status === Status.LOADING) { + this.updateMentionsFromTitle(this.view.webContents.getTitle()); + this.findUnreadState(null); + } + this.status = Status.WAITING_MM; + this.removeLoading = setTimeout(this.setInitialized, MAX_LOADING_SCREEN_SECONDS, true); + this.emit(LOAD_SUCCESS, this.name, loadURL); + const mainWindow = MainWindow.get(); + if (mainWindow) { + this.setBounds(getWindowBoundaries(mainWindow, shouldHaveBackBar(this.tab.url || '', this.currentURL))); + } + }; + } + + /** + * WebContents event handlers + */ + + private handleDidFinishLoad = () => { + this.log.debug('did-finish-load', this.name); + + // wait for screen to truly finish loading before sending the message down + const timeout = setInterval(() => { + if (!this.view.webContents) { + return; + } + + if (!this.view.webContents.isLoading()) { + try { + this.view.webContents.send(SET_VIEW_OPTIONS, this.name, this.tab.shouldNotify); + clearTimeout(timeout); + } catch (e) { + this.log.error('failed to send view options to view'); + } + } + }, 100); + } + + private handleDidNavigate = (event: Event, url: string) => { + this.log.debug('handleDidNavigate', url); if (shouldHaveBackBar(this.tab.url || '', url)) { this.setBounds(getWindowBoundaries(MainWindow.get()!, true)); WindowManager.sendToRenderer(TOGGLE_BACK_BUTTON, true); - log.info('show back button'); + this.log.debug('show back button'); } else { this.setBounds(getWindowBoundaries(MainWindow.get()!)); WindowManager.sendToRenderer(TOGGLE_BACK_BUTTON, false); - log.info('hide back button'); + this.log.debug('hide back button'); } } - handleUpdateTarget = (e: Event, url: string) => { - log.silly('handleUpdateTarget', {tabName: this.tab.name, url}); + private handleUpdateTarget = (e: Event, url: string) => { + this.log.silly('handleUpdateTarget', url); if (url && !urlUtils.isInternalURL(urlUtils.parseURL(url), this.tab.server.url)) { this.emit(UPDATE_TARGET_URL, url); } else { this.emit(UPDATE_TARGET_URL); } } - - titleParser = /(\((\d+)\) )?(\* )?/g - - handleTitleUpdate = (e: Event, title: string) => { - log.debug('handleTitleUpdate', {tabName: this.tab.name, title}); - - this.updateMentionsFromTitle(title); - } - - updateMentionsFromTitle = (title: string) => { - const resultsIterator = title.matchAll(this.titleParser); - const results = resultsIterator.next(); // we are only interested in the first set - const mentions = (results && results.value && parseInt(results.value[MENTIONS_GROUP], 10)) || 0; - - appState.updateMentions(this.tab.name, mentions); - } - - handleFaviconUpdate = (e: Event, favicons: string[]) => { - log.silly('handleFaviconUpdate', {tabName: this.tab.name, favicons}); - - // if unread state is stored for that favicon, retrieve value. - // if not, get related info from preload and store it for future changes - this.currentFavicon = favicons[0]; - this.findUnreadState(favicons[0]); - } - - // if favicon is null, it will affect appState, but won't be memoized - findUnreadState = (favicon: string | null) => { - try { - this.view.webContents.send(IS_UNREAD, favicon, this.tab.name); - } catch (err: any) { - log.error(`There was an error trying to request the unread state: ${err}`); - log.error(err.stack); - } - } } diff --git a/src/main/views/viewManager.test.js b/src/main/views/viewManager.test.js index ba0ff7da..a94aad7d 100644 --- a/src/main/views/viewManager.test.js +++ b/src/main/views/viewManager.test.js @@ -5,7 +5,6 @@ 'use strict'; import {dialog, ipcMain} from 'electron'; -import {Tuple as tuple} from '@bloomberg/record-tuple-polyfill'; import {BROWSER_HISTORY_PUSH, LOAD_SUCCESS, MAIN_WINDOW_SHOWN} from 'common/communication'; import Config from 'common/config'; @@ -127,65 +126,6 @@ describe('main/views/viewManager', () => { }); }); - describe('handleAppLoggedIn', () => { - const viewManager = new ViewManager({}); - - afterEach(() => { - jest.resetAllMocks(); - }); - - it('should reload view when URL is not on subpath of original server URL', () => { - const view = { - load: jest.fn(), - view: { - webContents: { - getURL: () => 'http://server-2.com/subpath', - }, - }, - tab: { - url: new URL('http://server-1.com/'), - }, - }; - viewManager.views.set('view1', view); - viewManager.handleAppLoggedIn({}, 'view1'); - expect(view.load).toHaveBeenCalledWith(new URL('http://server-1.com/')); - }); - - it('should not reload if URLs are matching', () => { - const view = { - load: jest.fn(), - view: { - webContents: { - getURL: () => 'http://server-1.com/', - }, - }, - tab: { - url: new URL('http://server-1.com/'), - }, - }; - viewManager.views.set('view1', view); - viewManager.handleAppLoggedIn({}, 'view1'); - expect(view.load).not.toHaveBeenCalled(); - }); - - it('should not reload if URL is subpath of server URL', () => { - const view = { - load: jest.fn(), - view: { - webContents: { - getURL: () => 'http://server-1.com/subpath', - }, - }, - tab: { - url: new URL('http://server-1.com/'), - }, - }; - viewManager.views.set('view1', view); - viewManager.handleAppLoggedIn({}, 'view1'); - expect(view.load).not.toHaveBeenCalled(); - }); - }); - describe('reloadConfiguration', () => { const viewManager = new ViewManager(); @@ -203,7 +143,6 @@ describe('main/views/viewManager', () => { viewManager.getServerView = jest.fn().mockImplementation((srv, tabName) => ({ name: `${srv.name}-${tabName}`, - urlTypeTuple: tuple(`http://${srv.name}.com/`, tabName), url: new URL(`http://${srv.name}.com`), })); MattermostServer.mockImplementation((server) => ({ @@ -219,10 +158,10 @@ describe('main/views/viewManager', () => { once: onceFn, destroy: destroyFn, name: tab.name, - urlTypeTuple: tab.urlTypeTuple, updateServerInfo: jest.fn(), tab, })); + getTabViewName.mockImplementation((a, b) => `${a}-${b}`); }); afterEach(() => { @@ -249,7 +188,6 @@ describe('main/views/viewManager', () => { const makeSpy = jest.spyOn(viewManager, 'makeView'); const view = new MattermostView({ name: 'server1-tab1', - urlTypeTuple: tuple(new URL('http://server1.com').href, 'tab1'), server: 'server1', }); viewManager.views.set('server1-tab1', view); @@ -303,7 +241,7 @@ describe('main/views/viewManager', () => { name: 'tab1', isOpen: true, }, - 'http://server1.com/', + 'http://server1.com', ); makeSpy.mockRestore(); }); @@ -318,7 +256,6 @@ describe('main/views/viewManager', () => { name: 'server1-tab1', url: new URL('http://server1.com'), }, - urlTypeTuple: tuple('http://server1.com/', 'tab1'), destroy: jest.fn(), updateServerInfo: jest.fn(), }; @@ -348,7 +285,6 @@ describe('main/views/viewManager', () => { name: 'server1-tab1', url: new URL('http://server1.com'), }, - urlTypeTuple: ['http://server.com/', 'tab1'], destroy: jest.fn(), updateServerInfo: jest.fn(), }; @@ -772,12 +708,8 @@ describe('main/views/viewManager', () => { resetLoadingStatus: jest.fn(), load: jest.fn(), once: jest.fn(), - isInitialized: jest.fn(), - view: { - webContents: { - send: jest.fn(), - }, - }, + isReady: jest.fn(), + sendToRenderer: jest.fn(), serverInfo: { remoteInfo: { serverVersion: '1.0.0', @@ -819,10 +751,10 @@ describe('main/views/viewManager', () => { }, }, }; - view.isInitialized.mockImplementation(() => true); + view.isReady.mockImplementation(() => true); viewManager.views.set('view1', view); viewManager.handleDeepLink('mattermost://server-1.com/deep/link?thing=yes'); - expect(view.view.webContents.send).toHaveBeenCalledWith(BROWSER_HISTORY_PUSH, '/deep/link?thing=yes'); + expect(view.sendToRenderer).toHaveBeenCalledWith(BROWSER_HISTORY_PUSH, '/deep/link?thing=yes'); }); it('should throw error if view is missing', () => { diff --git a/src/main/views/viewManager.ts b/src/main/views/viewManager.ts index 37205ddb..47815026 100644 --- a/src/main/views/viewManager.ts +++ b/src/main/views/viewManager.ts @@ -3,8 +3,6 @@ import {BrowserView, dialog, ipcMain, IpcMainEvent, IpcMainInvokeEvent} from 'electron'; import {BrowserViewConstructorOptions} from 'electron/main'; -import {Tuple as tuple} from '@bloomberg/record-tuple-polyfill'; - import {Tab, TeamWithTabs} from 'types/config'; import {SECOND, TAB_BAR_HEIGHT} from 'common/utils/constants'; @@ -26,13 +24,14 @@ import { APP_LOGGED_OUT, UNREAD_RESULT, GET_VIEW_NAME, + HISTORY, } from 'common/communication'; import Config from 'common/config'; import {Logger} from 'common/log'; import urlUtils, {equalUrlsIgnoringSubpath} from 'common/utils/url'; import Utils from 'common/utils/util'; import {MattermostServer} from 'common/servers/MattermostServer'; -import {getTabViewName, TabTuple, TabType, TAB_FOCALBOARD, TAB_MESSAGING, TAB_PLAYBOOKS} from 'common/tabs/TabView'; +import {getTabViewName, TAB_FOCALBOARD, TAB_MESSAGING, TAB_PLAYBOOKS} from 'common/tabs/TabView'; import MessagingTabView from 'common/tabs/MessagingTabView'; import FocalboardTabView from 'common/tabs/FocalboardTabView'; import PlaybooksTabView from 'common/tabs/PlaybooksTabView'; @@ -46,7 +45,6 @@ import {getLocalURLString, getLocalPreload} from '../utils'; import {MattermostView} from './MattermostView'; import modalManager from './modalManager'; -import WebContentsEventManager from './webContentEvents'; import LoadingScreen from './loadingScreen'; const log = new Logger('ViewManager'); @@ -69,6 +67,7 @@ export class ViewManager { this.views = new Map(); // keep in mind that this doesn't need to hold server order, only tabs on the renderer need that. this.closedViews = new Map(); + ipcMain.on(HISTORY, this.handleHistory); ipcMain.on(REACT_APP_INITIALIZED, this.handleReactAppInitialized); ipcMain.on(BROWSER_HISTORY_PUSH, this.handleBrowserHistoryPush); ipcMain.on(BROWSER_HISTORY_BUTTON, this.handleBrowserHistoryButton); @@ -97,7 +96,7 @@ export class ViewManager { } getViewByWebContentsId = (webContentsId: number) => { - return [...this.views.values()].find((view) => view.view.webContents.id === webContentsId); + return [...this.views.values()].find((view) => view.webContentsId === webContentsId); } showByName = (name: string) => { @@ -157,8 +156,8 @@ export class ViewManager { sendToAllViews = (channel: string, ...args: unknown[]) => { this.views.forEach((view) => { - if (!view.view.webContents.isDestroyed()) { - view.view.webContents.send(channel, ...args); + if (!view.isDestroyed()) { + view.sendToRenderer(channel, ...args); } }); } @@ -187,9 +186,9 @@ export class ViewManager { return; } - if (view.isInitialized() && view.serverInfo.remoteInfo.serverVersion && Utils.isVersionGreaterThanOrEqualTo(view.serverInfo.remoteInfo.serverVersion, '6.0.0')) { + if (view.isReady() && view.serverInfo.remoteInfo.serverVersion && Utils.isVersionGreaterThanOrEqualTo(view.serverInfo.remoteInfo.serverVersion, '6.0.0')) { const pathName = `/${urlWithSchema.replace(view.tab.server.url.toString(), '')}`; - view.view.webContents.send(BROWSER_HISTORY_PUSH, pathName); + view.sendToRenderer(BROWSER_HISTORY_PUSH, pathName); this.deeplinkSuccess(view.name); } else { // attempting to change parsedURL protocol results in it not being modified. @@ -298,12 +297,6 @@ export class ViewManager { if (this.currentView === viewName) { this.showByName(this.currentView); } - const view = this.views.get(viewName); - if (!view) { - log.error(`Couldn't find a view with the name ${viewName}`); - return; - } - WebContentsEventManager.addMattermostViewEventListeners(view); } private finishLoading = (server: string) => { @@ -352,7 +345,7 @@ export class ViewManager { const localURL = getLocalURLString('urlView.html', query); urlView.webContents.loadURL(localURL); mainWindow.addBrowserView(urlView); - const boundaries = this.views.get(this.currentView || '')?.view.getBounds() ?? mainWindow.getBounds(); + const boundaries = this.views.get(this.currentView || '')?.getBounds() ?? mainWindow.getBounds(); const hideView = () => { delete this.urlViewCancel; @@ -407,15 +400,13 @@ export class ViewManager { reloadConfiguration = () => { log.debug('reloadConfiguration'); - const focusedTuple: TabTuple | undefined = this.views.get(this.currentView as string)?.urlTypeTuple; - - const current: Map = new Map(); + const current: Map = new Map(); for (const view of this.views.values()) { - current.set(view.urlTypeTuple, view); + current.set(view.name, view); } - const views: Map = new Map(); - const closed: Map = new Map(); + const views: Map = new Map(); + const closed: Map = new Map(); const sortedTabs = this.getServers().flatMap((x) => [...x.tabs]. sort((a, b) => a.order - b.order). @@ -424,16 +415,16 @@ export class ViewManager { for (const [team, tab] of sortedTabs) { const srv = new MattermostServer(team); const info = new ServerInfo(srv); - const tabTuple = tuple(new URL(team.url).href, tab.name as TabType); - const recycle = current.get(tabTuple); + const tabName = getTabViewName(team.name, tab.name); + const recycle = current.get(tabName); if (!tab.isOpen) { const view = this.getServerView(srv, tab.name); - closed.set(tabTuple, {srv, tab, name: view.name}); + closed.set(tabName, {srv, tab, name: view.name}); } else if (recycle) { recycle.updateServerInfo(srv); - views.set(tabTuple, recycle); + views.set(tabName, recycle); } else { - views.set(tabTuple, this.makeView(srv, info, tab, tabTuple[0])); + views.set(tabName, this.makeView(srv, info, tab, team.url)); } } @@ -456,7 +447,7 @@ export class ViewManager { this.closedViews.set(x.name, {srv: x.srv, tab: x.tab}); } - if ((focusedTuple && closed.has(focusedTuple)) || (this.currentView && this.closedViews.has(this.currentView))) { + if ((this.currentView && closed.has(this.currentView)) || (this.currentView && this.closedViews.has(this.currentView))) { if (this.getServers().length) { this.currentView = undefined; this.showInitial(); @@ -466,8 +457,8 @@ export class ViewManager { } // show the focused tab (or initial) - if (focusedTuple && views.has(focusedTuple)) { - const view = views.get(focusedTuple); + if (this.currentView && views.has(this.currentView)) { + const view = views.get(this.currentView); if (view) { this.currentView = view.name; this.showByName(view.name); @@ -478,25 +469,16 @@ export class ViewManager { } } - private handleAppLoggedIn = (event: IpcMainEvent, viewName: string) => { - log.debug('handleAppLoggedIn', viewName); - - const view = this.views.get(viewName); - if (view && !view.isLoggedIn) { - view.isLoggedIn = true; - if (view.view.webContents.getURL() !== view.tab.url.toString() && !view.view.webContents.getURL().startsWith(view.tab.url.toString())) { - view.load(view.tab.url); - } - } + private handleHistory = (event: IpcMainEvent, offset: number) => { + this.getCurrentView()?.goToOffset(offset); } - private handleAppLoggedOut = (event: IpcMainEvent, viewName: string) => { - log.debug('handleAppLoggedOut', viewName); + private handleAppLoggedIn = (event: IpcMainEvent, viewId: string) => { + this.getView(viewId)?.onLogin(true); + } - const view = this.views.get(viewName); - if (view && view.isLoggedIn) { - view.isLoggedIn = false; - } + private handleAppLoggedOut = (event: IpcMainEvent, viewId: string) => { + this.getView(viewId)?.onLogin(false); } private handleBrowserHistoryPush = (e: IpcMainEvent, viewName: string, pathName: string) => { @@ -520,7 +502,7 @@ export class ViewManager { // Special case check for Channels to not force a redirect to "/", causing a refresh if (!(redirectedView !== currentView && redirectedView?.tab.type === TAB_MESSAGING && cleanedPathName === '/')) { - redirectedView?.view.webContents.send(BROWSER_HISTORY_PUSH, cleanedPathName); + redirectedView?.sendToRenderer(BROWSER_HISTORY_PUSH, cleanedPathName); if (redirectedView) { this.handleBrowserHistoryButton(e, redirectedView.name); } diff --git a/src/main/views/webContentEvents.ts b/src/main/views/webContentEvents.ts index ec3a370c..ce5f4c75 100644 --- a/src/main/views/webContentEvents.ts +++ b/src/main/views/webContentEvents.ts @@ -17,7 +17,6 @@ import {protocols} from '../../../electron-builder.json'; import allowProtocolDialog from '../allowProtocolDialog'; import {composeUserAgent} from '../utils'; -import {MattermostView} from './MattermostView'; import ViewManager from './viewManager'; type CustomLogin = { @@ -268,24 +267,6 @@ export class WebContentsEventManager { } }; - addMattermostViewEventListeners = (mmview: MattermostView) => { - this.addWebContentsEventListeners( - mmview.view.webContents, - (contents: WebContents) => { - contents.on('page-title-updated', mmview.handleTitleUpdate); - contents.on('page-favicon-updated', mmview.handleFaviconUpdate); - contents.on('update-target-url', mmview.handleUpdateTarget); - contents.on('did-navigate', mmview.handleDidNavigate); - }, - (contents: WebContents) => { - contents.removeListener('page-title-updated', mmview.handleTitleUpdate); - contents.removeListener('page-favicon-updated', mmview.handleFaviconUpdate); - contents.removeListener('update-target-url', mmview.handleUpdateTarget); - contents.removeListener('did-navigate', mmview.handleDidNavigate); - }, - ); - }; - addWebContentsEventListeners = ( contents: WebContents, addListeners?: (contents: WebContents) => void, diff --git a/src/main/windows/callsWidgetWindow.test.js b/src/main/windows/callsWidgetWindow.test.js index 7eb495ba..42c28747 100644 --- a/src/main/windows/callsWidgetWindow.test.js +++ b/src/main/windows/callsWidgetWindow.test.js @@ -52,12 +52,8 @@ describe('main/windows/callsWidgetWindow', () => { }; const mainView = { - view: { - webContents: { - send: jest.fn(), - id: 'mainViewID', - }, - }, + sendToRenderer: jest.fn(), + webContentsId: 'mainViewID', serverInfo: { server: { name: 'test-server-name', @@ -434,12 +430,12 @@ describe('main/windows/callsWidgetWindow', () => { widgetWindow.onJoinedCall({ sender: {id: 'badID'}, }, message); - expect(widgetWindow.mainView.view.webContents.send).not.toHaveBeenCalled(); + expect(widgetWindow.mainView.sendToRenderer).not.toHaveBeenCalled(); widgetWindow.onJoinedCall({ sender: baseWindow.webContents, }, 'widget', message); - expect(widgetWindow.mainView.view.webContents.send).toHaveBeenCalledWith(CALLS_JOINED_CALL, message); + expect(widgetWindow.mainView.sendToRenderer).toHaveBeenCalledWith(CALLS_JOINED_CALL, message); }); it('menubar disabled on popout', () => { @@ -589,7 +585,7 @@ describe('main/windows/callsWidgetWindow', () => { })).toEqual(false); expect(widgetWindow.isAllowedEvent({ - sender: widgetWindow.mainView.view.webContents, + sender: {id: 'mainViewID'}, })).toEqual(true); expect(widgetWindow.isAllowedEvent({ diff --git a/src/main/windows/callsWidgetWindow.ts b/src/main/windows/callsWidgetWindow.ts index e2c1afe7..cb87790f 100644 --- a/src/main/windows/callsWidgetWindow.ts +++ b/src/main/windows/callsWidgetWindow.ts @@ -190,7 +190,7 @@ export default class CallsWidgetWindow extends EventEmitter { return; } - this.mainView.view.webContents.send(CALLS_JOINED_CALL, message); + this.mainView.sendToRenderer(CALLS_JOINED_CALL, message); } private setBounds(bounds: Rectangle) { @@ -297,7 +297,7 @@ export default class CallsWidgetWindow extends EventEmitter { // Only allow events coming from either the widget window or the // original Mattermost view that initiated it. return event.sender.id === this.getWebContentsId() || - event.sender.id === this.getMainView().getWebContents().id; + event.sender.id === this.getMainView().webContentsId; } } diff --git a/src/main/windows/windowManager.test.js b/src/main/windows/windowManager.test.js index 4225dd94..2251cddf 100644 --- a/src/main/windows/windowManager.test.js +++ b/src/main/windows/windowManager.test.js @@ -563,61 +563,6 @@ describe('main/windows/windowManager', () => { }); }); - describe('handleHistory', () => { - const windowManager = new WindowManager(); - - it('should only go to offset if it can', () => { - const view = { - view: { - webContents: { - goToOffset: jest.fn(), - canGoToOffset: () => false, - }, - }, - }; - ViewManager.getCurrentView.mockReturnValue(view); - - windowManager.handleHistory(null, 1); - expect(view.view.webContents.goToOffset).not.toBeCalled(); - - ViewManager.getCurrentView.mockReturnValue({ - ...view, - view: { - ...view.view, - webContents: { - ...view.view.webContents, - canGoToOffset: () => true, - }, - }, - }); - - windowManager.handleHistory(null, 1); - expect(view.view.webContents.goToOffset).toBeCalled(); - }); - - it('should load base URL if an error occurs', () => { - const view = { - load: jest.fn(), - tab: { - url: 'http://server-1.com', - }, - view: { - webContents: { - goToOffset: jest.fn(), - canGoToOffset: () => true, - }, - }, - }; - view.view.webContents.goToOffset.mockImplementation(() => { - throw new Error('hi'); - }); - ViewManager.getCurrentView.mockReturnValue(view); - - windowManager.handleHistory(null, 1); - expect(view.load).toBeCalledWith('http://server-1.com'); - }); - }); - describe('selectTab', () => { const windowManager = new WindowManager(); windowManager.switchTab = jest.fn(); @@ -801,11 +746,7 @@ describe('main/windows/windowManager', () => { const map = Config.teams.reduce((arr, item) => { item.tabs.forEach((tab) => { arr.push([`${item.name}_${tab.name}`, { - view: { - webContents: { - send: jest.fn(), - }, - }, + sendToRenderer: jest.fn(), }]); }); return arr; @@ -838,7 +779,7 @@ describe('main/windows/windowManager', () => { await windowManager.handleGetDesktopSources('server-1_tab-1', null); - expect(ViewManager.getView('server-1_tab-1').view.webContents.send).toHaveBeenCalledWith('desktop-sources-result', [ + expect(ViewManager.getView('server-1_tab-1').sendToRenderer).toHaveBeenCalledWith('desktop-sources-result', [ { id: 'screen0', }, @@ -854,7 +795,7 @@ describe('main/windows/windowManager', () => { expect(windowManager.callsWidgetWindow.win.webContents.send).toHaveBeenCalledWith('calls-error', { err: 'screen-permissions', }); - expect(ViewManager.getView('server-2_tab-1').view.webContents.send).toHaveBeenCalledWith('calls-error', { + expect(ViewManager.getView('server-2_tab-1').sendToRenderer).toHaveBeenCalledWith('calls-error', { err: 'screen-permissions', }); expect(windowManager.callsWidgetWindow.win.webContents.send).toHaveBeenCalledTimes(1); @@ -877,10 +818,10 @@ describe('main/windows/windowManager', () => { expect(windowManager.callsWidgetWindow.win.webContents.send).toHaveBeenCalledWith('calls-error', { err: 'screen-permissions', }); - expect(ViewManager.getView('server-1_tab-1').view.webContents.send).toHaveBeenCalledWith('calls-error', { + expect(ViewManager.getView('server-1_tab-1').sendToRenderer).toHaveBeenCalledWith('calls-error', { err: 'screen-permissions', }); - expect(ViewManager.getView('server-1_tab-1').view.webContents.send).toHaveBeenCalledTimes(1); + expect(ViewManager.getView('server-1_tab-1').sendToRenderer).toHaveBeenCalledTimes(1); expect(windowManager.callsWidgetWindow.win.webContents.send).toHaveBeenCalledTimes(1); }); @@ -908,7 +849,7 @@ describe('main/windows/windowManager', () => { expect(windowManager.callsWidgetWindow.win.webContents.send).toHaveBeenCalledWith('calls-error', { err: 'screen-permissions', }); - expect(ViewManager.getView('server-1_tab-1').view.webContents.send).toHaveBeenCalledWith('calls-error', { + expect(ViewManager.getView('server-1_tab-1').sendToRenderer).toHaveBeenCalledWith('calls-error', { err: 'screen-permissions', }); @@ -932,11 +873,7 @@ describe('main/windows/windowManager', () => { return { getServerName: () => 'server-1', getMainView: jest.fn().mockReturnValue({ - view: { - webContents: { - send: jest.fn(), - }, - }, + sendToRenderer: jest.fn(), }), }; }); @@ -1007,11 +944,7 @@ describe('main/windows/windowManager', () => { return { getServerName: () => 'server-2', getMainView: jest.fn().mockReturnValue({ - view: { - webContents: { - send: jest.fn(), - }, - }, + sendToRenderer: jest.fn(), }), getChannelURL: jest.fn(), }; @@ -1086,11 +1019,7 @@ describe('main/windows/windowManager', () => { return { getServerName: () => 'server-2', getMainView: jest.fn().mockReturnValue({ - view: { - webContents: { - send: jest.fn(), - }, - }, + sendToRenderer: jest.fn(), }), }; }); @@ -1107,7 +1036,7 @@ describe('main/windows/windowManager', () => { windowManager.handleCallsError('', {err: 'client-error'}); expect(windowManager.switchServer).toHaveBeenCalledWith('server-2'); expect(mainWindow.focus).toHaveBeenCalled(); - expect(windowManager.callsWidgetWindow.getMainView().view.webContents.send).toHaveBeenCalledWith('calls-error', {err: 'client-error'}); + expect(windowManager.callsWidgetWindow.getMainView().sendToRenderer).toHaveBeenCalledWith('calls-error', {err: 'client-error'}); }); }); @@ -1115,11 +1044,7 @@ describe('main/windows/windowManager', () => { const windowManager = new WindowManager(); windowManager.switchServer = jest.fn(); const view1 = { - view: { - webContents: { - send: jest.fn(), - }, - }, + sendToRenderer: jest.fn(), }; beforeEach(() => { @@ -1141,7 +1066,7 @@ describe('main/windows/windowManager', () => { windowManager.callsWidgetWindow = new CallsWidgetWindow(); windowManager.handleCallsLinkClick('', {link: '/other/subpath'}); expect(windowManager.switchServer).toHaveBeenCalledWith('server-1'); - expect(view1.view.webContents.send).toBeCalledWith('browser-history-push', '/other/subpath'); + expect(view1.sendToRenderer).toBeCalledWith('browser-history-push', '/other/subpath'); }); }); diff --git a/src/main/windows/windowManager.ts b/src/main/windows/windowManager.ts index 2f26652a..297d0e32 100644 --- a/src/main/windows/windowManager.ts +++ b/src/main/windows/windowManager.ts @@ -15,7 +15,6 @@ import { import { MAXIMIZE_CHANGE, - HISTORY, FOCUS_THREE_DOT_MENU, GET_DARK_MODE, UPDATE_SHORTCUT_MENU, @@ -74,7 +73,6 @@ export class WindowManager { constructor() { this.assetsDir = path.resolve(app.getAppPath(), 'assets'); - ipcMain.on(HISTORY, this.handleHistory); ipcMain.handle(GET_DARK_MODE, this.handleGetDarkMode); ipcMain.handle(GET_VIEW_WEBCONTENTS_ID, this.handleGetWebContentsId); ipcMain.on(VIEW_FINISHED_RESIZING, this.handleViewFinishedResizing); @@ -133,7 +131,7 @@ export class WindowManager { if (this.callsWidgetWindow) { this.switchServer(this.callsWidgetWindow.getServerName()); MainWindow.get()?.focus(); - this.callsWidgetWindow.getMainView().view.webContents.send(DESKTOP_SOURCES_MODAL_REQUEST); + this.callsWidgetWindow.getMainView().sendToRenderer(DESKTOP_SOURCES_MODAL_REQUEST); } } @@ -143,7 +141,7 @@ export class WindowManager { if (this.callsWidgetWindow) { this.switchServer(this.callsWidgetWindow.getServerName()); MainWindow.get()?.focus(); - this.callsWidgetWindow.getMainView().view.webContents.send(BROWSER_HISTORY_PUSH, this.callsWidgetWindow.getChannelURL()); + this.callsWidgetWindow.getMainView().sendToRenderer(BROWSER_HISTORY_PUSH, this.callsWidgetWindow.getChannelURL()); } } @@ -153,7 +151,7 @@ export class WindowManager { if (this.callsWidgetWindow) { this.switchServer(this.callsWidgetWindow.getServerName()); MainWindow.get()?.focus(); - this.callsWidgetWindow.getMainView().view.webContents.send(CALLS_ERROR, msg); + this.callsWidgetWindow.getMainView().sendToRenderer(CALLS_ERROR, msg); } } @@ -163,7 +161,7 @@ export class WindowManager { if (this.callsWidgetWindow) { this.switchServer(this.callsWidgetWindow.getServerName()); MainWindow.get()?.focus(); - this.callsWidgetWindow.getMainView().view.webContents.send(BROWSER_HISTORY_PUSH, msg.link); + this.callsWidgetWindow.getMainView().sendToRenderer(BROWSER_HISTORY_PUSH, msg.link); } } @@ -314,7 +312,7 @@ export class WindowManager { const currentView = ViewManager.getCurrentView(); if (currentView) { - const adjustedBounds = getAdjustedWindowBoundaries(bounds.width, bounds.height, shouldHaveBackBar(currentView.tab.url, currentView.view.webContents.getURL())); + const adjustedBounds = getAdjustedWindowBoundaries(bounds.width, bounds.height, shouldHaveBackBar(currentView.tab.url, currentView.currentURL)); this.setBoundsFunction(currentView, adjustedBounds); } } @@ -520,27 +518,6 @@ export class WindowManager { } } - sendToFind = () => { - const currentView = ViewManager.getCurrentView(); - if (currentView) { - currentView.view.webContents.sendInputEvent({type: 'keyDown', keyCode: 'F', modifiers: [process.platform === 'darwin' ? 'cmd' : 'ctrl', 'shift']}); - } - } - - handleHistory = (event: IpcMainEvent, offset: number) => { - log.debug('handleHistory', offset); - - const activeView = ViewManager.getCurrentView(); - if (activeView && activeView.view.webContents.canGoToOffset(offset)) { - try { - activeView.view.webContents.goToOffset(offset); - } catch (error) { - log.error(error); - activeView.load(activeView.tab.url); - } - } - } - selectNextTab = () => { this.selectTab((order) => order + 1); } @@ -627,7 +604,7 @@ export class WindowManager { if (!hasScreenPermissions || !sources.length) { log.info('missing screen permissions'); - view.view.webContents.send(CALLS_ERROR, screenPermissionsErrMsg); + view.sendToRenderer(CALLS_ERROR, screenPermissionsErrMsg); this.callsWidgetWindow?.win.webContents.send(CALLS_ERROR, screenPermissionsErrMsg); return; } @@ -641,12 +618,12 @@ export class WindowManager { }); if (message.length > 0) { - view.view.webContents.send(DESKTOP_SOURCES_RESULT, message); + view.sendToRenderer(DESKTOP_SOURCES_RESULT, message); } }).catch((err) => { log.error('desktopCapturer.getSources failed', err); - view.view.webContents.send(CALLS_ERROR, screenPermissionsErrMsg); + view.sendToRenderer(CALLS_ERROR, screenPermissionsErrMsg); this.callsWidgetWindow?.win.webContents.send(CALLS_ERROR, screenPermissionsErrMsg); }); } diff --git a/src/types/external/tuple-record-polyfill.d.ts b/src/types/external/tuple-record-polyfill.d.ts deleted file mode 100644 index 3f0f5746..00000000 --- a/src/types/external/tuple-record-polyfill.d.ts +++ /dev/null @@ -1,16 +0,0 @@ -// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -declare module '@bloomberg/record-tuple-polyfill' { - export function Tuple(A): [A]; - export function Tuple(a: A, b: B): [A, B]; - export function Tuple(a: A, b: B, c: C): [A, B, C]; - export function Tuple(a: A, b: B, c: C, d: D): [A, B, C, D]; - export function Tuple(a: A, b: B, c: C, d: D, e: E): [A, B, C, D, E]; - export function Tuple(a: A, b: B, c: C, d: D, e: E, f: F): [A, B, C, D, E, F]; - export function Tuple(a: A, b: B, c: C, d: D, e: E, f: F, g: G): [A, B, C, D, E, F, G]; - export function Tuple(a: A, b: B, c: C, d: D, e: E, f: F, g: G, h: H): [A, B, C, D, E, F, G, H]; - export function Tuple(a: A, b: B, c: C, d: D, e: E, f: F, g: G, h: H, i: I): [A, B, C, D, E, F, G, H, I]; - export function Tuple(a: A, b: B, c: C, d: D, e: E, f: F, g: G, h: H, i: I, j: J): [A, B, C, D, E, F, G, H, I, J]; - export function Record(x: {[key: string]: T}): {[key: string]: T}; -}