[MM-50485] Migrate app to ServerManager, remove view names and replace with IDs (#2672)

* Migrate app to ServerManager, remove view names and replace with IDs

* Fixed a test

* Fixed a bug when adding the initial server

* Merge'd

* Bug fixes and PR feedback
This commit is contained in:
Devin Binnie
2023-04-12 12:52:34 -04:00
committed by GitHub
parent d87097b1eb
commit 686b4ac9f1
58 changed files with 1570 additions and 2175 deletions

View File

@@ -180,7 +180,7 @@ describe('main/views/MattermostView', () => {
await expect(promise).rejects.toThrow(error);
expect(mattermostView.view.webContents.loadURL).toBeCalledWith('http://server-1.com', expect.any(Object));
expect(mattermostView.loadRetry).not.toBeCalled();
expect(WindowManager.sendToRenderer).toBeCalledWith(LOAD_FAILED, mattermostView.tab.name, expect.any(String), expect.any(String));
expect(WindowManager.sendToRenderer).toBeCalledWith(LOAD_FAILED, mattermostView.tab.id, expect.any(String), expect.any(String));
expect(mattermostView.status).toBe(-1);
jest.runAllTimers();
expect(retryInBackgroundFn).toBeCalled();
@@ -374,14 +374,7 @@ describe('main/views/MattermostView', () => {
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();
expect(appState.updateMentions).toBeCalledWith(mattermostView.tab.id, 0, false);
});
it('should clear outstanding timeouts', () => {
@@ -479,12 +472,12 @@ describe('main/views/MattermostView', () => {
it('should parse mentions from title', () => {
mattermostView.updateMentionsFromTitle('(7) Mattermost');
expect(appState.updateMentions).toHaveBeenCalledWith(mattermostView.tab.name, 7);
expect(appState.updateMentions).toHaveBeenCalledWith(mattermostView.tab.id, 7);
});
it('should parse unreads from title', () => {
mattermostView.updateMentionsFromTitle('* Mattermost');
expect(appState.updateMentions).toHaveBeenCalledWith(mattermostView.tab.name, 0);
expect(appState.updateMentions).toHaveBeenCalledWith(mattermostView.tab.id, 0);
});
});
});

View File

@@ -18,12 +18,12 @@ import {
SET_VIEW_OPTIONS,
LOADSCREEN_END,
BROWSER_HISTORY_BUTTON,
SERVERS_URL_MODIFIED,
} from 'common/communication';
import ServerManager from 'common/servers/serverManager';
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';
@@ -45,7 +45,6 @@ const titleParser = /(\((\d+)\) )?(\* )?/g;
export class MattermostView extends EventEmitter {
tab: TabView;
serverInfo: ServerInfo;
isVisible: boolean;
private log: Logger;
@@ -60,10 +59,9 @@ export class MattermostView extends EventEmitter {
private maxRetries: number;
private altPressStatus: boolean;
constructor(tab: TabView, serverInfo: ServerInfo, options: BrowserViewConstructorOptions) {
constructor(tab: TabView, options: BrowserViewConstructorOptions) {
super();
this.tab = tab;
this.serverInfo = serverInfo;
const preload = getLocalPreload('preload.js');
this.options = Object.assign({}, options);
@@ -81,7 +79,7 @@ export class MattermostView extends EventEmitter {
this.view = new BrowserView(this.options);
this.resetLoadingStatus();
this.log = new Logger(this.name, 'MattermostView');
this.log = ServerManager.getViewLog(this.id, 'MattermostView');
this.log.verbose('View created');
this.view.webContents.on('did-finish-load', this.handleDidFinishLoad);
@@ -103,10 +101,12 @@ export class MattermostView extends EventEmitter {
MainWindow.get()?.on('blur', () => {
this.altPressStatus = false;
});
ServerManager.on(SERVERS_URL_MODIFIED, this.handleServerWasModified);
}
get name() {
return this.tab.name;
get id() {
return this.tab.id;
}
get isAtRoot() {
return this.atRoot;
@@ -121,17 +121,6 @@ export class MattermostView extends EventEmitter {
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?.();
}
onLogin = (loggedIn: boolean) => {
if (this.isLoggedIn === loggedIn) {
return;
@@ -170,16 +159,6 @@ export class MattermostView extends EventEmitter {
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) => {
if (!this.tab) {
return;
@@ -201,9 +180,9 @@ export class MattermostView extends EventEmitter {
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.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);
WindowManager.sendToRenderer(LOAD_FAILED, this.id, err.toString(), loadURL.toString());
this.emit(LOAD_FAILED, this.id, err.toString(), loadURL.toString());
this.log.info(`Invalid certificate, stop retrying until the user decides what to do: ${err}.`);
this.status = Status.ERROR;
return;
}
@@ -258,7 +237,7 @@ export class MattermostView extends EventEmitter {
destroy = () => {
WebContentsEventManager.removeWebContentsListeners(this.webContentsId);
appState.updateMentions(this.name, 0, false);
appState.updateMentions(this.id, 0, false);
MainWindow.get()?.removeBrowserView(this.view);
// workaround to eliminate zombie processes
@@ -274,8 +253,6 @@ export class MattermostView extends EventEmitter {
if (this.removeLoading) {
clearTimeout(this.removeLoading);
}
this.contextMenu.dispose();
}
/**
@@ -307,7 +284,7 @@ export class MattermostView extends EventEmitter {
if (timedout) {
this.log.verbose('timeout expired will show the browserview');
this.emit(LOADSCREEN_END, this.name);
this.emit(LOADSCREEN_END, this.id);
}
clearTimeout(this.removeLoading);
delete this.removeLoading;
@@ -376,13 +353,13 @@ export class MattermostView extends EventEmitter {
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);
appState.updateMentions(this.id, 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);
this.view.webContents.send(IS_UNREAD, favicon, this.id);
} catch (err: any) {
this.log.error('There was an error trying to request the unread state', err);
}
@@ -417,8 +394,8 @@ export class MattermostView extends EventEmitter {
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());
WindowManager.sendToRenderer(LOAD_FAILED, this.id, err.toString(), loadURL.toString());
this.emit(LOAD_FAILED, this.id, 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);
@@ -442,14 +419,14 @@ export class MattermostView extends EventEmitter {
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());
WindowManager.sendToRenderer(LOAD_RETRY, this.id, 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);
WindowManager.sendToRenderer(LOAD_SUCCESS, this.id);
this.maxRetries = MAX_SERVER_RETRIES;
if (this.status === Status.LOADING) {
this.updateMentionsFromTitle(this.view.webContents.getTitle());
@@ -457,7 +434,7 @@ export class MattermostView extends EventEmitter {
}
this.status = Status.WAITING_MM;
this.removeLoading = setTimeout(this.setInitialized, MAX_LOADING_SCREEN_SECONDS, true);
this.emit(LOAD_SUCCESS, this.name, loadURL);
this.emit(LOAD_SUCCESS, this.id, loadURL);
const mainWindow = MainWindow.get();
if (mainWindow) {
this.setBounds(getWindowBoundaries(mainWindow, shouldHaveBackBar(this.tab.url || '', this.currentURL)));
@@ -470,7 +447,7 @@ export class MattermostView extends EventEmitter {
*/
private handleDidFinishLoad = () => {
this.log.debug('did-finish-load', this.name);
this.log.debug('did-finish-load');
// wait for screen to truly finish loading before sending the message down
const timeout = setInterval(() => {
@@ -480,7 +457,7 @@ export class MattermostView extends EventEmitter {
if (!this.view.webContents.isLoading()) {
try {
this.view.webContents.send(SET_VIEW_OPTIONS, this.name, this.tab.shouldNotify);
this.view.webContents.send(SET_VIEW_OPTIONS, this.id, this.tab.shouldNotify);
clearTimeout(timeout);
} catch (e) {
this.log.error('failed to send view options to view');
@@ -492,12 +469,17 @@ export class MattermostView extends EventEmitter {
private handleDidNavigate = (event: Event, url: string) => {
this.log.debug('handleDidNavigate', url);
const mainWindow = MainWindow.get();
if (!mainWindow) {
return;
}
if (shouldHaveBackBar(this.tab.url || '', url)) {
this.setBounds(getWindowBoundaries(MainWindow.get()!, true));
this.setBounds(getWindowBoundaries(mainWindow, true));
WindowManager.sendToRenderer(TOGGLE_BACK_BUTTON, true);
this.log.debug('show back button');
} else {
this.setBounds(getWindowBoundaries(MainWindow.get()!));
this.setBounds(getWindowBoundaries(mainWindow));
WindowManager.sendToRenderer(TOGGLE_BACK_BUTTON, false);
this.log.debug('hide back button');
}
@@ -511,4 +493,10 @@ export class MattermostView extends EventEmitter {
this.emit(UPDATE_TARGET_URL);
}
}
private handleServerWasModified = (serverIds: string) => {
if (serverIds.includes(this.tab.server.id)) {
this.reload();
}
}
}

View File

@@ -36,6 +36,11 @@ jest.mock('../windows/windowManager', () => ({
sendToRenderer: jest.fn(),
}));
jest.mock('common/servers/serverManager', () => ({
on: jest.fn(),
getOrderedServers: jest.fn().mockReturnValue([]),
}));
describe('main/views/teamDropdownView', () => {
describe('getBounds', () => {
beforeEach(() => {
@@ -62,52 +67,4 @@ describe('main/views/teamDropdownView', () => {
teamDropdownView.handleClose();
expect(teamDropdownView.view.setBounds).toBeCalledWith({width: 0, height: 0, x: expect.any(Number), y: expect.any(Number)});
});
describe('addGpoToTeams', () => {
it('should return teams with "isGPO": false when no config.registryTeams exist', () => {
const teamDropdownView = new TeamDropdownView();
const teams = [{
name: 'team-1',
url: 'https://mattermost.team-1.com',
}, {
name: 'team-2',
url: 'https://mattermost.team-2.com',
}];
const registryTeams = [];
expect(teamDropdownView.addGpoToTeams(teams, registryTeams)).toStrictEqual([{
name: 'team-1',
url: 'https://mattermost.team-1.com',
isGpo: false,
}, {
name: 'team-2',
url: 'https://mattermost.team-2.com',
isGpo: false,
}]);
});
it('should return teams with "isGPO": true if they exist in config.registryTeams', () => {
const teamDropdownView = new TeamDropdownView();
const teams = [{
name: 'team-1',
url: 'https://mattermost.team-1.com',
}, {
name: 'team-2',
url: 'https://mattermost.team-2.com',
}];
const registryTeams = [{
name: 'team-1',
url: 'https://mattermost.team-1.com',
}];
expect(teamDropdownView.addGpoToTeams(teams, registryTeams)).toStrictEqual([{
name: 'team-1',
url: 'https://mattermost.team-1.com',
isGpo: true,
}, {
name: 'team-2',
url: 'https://mattermost.team-2.com',
isGpo: false,
}]);
});
});
});

View File

@@ -3,7 +3,7 @@
import {BrowserView, ipcMain, IpcMainEvent} from 'electron';
import {CombinedConfig, Team, TeamWithTabs, TeamWithTabsAndGpo} from 'types/config';
import {CombinedConfig, MattermostTeam} from 'types/config';
import {
CLOSE_TEAMS_DROPDOWN,
@@ -14,10 +14,12 @@ import {
REQUEST_TEAMS_DROPDOWN_INFO,
RECEIVE_DROPDOWN_MENU_SIZE,
SET_ACTIVE_VIEW,
SERVERS_UPDATE,
} from 'common/communication';
import Config from 'common/config';
import {Logger} from 'common/log';
import {TAB_BAR_HEIGHT, THREE_DOT_MENU_WIDTH, THREE_DOT_MENU_WIDTH_MAC, MENU_SHADOW_WIDTH} from 'common/utils/constants';
import ServerManager from 'common/servers/serverManager';
import {getLocalPreload, getLocalURLString} from 'main/utils';
@@ -30,7 +32,7 @@ const log = new Logger('TeamDropdownView');
export default class TeamDropdownView {
view: BrowserView;
bounds?: Electron.Rectangle;
teams: TeamWithTabsAndGpo[];
teams: MattermostTeam[];
activeTeam?: string;
darkMode: boolean;
enableServerManagement?: boolean;
@@ -42,7 +44,8 @@ export default class TeamDropdownView {
isOpen: boolean;
constructor() {
this.teams = this.addGpoToTeams(Config.teams, []);
this.teams = this.getOrderedTeams();
this.hasGPOTeams = this.teams.some((srv) => srv.isPredefined);
this.darkMode = Config.darkMode;
this.enableServerManagement = Config.enableServerManagement;
this.isOpen = false;
@@ -69,6 +72,17 @@ export default class TeamDropdownView {
ipcMain.on(RECEIVE_DROPDOWN_MENU_SIZE, this.handleReceivedMenuSize);
ipcMain.on(SET_ACTIVE_VIEW, this.updateActiveTeam);
AppState.on(UPDATE_DROPDOWN_MENTIONS, this.updateMentions);
ServerManager.on(SERVERS_UPDATE, this.updateServers);
}
private getOrderedTeams = () => {
return ServerManager.getOrderedServers().map((team) => team.toMattermostTeam());
}
updateServers = () => {
this.teams = this.getOrderedTeams();
this.hasGPOTeams = this.teams.some((srv) => srv.isPredefined);
}
updateConfig = (event: IpcMainEvent, config: CombinedConfig) => {
@@ -76,23 +90,33 @@ export default class TeamDropdownView {
this.darkMode = config.darkMode;
this.enableServerManagement = config.enableServerManagement;
this.hasGPOTeams = config.registryTeams && config.registryTeams.length > 0;
this.updateDropdown();
}
updateActiveTeam = (event: IpcMainEvent, name: string) => {
log.silly('updateActiveTeam', {name});
updateActiveTeam = (event: IpcMainEvent, serverId: string) => {
log.silly('updateActiveTeam', {serverId});
this.activeTeam = name;
this.activeTeam = serverId;
this.updateDropdown();
}
private reduceNotifications = <T>(items: Map<string, T>, modifier: (base?: T, value?: T) => T) => {
return [...items.keys()].reduce((map, key) => {
const view = ServerManager.getTab(key);
if (!view) {
return map;
}
map.set(view.server.id, modifier(map.get(view.server.id), items.get(key)));
return map;
}, new Map());
}
updateMentions = (expired: Map<string, boolean>, mentions: Map<string, number>, unreads: Map<string, boolean>) => {
log.silly('updateMentions', {expired, mentions, unreads});
this.unreads = unreads;
this.mentions = mentions;
this.expired = expired;
this.unreads = this.reduceNotifications(unreads, (base, value) => base || value || false);
this.mentions = this.reduceNotifications(mentions, (base, value) => (base ?? 0) + (value ?? 0));
this.expired = this.reduceNotifications(expired, (base, value) => base || value || false);
this.updateDropdown();
}
@@ -164,16 +188,4 @@ export default class TeamDropdownView {
// @ts-ignore
this.view.webContents.destroy();
}
addGpoToTeams = (teams: TeamWithTabs[], registryTeams: Team[]): TeamWithTabsAndGpo[] => {
if (!registryTeams || registryTeams.length === 0) {
return teams.map((team) => ({...team, isGpo: false}));
}
return teams.map((team) => {
return {
...team,
isGpo: registryTeams.some((regTeam) => regTeam!.url === team!.url),
};
});
}
}

View File

@@ -4,13 +4,12 @@
/* eslint-disable max-lines */
'use strict';
import {dialog, ipcMain} from 'electron';
import {dialog} from 'electron';
import {BROWSER_HISTORY_PUSH, LOAD_SUCCESS, MAIN_WINDOW_SHOWN} from 'common/communication';
import Config from 'common/config';
import {MattermostServer} from 'common/servers/MattermostServer';
import {getTabViewName} from 'common/tabs/TabView';
import {equalUrlsIgnoringSubpath} from 'common/utils/url';
import {BROWSER_HISTORY_PUSH, LOAD_SUCCESS, SET_ACTIVE_VIEW} from 'common/communication';
import {TAB_MESSAGING} from 'common/tabs/TabView';
import ServerManager from 'common/servers/serverManager';
import urlUtils from 'common/utils/url';
import MainWindow from 'main/windows/mainWindow';
@@ -71,32 +70,61 @@ jest.mock('main/views/loadingScreen', () => ({
jest.mock('main/windows/mainWindow', () => ({
get: jest.fn(),
}));
jest.mock('common/servers/serverManager', () => ({
getCurrentServer: jest.fn(),
getOrderedTabsForServer: jest.fn(),
getAllServers: jest.fn(),
hasServers: jest.fn(),
getLastActiveServer: jest.fn(),
getLastActiveTabForServer: jest.fn(),
lookupTabByURL: jest.fn(),
getRemoteInfo: jest.fn(),
on: jest.fn(),
getServerLog: () => ({
error: jest.fn(),
warn: jest.fn(),
info: jest.fn(),
verbose: jest.fn(),
debug: jest.fn(),
silly: jest.fn(),
}),
getViewLog: () => ({
error: jest.fn(),
warn: jest.fn(),
info: jest.fn(),
verbose: jest.fn(),
debug: jest.fn(),
silly: jest.fn(),
}),
}));
jest.mock('./MattermostView', () => ({
MattermostView: jest.fn(),
}));
jest.mock('./modalManager', () => ({
showModal: jest.fn(),
isModalDisplayed: jest.fn(),
}));
jest.mock('./webContentEvents', () => ({}));
jest.mock('../appState', () => ({}));
describe('main/views/viewManager', () => {
describe('loadView', () => {
const viewManager = new ViewManager({});
const viewManager = new ViewManager();
const onceFn = jest.fn();
const loadFn = jest.fn();
const destroyFn = jest.fn();
beforeEach(() => {
viewManager.showByName = jest.fn();
viewManager.getServerView = jest.fn().mockImplementation((srv, tabName) => ({name: `${srv.name}-${tabName}`}));
viewManager.showById = jest.fn();
MainWindow.get.mockReturnValue({});
MattermostView.mockImplementation((tab) => ({
on: jest.fn(),
load: loadFn,
once: onceFn,
destroy: destroyFn,
name: tab.name,
id: tab.id,
}));
});
@@ -107,48 +135,39 @@ describe('main/views/viewManager', () => {
});
it('should add closed tabs to closedViews', () => {
viewManager.loadView({name: 'server1'}, {}, {name: 'tab1', isOpen: false});
expect(viewManager.closedViews.has('server1-tab1')).toBe(true);
viewManager.loadView({id: 'server1'}, {id: 'tab1', isOpen: false});
expect(viewManager.closedViews.has('tab1')).toBe(true);
});
it('should remove from remove from closedViews when the tab is open', () => {
viewManager.closedViews.set('server1-tab1', {});
expect(viewManager.closedViews.has('server1-tab1')).toBe(true);
viewManager.loadView({name: 'server1'}, {}, {name: 'tab1', isOpen: true});
expect(viewManager.closedViews.has('server1-tab1')).toBe(false);
viewManager.closedViews.set('tab1', {});
expect(viewManager.closedViews.has('tab1')).toBe(true);
viewManager.loadView({id: 'server1'}, {id: 'tab1', isOpen: true});
expect(viewManager.closedViews.has('tab1')).toBe(false);
});
it('should add view to views map and add listeners', () => {
viewManager.loadView({name: 'server1'}, {}, {name: 'tab1', isOpen: true}, 'http://server-1.com/subpath');
expect(viewManager.views.has('server1-tab1')).toBe(true);
viewManager.loadView({id: 'server1'}, {id: 'tab1', isOpen: true}, 'http://server-1.com/subpath');
expect(viewManager.views.has('tab1')).toBe(true);
expect(onceFn).toHaveBeenCalledWith(LOAD_SUCCESS, viewManager.activateView);
expect(loadFn).toHaveBeenCalledWith('http://server-1.com/subpath');
});
});
describe('reloadConfiguration', () => {
describe('handleReloadConfiguration', () => {
const viewManager = new ViewManager();
beforeEach(() => {
viewManager.loadView = jest.fn();
viewManager.showByName = jest.fn();
viewManager.showById = jest.fn();
viewManager.showInitial = jest.fn();
const mainWindow = {
viewManager.focus = jest.fn();
MainWindow.get.mockReturnValue({
webContents: {
send: jest.fn(),
},
};
MainWindow.get.mockReturnValue(mainWindow);
});
viewManager.getServerView = jest.fn().mockImplementation((srv, tabName) => ({
name: `${srv.name}-${tabName}`,
url: new URL(`http://${srv.name}.com`),
}));
MattermostServer.mockImplementation((server) => ({
name: server.name,
url: new URL(server.url),
}));
const onceFn = jest.fn();
const loadFn = jest.fn();
const destroyFn = jest.fn();
@@ -157,11 +176,10 @@ describe('main/views/viewManager', () => {
load: loadFn,
once: onceFn,
destroy: destroyFn,
name: tab.name,
id: tab.id,
updateServerInfo: jest.fn(),
tab,
}));
getTabViewName.mockImplementation((a, b) => `${a}-${b}`);
});
afterEach(() => {
@@ -172,342 +190,289 @@ describe('main/views/viewManager', () => {
});
it('should recycle existing views', () => {
Config.teams = [
{
name: 'server1',
url: 'http://server1.com',
order: 1,
tabs: [
{
name: 'tab1',
isOpen: true,
},
],
},
];
const makeSpy = jest.spyOn(viewManager, 'makeView');
const view = new MattermostView({
name: 'server1-tab1',
server: 'server1',
id: 'tab1',
server: {
id: 'server1',
},
});
viewManager.views.set('server1-tab1', view);
viewManager.reloadConfiguration();
expect(viewManager.views.get('server1-tab1')).toBe(view);
viewManager.views.set('tab1', view);
ServerManager.getAllServers.mockReturnValue([{
id: 'server1',
url: new URL('http://server1.com'),
}]);
ServerManager.getOrderedTabsForServer.mockReturnValue([
{
id: 'tab1',
isOpen: true,
},
]);
viewManager.handleReloadConfiguration();
expect(viewManager.views.get('tab1')).toBe(view);
expect(makeSpy).not.toHaveBeenCalled();
makeSpy.mockRestore();
});
it('should close tabs that arent open', () => {
Config.teams = [
ServerManager.getAllServers.mockReturnValue([{
id: 'server1',
url: new URL('http://server1.com'),
}]);
ServerManager.getOrderedTabsForServer.mockReturnValue([
{
name: 'server1',
url: 'http://server1.com',
order: 1,
tabs: [
{
name: 'tab1',
isOpen: false,
},
],
id: 'tab1',
isOpen: false,
},
];
viewManager.reloadConfiguration();
expect(viewManager.closedViews.has('server1-tab1')).toBe(true);
]);
viewManager.handleReloadConfiguration();
expect(viewManager.closedViews.has('tab1')).toBe(true);
});
it('should create new views for new tabs', () => {
const makeSpy = jest.spyOn(viewManager, 'makeView');
Config.teams = [
ServerManager.getAllServers.mockReturnValue([{
id: 'server1',
name: 'server1',
url: new URL('http://server1.com'),
}]);
ServerManager.getOrderedTabsForServer.mockReturnValue([
{
name: 'server1',
url: 'http://server1.com',
order: 1,
tabs: [
{
name: 'tab1',
isOpen: true,
},
],
id: 'tab1',
name: 'tab1',
isOpen: true,
url: new URL('http://server1.com/tab'),
},
];
viewManager.reloadConfiguration();
]);
viewManager.handleReloadConfiguration();
expect(makeSpy).toHaveBeenCalledWith(
{
id: 'server1',
name: 'server1',
url: new URL('http://server1.com'),
},
expect.any(Object),
{
id: 'tab1',
name: 'tab1',
isOpen: true,
url: new URL('http://server1.com/tab'),
},
'http://server1.com',
);
makeSpy.mockRestore();
});
it('should set focus to current view on reload', () => {
const view = {
name: 'server1-tab1',
id: 'tab1',
tab: {
server: {
name: 'server-1',
id: 'server-1',
},
name: 'server1-tab1',
id: 'tab1',
url: new URL('http://server1.com'),
},
destroy: jest.fn(),
updateServerInfo: jest.fn(),
focus: jest.fn(),
};
viewManager.currentView = 'server1-tab1';
viewManager.views.set('server1-tab1', view);
Config.teams = [
viewManager.currentView = 'tab1';
viewManager.views.set('tab1', view);
ServerManager.getAllServers.mockReturnValue([{
id: 'server1',
url: new URL('http://server1.com'),
}]);
ServerManager.getOrderedTabsForServer.mockReturnValue([
{
name: 'server1',
url: 'http://server1.com',
order: 1,
tabs: [
{
name: 'tab1',
isOpen: true,
},
],
id: 'tab1',
isOpen: true,
},
];
viewManager.reloadConfiguration();
expect(viewManager.showByName).toHaveBeenCalledWith('server1-tab1');
]);
viewManager.handleReloadConfiguration();
expect(view.focus).toHaveBeenCalled();
});
it('should show initial if currentView has been removed', () => {
const view = {
name: 'server1-tab1',
id: 'tab1',
tab: {
name: 'server1-tab1',
id: 'tab1',
url: new URL('http://server1.com'),
},
destroy: jest.fn(),
updateServerInfo: jest.fn(),
};
viewManager.currentView = 'server1-tab1';
viewManager.views.set('server1-tab1', view);
Config.teams = [
viewManager.currentView = 'tab1';
viewManager.views.set('tab1', view);
ServerManager.getAllServers.mockReturnValue([{
id: 'server2',
url: new URL('http://server2.com'),
}]);
ServerManager.getOrderedTabsForServer.mockReturnValue([
{
name: 'server2',
url: 'http://server2.com',
order: 1,
tabs: [
{
name: 'tab1',
isOpen: true,
},
],
id: 'tab1',
isOpen: false,
},
];
viewManager.reloadConfiguration();
]);
viewManager.handleReloadConfiguration();
expect(viewManager.showInitial).toBeCalled();
});
it('should remove unused views', () => {
const view = {
name: 'server1-tab1',
name: 'tab1',
tab: {
name: 'server1-tab1',
name: 'tab1',
url: new URL('http://server1.com'),
},
destroy: jest.fn(),
};
viewManager.views.set('server1-tab1', view);
Config.teams = [
viewManager.views.set('tab1', view);
ServerManager.getAllServers.mockReturnValue([{
id: 'server2',
url: new URL('http://server2.com'),
}]);
ServerManager.getOrderedTabsForServer.mockReturnValue([
{
name: 'server2',
url: 'http://server2.com',
order: 1,
tabs: [
{
name: 'tab1',
isOpen: true,
},
],
id: 'tab1',
isOpen: false,
},
];
viewManager.reloadConfiguration();
]);
viewManager.handleReloadConfiguration();
expect(view.destroy).toBeCalled();
expect(viewManager.showInitial).toBeCalled();
});
});
describe('showInitial', () => {
const viewManager = new ViewManager({});
const viewManager = new ViewManager();
const window = {webContents: {send: jest.fn()}};
beforeEach(() => {
Config.teams = [{
name: 'server-1',
order: 1,
tabs: [
{
name: 'tab-1',
order: 0,
isOpen: false,
},
{
name: 'tab-2',
order: 2,
isOpen: true,
},
{
name: 'tab-3',
order: 1,
isOpen: true,
},
],
}, {
name: 'server-2',
order: 0,
tabs: [
{
name: 'tab-1',
order: 0,
isOpen: false,
},
{
name: 'tab-2',
order: 2,
isOpen: true,
},
{
name: 'tab-3',
order: 1,
isOpen: true,
},
],
}];
viewManager.showByName = jest.fn();
getTabViewName.mockImplementation((server, tab) => `${server}_${tab}`);
viewManager.showById = jest.fn();
MainWindow.get.mockReturnValue(window);
ServerManager.hasServers.mockReturnValue(true);
ServerManager.getCurrentServer.mockReturnValue({id: 'server-0'});
});
afterEach(() => {
jest.resetAllMocks();
delete viewManager.lastActiveServer;
});
it('should show first server and first open tab in order when last active not defined', () => {
it('should show last active tab and server', () => {
ServerManager.getLastActiveServer.mockReturnValue({id: 'server-1'});
ServerManager.getLastActiveTabForServer.mockReturnValue({id: 'tab-1'});
viewManager.showInitial();
expect(viewManager.showByName).toHaveBeenCalledWith('server-2_tab-3');
});
it('should show first tab in order of last active server', () => {
viewManager.lastActiveServer = 1;
viewManager.showInitial();
expect(viewManager.showByName).toHaveBeenCalledWith('server-1_tab-3');
});
it('should show last active tab of first server', () => {
Config.teams = [{
name: 'server-1',
order: 1,
tabs: [
{
name: 'tab-1',
order: 0,
isOpen: false,
},
{
name: 'tab-2',
order: 2,
isOpen: true,
},
{
name: 'tab-3',
order: 1,
isOpen: true,
},
],
}, {
name: 'server-2',
order: 0,
tabs: [
{
name: 'tab-1',
order: 0,
isOpen: false,
},
{
name: 'tab-2',
order: 2,
isOpen: true,
},
{
name: 'tab-3',
order: 1,
isOpen: true,
},
],
lastActiveTab: 2,
}];
viewManager.showInitial();
expect(viewManager.showByName).toHaveBeenCalledWith('server-2_tab-2');
});
it('should show next tab when last active tab is closed', () => {
Config.teams = [{
name: 'server-1',
order: 1,
tabs: [
{
name: 'tab-1',
order: 0,
isOpen: false,
},
{
name: 'tab-2',
order: 2,
isOpen: true,
},
{
name: 'tab-3',
order: 1,
isOpen: true,
},
],
}, {
name: 'server-2',
order: 0,
tabs: [
{
name: 'tab-1',
order: 0,
isOpen: true,
},
{
name: 'tab-2',
order: 2,
isOpen: false,
},
{
name: 'tab-3',
order: 1,
isOpen: true,
},
],
lastActiveTab: 2,
}];
viewManager.showInitial();
expect(viewManager.showByName).toHaveBeenCalledWith('server-2_tab-1');
expect(viewManager.showById).toHaveBeenCalledWith('tab-1');
});
it('should open new server modal when no servers exist', () => {
viewManager.mainWindow = {
webContents: {
send: jest.fn(),
},
};
Config.teams = [];
ServerManager.hasServers.mockReturnValue(false);
viewManager.showInitial();
expect(ipcMain.emit).toHaveBeenCalledWith(MAIN_WINDOW_SHOWN);
expect(window.webContents.send).toHaveBeenCalledWith(SET_ACTIVE_VIEW);
});
});
describe('showByName', () => {
describe('handleBrowserHistoryPush', () => {
const viewManager = new ViewManager();
viewManager.handleBrowserHistoryButton = jest.fn();
viewManager.showById = jest.fn();
const servers = [
{
name: 'server-1',
url: 'http://server-1.com',
order: 0,
tabs: [
{
name: 'tab-messaging',
order: 0,
isOpen: true,
},
{
name: 'other_type_1',
order: 2,
isOpen: true,
},
{
name: 'other_type_2',
order: 1,
isOpen: false,
},
],
},
];
const view1 = {
id: 'server-1_tab-messaging',
isLoggedIn: true,
tab: {
type: TAB_MESSAGING,
server: {
url: 'http://server-1.com',
},
},
sendToRenderer: jest.fn(),
};
const view2 = {
...view1,
id: 'server-1_other_type_1',
tab: {
...view1.tab,
type: 'other_type_1',
},
};
const view3 = {
...view1,
id: 'server-1_other_type_2',
tab: {
...view1.tab,
type: 'other_type_2',
},
};
const views = new Map([
['server-1_tab-messaging', view1],
['server-1_other_type_1', view2],
]);
const closedViews = new Map([
['server-1_other_type_2', view3],
]);
viewManager.getView = (viewId) => views.get(viewId);
viewManager.isViewClosed = (viewId) => closedViews.has(viewId);
viewManager.openClosedTab = jest.fn();
beforeEach(() => {
ServerManager.getAllServers.mockReturnValue(servers);
ServerManager.getCurrentServer.mockReturnValue(servers[0]);
urlUtils.cleanPathName.mockImplementation((base, path) => path);
});
afterEach(() => {
jest.resetAllMocks();
});
it('should open closed view if pushing to it', () => {
viewManager.openClosedTab.mockImplementation((name) => {
const view = closedViews.get(name);
closedViews.delete(name);
views.set(name, view);
});
ServerManager.lookupTabByURL.mockReturnValue({id: 'server-1_other_type_2'});
viewManager.handleBrowserHistoryPush(null, 'server-1_tab-messaging', '/other_type_2/subpath');
expect(viewManager.openClosedTab).toBeCalledWith('server-1_other_type_2', 'http://server-1.com/other_type_2/subpath');
});
it('should open redirect view if different from current view', () => {
ServerManager.lookupTabByURL.mockReturnValue({id: 'server-1_other_type_1'});
viewManager.handleBrowserHistoryPush(null, 'server-1_tab-messaging', '/other_type_1/subpath');
expect(viewManager.showById).toBeCalledWith('server-1_other_type_1');
});
it('should ignore redirects to "/" to Messages from other tabs', () => {
ServerManager.lookupTabByURL.mockReturnValue({id: 'server-1_tab-messaging'});
viewManager.handleBrowserHistoryPush(null, 'server-1_other_type_1', '/');
expect(view1.sendToRenderer).not.toBeCalled();
});
});
describe('showById', () => {
const viewManager = new ViewManager({});
const baseView = {
isReady: jest.fn(),
@@ -545,12 +510,12 @@ describe('main/views/viewManager', () => {
};
viewManager.views.set('server1-tab1', view);
viewManager.showByName('server1-tab1');
viewManager.showById('server1-tab1');
expect(viewManager.currentView).toBeUndefined();
expect(view.isReady).not.toBeCalled();
expect(view.show).not.toBeCalled();
viewManager.showByName('some-view-name');
viewManager.showById('some-view-name');
expect(viewManager.currentView).toBeUndefined();
expect(view.isReady).not.toBeCalled();
expect(view.show).not.toBeCalled();
@@ -569,7 +534,7 @@ describe('main/views/viewManager', () => {
viewManager.views.set('oldView', oldView);
viewManager.views.set('newView', newView);
viewManager.currentView = 'oldView';
viewManager.showByName('newView');
viewManager.showById('newView');
expect(oldView.hide).toHaveBeenCalled();
});
@@ -577,7 +542,7 @@ describe('main/views/viewManager', () => {
const view = {...baseView};
view.isErrored.mockReturnValue(true);
viewManager.views.set('view1', view);
viewManager.showByName('view1');
viewManager.showById('view1');
expect(view.show).not.toHaveBeenCalled();
});
@@ -586,7 +551,7 @@ describe('main/views/viewManager', () => {
view.isErrored.mockReturnValue(false);
view.needsLoadingScreen.mockImplementation(() => true);
viewManager.views.set('view1', view);
viewManager.showByName('view1');
viewManager.showById('view1');
expect(LoadingScreen.show).toHaveBeenCalled();
});
@@ -595,113 +560,12 @@ describe('main/views/viewManager', () => {
view.needsLoadingScreen.mockImplementation(() => false);
view.isErrored.mockReturnValue(false);
viewManager.views.set('view1', view);
viewManager.showByName('view1');
viewManager.showById('view1');
expect(viewManager.currentView).toBe('view1');
expect(view.show).toHaveBeenCalled();
});
});
describe('getViewByURL', () => {
const viewManager = new ViewManager();
const servers = [
{
name: 'server-1',
url: 'http://server-1.com',
tabs: [
{
name: 'tab',
},
{
name: 'tab-type1',
},
{
name: 'tab-type2',
},
],
},
{
name: 'server-2',
url: 'http://server-2.com/subpath',
tabs: [
{
name: 'tab-type1',
},
{
name: 'tab-type2',
},
{
name: 'tab',
},
],
},
];
viewManager.getServerView = (srv, tabName) => {
const postfix = tabName.split('-')[1];
return {
name: `${srv.name}_${tabName}`,
url: new URL(`${srv.url.toString().replace(/\/$/, '')}${postfix ? `/${postfix}` : ''}`),
};
};
beforeEach(() => {
Config.teams = servers.concat();
MattermostServer.mockImplementation((server) => ({
name: server.name,
url: new URL(server.url),
}));
equalUrlsIgnoringSubpath.mockImplementation((url1, url2) => `${url1}`.startsWith(`${url2}`));
});
afterEach(() => {
jest.resetAllMocks();
});
it('should match the correct server - base URL', () => {
const inputURL = new URL('http://server-1.com');
expect(viewManager.getViewByURL(inputURL)).toStrictEqual({name: 'server-1_tab', url: new URL('http://server-1.com')});
});
it('should match the correct server - base tab', () => {
const inputURL = new URL('http://server-1.com/team');
expect(viewManager.getViewByURL(inputURL)).toStrictEqual({name: 'server-1_tab', url: new URL('http://server-1.com')});
});
it('should match the correct server - different tab', () => {
const inputURL = new URL('http://server-1.com/type1/app');
expect(viewManager.getViewByURL(inputURL)).toStrictEqual({name: 'server-1_tab-type1', url: new URL('http://server-1.com/type1')});
});
it('should return undefined for server with subpath and URL without', () => {
const inputURL = new URL('http://server-2.com');
expect(viewManager.getViewByURL(inputURL)).toBe(undefined);
});
it('should return undefined for server with subpath and URL with wrong subpath', () => {
const inputURL = new URL('http://server-2.com/different/subpath');
expect(viewManager.getViewByURL(inputURL)).toBe(undefined);
});
it('should match the correct server with a subpath - base URL', () => {
const inputURL = new URL('http://server-2.com/subpath');
expect(viewManager.getViewByURL(inputURL)).toStrictEqual({name: 'server-2_tab', url: new URL('http://server-2.com/subpath')});
});
it('should match the correct server with a subpath - base tab', () => {
const inputURL = new URL('http://server-2.com/subpath/team');
expect(viewManager.getViewByURL(inputURL)).toStrictEqual({name: 'server-2_tab', url: new URL('http://server-2.com/subpath')});
});
it('should match the correct server with a subpath - different tab', () => {
const inputURL = new URL('http://server-2.com/subpath/type2/team');
expect(viewManager.getViewByURL(inputURL)).toStrictEqual({name: 'server-2_tab-type2', url: new URL('http://server-2.com/subpath/type2')});
});
it('should return undefined for wrong server', () => {
const inputURL = new URL('http://server-3.com');
expect(viewManager.getViewByURL(inputURL)).toBe(undefined);
});
});
describe('handleDeepLink', () => {
const viewManager = new ViewManager({});
const baseView = {
@@ -719,7 +583,6 @@ describe('main/views/viewManager', () => {
beforeEach(() => {
viewManager.openClosedTab = jest.fn();
viewManager.getViewByURL = jest.fn();
});
afterEach(() => {
@@ -729,7 +592,7 @@ describe('main/views/viewManager', () => {
});
it('should load URL into matching view', () => {
viewManager.getViewByURL.mockImplementation(() => ({name: 'view1', url: 'http://server-1.com/'}));
ServerManager.lookupTabByURL.mockImplementation(() => ({id: 'view1', url: new URL('http://server-1.com/')}));
const view = {...baseView};
viewManager.views.set('view1', view);
viewManager.handleDeepLink('mattermost://server-1.com/deep/link?thing=yes');
@@ -737,14 +600,10 @@ describe('main/views/viewManager', () => {
});
it('should send the URL to the view if its already loaded on a 6.0 server', () => {
viewManager.getViewByURL.mockImplementation(() => ({name: 'view1', url: 'http://server-1.com/'}));
ServerManager.lookupTabByURL.mockImplementation(() => ({id: 'view1', url: new URL('http://server-1.com/')}));
ServerManager.getRemoteInfo.mockReturnValue({serverVersion: '6.0.0'});
const view = {
...baseView,
serverInfo: {
remoteInfo: {
serverVersion: '6.0.0',
},
},
tab: {
server: {
url: new URL('http://server-1.com'),
@@ -758,7 +617,7 @@ describe('main/views/viewManager', () => {
});
it('should throw error if view is missing', () => {
viewManager.getViewByURL.mockImplementation(() => ({name: 'view1', url: 'http://server-1.com/'}));
ServerManager.lookupTabByURL.mockImplementation(() => ({id: 'view1', url: new URL('http://server-1.com/')}));
const view = {...baseView};
viewManager.handleDeepLink('mattermost://server-1.com/deep/link?thing=yes');
expect(view.load).not.toHaveBeenCalled();
@@ -772,10 +631,10 @@ describe('main/views/viewManager', () => {
});
it('should reopen closed tab if called upon', () => {
viewManager.getViewByURL.mockImplementation(() => ({name: 'view1', url: 'https://server-1.com/'}));
ServerManager.lookupTabByURL.mockImplementation(() => ({id: 'view1', url: new URL('http://server-1.com/')}));
viewManager.closedViews.set('view1', {});
viewManager.handleDeepLink('mattermost://server-1.com/deep/link?thing=yes');
expect(viewManager.openClosedTab).toHaveBeenCalledWith('view1', 'https://server-1.com/deep/link?thing=yes');
expect(viewManager.openClosedTab).toHaveBeenCalledWith('view1', 'http://server-1.com/deep/link?thing=yes');
});
});
});

View File

@@ -1,9 +1,7 @@
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {BrowserView, dialog, ipcMain, IpcMainEvent, IpcMainInvokeEvent} from 'electron';
import {BrowserViewConstructorOptions} from 'electron/main';
import {Tab, TeamWithTabs} from 'types/config';
import {BrowserView, dialog, ipcMain, IpcMainEvent, IpcMainInvokeEvent} from 'electron';
import {SECOND, TAB_BAR_HEIGHT} from 'common/utils/constants';
import {
@@ -16,28 +14,25 @@ import {
BROWSER_HISTORY_PUSH,
UPDATE_LAST_ACTIVE,
UPDATE_URL_VIEW_WIDTH,
MAIN_WINDOW_SHOWN,
RELOAD_CURRENT_VIEW,
SERVERS_UPDATE,
REACT_APP_INITIALIZED,
APP_LOGGED_IN,
BROWSER_HISTORY_BUTTON,
APP_LOGGED_OUT,
APP_LOGGED_IN,
RELOAD_CURRENT_VIEW,
UNREAD_RESULT,
GET_VIEW_NAME,
HISTORY,
GET_VIEW_INFO_FOR_TEST,
} from 'common/communication';
import Config from 'common/config';
import {Logger} from 'common/log';
import urlUtils, {equalUrlsIgnoringSubpath} from 'common/utils/url';
import urlUtils from 'common/utils/url';
import Utils from 'common/utils/util';
import {MattermostServer} from 'common/servers/MattermostServer';
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';
import ServerManager from 'common/servers/serverManager';
import {TabView, TAB_MESSAGING} from 'common/tabs/TabView';
import {localizeMessage} from 'main/i18nManager';
import {ServerInfo} from 'main/server/serverInfo';
import MainWindow from 'main/windows/mainWindow';
import * as appState from '../appState';
@@ -52,21 +47,17 @@ const URL_VIEW_DURATION = 10 * SECOND;
const URL_VIEW_HEIGHT = 20;
export class ViewManager {
private closedViews: Map<string, {srv: MattermostServer; tab: Tab}>;
private closedViews: Map<string, {srv: MattermostServer; tab: TabView}>;
private views: Map<string, MattermostView>;
private currentView?: string;
private urlViewCancel?: () => void;
private lastActiveServer?: number;
private viewOptions: BrowserViewConstructorOptions;
constructor() {
this.lastActiveServer = Config.lastActiveTeam;
this.viewOptions = {webPreferences: {spellcheck: Config.useSpellChecker}};
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.handle(GET_VIEW_INFO_FOR_TEST, this.handleGetViewInfoForTest);
ipcMain.on(HISTORY, this.handleHistory);
ipcMain.on(REACT_APP_INITIALIZED, this.handleReactAppInitialized);
ipcMain.on(BROWSER_HISTORY_PUSH, this.handleBrowserHistoryPush);
@@ -75,23 +66,24 @@ export class ViewManager {
ipcMain.on(APP_LOGGED_OUT, this.handleAppLoggedOut);
ipcMain.on(RELOAD_CURRENT_VIEW, this.handleReloadCurrentView);
ipcMain.on(UNREAD_RESULT, this.handleFaviconIsUnread);
ipcMain.handle(GET_VIEW_NAME, this.handleGetViewName);
ServerManager.on(SERVERS_UPDATE, this.handleReloadConfiguration);
}
init = () => {
this.getServers().forEach((server) => this.loadServer(server));
LoadingScreen.show();
ServerManager.getAllServers().forEach((server) => this.loadServer(server));
this.showInitial();
}
getView = (viewName: string) => {
return this.views.get(viewName);
getView = (viewId: string) => {
return this.views.get(viewId);
}
getCurrentView = () => {
if (this.currentView) {
return this.views.get(this.currentView);
}
return undefined;
}
@@ -99,42 +91,50 @@ export class ViewManager {
return [...this.views.values()].find((view) => view.webContentsId === webContentsId);
}
showByName = (name: string) => {
log.debug('viewManager.showByName', name);
isViewClosed = (viewId: string) => {
return this.closedViews.has(viewId);
}
const newView = this.views.get(name);
showById = (tabId: string) => {
this.getViewLogger(tabId).debug('showById', tabId);
const newView = this.views.get(tabId);
if (newView) {
if (newView.isVisible) {
return;
}
if (this.currentView && this.currentView !== name) {
let hidePrevious;
if (this.currentView && this.currentView !== tabId) {
const previous = this.getCurrentView();
if (previous) {
previous.hide();
hidePrevious = () => previous.hide();
}
}
this.currentView = name;
this.currentView = tabId;
if (!newView.isErrored()) {
newView.show();
if (newView.needsLoadingScreen()) {
LoadingScreen.show();
}
}
MainWindow.get()?.webContents.send(SET_ACTIVE_VIEW, newView.tab.server.name, newView.tab.type);
ipcMain.emit(SET_ACTIVE_VIEW, true, newView.tab.server.name, newView.tab.type);
hidePrevious?.();
MainWindow.get()?.webContents.send(SET_ACTIVE_VIEW, newView.tab.server.id, newView.tab.id);
ipcMain.emit(SET_ACTIVE_VIEW, true, newView.tab.server.id, newView.tab.id);
if (newView.isReady()) {
ipcMain.emit(UPDATE_LAST_ACTIVE, true, newView.tab.server.name, newView.tab.type);
ipcMain.emit(UPDATE_LAST_ACTIVE, true, newView.tab.id);
} else {
log.warn(`couldn't show ${name}, not ready`);
this.getViewLogger(tabId).warn(`couldn't show ${tabId}, not ready`);
}
} else {
log.warn(`Couldn't find a view with name: ${name}`);
this.getViewLogger(tabId).warn(`Couldn't find a view with name: ${tabId}`);
}
modalManager.showModal();
}
focusCurrentView = () => {
log.debug('focusCurrentView');
if (modalManager.isModalDisplayed()) {
modalManager.focusCurrentModal();
return;
@@ -171,25 +171,24 @@ export class ViewManager {
*/
handleDeepLink = (url: string | URL) => {
// TODO: fix for new tabs
if (url) {
const parsedURL = urlUtils.parseURL(url)!;
const tabView = this.getViewByURL(parsedURL, true);
const tabView = ServerManager.lookupTabByURL(parsedURL, true);
if (tabView) {
const urlWithSchema = `${urlUtils.parseURL(tabView.url)?.origin}${parsedURL.pathname}${parsedURL.search}`;
if (this.closedViews.has(tabView.name)) {
this.openClosedTab(tabView.name, urlWithSchema);
const urlWithSchema = `${tabView.url.origin}${parsedURL.pathname}${parsedURL.search}`;
if (this.closedViews.has(tabView.id)) {
this.openClosedTab(tabView.id, urlWithSchema);
} else {
const view = this.views.get(tabView.name);
const view = this.views.get(tabView.id);
if (!view) {
log.error(`Couldn't find a view matching the name ${tabView.name}`);
log.error(`Couldn't find a view matching the id ${tabView.id}`);
return;
}
if (view.isReady() && view.serverInfo.remoteInfo.serverVersion && Utils.isVersionGreaterThanOrEqualTo(view.serverInfo.remoteInfo.serverVersion, '6.0.0')) {
if (view.isReady() && ServerManager.getRemoteInfo(view.tab.server.id)?.serverVersion && Utils.isVersionGreaterThanOrEqualTo(ServerManager.getRemoteInfo(view.tab.server.id)?.serverVersion ?? '', '6.0.0')) {
const pathName = `/${urlWithSchema.replace(view.tab.server.url.toString(), '')}`;
view.sendToRenderer(BROWSER_HISTORY_PUSH, pathName);
this.deeplinkSuccess(view.name);
this.deeplinkSuccess(view.id);
} else {
// attempting to change parsedURL protocol results in it not being modified.
view.resetLoadingStatus();
@@ -207,83 +206,67 @@ export class ViewManager {
}
};
private deeplinkSuccess = (viewName: string) => {
log.debug('deeplinkSuccess', viewName);
private deeplinkSuccess = (viewId: string) => {
this.getViewLogger(viewId).debug('deeplinkSuccess');
const view = this.views.get(viewName);
if (!view) {
return;
}
this.showByName(viewName);
view.removeListener(LOAD_FAILED, this.deeplinkFailed);
this.showById(viewId);
this.views.get(viewId)?.removeListener(LOAD_FAILED, this.deeplinkFailed);
};
private deeplinkFailed = (viewName: string, err: string, url: string) => {
log.error(`[${viewName}] failed to load deeplink ${url}: ${err}`);
const view = this.views.get(viewName);
if (!view) {
return;
}
view.removeListener(LOAD_SUCCESS, this.deeplinkSuccess);
private deeplinkFailed = (viewId: string, err: string, url: string) => {
this.getViewLogger(viewId).error(`failed to load deeplink ${url}`, err);
this.views.get(viewId)?.removeListener(LOAD_SUCCESS, this.deeplinkSuccess);
}
/**
* View loading helpers
*/
private loadServer = (server: TeamWithTabs) => {
const srv = new MattermostServer(server);
const serverInfo = new ServerInfo(srv);
server.tabs.forEach((tab) => this.loadView(srv, serverInfo, tab));
private loadServer = (server: MattermostServer) => {
const tabs = ServerManager.getOrderedTabsForServer(server.id);
tabs.forEach((tab) => this.loadView(server, tab));
}
private loadView = (srv: MattermostServer, serverInfo: ServerInfo, tab: Tab, url?: string) => {
private loadView = (srv: MattermostServer, tab: TabView, url?: string) => {
if (!tab.isOpen) {
this.closedViews.set(getTabViewName(srv.name, tab.name), {srv, tab});
this.closedViews.set(tab.id, {srv, tab});
return;
}
const view = this.makeView(srv, serverInfo, tab, url);
const view = this.makeView(srv, tab, url);
this.addView(view);
}
private makeView = (srv: MattermostServer, serverInfo: ServerInfo, tab: Tab, url?: string): MattermostView => {
const tabView = this.getServerView(srv, tab.name);
const view = new MattermostView(tabView, serverInfo, this.viewOptions);
private makeView = (srv: MattermostServer, tab: TabView, url?: string): MattermostView => {
const mainWindow = MainWindow.get();
if (!mainWindow) {
throw new Error('Cannot create view, no main window present');
}
const view = new MattermostView(tab, {webPreferences: {spellcheck: Config.useSpellChecker}});
view.once(LOAD_SUCCESS, this.activateView);
view.load(url);
view.on(UPDATE_TARGET_URL, this.showURLView);
view.on(LOADSCREEN_END, this.finishLoading);
view.on(LOAD_FAILED, this.failLoading);
view.on(UPDATE_TARGET_URL, this.showURLView);
view.load(url);
return view;
}
private addView = (view: MattermostView): void => {
this.views.set(view.name, view);
if (this.closedViews.has(view.name)) {
this.closedViews.delete(view.name);
this.views.set(view.id, view);
if (this.closedViews.has(view.id)) {
this.closedViews.delete(view.id);
}
}
private showInitial = () => {
log.verbose('showInitial');
const servers = this.getServers();
if (servers.length) {
const element = servers.find((e) => e.order === this.lastActiveServer) || servers.find((e) => e.order === 0);
if (element && element.tabs.length) {
let tab = element.tabs.find((tab) => tab.order === element.lastActiveTab) || element.tabs.find((tab) => tab.order === 0);
if (!tab?.isOpen) {
const openTabs = element.tabs.filter((tab) => tab.isOpen);
tab = openTabs.find((e) => e.order === 0) || openTabs.concat().sort((a, b) => a.order - b.order)[0];
}
if (tab) {
const tabView = getTabViewName(element.name, tab.name);
this.showByName(tabView);
}
}
if (ServerManager.hasServers()) {
const lastActiveServer = ServerManager.getCurrentServer();
const lastActiveTab = ServerManager.getLastActiveTabForServer(lastActiveServer.id);
this.showById(lastActiveTab.id);
} else {
MainWindow.get()?.webContents.send(SET_ACTIVE_VIEW, null, null);
ipcMain.emit(MAIN_WINDOW_SHOWN);
MainWindow.get()?.webContents.send(SET_ACTIVE_VIEW);
}
}
@@ -291,29 +274,28 @@ export class ViewManager {
* Mattermost view event handlers
*/
private activateView = (viewName: string) => {
log.debug('activateView', viewName);
private activateView = (viewId: string) => {
this.getViewLogger(viewId).debug('activateView');
if (this.currentView === viewName) {
this.showByName(this.currentView);
if (this.currentView === viewId) {
this.showById(this.currentView);
}
}
private finishLoading = (server: string) => {
log.debug('finishLoading', server);
private finishLoading = (viewId: string) => {
this.getViewLogger(viewId).debug('finishLoading');
const view = this.views.get(server);
if (view && this.getCurrentView() === view) {
this.showByName(this.currentView!);
if (this.currentView === viewId) {
this.showById(this.currentView);
LoadingScreen.fade();
}
}
private failLoading = (tabName: string) => {
log.debug('failLoading', tabName);
private failLoading = (viewId: string) => {
this.getViewLogger(viewId).debug('failLoading');
LoadingScreen.fade();
if (this.currentView === tabName) {
if (this.currentView === viewId) {
this.getCurrentView()?.hide();
}
}
@@ -344,7 +326,7 @@ export class ViewManager {
const query = new Map([['url', urlString]]);
const localURL = getLocalURLString('urlView.html', query);
urlView.webContents.loadURL(localURL);
mainWindow.addBrowserView(urlView);
MainWindow.get()?.addBrowserView(urlView);
const boundaries = this.views.get(this.currentView || '')?.getBounds() ?? mainWindow.getBounds();
const hideView = () => {
@@ -372,7 +354,7 @@ export class ViewManager {
height: URL_VIEW_HEIGHT,
};
log.silly('showURLView setBounds', boundaries, bounds);
log.silly('showURLView.setBounds', boundaries, bounds);
urlView.setBounds(bounds);
};
@@ -397,34 +379,30 @@ export class ViewManager {
* Servers or tabs have been added or edited. We need to
* close, open, or reload tabs, taking care to reuse tabs and
* preserve focus on the currently selected tab. */
reloadConfiguration = () => {
log.debug('reloadConfiguration');
private handleReloadConfiguration = () => {
log.debug('handleReloadConfiguration');
const currentTabId: string | undefined = this.views.get(this.currentView as string)?.tab.id;
const current: Map<string, MattermostView> = new Map();
for (const view of this.views.values()) {
current.set(view.name, view);
current.set(view.tab.id, view);
}
const views: Map<string, MattermostView> = new Map();
const closed: Map<string, {srv: MattermostServer; tab: Tab; name: string}> = new Map();
const closed: Map<string, {srv: MattermostServer; tab: TabView}> = new Map();
const sortedTabs = this.getServers().flatMap((x) => [...x.tabs].
sort((a, b) => a.order - b.order).
map((t): [TeamWithTabs, Tab] => [x, t]));
const sortedTabs = ServerManager.getAllServers().flatMap((x) => ServerManager.getOrderedTabsForServer(x.id).
map((t): [MattermostServer, TabView] => [x, t]));
for (const [team, tab] of sortedTabs) {
const srv = new MattermostServer(team);
const info = new ServerInfo(srv);
const tabName = getTabViewName(team.name, tab.name);
const recycle = current.get(tabName);
for (const [srv, tab] of sortedTabs) {
const recycle = current.get(tab.id);
if (!tab.isOpen) {
const view = this.getServerView(srv, tab.name);
closed.set(tabName, {srv, tab, name: view.name});
closed.set(tab.id, {srv, tab});
} else if (recycle) {
recycle.updateServerInfo(srv);
views.set(tabName, recycle);
views.set(tab.id, recycle);
} else {
views.set(tabName, this.makeView(srv, info, tab, team.url));
views.set(tab.id, this.makeView(srv, tab));
}
}
@@ -439,16 +417,16 @@ export class ViewManager {
// commit views
this.views = new Map();
for (const x of views.values()) {
this.views.set(x.name, x);
this.views.set(x.id, x);
}
// commit closed
for (const x of closed.values()) {
this.closedViews.set(x.name, {srv: x.srv, tab: x.tab});
this.closedViews.set(x.tab.id, {srv: x.srv, tab: x.tab});
}
if ((this.currentView && closed.has(this.currentView)) || (this.currentView && this.closedViews.has(this.currentView))) {
if (this.getServers().length) {
if ((currentTabId && closed.has(currentTabId)) || (this.currentView && this.closedViews.has(this.currentView))) {
if (ServerManager.hasServers()) {
this.currentView = undefined;
this.showInitial();
} else {
@@ -457,12 +435,14 @@ export class ViewManager {
}
// show the focused tab (or initial)
if (this.currentView && views.has(this.currentView)) {
const view = views.get(this.currentView);
if (view) {
this.currentView = view.name;
this.showByName(view.name);
MainWindow.get()?.webContents.send(SET_ACTIVE_VIEW, view.tab.server.name, view.tab.type);
if (currentTabId && views.has(currentTabId)) {
const view = views.get(currentTabId);
if (view && view.id !== this.currentView) {
this.currentView = view.id;
this.showById(view.id);
MainWindow.get()?.webContents.send(SET_ACTIVE_VIEW, view.tab.server.id, view.tab.id);
} else {
this.focusCurrentView();
}
} else {
this.showInitial();
@@ -481,21 +461,21 @@ export class ViewManager {
this.getView(viewId)?.onLogin(false);
}
private handleBrowserHistoryPush = (e: IpcMainEvent, viewName: string, pathName: string) => {
log.debug('handleBrowserHistoryPush', {viewName, pathName});
private handleBrowserHistoryPush = (e: IpcMainEvent, viewId: string, pathName: string) => {
log.debug('handleBrowserHistoryPush', {viewId, pathName});
const currentView = this.views.get(viewName);
const currentView = this.getView(viewId);
const cleanedPathName = urlUtils.cleanPathName(currentView?.tab.server.url.pathname || '', pathName);
const redirectedViewName = this.getViewByURL(`${currentView?.tab.server.url.toString().replace(/\/$/, '')}${cleanedPathName}`)?.name || viewName;
if (this.closedViews.has(redirectedViewName)) {
const redirectedviewId = ServerManager.lookupTabByURL(`${currentView?.tab.server.url.toString().replace(/\/$/, '')}${cleanedPathName}`)?.id || viewId;
if (this.isViewClosed(redirectedviewId)) {
// If it's a closed view, just open it and stop
this.openClosedTab(redirectedViewName, `${currentView?.tab.server.url}${cleanedPathName}`);
this.openClosedTab(redirectedviewId, `${currentView?.tab.server.url}${cleanedPathName}`);
return;
}
let redirectedView = this.views.get(redirectedViewName) || currentView;
if (redirectedView !== currentView && redirectedView?.tab.name === this.currentView && redirectedView?.isLoggedIn) {
log.info('redirecting to a new view', redirectedView?.name || viewName);
this.showByName(redirectedView?.name || viewName);
let redirectedView = this.getView(redirectedviewId) || currentView;
if (redirectedView !== currentView && redirectedView?.tab.server.id === ServerManager.getCurrentServer().id && redirectedView?.isLoggedIn) {
log.info('redirecting to a new view', redirectedView?.id || viewId);
this.showById(redirectedView?.id || viewId);
} else {
redirectedView = currentView;
}
@@ -504,21 +484,19 @@ export class ViewManager {
if (!(redirectedView !== currentView && redirectedView?.tab.type === TAB_MESSAGING && cleanedPathName === '/')) {
redirectedView?.sendToRenderer(BROWSER_HISTORY_PUSH, cleanedPathName);
if (redirectedView) {
this.handleBrowserHistoryButton(e, redirectedView.name);
this.handleBrowserHistoryButton(e, redirectedView.id);
}
}
}
private handleBrowserHistoryButton = (e: IpcMainEvent, viewName: string) => {
log.debug('handleBrowserHistoryButton', viewName);
this.getView(viewName)?.updateHistoryButton();
private handleBrowserHistoryButton = (e: IpcMainEvent, viewId: string) => {
this.getView(viewId)?.updateHistoryButton();
}
private handleReactAppInitialized = (e: IpcMainEvent, viewName: string) => {
log.debug('handleReactAppInitialized', viewName);
private handleReactAppInitialized = (e: IpcMainEvent, viewId: string) => {
log.debug('handleReactAppInitialized', viewId);
const view = this.views.get(viewName);
const view = this.views.get(viewId);
if (view) {
view.setInitialized();
if (this.getCurrentView() === view) {
@@ -535,94 +513,53 @@ export class ViewManager {
return;
}
view?.reload();
this.showByName(view?.name);
this.showById(view?.id);
}
// 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.
private handleFaviconIsUnread = (e: Event, favicon: string, viewName: string, result: boolean) => {
log.silly('handleFaviconIsUnread', {favicon, viewName, result});
private handleFaviconIsUnread = (e: Event, favicon: string, viewId: string, result: boolean) => {
log.silly('handleFaviconIsUnread', {favicon, viewId, result});
appState.updateUnreads(viewName, result);
appState.updateUnreads(viewId, result);
}
/**
* Helper functions
*/
private openClosedTab = (name: string, url?: string) => {
if (!this.closedViews.has(name)) {
private openClosedTab = (id: string, url?: string) => {
if (!this.closedViews.has(id)) {
return;
}
const {srv, tab} = this.closedViews.get(name)!;
const {srv, tab} = this.closedViews.get(id)!;
tab.isOpen = true;
this.loadView(srv, new ServerInfo(srv), tab, url);
this.showByName(name);
const view = this.views.get(name)!;
this.loadView(srv, tab, url);
this.showById(id);
const view = this.views.get(id)!;
view.isVisible = true;
view.on(LOAD_SUCCESS, () => {
view.isVisible = false;
this.showByName(name);
this.showById(id);
});
ipcMain.emit(OPEN_TAB, null, srv.name, tab.name);
ipcMain.emit(OPEN_TAB, null, tab.id);
}
getViewByURL = (inputURL: URL | string, ignoreScheme = false) => {
log.silly('getViewByURL', `${inputURL}`, ignoreScheme);
private getViewLogger = (viewId: string) => {
return ServerManager.getViewLog(viewId, 'ViewManager');
}
const parsedURL = urlUtils.parseURL(inputURL);
if (!parsedURL) {
return undefined;
}
const server = this.getServers().find((team) => {
const parsedServerUrl = urlUtils.parseURL(team.url)!;
return equalUrlsIgnoringSubpath(parsedURL, parsedServerUrl, ignoreScheme) && parsedURL.pathname.match(new RegExp(`^${parsedServerUrl.pathname}(.+)?(/(.+))?$`));
});
if (!server) {
return undefined;
}
const mmServer = new MattermostServer(server);
let selectedTab = this.getServerView(mmServer, TAB_MESSAGING);
server.tabs.
filter((tab) => tab.name !== TAB_MESSAGING).
forEach((tab) => {
const tabCandidate = this.getServerView(mmServer, tab.name);
if (parsedURL.pathname.match(new RegExp(`^${tabCandidate.url.pathname}(/(.+))?`))) {
selectedTab = tabCandidate;
}
});
return selectedTab;
}
private getServerView = (srv: MattermostServer, tabName: string) => {
switch (tabName) {
case TAB_MESSAGING:
return new MessagingTabView(srv);
case TAB_FOCALBOARD:
return new FocalboardTabView(srv);
case TAB_PLAYBOOKS:
return new PlaybooksTabView(srv);
default:
throw new Error('Not implemeneted');
}
}
private getServers = () => {
return Config.teams.concat();
}
handleGetViewName = (event: IpcMainInvokeEvent) => {
return this.getViewByWebContentsId(event.sender.id);
}
setServerInitialized = (server: string) => {
const view = this.views.get(server);
if (view) {
view.setInitialized();
if (this.getCurrentView() === view) {
LoadingScreen.fade();
}
private handleGetViewInfoForTest = (event: IpcMainInvokeEvent) => {
const view = this.getViewByWebContentsId(event.sender.id);
if (!view) {
return null;
}
return {
id: view.id,
webContentsId: view.webContentsId,
serverName: view.tab.server.name,
tabType: view.tab.type,
};
}
}

View File

@@ -23,12 +23,13 @@ jest.mock('electron', () => ({
session: {},
}));
jest.mock('main/contextMenu', () => jest.fn());
jest.mock('main/windows/mainWindow', () => ({
get: jest.fn(),
}));
jest.mock('../allowProtocolDialog', () => ({}));
jest.mock('main/windows/callsWidgetWindow', () => ({}));
jest.mock('main/views/viewManager', () => ({
getViewByWebContentsId: jest.fn(),
getViewByURL: jest.fn(),
}));
jest.mock('../windows/windowManager', () => ({
getServerURLFromWebContentsId: jest.fn(),

View File

@@ -9,17 +9,18 @@ import urlUtils from 'common/utils/url';
import {flushCookiesStore} from 'main/app/utils';
import ContextMenu from 'main/contextMenu';
import ServerManager from 'common/servers/serverManager';
import CallsWidgetWindow from 'main/windows/callsWidgetWindow';
import MainWindow from 'main/windows/mainWindow';
import WindowManager from 'main/windows/windowManager';
import ViewManager from 'main/views/viewManager';
import CallsWidgetWindow from 'main/windows/callsWidgetWindow';
import {protocols} from '../../../electron-builder.json';
import allowProtocolDialog from '../allowProtocolDialog';
import {composeUserAgent} from '../utils';
import ViewManager from './viewManager';
type CustomLogin = {
inProgress: boolean;
}
@@ -38,10 +39,16 @@ export class WebContentsEventManager {
}
private log = (webContentsId?: number) => {
if (webContentsId) {
return log.withPrefix(String(webContentsId));
if (!webContentsId) {
return log;
}
return log;
const view = ViewManager.getViewByWebContentsId(webContentsId);
if (!view) {
return log;
}
return ServerManager.getViewLog(view.id, 'WebContentsEventManager');
}
private isTrustedPopupWindow = (webContentsId: number) => {
@@ -59,7 +66,7 @@ export class WebContentsEventManager {
return WindowManager.getServerURLFromWebContentsId(webContentsId);
}
generateWillNavigate = (webContentsId: number) => {
private generateWillNavigate = (webContentsId: number) => {
return (event: Event, url: string) => {
this.log(webContentsId).debug('will-navigate', url);
@@ -95,9 +102,9 @@ export class WebContentsEventManager {
};
};
generateDidStartNavigation = (webContentsId: number) => {
private generateDidStartNavigation = (webContentsId: number) => {
return (event: Event, url: string) => {
this.log(webContentsId).debug('did-start-navigation', {webContentsId, url});
this.log(webContentsId).debug('did-start-navigation', url);
const parsedURL = urlUtils.parseURL(url)!;
const serverURL = this.getServerURLFromWebContentsId(webContentsId);
@@ -114,12 +121,12 @@ export class WebContentsEventManager {
};
};
denyNewWindow = (details: Electron.HandlerDetails): {action: 'deny' | 'allow'} => {
private denyNewWindow = (details: Electron.HandlerDetails): {action: 'deny' | 'allow'} => {
this.log().warn(`Prevented popup window to open a new window to ${details.url}.`);
return {action: 'deny'};
};
generateNewWindowListener = (webContentsId: number, spellcheck?: boolean) => {
private generateNewWindowListener = (webContentsId: number, spellcheck?: boolean) => {
return (details: Electron.HandlerDetails): {action: 'deny' | 'allow'} => {
this.log(webContentsId).debug('new-window', details.url);
@@ -199,7 +206,7 @@ export class WebContentsEventManager {
this.popupWindow = {
win: new BrowserWindow({
backgroundColor: '#fff', // prevents blurry text: https://electronjs.org/docs/faq#the-font-looks-blurry-what-is-this-and-what-can-i-do
//parent: WindowManager.getMainWindow(),
parent: MainWindow.get(),
show: false,
center: true,
webPreferences: {
@@ -250,7 +257,7 @@ export class WebContentsEventManager {
return {action: 'deny'};
}
const otherServerURL = ViewManager.getViewByURL(parsedURL);
const otherServerURL = ServerManager.lookupTabByURL(parsedURL);
if (otherServerURL && urlUtils.isTeamUrl(otherServerURL.server.url, parsedURL, true)) {
WindowManager.showMainWindow(parsedURL);
return {action: 'deny'};