diff --git a/package-lock.json b/package-lock.json index 6e59a9ac..41a7b744 100644 --- a/package-lock.json +++ b/package-lock.json @@ -33,6 +33,7 @@ "@types/react": "17.0.43", "@types/react-beautiful-dnd": "13.0.0", "@types/react-dom": "17.0.14", + "@types/uuid": "^9.0.1", "@types/valid-url": "1.0.3", "@types/winreg": "1.2.31", "@typescript-eslint/eslint-plugin": "5.18.0", @@ -102,6 +103,7 @@ "style-loader": "3.3.1", "ts-prune": "0.10.3", "typescript": "4.6.3", + "uuid": "9.0.0", "valid-url": "1.0.9", "webpack": "5.71.0", "webpack-cli": "4.10.0", @@ -9563,6 +9565,12 @@ "integrity": "sha512-PBjIUxZHOuj0R15/xuwJYjFi+KZdNFrehocChv4g5hu6aFroHue8m0lBP0POdK2nKzbw0cgV1mws8+V/JAcEkQ==", "dev": true }, + "node_modules/@types/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-rFT3ak0/2trgvp4yYZo5iKFEPsET7vKydKF+VRCxlQ9bpheehyAJH89dAkaLEq/j/RZXJIqcgsmPJKUP1Z28HA==", + "dev": true + }, "node_modules/@types/valid-url": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/@types/valid-url/-/valid-url-1.0.3.tgz", @@ -12061,6 +12069,16 @@ "node": ">=0.10.0" } }, + "node_modules/bin-build/node_modules/uuid": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz", + "integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==", + "deprecated": "Please upgrade to version 7 or higher. Older versions may use Math.random() in certain circumstances, which is known to be problematic. See https://v8.dev/blog/math-random for details.", + "dev": true, + "bin": { + "uuid": "bin/uuid" + } + }, "node_modules/binary-extensions": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz", @@ -30811,6 +30829,16 @@ "node": ">=8" } }, + "node_modules/tempfile/node_modules/uuid": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz", + "integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==", + "deprecated": "Please upgrade to version 7 or higher. Older versions may use Math.random() in certain circumstances, which is known to be problematic. See https://v8.dev/blog/math-random for details.", + "dev": true, + "bin": { + "uuid": "bin/uuid" + } + }, "node_modules/terser": { "version": "4.8.0", "resolved": "https://registry.npmjs.org/terser/-/terser-4.8.0.tgz", @@ -32145,12 +32173,12 @@ } }, "node_modules/uuid": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz", - "integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==", + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.0.tgz", + "integrity": "sha512-MXcSTerfPa4uqyzStbRoTgt5XIe3x5+42+q1sDuy3R5MDk66URdLMOZe5aPX/SQd+kuYAh0FdP/pO28IkQyTeg==", "dev": true, "bin": { - "uuid": "bin/uuid" + "uuid": "dist/bin/uuid" } }, "node_modules/uuid-browser": { @@ -32858,6 +32886,16 @@ "node": ">= 6" } }, + "node_modules/webpack-log/node_modules/uuid": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz", + "integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==", + "deprecated": "Please upgrade to version 7 or higher. Older versions may use Math.random() in certain circumstances, which is known to be problematic. See https://v8.dev/blog/math-random for details.", + "dev": true, + "bin": { + "uuid": "bin/uuid" + } + }, "node_modules/webpack-merge": { "version": "5.8.0", "resolved": "https://registry.npmjs.org/webpack-merge/-/webpack-merge-5.8.0.tgz", @@ -40675,6 +40713,12 @@ "integrity": "sha512-PBjIUxZHOuj0R15/xuwJYjFi+KZdNFrehocChv4g5hu6aFroHue8m0lBP0POdK2nKzbw0cgV1mws8+V/JAcEkQ==", "dev": true }, + "@types/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-rFT3ak0/2trgvp4yYZo5iKFEPsET7vKydKF+VRCxlQ9bpheehyAJH89dAkaLEq/j/RZXJIqcgsmPJKUP1Z28HA==", + "dev": true + }, "@types/valid-url": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/@types/valid-url/-/valid-url-1.0.3.tgz", @@ -42669,6 +42713,12 @@ "requires": { "prepend-http": "^1.0.1" } + }, + "uuid": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz", + "integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==", + "dev": true } } }, @@ -57296,6 +57346,14 @@ "requires": { "temp-dir": "^2.0.0", "uuid": "^3.3.2" + }, + "dependencies": { + "uuid": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz", + "integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==", + "dev": true + } } }, "terser": { @@ -58303,9 +58361,9 @@ "dev": true }, "uuid": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz", - "integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==", + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.0.tgz", + "integrity": "sha512-MXcSTerfPa4uqyzStbRoTgt5XIe3x5+42+q1sDuy3R5MDk66URdLMOZe5aPX/SQd+kuYAh0FdP/pO28IkQyTeg==", "dev": true }, "uuid-browser": { @@ -58845,6 +58903,14 @@ "requires": { "ansi-colors": "^3.0.0", "uuid": "^3.3.2" + }, + "dependencies": { + "uuid": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz", + "integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==", + "dev": true + } } }, "webpack-merge": { diff --git a/package.json b/package.json index cd4434e1..b34041ff 100644 --- a/package.json +++ b/package.json @@ -151,6 +151,7 @@ "@types/react": "17.0.43", "@types/react-beautiful-dnd": "13.0.0", "@types/react-dom": "17.0.14", + "@types/uuid": "^9.0.1", "@types/valid-url": "1.0.3", "@types/winreg": "1.2.31", "@typescript-eslint/eslint-plugin": "5.18.0", @@ -220,6 +221,7 @@ "style-loader": "3.3.1", "ts-prune": "0.10.3", "typescript": "4.6.3", + "uuid": "9.0.0", "valid-url": "1.0.9", "webpack": "5.71.0", "webpack-cli": "4.10.0", diff --git a/src/common/communication.ts b/src/common/communication.ts index 0745c510..6fe4cde2 100644 --- a/src/common/communication.ts +++ b/src/common/communication.ts @@ -163,3 +163,6 @@ export const DOWNLOADS_DROPDOWN_MENU_CANCEL_DOWNLOAD = 'downloads-dropdown-menu- export const DOWNLOADS_DROPDOWN_MENU_CLEAR_FILE = 'downloads-dropdown-menu-clear-file'; export const DOWNLOADS_DROPDOWN_MENU_OPEN_FILE = 'downloads-dropdown-menu-open-file'; export const DOWNLOADS_DROPDOWN_MENU_SHOW_FILE_IN_FOLDER = 'downloads-dropdown-menu-show-file-in-folder'; + +export const SERVERS_URL_MODIFIED = 'servers-modified'; +export const SERVERS_UPDATE = 'servers-update'; diff --git a/src/common/config/RegistryConfig.ts b/src/common/config/RegistryConfig.ts index c7292908..a286f351 100644 --- a/src/common/config/RegistryConfig.ts +++ b/src/common/config/RegistryConfig.ts @@ -6,7 +6,7 @@ import {EventEmitter} from 'events'; import WindowsRegistry from 'winreg'; import WindowsRegistryUTF8 from 'winreg-utf8'; -import {RegistryConfig as RegistryConfigType, Team} from 'types/config'; +import {RegistryConfig as RegistryConfigType, FullTeam} from 'types/config'; import {Logger} from 'common/log'; @@ -78,7 +78,7 @@ export default class RegistryConfig extends EventEmitter { */ async getServersListFromRegistry() { const defaultServers = await this.getRegistryEntry(`${BASE_REGISTRY_KEY_PATH}\\DefaultServerList`); - return defaultServers.flat(2).reduce((servers: Team[], server, index) => { + return defaultServers.flat(2).reduce((servers: FullTeam[], server, index) => { if (server) { servers.push({ name: (server as WindowsRegistry.RegistryItem).name, diff --git a/src/common/config/index.ts b/src/common/config/index.ts index 8c258015..591bd34d 100644 --- a/src/common/config/index.ts +++ b/src/common/config/index.ts @@ -13,6 +13,7 @@ import { AnyConfig, BuildConfig, CombinedConfig, + ConfigServer, Config as ConfigType, LocalConfiguration, RegistryConfig as RegistryConfigType, @@ -203,6 +204,14 @@ export class Config extends EventEmitter { this.reload(); } + setServers = (servers: ConfigServer[], lastActiveTeam?: number) => { + log.debug('setServers', servers, lastActiveTeam); + + this.localConfigData = Object.assign({}, this.localConfigData, {teams: servers, lastActiveTeam: lastActiveTeam ?? this.localConfigData?.lastActiveTeam}); + this.regenerateCombinedConfigData(); + this.saveLocalConfigData(); + } + /** * Used to replace the existing config data with new config data * @@ -277,7 +286,7 @@ export class Config extends EventEmitter { return this.combinedData?.darkMode ?? defaultPreferences.darkMode; } get localTeams() { - return this.localConfigData?.teams ?? defaultPreferences.version; + return this.localConfigData?.teams ?? defaultPreferences.teams; } get enableHardwareAcceleration() { return this.combinedData?.enableHardwareAcceleration ?? defaultPreferences.enableHardwareAcceleration; diff --git a/src/common/log.ts b/src/common/log.ts index cf52f2f5..6e718c43 100644 --- a/src/common/log.ts +++ b/src/common/log.ts @@ -15,6 +15,8 @@ export const setLoggingLevel = (level: string) => { log.transports.file.level = level as LevelOption; }; +export const getLevel = () => log.transports.file.level as string; + export class Logger { private prefixes: string[]; diff --git a/src/common/servers/MattermostServer.ts b/src/common/servers/MattermostServer.ts index 29e5a08f..6081d94a 100644 --- a/src/common/servers/MattermostServer.ts +++ b/src/common/servers/MattermostServer.ts @@ -1,14 +1,30 @@ // Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. +import {v4 as uuid} from 'uuid'; + +import {Team} from 'types/config'; + import urlUtils from 'common/utils/url'; export class MattermostServer { + id: string; name: string; - url: URL; - constructor(name: string, serverUrl: string) { - this.name = name; - this.url = urlUtils.parseURL(serverUrl)!; + url!: URL; + isPredefined: boolean; + + constructor(server: Team, isPredefined = false) { + this.id = uuid(); + this.name = server.name; + this.updateURL(server.url); + this.isPredefined = isPredefined; + if (!this.url) { + throw new Error('Invalid url for creating a server'); + } + } + + updateURL = (url: string) => { + this.url = urlUtils.parseURL(url)!; if (!this.url) { throw new Error('Invalid url for creating a server'); } diff --git a/src/common/servers/serverManager.test.js b/src/common/servers/serverManager.test.js new file mode 100644 index 00000000..d6218333 --- /dev/null +++ b/src/common/servers/serverManager.test.js @@ -0,0 +1,181 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {TAB_MESSAGING, TAB_FOCALBOARD, TAB_PLAYBOOKS} from 'common/tabs/TabView'; +import urlUtils, {equalUrlsIgnoringSubpath} from 'common/utils/url'; +import Utils from 'common/utils/util'; + +import {ServerManager} from './serverManager'; + +jest.mock('common/config', () => ({ + set: jest.fn(), +})); +jest.mock('common/utils/url', () => ({ + parseURL: jest.fn(), + equalUrlsIgnoringSubpath: jest.fn(), +})); +jest.mock('common/utils/util', () => ({ + isVersionGreaterThanOrEqualTo: jest.fn(), +})); +jest.mock('main/server/serverInfo', () => ({ + ServerInfo: jest.fn(), +})); + +describe('common/servers/serverManager', () => { + describe('updateRemoteInfos', () => { + const serverManager = new ServerManager(); + + beforeEach(() => { + const server = {id: 'server-1', url: new URL('http://server-1.com'), name: 'server-1'}; + server.updateURL = (url) => { + server.url = new URL(url); + }; + serverManager.servers = new Map([['server-1', server]]); + serverManager.tabs = new Map([ + ['tab-1', {id: 'tab-1', name: TAB_MESSAGING, isOpen: true, server}], + ['tab-2', {id: 'tab-2', name: TAB_PLAYBOOKS, server}], + ['tab-3', {id: 'tab-3', name: TAB_FOCALBOARD, server}], + ]); + serverManager.tabOrder = new Map([['server-1', ['tab-1', 'tab-2', 'tab-3']]]); + serverManager.persistServers = jest.fn(); + Utils.isVersionGreaterThanOrEqualTo.mockImplementation((version) => version === '6.0.0'); + }); + + it('should not save when there is nothing to update', () => { + serverManager.updateRemoteInfos(new Map([['server-1', { + siteURL: 'http://server-1.com', + serverVersion: '6.0.0', + hasPlaybooks: false, + hasFocalboard: false, + }]])); + + expect(serverManager.persistServers).not.toHaveBeenCalled(); + }); + + it('should open all tabs', async () => { + serverManager.updateRemoteInfos(new Map([['server-1', { + siteURL: 'http://server-1.com', + serverVersion: '6.0.0', + hasPlaybooks: true, + hasFocalboard: true, + }]])); + + expect(serverManager.tabs.get('tab-2').isOpen).toBe(true); + expect(serverManager.tabs.get('tab-3').isOpen).toBe(true); + }); + + it('should open only playbooks', async () => { + serverManager.updateRemoteInfos(new Map([['server-1', { + siteURL: 'http://server-1.com', + serverVersion: '6.0.0', + hasPlaybooks: true, + hasFocalboard: false, + }]])); + + expect(serverManager.tabs.get('tab-2').isOpen).toBe(true); + expect(serverManager.tabs.get('tab-3').isOpen).toBeUndefined(); + }); + + it('should open none when server version is too old', async () => { + serverManager.updateRemoteInfos(new Map([['server-1', { + siteURL: 'http://server-1.com', + serverVersion: '5.0.0', + hasPlaybooks: true, + hasFocalboard: true, + }]])); + + expect(serverManager.tabs.get('tab-2').isOpen).toBeUndefined(); + expect(serverManager.tabs.get('tab-3').isOpen).toBeUndefined(); + }); + + it('should update server URL using site URL', async () => { + serverManager.updateRemoteInfos(new Map([['server-1', { + siteURL: 'http://server-2.com', + serverVersion: '6.0.0', + hasPlaybooks: true, + hasFocalboard: true, + }]])); + + expect(serverManager.servers.get('server-1').url.toString()).toBe('http://server-2.com/'); + }); + }); + + describe('lookupTabByURL', () => { + const serverManager = new ServerManager(); + serverManager.getAllServers = () => [ + {id: 'server-1', url: new URL('http://server-1.com')}, + {id: 'server-2', url: new URL('http://server-2.com/subpath')}, + ]; + serverManager.getOrderedTabsForServer = (serverId) => { + if (serverId === 'server-1') { + return [ + {id: 'tab-1', url: new URL('http://server-1.com')}, + {id: 'tab-1-type-1', url: new URL('http://server-1.com/type1')}, + {id: 'tab-1-type-2', url: new URL('http://server-1.com/type2')}, + ]; + } + if (serverId === 'server-2') { + return [ + {id: 'tab-2', url: new URL('http://server-2.com/subpath')}, + {id: 'tab-2-type-1', url: new URL('http://server-2.com/subpath/type1')}, + {id: 'tab-2-type-2', url: new URL('http://server-2.com/subpath/type2')}, + ]; + } + return []; + }; + + beforeEach(() => { + urlUtils.parseURL.mockImplementation((url) => new URL(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(serverManager.lookupTabByURL(inputURL)).toStrictEqual({id: 'tab-1', 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(serverManager.lookupTabByURL(inputURL)).toStrictEqual({id: 'tab-1', 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(serverManager.lookupTabByURL(inputURL)).toStrictEqual({id: 'tab-1-type-1', 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(serverManager.lookupTabByURL(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(serverManager.lookupTabByURL(inputURL)).toBe(undefined); + }); + + it('should match the correct server with a subpath - base URL', () => { + const inputURL = new URL('http://server-2.com/subpath'); + expect(serverManager.lookupTabByURL(inputURL)).toStrictEqual({id: 'tab-2', 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(serverManager.lookupTabByURL(inputURL)).toStrictEqual({id: 'tab-2', 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(serverManager.lookupTabByURL(inputURL)).toStrictEqual({id: 'tab-2-type-2', 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(serverManager.lookupTabByURL(inputURL)).toBe(undefined); + }); + }); +}); diff --git a/src/common/servers/serverManager.ts b/src/common/servers/serverManager.ts new file mode 100644 index 00000000..04ac4fe1 --- /dev/null +++ b/src/common/servers/serverManager.ts @@ -0,0 +1,461 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import EventEmitter from 'events'; + +import {Team, ConfigServer, ConfigTab} from 'types/config'; +import {RemoteInfo} from 'types/server'; + +import Config from 'common/config'; +import { + SERVERS_URL_MODIFIED, + SERVERS_UPDATE, +} from 'common/communication'; +import {Logger, getLevel} from 'common/log'; +import {MattermostServer} from 'common/servers/MattermostServer'; +import {TAB_FOCALBOARD, TAB_MESSAGING, TAB_PLAYBOOKS, TabView, getDefaultTabs} from 'common/tabs/TabView'; +import MessagingTabView from 'common/tabs/MessagingTabView'; +import FocalboardTabView from 'common/tabs/FocalboardTabView'; +import PlaybooksTabView from 'common/tabs/PlaybooksTabView'; +import urlUtils, {equalUrlsIgnoringSubpath} from 'common/utils/url'; +import Utils from 'common/utils/util'; + +const log = new Logger('ServerManager'); + +export class ServerManager extends EventEmitter { + private servers: Map; + private remoteInfo: Map; + private serverOrder: string[]; + private currentServerId?: string; + + private tabs: Map; + private tabOrder: Map; + private lastActiveTab: Map; + + constructor() { + super(); + + this.servers = new Map(); + this.remoteInfo = new Map(); + this.serverOrder = []; + this.tabs = new Map(); + this.tabOrder = new Map(); + this.lastActiveTab = new Map(); + } + + getOrderedTabsForServer = (serverId: string) => { + log.withPrefix(serverId).debug('getOrderedTabsForServer'); + + const tabOrder = this.tabOrder.get(serverId); + if (!tabOrder) { + return []; + } + return tabOrder.reduce((tabs, tabId) => { + const tab = this.tabs.get(tabId); + if (tab) { + tabs.push(tab); + } + return tabs; + }, [] as TabView[]); + } + + getOrderedServers = () => { + log.debug('getOrderedServers'); + + return this.serverOrder.reduce((servers, srv) => { + const server = this.servers.get(srv); + if (server) { + servers.push(server); + } + return servers; + }, [] as MattermostServer[]); + } + + getCurrentServer = () => { + log.debug('getCurrentServer'); + + if (!this.currentServerId) { + throw new Error('No server set as current'); + } + const server = this.servers.get(this.currentServerId); + if (!server) { + throw new Error('Current server does not exist'); + } + return server; + } + + getLastActiveTabForServer = (serverId: string) => { + log.withPrefix(serverId).debug('getLastActiveTabForServer'); + + const lastActiveTab = this.lastActiveTab.get(serverId); + if (lastActiveTab) { + const tab = this.tabs.get(lastActiveTab); + if (tab && tab?.isOpen) { + return tab; + } + } + return this.getFirstOpenTabForServer(serverId); + } + + getServer = (id: string) => { + return this.servers.get(id); + } + + getTab = (id: string) => { + return this.tabs.get(id); + } + + getAllServers = () => { + return [...this.servers.values()]; + } + + hasServers = () => { + return Boolean(this.servers.size); + } + + getRemoteInfo = (serverId: string) => { + return this.remoteInfo.get(serverId); + } + + updateRemoteInfos = (remoteInfos: Map) => { + let hasUpdates = false; + remoteInfos.forEach((remoteInfo, serverId) => { + this.remoteInfo.set(serverId, remoteInfo); + hasUpdates = this.updateServerURL(serverId) || hasUpdates; + hasUpdates = this.openExtraTabs(serverId) || hasUpdates; + }); + + if (hasUpdates) { + this.persistServers(); + } + } + + lookupTabByURL = (inputURL: URL | string, ignoreScheme = false) => { + log.silly('lookupTabByURL', `${inputURL}`, ignoreScheme); + + const parsedURL = urlUtils.parseURL(inputURL); + if (!parsedURL) { + return undefined; + } + const server = this.getAllServers().find((server) => { + return equalUrlsIgnoringSubpath(parsedURL, server.url, ignoreScheme) && parsedURL.pathname.match(new RegExp(`^${server.url.pathname}(.+)?(/(.+))?$`)); + }); + if (!server) { + return undefined; + } + const tabs = this.getOrderedTabsForServer(server.id); + + let selectedTab = tabs.find((tab) => tab && tab.name === TAB_MESSAGING); + tabs. + filter((tab) => tab && tab.name !== TAB_MESSAGING). + forEach((tab) => { + if (parsedURL.pathname.match(new RegExp(`^${tab.url.pathname}(/(.+))?`))) { + selectedTab = tab; + } + }); + return selectedTab; + } + + updateServerOrder = (serverOrder: string[]) => { + log.debug('updateServerOrder', serverOrder); + + this.serverOrder = serverOrder; + this.persistServers(); + } + + updateTabOrder = (serverId: string, tabOrder: string[]) => { + log.withPrefix(serverId).debug('updateTabOrder', tabOrder); + + this.tabOrder.set(serverId, tabOrder); + this.persistServers(); + } + + addServer = (server: Team) => { + const newServer = new MattermostServer(server, false); + + if (this.servers.has(newServer.id)) { + throw new Error('ID Collision detected. Cannot add server.'); + } + this.servers.set(newServer.id, newServer); + + this.serverOrder.push(newServer.id); + const tabOrder: string[] = []; + getDefaultTabs().forEach((tab) => { + const newTab = this.getTabView(newServer, tab.name, tab.isOpen); + this.tabs.set(newTab.id, newTab); + tabOrder.push(newTab.id); + }); + this.tabOrder.set(newServer.id, tabOrder); + + // Emit this event whenever we update a server URL to ensure remote info is fetched + this.emit(SERVERS_URL_MODIFIED, [newServer.id]); + this.persistServers(); + return newServer; + } + + editServer = (serverId: string, server: Team) => { + const existingServer = this.servers.get(serverId); + if (!existingServer) { + return; + } + + let urlModified; + if (existingServer.url.toString() !== urlUtils.parseURL(server.url)?.toString()) { + // Emit this event whenever we update a server URL to ensure remote info is fetched + urlModified = () => this.emit(SERVERS_URL_MODIFIED, [serverId]); + } + existingServer.name = server.name; + existingServer.updateURL(server.url); + this.servers.set(serverId, existingServer); + + this.tabOrder.get(serverId)?.forEach((tabId) => { + const tab = this.tabs.get(tabId); + if (tab) { + tab.server = existingServer; + this.tabs.set(tabId, tab); + } + }); + + urlModified?.(); + this.persistServers(); + } + + removeServer = (serverId: string) => { + this.tabOrder.get(serverId)?.forEach((tabId) => this.tabs.delete(tabId)); + this.tabOrder.delete(serverId); + this.lastActiveTab.delete(serverId); + + const index = this.serverOrder.findIndex((id) => id === serverId); + this.serverOrder.splice(index, 1); + this.remoteInfo.delete(serverId); + this.servers.delete(serverId); + + this.persistServers(); + } + + setTabIsOpen = (tabId: string, isOpen: boolean) => { + const tab = this.tabs.get(tabId); + if (!tab) { + return; + } + tab.isOpen = isOpen; + + this.persistServers(); + } + + updateLastActive = (tabId: string) => { + const tab = this.tabs.get(tabId); + if (!tab) { + return; + } + this.lastActiveTab.set(tab.server.id, tabId); + + this.currentServerId = tab.server.id; + + const serverOrder = this.serverOrder.findIndex((srv) => srv === tab.server.id); + if (serverOrder < 0) { + throw new Error('Server order corrupt, ID not found.'); + } + + this.persistServers(serverOrder); + } + + reloadFromConfig = () => { + const serverOrder: string[] = []; + Config.predefinedTeams.forEach((team) => { + const id = this.initServer(team, true); + serverOrder.push(id); + }); + if (Config.enableServerManagement) { + Config.localTeams.sort((a, b) => a.order - b.order).forEach((team) => { + const id = this.initServer(team, false); + serverOrder.push(id); + }); + } + this.filterOutDuplicateTeams(); + this.serverOrder = serverOrder; + if (Config.lastActiveTeam) { + this.currentServerId = this.serverOrder[Config.lastActiveTeam]; + } else { + this.currentServerId = this.serverOrder[0]; + } + } + + private filterOutDuplicateTeams = () => { + const servers = [...this.servers.keys()].map((key) => ({key, value: this.servers.get(key)!})); + const uniqueServers = new Set(); + servers.forEach((server) => { + if (uniqueServers.has(`${server.value.name}:${server.value.url}`)) { + this.servers.delete(server.key); + } else { + uniqueServers.add(`${server.value.name}:${server.value.url}`); + } + }); + } + + private initServer = (team: ConfigServer, isPredefined: boolean) => { + const server = new MattermostServer(team, isPredefined); + this.servers.set(server.id, server); + + log.withPrefix(server.id).debug('initialized server'); + + const tabOrder: string[] = []; + team.tabs.sort((a, b) => a.order - b.order).forEach((tab) => { + const tabView = this.getTabView(server, tab.name, tab.isOpen); + log.withPrefix(tabView.id).debug('initialized tab'); + + this.tabs.set(tabView.id, tabView); + tabOrder.push(tabView.id); + }); + this.tabOrder.set(server.id, tabOrder); + if (typeof team.lastActiveTab !== 'undefined') { + this.lastActiveTab.set(server.id, tabOrder[team.lastActiveTab]); + } + return server.id; + } + + private getFirstOpenTabForServer = (serverId: string) => { + const tabOrder = this.getOrderedTabsForServer(serverId); + const openTabs = tabOrder.filter((tab) => tab.isOpen); + const firstTab = openTabs[0]; + if (!firstTab) { + throw new Error(`No tabs open for server id ${serverId}`); + } + return firstTab; + } + + private persistServers = async (lastActiveTeam?: number) => { + this.emit(SERVERS_UPDATE); + + const localServers = [...this.servers.values()]. + reduce((servers, srv) => { + if (srv.isPredefined) { + return servers; + } + servers.push(this.toConfigServer(srv)); + return servers; + }, [] as ConfigServer[]); + await Config.setServers(localServers, lastActiveTeam); + } + + private getLastActiveTab = (serverId: string) => { + let lastActiveTab: number | undefined; + if (this.lastActiveTab.has(serverId)) { + const index = this.tabOrder.get(serverId)?.indexOf(this.lastActiveTab.get(serverId)!); + if (typeof index !== 'undefined' && index >= 0) { + lastActiveTab = index; + } + } + return lastActiveTab; + } + + private toConfigServer = (server: MattermostServer): ConfigServer => { + return { + name: server.name, + url: `${server.url}`, + order: this.serverOrder.indexOf(server.id), + lastActiveTab: this.getLastActiveTab(server.id), + tabs: this.tabOrder.get(server.id)?.reduce((tabs, tabId, index) => { + const tab = this.tabs.get(tabId); + if (!tab) { + return tabs; + } + tabs.push({ + name: tab?.type, + order: index, + isOpen: tab.isOpen, + }); + return tabs; + }, [] as ConfigTab[]) ?? [], + }; + } + + private getTabView = (srv: MattermostServer, tabName: string, isOpen?: boolean) => { + switch (tabName) { + case TAB_MESSAGING: + return new MessagingTabView(srv, isOpen); + case TAB_FOCALBOARD: + return new FocalboardTabView(srv, isOpen); + case TAB_PLAYBOOKS: + return new PlaybooksTabView(srv, isOpen); + default: + throw new Error('Not implemeneted'); + } + } + + private updateServerURL = (serverId: string) => { + const server = this.servers.get(serverId); + const remoteInfo = this.remoteInfo.get(serverId); + + if (!(server && remoteInfo)) { + return false; + } + + if (remoteInfo.siteURL && server.url.toString() !== new URL(remoteInfo.siteURL).toString()) { + server.updateURL(remoteInfo.siteURL); + this.servers.set(serverId, server); + return true; + } + return false; + } + + private openExtraTabs = (serverId: string) => { + const server = this.servers.get(serverId); + const remoteInfo = this.remoteInfo.get(serverId); + + if (!(server && remoteInfo)) { + return false; + } + + if (!(remoteInfo.serverVersion && Utils.isVersionGreaterThanOrEqualTo(remoteInfo.serverVersion, '6.0.0'))) { + return false; + } + + let hasUpdates = false; + const tabOrder = this.tabOrder.get(serverId); + if (tabOrder) { + tabOrder.forEach((tabId) => { + const tab = this.tabs.get(tabId); + if (tab) { + if (tab.name === TAB_PLAYBOOKS && remoteInfo.hasPlaybooks && typeof tab.isOpen === 'undefined') { + log.withPrefix(tab.id).verbose('opening Playbooks'); + tab.isOpen = true; + this.tabs.set(tabId, tab); + hasUpdates = true; + } + if (tab.name === TAB_FOCALBOARD && remoteInfo.hasFocalboard && typeof tab.isOpen === 'undefined') { + log.withPrefix(tab.id).verbose('opening Boards'); + tab.isOpen = true; + this.tabs.set(tabId, tab); + hasUpdates = true; + } + } + }); + } + return hasUpdates; + } + + private includeId = (id: string, ...prefixes: string[]) => { + const shouldInclude = ['debug', 'silly'].includes(getLevel()); + return shouldInclude ? [id, ...prefixes] : prefixes; + }; + + getServerLog = (serverId: string, ...additionalPrefixes: string[]) => { + const server = this.getServer(serverId); + if (!server) { + return new Logger(serverId); + } + return new Logger(...additionalPrefixes, ...this.includeId(serverId, server.name)); + }; + + getViewLog = (viewId: string, ...additionalPrefixes: string[]) => { + const view = this.getTab(viewId); + if (!view) { + return new Logger(viewId); + } + return new Logger(...additionalPrefixes, ...this.includeId(viewId, view.server.name, view.name)); + }; +} + +const serverManager = new ServerManager(); +export default serverManager; diff --git a/src/common/tabs/BaseTabView.ts b/src/common/tabs/BaseTabView.ts index 84a2b5dd..e35350af 100644 --- a/src/common/tabs/BaseTabView.ts +++ b/src/common/tabs/BaseTabView.ts @@ -1,6 +1,7 @@ // Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. // 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'; @@ -8,10 +9,14 @@ import {MattermostServer} from 'common/servers/MattermostServer'; import {getTabViewName, TabType, TabView, TabTuple} from './TabView'; export default abstract class BaseTabView implements TabView { + id: string; server: MattermostServer; + isOpen?: boolean; - constructor(server: MattermostServer) { + constructor(server: MattermostServer, isOpen?: boolean) { + this.id = uuid(); this.server = server; + this.isOpen = isOpen; } get name(): string { return getTabViewName(this.server.name, this.type); diff --git a/src/common/tabs/TabView.ts b/src/common/tabs/TabView.ts index 69395b19..4da85dc4 100644 --- a/src/common/tabs/TabView.ts +++ b/src/common/tabs/TabView.ts @@ -1,7 +1,7 @@ // Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {Team} from 'types/config'; +import {FullTeam} from 'types/config'; import {MattermostServer} from 'common/servers/MattermostServer'; @@ -12,7 +12,9 @@ export type TabType = typeof TAB_MESSAGING | typeof TAB_FOCALBOARD | typeof TAB_ export type TabTuple = [string, TabType]; export interface TabView { + id: string; server: MattermostServer; + isOpen?: boolean; get name(): string; get type(): TabType; @@ -21,27 +23,31 @@ export interface TabView { get urlTypeTuple(): TabTuple; } -export function getDefaultTeamWithTabsFromTeam(team: Team) { +export function getDefaultTeamWithTabsFromTeam(team: FullTeam) { return { ...team, - tabs: [ - { - name: TAB_MESSAGING, - order: 0, - isOpen: true, - }, - { - name: TAB_FOCALBOARD, - order: 1, - }, - { - name: TAB_PLAYBOOKS, - order: 2, - }, - ], + tabs: getDefaultTabs(), }; } +export function getDefaultTabs() { + return [ + { + name: TAB_MESSAGING, + order: 0, + isOpen: true, + }, + { + name: TAB_FOCALBOARD, + order: 1, + }, + { + name: TAB_PLAYBOOKS, + order: 2, + }, + ]; +} + export function getTabDisplayName(tabType: TabType) { switch (tabType) { case TAB_MESSAGING: diff --git a/src/jest/jestSetup.js b/src/jest/jestSetup.js index c563fbf0..5459ad2a 100644 --- a/src/jest/jestSetup.js +++ b/src/jest/jestSetup.js @@ -29,6 +29,7 @@ jest.mock('common/log', () => { }), })), setLoggingLevel: jest.fn(), + getLevel: jest.fn(), }; }); diff --git a/src/main/app/utils.ts b/src/main/app/utils.ts index 0c7c7904..b2482fae 100644 --- a/src/main/app/utils.ts +++ b/src/main/app/utils.ts @@ -56,7 +56,7 @@ export function updateServerInfos(teams: TeamWithTabs[]) { log.silly('app.utils.updateServerInfos'); const serverInfos: Array> = []; teams.forEach((team) => { - const serverInfo = new ServerInfo(new MattermostServer(team.name, team.url)); + const serverInfo = new ServerInfo(new MattermostServer(team)); serverInfos.push(serverInfo.promise); }); Promise.all(serverInfos).then((data: Array) => { diff --git a/src/main/views/MattermostView.test.js b/src/main/views/MattermostView.test.js index 2c803fea..e37c06e3 100644 --- a/src/main/views/MattermostView.test.js +++ b/src/main/views/MattermostView.test.js @@ -52,7 +52,7 @@ jest.mock('../utils', () => ({ shouldHaveBackBar: jest.fn(), })); -const server = new MattermostServer('server_name', 'http://server-1.com'); +const server = new MattermostServer({name: 'server_name', url: 'http://server-1.com'}); const tabView = new MessagingTabView(server); describe('main/views/MattermostView', () => { diff --git a/src/main/views/viewManager.test.js b/src/main/views/viewManager.test.js index 8a08a02b..ba0ff7da 100644 --- a/src/main/views/viewManager.test.js +++ b/src/main/views/viewManager.test.js @@ -206,9 +206,9 @@ describe('main/views/viewManager', () => { urlTypeTuple: tuple(`http://${srv.name}.com/`, tabName), url: new URL(`http://${srv.name}.com`), })); - MattermostServer.mockImplementation((name, url) => ({ - name, - url: new URL(url), + MattermostServer.mockImplementation((server) => ({ + name: server.name, + url: new URL(server.url), })); const onceFn = jest.fn(); const loadFn = jest.fn(); @@ -709,9 +709,9 @@ describe('main/views/viewManager', () => { beforeEach(() => { Config.teams = servers.concat(); - MattermostServer.mockImplementation((name, url) => ({ - name, - url: new URL(url), + MattermostServer.mockImplementation((server) => ({ + name: server.name, + url: new URL(server.url), })); equalUrlsIgnoringSubpath.mockImplementation((url1, url2) => `${url1}`.startsWith(`${url2}`)); }); diff --git a/src/main/views/viewManager.ts b/src/main/views/viewManager.ts index 4764e413..37205ddb 100644 --- a/src/main/views/viewManager.ts +++ b/src/main/views/viewManager.ts @@ -233,7 +233,7 @@ export class ViewManager { */ private loadServer = (server: TeamWithTabs) => { - const srv = new MattermostServer(server.name, server.url); + const srv = new MattermostServer(server); const serverInfo = new ServerInfo(srv); server.tabs.forEach((tab) => this.loadView(srv, serverInfo, tab)); } @@ -422,7 +422,7 @@ export class ViewManager { map((t): [TeamWithTabs, Tab] => [x, t])); for (const [team, tab] of sortedTabs) { - const srv = new MattermostServer(team.name, team.url); + 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); @@ -599,8 +599,7 @@ export class ViewManager { if (!server) { return undefined; } - - const mmServer = new MattermostServer(server.name, server.url); + const mmServer = new MattermostServer(server); let selectedTab = this.getServerView(mmServer, TAB_MESSAGING); server.tabs. filter((tab) => tab.name !== TAB_MESSAGING). diff --git a/src/renderer/components/TabBar.tsx b/src/renderer/components/TabBar.tsx index 5234e713..fcb6ecdb 100644 --- a/src/renderer/components/TabBar.tsx +++ b/src/renderer/components/TabBar.tsx @@ -8,7 +8,7 @@ import {DragDropContext, Draggable, DraggingStyle, Droppable, DropResult, NotDra import {FormattedMessage, injectIntl, IntlShape} from 'react-intl'; import classNames from 'classnames'; -import {Tab} from 'types/config'; +import {ConfigTab} from 'types/config'; import {getTabViewName, TabType, canCloseTab, getTabDisplayName} from 'common/tabs/TabView'; @@ -19,7 +19,7 @@ type Props = { isDarkMode: boolean; onSelect: (name: string, index: number) => void; onCloseTab: (name: string) => void; - tabs: Tab[]; + tabs: ConfigTab[]; sessionsExpired: Record; unreadCounts: Record; mentionCounts: Record; diff --git a/src/renderer/dropdown.tsx b/src/renderer/dropdown.tsx index 9b1edd4d..13a7a4b5 100644 --- a/src/renderer/dropdown.tsx +++ b/src/renderer/dropdown.tsx @@ -7,7 +7,7 @@ import {FormattedMessage} from 'react-intl'; import classNames from 'classnames'; import {DragDropContext, Draggable, DraggingStyle, Droppable, DropResult, NotDraggingStyle} from 'react-beautiful-dnd'; -import {Team, TeamWithTabs, TeamWithTabsAndGpo} from 'types/config'; +import {FullTeam, TeamWithTabs, TeamWithTabsAndGpo} from 'types/config'; import {getTabViewName} from 'common/tabs/TabView'; import {TAB_BAR_HEIGHT, THREE_DOT_MENU_WIDTH_MAC} from 'common/utils/constants'; @@ -83,7 +83,7 @@ class TeamDropdown extends React.PureComponent, State> { }); } - selectServer = (team: Team) => { + selectServer = (team: FullTeam) => { return () => { window.desktop.serverDropdown.switchServer(team.name); this.closeMenu(); @@ -106,7 +106,7 @@ class TeamDropdown extends React.PureComponent, State> { this.closeMenu(); } - isActiveTeam = (team: Team) => { + isActiveTeam = (team: FullTeam) => { return team.name === this.state.activeTeam; } @@ -139,7 +139,7 @@ class TeamDropdown extends React.PureComponent, State> { tabOrder.forEach((t, order) => { teams[t.index].order = order; }); - this.setState({teams, orderedTeams: teams.concat().sort((a: Team, b: Team) => a.order - b.order), isAnyDragging: false}); + this.setState({teams, orderedTeams: teams.concat().sort((a: FullTeam, b: FullTeam) => a.order - b.order), isAnyDragging: false}); window.desktop.updateTeams(teams); } diff --git a/src/types/config.ts b/src/types/config.ts index 34225dc0..574f01c9 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -3,26 +3,36 @@ export type Tab = { name: string; - order: number; isOpen?: boolean; } export type Team = { name: string; - order: number; url: string; +} + +export type FullTeam = Team & { + order: number; lastActiveTab?: number; } -export type TeamWithIndex = Team & {index: number}; -export type TeamWithTabs = Team & {tabs: Tab[]}; +export type ConfigTab = Tab & { + order: number; +} + +export type ConfigServer = FullTeam & { + tabs: ConfigTab[]; +} + +export type TeamWithIndex = FullTeam & {index: number}; +export type TeamWithTabs = ConfigServer & {tabs: Tab[]}; export type TeamWithTabsAndGpo = TeamWithTabs & {isGpo?: boolean}; export type Config = ConfigV3; export type ConfigV3 = { version: 3; - teams: TeamWithTabs[]; + teams: ConfigServer[]; showTrayIcon: boolean; trayIconTheme: string; minimizeToTray: boolean; @@ -101,7 +111,7 @@ export type ConfigV0 = {version: 0; url: string}; export type AnyConfig = ConfigV3 | ConfigV2 | ConfigV1 | ConfigV0; export type BuildConfig = { - defaultTeams?: Team[]; + defaultTeams?: FullTeam[]; helpLink: string; enableServerManagement: boolean; enableAutoUpdater: boolean; @@ -110,7 +120,7 @@ export type BuildConfig = { } export type RegistryConfig = { - teams: Team[]; + teams: FullTeam[]; enableServerManagement: boolean; enableAutoUpdater: boolean; }