Files
mattermostest/src/main/views/MattermostView.ts
Devin Binnie 1c44c8527a E2E test fixups (#2045)
* Reinstall reporter and re-enable skipped tests

* Fixups for Linux

* Mac fixes

* Windows fixes

* Use keyboard shortcuts instead of menu for most menu actions

* Couple fixes

* One more fix for now

* Windows fixes

* Lint fixes

* Change up developer tools tests to be more consistent

* Fix key for mac

* Couple fixes for flaky tests/to avoid crashes on unload
2022-04-19 09:56:41 -04:00

424 lines
15 KiB
TypeScript

// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {BrowserView, app, ipcMain, BrowserWindow} from 'electron';
import {BrowserViewConstructorOptions, Event, Input} from 'electron/main';
import log from 'electron-log';
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 {
LOAD_RETRY,
LOAD_SUCCESS,
LOAD_FAILED,
UPDATE_TARGET_URL,
IS_UNREAD,
UNREAD_RESULT,
TOGGLE_BACK_BUTTON,
SET_VIEW_OPTIONS,
LOADSCREEN_END,
} from 'common/communication';
import {TabView} from 'common/tabs/TabView';
import {ServerInfo} from 'main/server/serverInfo';
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 {
LOADING,
READY,
WAITING_MM,
ERROR = -1,
}
const ASTERISK_GROUP = 3;
const MENTIONS_GROUP = 2;
export class MattermostView extends EventEmitter {
tab: TabView;
window: BrowserWindow;
view: BrowserView;
isVisible: boolean;
isLoggedIn: boolean;
isAtRoot: boolean;
options: BrowserViewConstructorOptions;
serverInfo: ServerInfo;
removeLoading?: number;
/**
* for backward compatibility when reading the title.
* null means we have yet to figure out if it uses it or not but we consider it false until proven wrong
*/
usesAsteriskForUnreads?: boolean;
currentFavicon?: string;
hasBeenShown: boolean;
contextMenu: ContextMenu;
status?: Status;
retryLoad?: NodeJS.Timeout;
maxRetries: number;
private altPressStatus: boolean;
constructor(tab: TabView, serverInfo: ServerInfo, win: BrowserWindow, options: BrowserViewConstructorOptions) {
super();
this.tab = tab;
this.window = win;
this.serverInfo = serverInfo;
const preload = getLocalPreload('preload.js');
this.options = Object.assign({}, options);
this.options.webPreferences = {
preload,
additionalArguments: [
`version=${app.getVersion()}`,
`appName=${app.name}`,
],
...options.webPreferences,
};
this.isVisible = false;
this.isLoggedIn = false;
this.isAtRoot = true;
this.view = new BrowserView(this.options);
this.resetLoadingStatus();
log.info(`BrowserView created for server ${this.tab.name}`);
this.hasBeenShown = false;
if (process.platform !== 'darwin') {
this.view.webContents.on('before-input-event', this.handleInputEvents);
}
this.view.webContents.on('did-finish-load', () => {
log.debug('MattermostView.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);
});
this.contextMenu = new ContextMenu({}, this.view);
this.maxRetries = MAX_SERVER_RETRIES;
this.altPressStatus = false;
this.window.on('blur', () => {
this.altPressStatus = false;
});
}
// 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;
}
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;
}
}
load = (someURL?: URL | string) => {
if (!this.tab) {
return;
}
let loadURL: string;
if (someURL) {
const parsedURL = urlUtils.parseURL(someURL);
if (parsedURL) {
loadURL = parsedURL.toString();
} else {
log.error('Cannot parse provided url, using current server url', someURL);
loadURL = this.tab.url.toString();
}
} else {
loadURL = this.tab.url.toString();
}
log.info(`[${Util.shorten(this.tab.name)}] Loading ${loadURL}`);
const loading = this.view.webContents.loadURL(loadURL, {userAgent: composeUserAgent()});
loading.then(this.loadSuccess(loadURL)).catch((err) => {
this.loadRetry(loadURL, err);
});
}
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 stablish 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 () => {
log.info(`[${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) {
ipcMain.on(UNREAD_RESULT, this.handleFaviconIsUnread);
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(this.window, shouldHaveBackBar(this.tab.url || '', this.view.webContents.getURL())));
};
}
show = (requestedVisibility?: boolean) => {
this.hasBeenShown = true;
const request = typeof requestedVisibility === 'undefined' ? true : requestedVisibility;
if (request && !this.isVisible) {
this.window.addBrowserView(this.view);
this.setBounds(getWindowBoundaries(this.window, shouldHaveBackBar(this.tab.url || '', this.view.webContents.getURL())));
if (this.status === Status.READY) {
this.focus();
}
} else if (!request && this.isVisible) {
this.window.removeBrowserView(this.view);
}
this.isVisible = request;
}
reload = () => {
this.resetLoadingStatus();
this.load();
}
hide = () => this.show(false);
setBounds = (boundaries: Electron.Rectangle) => {
this.view.setBounds(boundaries);
}
destroy = () => {
WebContentsEventManager.removeWebContentsListeners(this.view.webContents.id);
appState.updateMentions(this.tab.name, 0, false);
if (this.window) {
this.window.removeBrowserView(this.view);
}
// workaround to eliminate zombie processes
// https://github.com/mattermost/desktop/pull/1519
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
this.view.webContents.destroy();
this.isVisible = false;
if (this.retryLoad) {
clearTimeout(this.retryLoad);
}
if (this.removeLoading) {
clearTimeout(this.removeLoading);
}
}
focus = () => {
if (this.view.webContents) {
this.view.webContents.focus();
} else {
log.warn('trying to focus the browserview, but it doesn\'t yet have webcontents.');
}
}
isReady = () => {
return this.status === Status.READY;
}
isErrored = () => {
return this.status === Status.ERROR;
}
needsLoadingScreen = () => {
return !(this.status === Status.READY || this.status === Status.ERROR);
}
setInitialized = (timedout?: boolean) => {
this.status = Status.READY;
if (timedout) {
log.info(`${this.tab.name} timeout expired will show the browserview`);
this.emit(LOADSCREEN_END, this.tab.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;
}
private registerAltKeyPressed = (input: Input) => {
const isAltPressed = input.key === 'Alt' && input.alt === true && input.control === false && input.shift === false && input.meta === false;
if (input.type === 'keyDown') {
this.altPressStatus = isAltPressed;
}
if (input.key !== 'Alt') {
this.altPressStatus = false;
}
};
private isAltKeyReleased = (input: Input) => {
return input.type === 'keyUp' && this.altPressStatus === true;
};
handleInputEvents = (_: Event, input: Input) => {
log.silly('MattermostView.handleInputEvents', {tabName: this.tab.name, input});
this.registerAltKeyPressed(input);
if (this.isAltKeyReleased(input)) {
WindowManager.focusThreeDotMenu();
}
}
handleDidNavigate = (event: Event, url: string) => {
log.debug('MattermostView.handleDidNavigate', {tabName: this.tab.name, url});
if (shouldHaveBackBar(this.tab.url || '', url)) {
this.setBounds(getWindowBoundaries(this.window, true));
WindowManager.sendToRenderer(TOGGLE_BACK_BUTTON, true);
log.info('show back button');
} else {
this.setBounds(getWindowBoundaries(this.window));
WindowManager.sendToRenderer(TOGGLE_BACK_BUTTON, false);
log.info('hide back button');
}
}
handleUpdateTarget = (e: Event, url: string) => {
log.silly('MattermostView.handleUpdateTarget', {tabName: this.tab.name, url});
if (url && !urlUtils.isInternalURL(urlUtils.parseURL(url), this.tab.server.url)) {
this.emit(UPDATE_TARGET_URL, url);
}
}
titleParser = /(\((\d+)\) )?(\* )?/g
handleTitleUpdate = (e: Event, title: string) => {
log.debug('MattermostView.handleTitleUpdate', {tabName: this.tab.name, title});
this.updateMentionsFromTitle(title);
}
updateMentionsFromTitle = (title: string) => {
//const title = this.view.webContents.getTitle();
const resultsIterator = title.matchAll(this.titleParser);
const results = resultsIterator.next(); // we are only interested in the first set
// if not using asterisk (version > v5.28), it'll be marked as undefined and wont be used to check if there are unread channels
const hasAsterisk = results && results.value && results.value[ASTERISK_GROUP];
if (typeof hasAsterisk !== 'undefined') {
this.usesAsteriskForUnreads = true;
}
let unreads;
if (this.usesAsteriskForUnreads) {
unreads = Boolean(hasAsterisk);
}
const mentions = (results && results.value && parseInt(results.value[MENTIONS_GROUP], 10)) || 0;
appState.updateMentions(this.tab.name, mentions, unreads);
}
handleFaviconUpdate = (e: Event, favicons: string[]) => {
log.silly('MattermostView.handleFaviconUpdate', {tabName: this.tab.name, favicons});
if (!this.usesAsteriskForUnreads) {
// 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);
}
}
// if favicon is null, it means it is the initial load,
// so don't memoize as we don't have the favicons and there is no rush to find out.
handleFaviconIsUnread = (e: Event, favicon: string, viewName: string, result: boolean) => {
log.silly('MattermostView.handleFaviconIsUnread', {favicon, viewName, result});
if (this.tab && viewName === this.tab.name) {
appState.updateUnreads(viewName, result);
}
}
}