From d3599fc50043284241a813c4454f8e66d89c66f4 Mon Sep 17 00:00:00 2001 From: Devin Binnie <52460000+devinbinnie@users.noreply.github.com> Date: Tue, 20 Jul 2021 09:05:53 -0400 Subject: [PATCH] [MM-36431] Logic to support multiple configurable tabs per server (#1655) * Updated config, added types and classes for messaging tab * Working app with tabs and servers * Remainder of logic * Make base tab abstract class * Account for new app case * Merge'd * PR feedback --- src/common/communication.ts | 5 +- src/common/config/defaultPreferences.ts | 6 +- src/common/config/index.ts | 22 ++- src/common/config/pastDefaultPreferences.ts | 25 ++- src/common/config/upgradePreferences.ts | 23 ++- .../servers}/MattermostServer.ts | 0 src/common/tabs/BaseTabView.ts | 23 +++ src/common/tabs/FocalboardTabView.ts | 15 ++ src/common/tabs/MessagingTabView.ts | 15 ++ src/common/tabs/PlaybooksTabView.ts | 15 ++ src/common/tabs/TabView.ts | 61 +++++++ src/common/utils/url.ts | 58 ++++--- src/main/Validator.ts | 71 +++++++- src/main/authManager.ts | 2 +- src/main/main.ts | 12 +- src/main/menus/app.ts | 6 +- src/main/preload/dropdown.js | 4 +- src/main/preload/mattermost.js | 20 +-- src/main/views/MattermostView.ts | 64 +++---- src/main/views/teamDropdownView.ts | 25 ++- src/main/views/viewManager.ts | 85 ++++++---- src/main/views/webContentEvents.ts | 16 +- src/main/windows/windowManager.ts | 69 +++++++- src/renderer/components/MainPage.tsx | 160 ++++++++---------- src/renderer/components/SettingsPage.tsx | 3 +- src/renderer/components/TabBar.tsx | 96 +++-------- src/renderer/dropdown.tsx | 41 +++-- src/renderer/index.tsx | 11 +- src/types/config.ts | 43 ++++- src/types/utils.ts | 1 - 30 files changed, 636 insertions(+), 361 deletions(-) rename src/{main => common/servers}/MattermostServer.ts (100%) create mode 100644 src/common/tabs/BaseTabView.ts create mode 100644 src/common/tabs/FocalboardTabView.ts create mode 100644 src/common/tabs/MessagingTabView.ts create mode 100644 src/common/tabs/PlaybooksTabView.ts create mode 100644 src/common/tabs/TabView.ts diff --git a/src/common/communication.ts b/src/common/communication.ts index 48e15e90..e4e8fd8c 100644 --- a/src/common/communication.ts +++ b/src/common/communication.ts @@ -2,7 +2,8 @@ // See LICENSE.txt for license information. export const SWITCH_SERVER = 'switch-server'; -export const SET_SERVER_KEY = 'set-server-key'; +export const SWITCH_TAB = 'switch-tab'; +export const SET_ACTIVE_VIEW = 'set-active-view'; export const MARK_READ = 'mark-read'; export const FOCUS_BROWSERVIEW = 'focus-browserview'; export const ZOOM = 'zoom'; @@ -62,7 +63,7 @@ export const SESSION_EXPIRED = 'session_expired'; export const UPDATE_TRAY = 'update_tray'; export const UPDATE_BADGE = 'update_badge'; -export const SET_SERVER_NAME = 'set-server-name'; +export const SET_VIEW_NAME = 'set-view-name'; export const REACT_APP_INITIALIZED = 'react-app-initialized'; export const TOGGLE_BACK_BUTTON = 'toggle-back-button'; diff --git a/src/common/config/defaultPreferences.ts b/src/common/config/defaultPreferences.ts index b963fdb1..79dd76c5 100644 --- a/src/common/config/defaultPreferences.ts +++ b/src/common/config/defaultPreferences.ts @@ -10,14 +10,14 @@ import os from 'os'; * @param {number} version - Scheme version. (Not application version) */ -import {ConfigV2} from 'types/config'; +import {ConfigV3} from 'types/config'; export const getDefaultDownloadLocation = (): string => { return path.join(os.homedir(), 'Downloads'); }; -const defaultPreferences: ConfigV2 = { - version: 2, +const defaultPreferences: ConfigV3 = { + version: 3, teams: [], showTrayIcon: true, trayIconTheme: 'light', diff --git a/src/common/config/index.ts b/src/common/config/index.ts index f3f3032f..6182b18d 100644 --- a/src/common/config/index.ts +++ b/src/common/config/index.ts @@ -16,12 +16,13 @@ import { Config as ConfigType, LocalConfiguration, RegistryConfig as RegistryConfigType, - Team, + TeamWithTabs, } from 'types/config'; import {UPDATE_TEAMS, GET_CONFIGURATION, UPDATE_CONFIGURATION, GET_LOCAL_CONFIGURATION} from 'common/communication'; -import * as Validator from '../../main/Validator'; +import * as Validator from 'main/Validator'; +import {getDefaultTeamWithTabsFromTeam} from 'common/tabs/TabView'; import defaultPreferences, {getDefaultDownloadLocation} from './defaultPreferences'; import upgradeConfigData from './upgradePreferences'; @@ -263,6 +264,9 @@ export default class Config extends EventEmitter { // validate based on config file version switch (configData.version) { + case 3: + configData = Validator.validateV3ConfigData(configData)!; + break; case 2: configData = Validator.validateV2ConfigData(configData)!; break; @@ -317,16 +321,16 @@ export default class Config extends EventEmitter { delete this.combinedData!.defaultTeams; // IMPORTANT: properly combine teams from all sources - let combinedTeams = []; + let combinedTeams: TeamWithTabs[] = []; // - start by adding default teams from buildConfig, if any if (this.buildConfigData?.defaultTeams?.length) { - combinedTeams.push(...this.buildConfigData.defaultTeams); + combinedTeams.push(...this.buildConfigData.defaultTeams.map((team) => getDefaultTeamWithTabsFromTeam(team))); } // - add registry defined teams, if any if (this.registryConfigData?.teams?.length) { - combinedTeams.push(...this.registryConfigData.teams); + combinedTeams.push(...this.registryConfigData.teams.map((team) => getDefaultTeamWithTabsFromTeam(team))); } // - add locally defined teams only if server management is enabled @@ -352,7 +356,7 @@ export default class Config extends EventEmitter { * * @param {array} teams array of teams to check for duplicates */ - filterOutDuplicateTeams = (teams: Team[]) => { + filterOutDuplicateTeams = (teams: TeamWithTabs[]) => { let newTeams = teams; const uniqueURLs = new Set(); newTeams = newTeams.filter((team) => { @@ -365,7 +369,7 @@ export default class Config extends EventEmitter { * Returns the provided array fo teams with existing teams filtered out * @param {array} teams array of teams to check for already defined teams */ - filterOutPredefinedTeams = (teams: Team[]) => { + filterOutPredefinedTeams = (teams: TeamWithTabs[]) => { let newTeams = teams; // filter out predefined teams @@ -380,7 +384,7 @@ export default class Config extends EventEmitter { * Apply a default sort order to the team list, if no order is specified. * @param {array} teams to sort */ - sortUnorderedTeams = (teams: Team[]) => { + sortUnorderedTeams = (teams: TeamWithTabs[]) => { // We want to preserve the array order of teams in the config, otherwise a lot of bugs will occur const mappedTeams = teams.map((team, index) => ({team, originalOrder: index})); @@ -470,7 +474,7 @@ export default class Config extends EventEmitter { return config; } - handleUpdateTeams = (event: Electron.IpcMainInvokeEvent, newTeams: Team[]) => { + handleUpdateTeams = (event: Electron.IpcMainInvokeEvent, newTeams: TeamWithTabs[]) => { this.set('teams', newTeams); return this.combinedData!.teams; } diff --git a/src/common/config/pastDefaultPreferences.ts b/src/common/config/pastDefaultPreferences.ts index 8028d75d..8afa77a7 100644 --- a/src/common/config/pastDefaultPreferences.ts +++ b/src/common/config/pastDefaultPreferences.ts @@ -1,9 +1,9 @@ // Copyright (c) 2015-2016 Yuya Ochiai // Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {ConfigV0, ConfigV1} from 'types/config'; +import {ConfigV0, ConfigV1, ConfigV2} from 'types/config'; -import defaultPreferences from './defaultPreferences'; +import defaultPreferences, {getDefaultDownloadLocation} from './defaultPreferences'; const pastDefaultPreferences = { 0: { @@ -26,7 +26,26 @@ const pastDefaultPreferences = { autostart: true, spellCheckerLocale: 'en-US', } as ConfigV1, - 2: defaultPreferences, + 2: { + version: 2, + teams: [], + showTrayIcon: true, + trayIconTheme: 'light', + minimizeToTray: true, + notifications: { + flashWindow: 2, + bounceIcon: true, + bounceIconType: 'informational', + }, + showUnreadBadge: true, + useSpellChecker: true, + enableHardwareAcceleration: true, + autostart: true, + spellCheckerLocale: 'en-US', + darkMode: false, + downloadLocation: getDefaultDownloadLocation(), + } as ConfigV2, + 3: defaultPreferences, }; export default pastDefaultPreferences; diff --git a/src/common/config/upgradePreferences.ts b/src/common/config/upgradePreferences.ts index 2bc14cda..92f1b6aa 100644 --- a/src/common/config/upgradePreferences.ts +++ b/src/common/config/upgradePreferences.ts @@ -1,7 +1,10 @@ // Copyright (c) 2015-2016 Yuya Ochiai // Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {ConfigV2, ConfigV1, ConfigV0, AnyConfig} from 'types/config'; + +import {ConfigV3, ConfigV2, ConfigV1, ConfigV0, AnyConfig} from 'types/config'; + +import {getDefaultTeamWithTabsFromTeam} from 'common/tabs/TabView'; import pastDefaultPreferences from './pastDefaultPreferences'; @@ -30,10 +33,24 @@ function upgradeV1toV2(configV1: ConfigV1) { return config; } -export default function upgradeToLatest(config: AnyConfig): ConfigV2 { +function upgradeV2toV3(configV2: ConfigV2) { + const config: ConfigV3 = Object.assign({}, deepCopy(pastDefaultPreferences[3]), configV2); + config.version = 3; + config.teams = configV2.teams.map((value) => { + return { + ...getDefaultTeamWithTabsFromTeam(value), + lastActiveTab: 0, + }; + }); + return config; +} + +export default function upgradeToLatest(config: AnyConfig): ConfigV3 { switch (config.version) { + case 3: + return config as ConfigV3; case 2: - return config as ConfigV2; + return upgradeToLatest(upgradeV2toV3(config as ConfigV2)); case 1: return upgradeToLatest(upgradeV1toV2(config as ConfigV1)); default: diff --git a/src/main/MattermostServer.ts b/src/common/servers/MattermostServer.ts similarity index 100% rename from src/main/MattermostServer.ts rename to src/common/servers/MattermostServer.ts diff --git a/src/common/tabs/BaseTabView.ts b/src/common/tabs/BaseTabView.ts new file mode 100644 index 00000000..34ae4eeb --- /dev/null +++ b/src/common/tabs/BaseTabView.ts @@ -0,0 +1,23 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {MattermostServer} from 'common/servers/MattermostServer'; + +import {getTabViewName, TabType, TabView} from './TabView'; + +export default abstract class BaseTabView implements TabView { + server: MattermostServer; + + constructor(server: MattermostServer) { + this.server = server; + } + get name(): string { + return getTabViewName(this.server.name, this.type); + } + get url(): URL { + throw new Error('Not implemented'); + } + get type(): TabType { + throw new Error('Not implemented'); + } +} diff --git a/src/common/tabs/FocalboardTabView.ts b/src/common/tabs/FocalboardTabView.ts new file mode 100644 index 00000000..7c526325 --- /dev/null +++ b/src/common/tabs/FocalboardTabView.ts @@ -0,0 +1,15 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import BaseTabView from './BaseTabView'; +import {TabType, TAB_FOCALBOARD} from './TabView'; + +export default class FocalboardTabView extends BaseTabView { + get url(): URL { + return this.server.url; + } + + get type(): TabType { + return TAB_FOCALBOARD; + } +} diff --git a/src/common/tabs/MessagingTabView.ts b/src/common/tabs/MessagingTabView.ts new file mode 100644 index 00000000..67146dc5 --- /dev/null +++ b/src/common/tabs/MessagingTabView.ts @@ -0,0 +1,15 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import BaseTabView from './BaseTabView'; +import {TabType, TAB_MESSAGING} from './TabView'; + +export default class MessagingTabView extends BaseTabView { + get url(): URL { + return this.server.url; + } + + get type(): TabType { + return TAB_MESSAGING; + } +} diff --git a/src/common/tabs/PlaybooksTabView.ts b/src/common/tabs/PlaybooksTabView.ts new file mode 100644 index 00000000..72b1323c --- /dev/null +++ b/src/common/tabs/PlaybooksTabView.ts @@ -0,0 +1,15 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import BaseTabView from './BaseTabView'; +import {TabType, TAB_PLAYBOOKS} from './TabView'; + +export default class PlaybooksTabView extends BaseTabView { + get url(): URL { + return this.server.url; + } + + get type(): TabType { + return TAB_PLAYBOOKS; + } +} diff --git a/src/common/tabs/TabView.ts b/src/common/tabs/TabView.ts new file mode 100644 index 00000000..05d90640 --- /dev/null +++ b/src/common/tabs/TabView.ts @@ -0,0 +1,61 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {Tab, Team} from 'types/config'; + +import {MattermostServer} from 'common/servers/MattermostServer'; + +import MessagingTabView from './MessagingTabView'; +import FocalboardTabView from './FocalboardTabView'; +import PlaybooksTabView from './PlaybooksTabView'; + +export const TAB_MESSAGING = 'TAB_MESSAGING'; +export const TAB_FOCALBOARD = 'TAB_FOCALBOARD'; +export const TAB_PLAYBOOKS = 'TAB_PLAYBOOKS'; +export type TabType = typeof TAB_MESSAGING | typeof TAB_FOCALBOARD | typeof TAB_PLAYBOOKS; + +export interface TabView { + server: MattermostServer; + + get name(): string; + get type(): TabType; + get url(): URL; +} + +export function getDefaultTeamWithTabsFromTeam(team: Team) { + return { + ...team, + tabs: [ + { + name: TAB_MESSAGING, + order: 0, + }, + { + name: TAB_FOCALBOARD, + order: 1, + }, + { + name: TAB_PLAYBOOKS, + order: 2, + }, + ], + }; +} + +// TODO: Might need to move this out +export function getServerView(srv: MattermostServer, tab: Tab) { + switch (tab.name) { + 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'); + } +} + +export function getTabViewName(serverName: string, tabType: string) { + return `${serverName}___${tabType}`; +} diff --git a/src/common/utils/url.ts b/src/common/utils/url.ts index c176bc22..5e53b42d 100644 --- a/src/common/utils/url.ts +++ b/src/common/utils/url.ts @@ -1,11 +1,14 @@ // Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {Team} from 'types/config'; -import {ServerFromURL} from 'types/utils'; import {isHttpsUri, isHttpUri, isUri} from 'valid-url'; +import {TeamWithTabs} from 'types/config'; +import {ServerFromURL} from 'types/utils'; + import buildConfig from '../config/buildConfig'; +import {MattermostServer} from '../servers/MattermostServer'; +import {getServerView} from '../tabs/TabView'; // supported custom login paths (oath, saml) const customLoginRegexPaths = [ @@ -156,32 +159,35 @@ function isManagedResource(serverUrl: URL | string, inputURL: URL | string) { managedResources.some((managedResource) => (parsedURL.pathname.toLowerCase().startsWith(`${server.subpath}${managedResource}/`) || parsedURL.pathname.toLowerCase().startsWith(`/${managedResource}/`)))); } -function getServer(inputURL: URL | string, teams: Team[], ignoreScheme = false): ServerFromURL | undefined { +function getView(inputURL: URL | string, teams: TeamWithTabs[], ignoreScheme = false): ServerFromURL | undefined { const parsedURL = parseURL(inputURL); if (!parsedURL) { return undefined; } let parsedServerUrl; + let firstOption; let secondOption; - for (let i = 0; i < teams.length; i++) { - parsedServerUrl = parseURL(teams[i].url); - if (!parsedServerUrl) { - continue; - } - - // check server and subpath matches (without subpath pathname is \ so it always matches) - if (equalUrlsWithSubpath(parsedServerUrl, parsedURL, ignoreScheme)) { - return {name: teams[i].name, url: parsedServerUrl, index: i}; - } - if (equalUrlsIgnoringSubpath(parsedServerUrl, parsedURL, ignoreScheme)) { - // in case the user added something on the path that doesn't really belong to the server - // there might be more than one that matches, but we can't differentiate, so last one - // is as good as any other in case there is no better match (e.g.: two subpath servers with the same origin) - // e.g.: https://community.mattermost.com/core - secondOption = {name: teams[i].name, url: parsedServerUrl, index: i}; - } - } - return secondOption; + teams.forEach((team) => { + const srv = new MattermostServer(team.name, team.url); + team.tabs.forEach((tab) => { + const tabView = getServerView(srv, tab); + parsedServerUrl = parseURL(tabView.url); + if (parsedServerUrl) { + // check server and subpath matches (without subpath pathname is \ so it always matches) + if (equalUrlsWithSubpath(parsedServerUrl, parsedURL, ignoreScheme)) { + firstOption = {name: tabView.name, url: parsedServerUrl}; + } + if (equalUrlsIgnoringSubpath(parsedServerUrl, parsedURL, ignoreScheme)) { + // in case the user added something on the path that doesn't really belong to the server + // there might be more than one that matches, but we can't differentiate, so last one + // is as good as any other in case there is no better match (e.g.: two subpath servers with the same origin) + // e.g.: https://community.mattermost.com/core + secondOption = {name: tabView.name, url: parsedServerUrl}; + } + } + }); + }); + return firstOption || secondOption; } // next two functions are defined to clarify intent @@ -199,15 +205,15 @@ function equalUrlsIgnoringSubpath(url1: URL, url2: URL, ignoreScheme?: boolean) return url1.origin.toLowerCase() === url2.origin.toLowerCase(); } -function isTrustedURL(url: URL | string, teams: Team[]) { +function isTrustedURL(url: URL | string, teams: TeamWithTabs[]) { const parsedURL = parseURL(url); if (!parsedURL) { return false; } - return getServer(parsedURL, teams) !== null; + return getView(parsedURL, teams) !== null; } -function isCustomLoginURL(url: URL | string, server: ServerFromURL, teams: Team[]): boolean { +function isCustomLoginURL(url: URL | string, server: ServerFromURL, teams: TeamWithTabs[]): boolean { const subpath = server ? server.url.pathname : ''; const parsedURL = parseURL(url); if (!parsedURL) { @@ -242,7 +248,7 @@ export default { isValidURI, isInternalURL, parseURL, - getServer, + getView, getServerInfo, isAdminUrl, isTeamUrl, diff --git a/src/main/Validator.ts b/src/main/Validator.ts index 05fabad3..9d5c76cd 100644 --- a/src/main/Validator.ts +++ b/src/main/Validator.ts @@ -5,7 +5,7 @@ import log from 'electron-log'; import Joi from '@hapi/joi'; import {Args} from 'types/args'; -import {ConfigV0, ConfigV1, ConfigV2} from 'types/config'; +import {ConfigV0, ConfigV1, ConfigV2, ConfigV3} from 'types/config'; import {SavedWindowState} from 'types/mainWindow'; import {AppState} from 'types/appState'; import {ComparableCertificate} from 'types/certificate'; @@ -92,6 +92,35 @@ const configDataSchemaV2 = Joi.object({ downloadLocation: Joi.string(), }); +const configDataSchemaV3 = Joi.object({ + version: Joi.number().min(2).default(2), + teams: Joi.array().items(Joi.object({ + name: Joi.string().required(), + url: Joi.string().required(), + order: Joi.number().integer().min(0), + lastActiveTab: Joi.number().integer().min(0).default(0), + tabs: Joi.array().items(Joi.object({ + name: Joi.string().required(), + order: Joi.number().integer().min(0), + })).default([]), + })).default([]), + showTrayIcon: Joi.boolean().default(false), + trayIconTheme: Joi.any().allow('').valid('light', 'dark').default('light'), + minimizeToTray: Joi.boolean().default(false), + notifications: Joi.object({ + flashWindow: Joi.any().valid(0, 2).default(0), + bounceIcon: Joi.boolean().default(false), + bounceIconType: Joi.any().allow('').valid('informational', 'critical').default('informational'), + }), + showUnreadBadge: Joi.boolean().default(true), + useSpellChecker: Joi.boolean().default(true), + enableHardwareAcceleration: Joi.boolean().default(true), + autostart: Joi.boolean().default(true), + spellCheckerLocale: Joi.string().regex(/^[a-z]{2}-[A-Z]{2}$/).default('en-US'), + darkMode: Joi.boolean().default(false), + downloadLocation: Joi.string(), +}); + // eg. data['community.mattermost.com'] = { data: 'certificate data', issuerName: 'COMODO RSA Domain Validation Secure Server CA'}; const certificateStoreSchema = Joi.object().pattern( Joi.string().uri(), @@ -155,15 +184,22 @@ export function validateV1ConfigData(data: ConfigV1) { return validateAgainstSchema(data, configDataSchemaV1); } +function cleanURL(url: string): string { + let updatedURL = url; + if (updatedURL.includes('\\')) { + updatedURL = updatedURL.toLowerCase().replace(/\\/gi, '/'); + } + return updatedURL; +} + export function validateV2ConfigData(data: ConfigV2) { if (Array.isArray(data.teams) && data.teams.length) { - // first replace possible backslashes with forward slashes - let teams = data.teams.map(({name, url, order}) => { - let updatedURL = url; - if (updatedURL.includes('\\')) { - updatedURL = updatedURL.toLowerCase().replace(/\\/gi, '/'); - } - return {name, url: updatedURL, order}; + // first replace possible backslashes with forward slashes + let teams = data.teams.map((team) => { + return { + ...team, + url: cleanURL(team.url), + }; }); // next filter out urls that are still invalid so all is not lost @@ -175,6 +211,25 @@ export function validateV2ConfigData(data: ConfigV2) { return validateAgainstSchema(data, configDataSchemaV2); } +export function validateV3ConfigData(data: ConfigV3) { + if (Array.isArray(data.teams) && data.teams.length) { + // first replace possible backslashes with forward slashes + let teams = data.teams.map((team) => { + return { + ...team, + url: cleanURL(team.url), + }; + }); + + // next filter out urls that are still invalid so all is not lost + teams = teams.filter(({url}) => urlUtils.isValidURL(url)); + + // replace original teams + data.teams = teams; + } + return validateAgainstSchema(data, configDataSchemaV3); +} + // validate certificate.json export function validateCertificateStore(data: string | Record) { const jsonData = (typeof data === 'object' ? data : JSON.parse(data)); diff --git a/src/main/authManager.ts b/src/main/authManager.ts index e251a1b4..7291a36e 100644 --- a/src/main/authManager.ts +++ b/src/main/authManager.ts @@ -44,7 +44,7 @@ export class AuthManager { handleAppLogin = (event: Event, webContents: WebContents, request: AuthenticationResponseDetails, authInfo: AuthInfo, callback?: (username?: string, password?: string) => void) => { event.preventDefault(); const parsedURL = new URL(request.url); - const server = urlUtils.getServer(parsedURL, this.config.teams); + const server = urlUtils.getView(parsedURL, this.config.teams); if (!server) { return; } diff --git a/src/main/main.ts b/src/main/main.ts index 31a6aba7..f3acc6d6 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -36,9 +36,10 @@ import { RELOAD_CONFIGURATION, USER_ACTIVITY_UPDATE, EMIT_CONFIGURATION, + SWITCH_TAB, } from 'common/communication'; import Config from 'common/config'; - +import {getDefaultTeamWithTabsFromTeam} from 'common/tabs/TabView'; import Utils from 'common/utils/util'; import urlUtils from 'common/utils/url'; @@ -234,6 +235,7 @@ function initializeInterCommunicationEventListeners() { } ipcMain.on(SWITCH_SERVER, handleSwitchServer); + ipcMain.on(SWITCH_TAB, handleSwitchTab); ipcMain.on(QUIT, handleQuit); @@ -468,6 +470,10 @@ function handleSwitchServer(event: IpcMainEvent, serverName: string) { WindowManager.switchServer(serverName); } +function handleSwitchTab(event: IpcMainEvent, serverName: string, tabName: string) { + WindowManager.switchTab(serverName, tabName); +} + function handleNewServerModal() { const html = getLocalURLString('newServer.html'); @@ -482,7 +488,7 @@ function handleNewServerModal() { modalPromise.then((data) => { const teams = config.teams; const order = teams.length; - teams.push({...data, order}); + teams.push(getDefaultTeamWithTabsFromTeam({...data, order})); config.set('teams', teams); }).catch((e) => { // e is undefined for user cancellation @@ -580,7 +586,7 @@ function initializeAfterAppReady() { item.on('done', (doneEvent, state) => { if (state === 'completed') { - displayDownloadCompleted(filename, item.savePath, urlUtils.getServer(webContents.getURL(), config.teams)!); + displayDownloadCompleted(filename, item.savePath, urlUtils.getView(webContents.getURL(), config.teams)!); } }); }); diff --git a/src/main/menus/app.ts b/src/main/menus/app.ts index a2206e59..f451ab84 100644 --- a/src/main/menus/app.ts +++ b/src/main/menus/app.ts @@ -5,7 +5,7 @@ import {app, Menu, MenuItemConstructorOptions, MenuItem, session, shell, WebContents, webContents} from 'electron'; -import {ADD_SERVER, SELECT_NEXT_TAB, SELECT_PREVIOUS_TAB} from 'common/communication'; +import {ADD_SERVER} from 'common/communication'; import Config from 'common/config'; import * as WindowManager from '../windows/windowManager'; @@ -217,14 +217,14 @@ function createTemplate(config: Config) { label: 'Select Next Server', accelerator: 'Ctrl+Tab', click() { - WindowManager.sendToRenderer(SELECT_NEXT_TAB); + WindowManager.selectNextTab(); }, enabled: (teams.length > 1), }, { label: 'Select Previous Server', accelerator: 'Ctrl+Shift+Tab', click() { - WindowManager.sendToRenderer(SELECT_PREVIOUS_TAB); + WindowManager.selectPreviousTab(); }, enabled: (teams.length > 1), }], diff --git a/src/main/preload/dropdown.js b/src/main/preload/dropdown.js index 154e04ca..6903ef04 100644 --- a/src/main/preload/dropdown.js +++ b/src/main/preload/dropdown.js @@ -44,6 +44,6 @@ window.addEventListener('message', async (event) => { } }); -ipcRenderer.on(UPDATE_TEAMS_DROPDOWN, (event, teams, activeTeam, darkMode, hasGPOTeams, expired, mentions, unreads) => { - window.postMessage({type: UPDATE_TEAMS_DROPDOWN, data: {teams, activeTeam, darkMode, hasGPOTeams, expired, mentions, unreads}}, window.location.href); +ipcRenderer.on(UPDATE_TEAMS_DROPDOWN, (event, teams, activeTeam, darkMode, enableServerManagement, hasGPOTeams, expired, mentions, unreads) => { + window.postMessage({type: UPDATE_TEAMS_DROPDOWN, data: {teams, activeTeam, darkMode, enableServerManagement, hasGPOTeams, expired, mentions, unreads}}, window.location.href); }); diff --git a/src/main/preload/mattermost.js b/src/main/preload/mattermost.js index 07013604..d86302c3 100644 --- a/src/main/preload/mattermost.js +++ b/src/main/preload/mattermost.js @@ -9,7 +9,7 @@ import {ipcRenderer, webFrame} from 'electron'; import log from 'electron-log'; -import {NOTIFY_MENTION, IS_UNREAD, UNREAD_RESULT, SESSION_EXPIRED, SET_SERVER_NAME, REACT_APP_INITIALIZED, USER_ACTIVITY_UPDATE, CLOSE_TEAMS_DROPDOWN} from 'common/communication'; +import {NOTIFY_MENTION, IS_UNREAD, UNREAD_RESULT, SESSION_EXPIRED, SET_VIEW_NAME, REACT_APP_INITIALIZED, USER_ACTIVITY_UPDATE, CLOSE_TEAMS_DROPDOWN} from 'common/communication'; const UNREAD_COUNT_INTERVAL = 1000; const CLEAR_CACHE_INTERVAL = 6 * 60 * 60 * 1000; // 6 hours @@ -19,7 +19,7 @@ Reflect.deleteProperty(global.Buffer); // http://electron.atom.io/docs/tutorial/ let appVersion; let appName; let sessionExpired; -let serverName; +let viewName; log.info('Initializing preload'); @@ -58,7 +58,7 @@ window.addEventListener('load', () => { return; } watchReactAppUntilInitialized(() => { - ipcRenderer.send(REACT_APP_INITIALIZED, serverName); + ipcRenderer.send(REACT_APP_INITIALIZED, viewName); }); }); @@ -146,12 +146,12 @@ const findUnread = (favicon) => { const result = document.getElementsByClassName(classPair); return result && result.length > 0; }); - ipcRenderer.send(UNREAD_RESULT, favicon, serverName, isUnread); + ipcRenderer.send(UNREAD_RESULT, favicon, viewName, isUnread); }; ipcRenderer.on(IS_UNREAD, (event, favicon, server) => { - if (typeof serverName === 'undefined') { - serverName = server; + if (typeof viewName === 'undefined') { + viewName = server; } if (isReactAppInitialized()) { findUnread(favicon); @@ -162,13 +162,13 @@ ipcRenderer.on(IS_UNREAD, (event, favicon, server) => { } }); -ipcRenderer.on(SET_SERVER_NAME, (_, name) => { - serverName = name; +ipcRenderer.on(SET_VIEW_NAME, (_, name) => { + viewName = name; }); function getUnreadCount() { // LHS not found => Log out => Count should be 0, but session may be expired. - if (typeof serverName !== 'undefined') { + if (typeof viewName !== 'undefined') { let isExpired; if (document.getElementById('sidebar-left') === null) { const extraParam = (new URLSearchParams(window.location.search)).get('extra'); @@ -178,7 +178,7 @@ function getUnreadCount() { } if (isExpired !== sessionExpired) { sessionExpired = isExpired; - ipcRenderer.send(SESSION_EXPIRED, sessionExpired, serverName); + ipcRenderer.send(SESSION_EXPIRED, sessionExpired, viewName); } } } diff --git a/src/main/views/MattermostView.ts b/src/main/views/MattermostView.ts index be1e9a81..93c81752 100644 --- a/src/main/views/MattermostView.ts +++ b/src/main/views/MattermostView.ts @@ -18,11 +18,11 @@ import { IS_UNREAD, UNREAD_RESULT, TOGGLE_BACK_BUTTON, - SET_SERVER_NAME, + SET_VIEW_NAME, LOADSCREEN_END, } from 'common/communication'; -import {MattermostServer} from 'main/MattermostServer'; +import {TabView} from 'common/tabs/TabView'; import ContextMenu from '../contextMenu'; import {getWindowBoundaries, getLocalPreload, composeUserAgent} from '../utils'; @@ -42,7 +42,7 @@ const ASTERISK_GROUP = 3; const MENTIONS_GROUP = 2; export class MattermostView extends EventEmitter { - server: MattermostServer; + tab: TabView; window: BrowserWindow; view: BrowserView; isVisible: boolean; @@ -67,9 +67,9 @@ export class MattermostView extends EventEmitter { retryLoad?: NodeJS.Timeout; maxRetries: number; - constructor(server: MattermostServer, win: BrowserWindow, options: BrowserViewConstructorOptions) { + constructor(tab: TabView, win: BrowserWindow, options: BrowserViewConstructorOptions) { super(); - this.server = server; + this.tab = tab; this.window = win; const preload = getLocalPreload('preload.js'); @@ -90,7 +90,7 @@ export class MattermostView extends EventEmitter { this.resetLoadingStatus(); this.faviconMemoize = new Map(); - log.info(`BrowserView created for server ${this.server.name}`); + log.info(`BrowserView created for server ${this.tab.name}`); this.isInitialized = false; this.hasBeenShown = false; @@ -107,7 +107,7 @@ export class MattermostView extends EventEmitter { // use the same name as the server // TODO: we'll need unique identifiers if we have multiple instances of the same server in different tabs (1:N relationships) get name() { - return this.server?.name; + return this.tab.name; } resetLoadingStatus = () => { @@ -119,7 +119,7 @@ export class MattermostView extends EventEmitter { } load = (someURL?: URL | string) => { - if (!this.server) { + if (!this.tab) { return; } @@ -130,12 +130,12 @@ export class MattermostView extends EventEmitter { loadURL = parsedURL.toString(); } else { log.error('Cannot parse provided url, using current server url', someURL); - loadURL = this.server.url.toString(); + loadURL = this.tab.url.toString(); } } else { - loadURL = this.server.url.toString(); + loadURL = this.tab.url.toString(); } - log.info(`[${Util.shorten(this.server.name)}] Loading ${loadURL}`); + log.info(`[${Util.shorten(this.tab.name)}] Loading ${loadURL}`); const loading = this.view.webContents.loadURL(loadURL, {userAgent: composeUserAgent()}); loading.then(this.loadSuccess(loadURL)).catch((err) => { this.loadRetry(loadURL, err); @@ -153,9 +153,9 @@ export class MattermostView extends EventEmitter { if (this.maxRetries-- > 0) { this.loadRetry(loadURL, err); } else { - WindowManager.sendToRenderer(LOAD_FAILED, this.server.name, err.toString(), loadURL.toString()); - this.emit(LOAD_FAILED, this.server.name, err.toString(), loadURL.toString()); - log.info(`[${Util.shorten(this.server.name)}] Couldn't stablish a connection with ${loadURL}: ${err}.`); + WindowManager.sendToRenderer(LOAD_FAILED, this.tab.name, err.toString(), loadURL.toString()); + this.emit(LOAD_FAILED, this.tab.name, err.toString(), loadURL.toString()); + log.info(`[${Util.shorten(this.tab.name)}] Couldn't stablish a connection with ${loadURL}: ${err}.`); this.status = Status.ERROR; } }); @@ -164,14 +164,14 @@ export class MattermostView extends EventEmitter { loadRetry = (loadURL: string, err: any) => { this.retryLoad = setTimeout(this.retry(loadURL), RELOAD_INTERVAL); - WindowManager.sendToRenderer(LOAD_RETRY, this.server.name, Date.now() + RELOAD_INTERVAL, err.toString(), loadURL.toString()); - log.info(`[${Util.shorten(this.server.name)}] failed loading ${loadURL}: ${err}, retrying in ${RELOAD_INTERVAL / SECOND} seconds`); + WindowManager.sendToRenderer(LOAD_RETRY, this.tab.name, Date.now() + RELOAD_INTERVAL, err.toString(), loadURL.toString()); + log.info(`[${Util.shorten(this.tab.name)}] failed loading ${loadURL}: ${err}, retrying in ${RELOAD_INTERVAL / SECOND} seconds`); } loadSuccess = (loadURL: string) => { return () => { - log.info(`[${Util.shorten(this.server.name)}] finished loading ${loadURL}`); - WindowManager.sendToRenderer(LOAD_SUCCESS, this.server.name); + log.info(`[${Util.shorten(this.tab.name)}] finished loading ${loadURL}`); + WindowManager.sendToRenderer(LOAD_SUCCESS, this.tab.name); this.maxRetries = MAX_SERVER_RETRIES; if (this.status === Status.LOADING) { ipcMain.on(UNREAD_RESULT, this.handleFaviconIsUnread); @@ -180,9 +180,9 @@ 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.server.name, loadURL); - this.view.webContents.send(SET_SERVER_NAME, this.server.name); - this.setBounds(getWindowBoundaries(this.window, !(urlUtils.isTeamUrl(this.server.url || '', this.view.webContents.getURL()) || urlUtils.isAdminUrl(this.server.url || '', this.view.webContents.getURL())))); + this.emit(LOAD_SUCCESS, this.tab.name, loadURL); + this.view.webContents.send(SET_VIEW_NAME, this.tab.name); + this.setBounds(getWindowBoundaries(this.window, !(urlUtils.isTeamUrl(this.tab.url || '', this.view.webContents.getURL()) || urlUtils.isAdminUrl(this.tab.url || '', this.view.webContents.getURL())))); }; } @@ -191,7 +191,7 @@ export class MattermostView extends EventEmitter { const request = typeof requestedVisibility === 'undefined' ? true : requestedVisibility; if (request && !this.isVisible) { this.window.addBrowserView(this.view); - this.setBounds(getWindowBoundaries(this.window, !(urlUtils.isTeamUrl(this.server.url || '', this.view.webContents.getURL()) || urlUtils.isAdminUrl(this.server.url || '', this.view.webContents.getURL())))); + this.setBounds(getWindowBoundaries(this.window, !(urlUtils.isTeamUrl(this.tab.url || '', this.view.webContents.getURL()) || urlUtils.isAdminUrl(this.tab.url || '', this.view.webContents.getURL())))); if (this.status === Status.READY) { this.focus(); } @@ -254,8 +254,8 @@ export class MattermostView extends EventEmitter { this.status = Status.READY; if (timedout) { - log.info(`${this.server.name} timeout expired will show the browserview`); - this.emit(LOADSCREEN_END, this.server.name); + log.info(`${this.tab.name} timeout expired will show the browserview`); + this.emit(LOADSCREEN_END, this.tab.name); } clearTimeout(this.removeLoading); delete this.removeLoading; @@ -291,7 +291,7 @@ export class MattermostView extends EventEmitter { } handleDidNavigate = (event: Event, url: string) => { - const isUrlTeamUrl = urlUtils.isTeamUrl(this.server.url || '', url) || urlUtils.isAdminUrl(this.server.url || '', url); + const isUrlTeamUrl = urlUtils.isTeamUrl(this.tab.url || '', url) || urlUtils.isAdminUrl(this.tab.url || '', url); if (isUrlTeamUrl) { this.setBounds(getWindowBoundaries(this.window)); WindowManager.sendToRenderer(TOGGLE_BACK_BUTTON, false); @@ -304,7 +304,7 @@ export class MattermostView extends EventEmitter { } handleUpdateTarget = (e: Event, url: string) => { - if (!url || !this.server.sameOrigin(url)) { + if (!url || !this.tab.server.sameOrigin(url)) { this.emit(UPDATE_TARGET_URL, url); } } @@ -331,7 +331,7 @@ export class MattermostView extends EventEmitter { } const mentions = (results && results.value && parseInt(results.value[MENTIONS_GROUP], 10)) || 0; - appState.updateMentions(this.server.name, mentions, unreads); + appState.updateMentions(this.tab.name, mentions, unreads); } handleFaviconUpdate = (e: Event, favicons: string[]) => { @@ -340,7 +340,7 @@ export class MattermostView extends EventEmitter { // if not, get related info from preload and store it for future changes this.currentFavicon = favicons[0]; if (this.faviconMemoize.has(favicons[0])) { - appState.updateUnreads(this.server.name, Boolean(this.faviconMemoize.get(favicons[0]))); + appState.updateUnreads(this.tab.name, Boolean(this.faviconMemoize.get(favicons[0]))); } else { this.findUnreadState(favicons[0]); } @@ -350,7 +350,7 @@ export class MattermostView extends EventEmitter { // if favicon is null, it will affect appState, but won't be memoized findUnreadState = (favicon: string | null) => { try { - this.view.webContents.send(IS_UNREAD, favicon, this.server.name); + this.view.webContents.send(IS_UNREAD, favicon, this.tab.name); } catch (err) { log.error(`There was an error trying to request the unread state: ${err}`); log.error(err.stack); @@ -359,13 +359,13 @@ export class MattermostView extends EventEmitter { // if favicon is null, it means it is the initial load, // so don't memoize as we don't have the favicons and there is no rush to find out. - handleFaviconIsUnread = (e: Event, favicon: string, serverName: string, result: boolean) => { - if (this.server && serverName === this.server.name) { + handleFaviconIsUnread = (e: Event, favicon: string, viewName: string, result: boolean) => { + if (this.tab && viewName === this.tab.name) { if (favicon) { this.faviconMemoize.set(favicon, result); } if (!favicon || favicon === this.currentFavicon) { - appState.updateUnreads(serverName, result); + appState.updateUnreads(viewName, result); } } } diff --git a/src/main/views/teamDropdownView.ts b/src/main/views/teamDropdownView.ts index 7d344c31..44a5e780 100644 --- a/src/main/views/teamDropdownView.ts +++ b/src/main/views/teamDropdownView.ts @@ -2,7 +2,7 @@ // See LICENSE.txt for license information. import {BrowserView, BrowserWindow, ipcMain, IpcMainEvent} from 'electron'; -import {CombinedConfig, Team} from 'types/config'; +import {CombinedConfig, TeamWithTabs} from 'types/config'; import { CLOSE_TEAMS_DROPDOWN, @@ -12,7 +12,7 @@ import { UPDATE_DROPDOWN_MENTIONS, REQUEST_TEAMS_DROPDOWN_INFO, RECEIVE_DROPDOWN_MENU_SIZE, - SET_SERVER_KEY, + SET_ACTIVE_VIEW, } from 'common/communication'; import * as AppState from '../appState'; import {TAB_BAR_HEIGHT, THREE_DOT_MENU_WIDTH, THREE_DOT_MENU_WIDTH_MAC, MENU_SHADOW_WIDTH} from 'common/utils/constants'; @@ -22,19 +22,21 @@ import * as WindowManager from '../windows/windowManager'; export default class TeamDropdownView { view: BrowserView; bounds?: Electron.Rectangle; - teams: Team[]; + teams: TeamWithTabs[]; activeTeam?: string; darkMode: boolean; + enableServerManagement?: boolean; hasGPOTeams?: boolean; unreads?: Map; mentions?: Map; expired?: Map; window: BrowserWindow; - constructor(window: BrowserWindow, teams: Team[], darkMode: boolean) { + constructor(window: BrowserWindow, teams: TeamWithTabs[], darkMode: boolean, enableServerManagement: boolean) { this.teams = teams; this.window = window; this.darkMode = darkMode; + this.enableServerManagement = enableServerManagement; const preload = getLocalPreload('dropdown.js'); this.view = new BrowserView({webPreferences: { @@ -51,13 +53,14 @@ export default class TeamDropdownView { ipcMain.on(EMIT_CONFIGURATION, this.updateConfig); ipcMain.on(REQUEST_TEAMS_DROPDOWN_INFO, this.updateDropdown); ipcMain.on(RECEIVE_DROPDOWN_MENU_SIZE, this.handleReceivedMenuSize); - ipcMain.on(SET_SERVER_KEY, this.updateActiveTeam); + ipcMain.on(SET_ACTIVE_VIEW, this.updateActiveTeam); AppState.on(UPDATE_DROPDOWN_MENTIONS, this.updateMentions); } updateConfig = (event: IpcMainEvent, config: CombinedConfig) => { this.teams = config.teams; this.darkMode = config.darkMode; + this.enableServerManagement = config.enableServerManagement; this.hasGPOTeams = config.registryTeams && config.registryTeams.length > 0; this.updateDropdown(); } @@ -75,7 +78,17 @@ export default class TeamDropdownView { } updateDropdown = () => { - this.view.webContents.send(UPDATE_TEAMS_DROPDOWN, this.teams, this.activeTeam, this.darkMode, this.hasGPOTeams, this.expired, this.mentions, this.unreads); + this.view.webContents.send( + UPDATE_TEAMS_DROPDOWN, + this.teams, + this.activeTeam, + this.darkMode, + this.enableServerManagement, + this.hasGPOTeams, + this.expired, + this.mentions, + this.unreads, + ); } handleOpen = () => { diff --git a/src/main/views/viewManager.ts b/src/main/views/viewManager.ts index bf856b45..599fdd47 100644 --- a/src/main/views/viewManager.ts +++ b/src/main/views/viewManager.ts @@ -4,21 +4,23 @@ import log from 'electron-log'; import {BrowserView, BrowserWindow, dialog, ipcMain} from 'electron'; import {BrowserViewConstructorOptions} from 'electron/main'; -import {CombinedConfig, Team} from 'types/config'; +import {CombinedConfig, Tab, TeamWithTabs} from 'types/config'; import {SECOND} from 'common/utils/constants'; import { UPDATE_TARGET_URL, - SET_SERVER_KEY, LOAD_SUCCESS, LOAD_FAILED, TOGGLE_LOADING_SCREEN_VISIBILITY, GET_LOADING_SCREEN_DATA, LOADSCREEN_END, + SET_ACTIVE_VIEW, } from 'common/communication'; import urlUtils from 'common/utils/url'; -import {MattermostServer} from '../MattermostServer'; +import {getServerView, getTabViewName} from 'common/tabs/TabView'; + +import {MattermostServer} from '../../common/servers/MattermostServer'; import {getLocalURLString, getLocalPreload, getWindowBoundaries} from '../utils'; import {MattermostView} from './MattermostView'; @@ -29,7 +31,7 @@ const URL_VIEW_DURATION = 10 * SECOND; const URL_VIEW_HEIGHT = 36; export class ViewManager { - configServers: Team[]; + configServers: TeamWithTabs[]; viewOptions: BrowserViewConstructorOptions; views: Map; currentView?: string; @@ -53,10 +55,15 @@ export class ViewManager { return this.configServers; } - loadServer = (server: Team) => { + loadServer = (server: TeamWithTabs) => { const srv = new MattermostServer(server.name, server.url); - const view = new MattermostView(srv, this.mainWindow, this.viewOptions); - this.views.set(server.name, view); + server.tabs.forEach((tab) => this.loadView(srv, tab)); + } + + loadView = (srv: MattermostServer, tab: Tab) => { + const tabView = getServerView(srv, tab); + const view = new MattermostView(tabView, this.mainWindow, this.viewOptions); + this.views.set(tabView.name, view); if (!this.loadingScreen) { this.createLoadingScreen(); } @@ -71,23 +78,27 @@ export class ViewManager { this.configServers.forEach((server) => this.loadServer(server)); } - reloadConfiguration = (configServers: Team[]) => { + reloadConfiguration = (configServers: TeamWithTabs[]) => { this.configServers = configServers.concat(); const oldviews = this.views; this.views = new Map(); const sorted = this.configServers.sort((a, b) => a.order - b.order); let setFocus; sorted.forEach((server) => { - const recycle = oldviews.get(server.name); - if (recycle && recycle.isVisible) { - setFocus = recycle.name; - } - if (recycle && recycle.server.name === server.name && recycle.server.url.toString() === urlUtils.parseURL(server.url)!.toString()) { - oldviews.delete(recycle.name); - this.views.set(recycle.name, recycle); - } else { - this.loadServer(server); - } + const srv = new MattermostServer(server.name, server.url); + server.tabs.forEach((tab) => { + const tabView = getServerView(srv, tab); + const recycle = oldviews.get(tabView.name); + if (recycle && recycle.isVisible) { + setFocus = recycle.name; + } + if (recycle && recycle.tab.name === tabView.name && recycle.tab.url.toString() === urlUtils.parseURL(tabView.url)!.toString()) { + oldviews.delete(recycle.name); + this.views.set(recycle.name, recycle); + } else { + this.loadView(srv, tab); + } + }); }); oldviews.forEach((unused) => { unused.destroy(); @@ -103,7 +114,11 @@ export class ViewManager { if (this.configServers.length) { const element = this.configServers.find((e) => e.order === 0); if (element) { - this.showByName(element.name); + const tab = element.tabs.find((e) => e.order === 0); + if (tab) { + const tabView = getTabViewName(element.name, tab.name); + this.showByName(tabView); + } } } } @@ -125,13 +140,8 @@ export class ViewManager { if (newView.needsLoadingScreen()) { this.showLoadingScreen(); } - const serverInfo = this.configServers.find((candidate) => candidate.name === newView.server.name); - if (!serverInfo) { - log.error(`Couldn't find a server in the config with the name ${newView.server.name}`); - return; - } - newView.window.webContents.send(SET_SERVER_KEY, serverInfo.order); - ipcMain.emit(SET_SERVER_KEY, true, name); + newView.window.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); if (newView.isReady()) { // if view is not ready, the renderer will have something to display instead. newView.show(); @@ -326,18 +336,18 @@ export class ViewManager { } } - deeplinkSuccess = (serverName: string) => { - const view = this.views.get(serverName); + deeplinkSuccess = (viewName: string) => { + const view = this.views.get(viewName); if (!view) { return; } - this.showByName(serverName); + this.showByName(viewName); view.removeListener(LOAD_FAILED, this.deeplinkFailed); }; - deeplinkFailed = (serverName: string, err: string, url: string) => { - log.error(`[${serverName}] failed to load deeplink ${url}: ${err}`); - const view = this.views.get(serverName); + 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; } @@ -345,18 +355,19 @@ export class ViewManager { } handleDeepLink = (url: string | URL) => { + // TODO: fix for new tabs if (url) { const parsedURL = urlUtils.parseURL(url)!; - const server = urlUtils.getServer(parsedURL, this.configServers, true); - if (server) { - const view = this.views.get(server.name); + const tabView = urlUtils.getView(parsedURL, this.configServers, true); + if (tabView) { + const view = this.views.get(tabView.name); if (!view) { - log.error(`Couldn't find a view matching the name ${server.name}`); + log.error(`Couldn't find a view matching the name ${tabView.name}`); return; } // attempting to change parsedURL protocol results in it not being modified. - const urlWithSchema = `${view.server.url.origin}${parsedURL.pathname}${parsedURL.search}`; + const urlWithSchema = `${view.tab.url.origin}${parsedURL.pathname}${parsedURL.search}`; view.resetLoadingStatus(); view.load(urlWithSchema); view.once(LOAD_SUCCESS, this.deeplinkSuccess); diff --git a/src/main/views/webContentEvents.ts b/src/main/views/webContentEvents.ts index 80b9503b..d0ca4a13 100644 --- a/src/main/views/webContentEvents.ts +++ b/src/main/views/webContentEvents.ts @@ -4,7 +4,7 @@ import {BrowserWindow, shell, WebContents} from 'electron'; import log from 'electron-log'; -import {Team} from 'types/config'; +import {TeamWithTabs} from 'types/config'; import urlUtils from 'common/utils/url'; @@ -37,12 +37,12 @@ function isTrustedPopupWindow(webContents: WebContents) { const scheme = protocols && protocols[0] && protocols[0].schemes && protocols[0].schemes[0]; -const generateWillNavigate = (getServersFunction: () => Team[]) => { +const generateWillNavigate = (getServersFunction: () => TeamWithTabs[]) => { return (event: Event & {sender: WebContents}, url: string) => { const contentID = event.sender.id; const parsedURL = urlUtils.parseURL(url)!; const configServers = getServersFunction(); - const server = urlUtils.getServer(parsedURL, configServers); + const server = urlUtils.getView(parsedURL, configServers); if (server && (urlUtils.isTeamUrl(server.url, parsedURL) || urlUtils.isAdminUrl(server.url, parsedURL) || isTrustedPopupWindow(event.sender))) { return; @@ -63,12 +63,12 @@ const generateWillNavigate = (getServersFunction: () => Team[]) => { }; }; -const generateDidStartNavigation = (getServersFunction: () => Team[]) => { +const generateDidStartNavigation = (getServersFunction: () => TeamWithTabs[]) => { return (event: Event & {sender: WebContents}, url: string) => { const serverList = getServersFunction(); const contentID = event.sender.id; const parsedURL = urlUtils.parseURL(url)!; - const server = urlUtils.getServer(parsedURL, serverList); + const server = urlUtils.getView(parsedURL, serverList); if (!urlUtils.isTrustedURL(parsedURL, serverList)) { return; @@ -82,7 +82,7 @@ const generateDidStartNavigation = (getServersFunction: () => Team[]) => { }; }; -const generateNewWindowListener = (getServersFunction: () => Team[], spellcheck?: boolean) => { +const generateNewWindowListener = (getServersFunction: () => TeamWithTabs[], spellcheck?: boolean) => { return (event: Event, url: string) => { const parsedURL = urlUtils.parseURL(url); if (!parsedURL) { @@ -110,7 +110,7 @@ const generateNewWindowListener = (getServersFunction: () => Team[], spellcheck? return; } - const server = urlUtils.getServer(parsedURL, configServers); + const server = urlUtils.getView(parsedURL, configServers); if (!server) { shell.openExternal(url); @@ -193,7 +193,7 @@ export const removeWebContentsListeners = (id: number) => { } }; -export const addWebContentsEventListeners = (mmview: MattermostView, getServersFunction: () => Team[]) => { +export const addWebContentsEventListeners = (mmview: MattermostView, getServersFunction: () => TeamWithTabs[]) => { const contents = mmview.view.webContents; // initialize custom login tracking diff --git a/src/main/windows/windowManager.ts b/src/main/windows/windowManager.ts index 8ee331d7..90cdfa08 100644 --- a/src/main/windows/windowManager.ts +++ b/src/main/windows/windowManager.ts @@ -10,6 +10,8 @@ import {CombinedConfig} from 'types/config'; import {MAXIMIZE_CHANGE, HISTORY, GET_LOADING_SCREEN_DATA, REACT_APP_INITIALIZED, LOADING_SCREEN_ANIMATION_FINISHED, FOCUS_THREE_DOT_MENU, GET_DARK_MODE} from 'common/communication'; import urlUtils from 'common/utils/url'; +import {getTabViewName} from 'common/tabs/TabView'; + import {getAdjustedWindowBoundaries} from '../utils'; import {ViewManager} from '../views/viewManager'; @@ -113,7 +115,7 @@ export function showMainWindow(deeplinkingURL?: string | URL) { status.viewManager.updateMainWindow(status.mainWindow); } - status.teamDropdown = new TeamDropdownView(status.mainWindow, status.config.teams, status.config.darkMode); + status.teamDropdown = new TeamDropdownView(status.mainWindow, status.config.teams, status.config.darkMode, status.config.enableServerManagement); } initializeViewManager(); @@ -158,7 +160,7 @@ function handleResizeMainWindow() { const setBoundsFunction = () => { if (currentView) { - currentView.setBounds(getAdjustedWindowBoundaries(bounds.width!, bounds.height!, !urlUtils.isTeamUrl(currentView.server.url, currentView.view.webContents.getURL()))); + currentView.setBounds(getAdjustedWindowBoundaries(bounds.width!, bounds.height!, !urlUtils.isTeamUrl(currentView.tab.url, currentView.view.webContents.getURL()))); } }; @@ -339,7 +341,20 @@ function initializeViewManager() { export function switchServer(serverName: string) { showMainWindow(); - status.viewManager?.showByName(serverName); + const server = status.config?.teams.find((team) => team.name === serverName); + if (!server) { + log.error('Cannot find server in config'); + return; + } + const lastActiveTab = server.tabs[server.lastActiveTab || 0]; + const tabViewName = getTabViewName(serverName, lastActiveTab.name); + status.viewManager?.showByName(tabViewName); +} + +export function switchTab(serverName: string, tabName: string) { + showMainWindow(); + const tabViewName = getTabViewName(serverName, tabName); + status.viewManager?.showByName(tabViewName); } export function focusBrowserView() { @@ -369,9 +384,9 @@ function handleLoadingScreenDataRequest() { }; } -function handleReactAppInitialized(e: IpcMainEvent, server: string) { +function handleReactAppInitialized(e: IpcMainEvent, view: string) { if (status.viewManager) { - status.viewManager.setServerInitialized(server); + status.viewManager.setServerInitialized(view); } } @@ -437,12 +452,52 @@ export function handleHistory(event: IpcMainEvent, offset: number) { activeView.view.webContents.goToOffset(offset); } catch (error) { log.error(error); - activeView.load(activeView.server.url); + activeView.load(activeView.tab.url); } } } } + +export function selectNextTab() { + const currentView = status.viewManager?.getCurrentView(); + if (!currentView) { + return; + } + + const currentTeamTabs = status.config?.teams.find((team) => team.name === currentView.tab.server.name)?.tabs; + const currentTab = currentTeamTabs?.find((tab) => tab.name === currentView.tab.type); + if (!currentTeamTabs || !currentTab) { + return; + } + + const currentOrder = currentTab.order; + const nextOrder = ((currentOrder + 1) % currentTeamTabs.length); + const nextIndex = currentTeamTabs.findIndex((tab) => tab.order === nextOrder); + const newTab = currentTeamTabs[nextIndex]; + switchTab(currentView.tab.server.name, newTab.name); +} + +export function selectPreviousTab() { + const currentView = status.viewManager?.getCurrentView(); + if (!currentView) { + return; + } + + const currentTeamTabs = status.config?.teams.find((team) => team.name === currentView.tab.server.name)?.tabs; + const currentTab = currentTeamTabs?.find((tab) => tab.name === currentView.tab.type); + if (!currentTeamTabs || !currentTab) { + return; + } + + const currentOrder = currentTab.order; + + // js modulo operator returns a negative number if result is negative, so we have to ensure it's positive + const nextOrder = ((currentTeamTabs.length + (currentOrder - 1)) % currentTeamTabs.length); + const nextIndex = currentTeamTabs.findIndex((tab) => tab.order === nextOrder); + const newTab = currentTeamTabs[nextIndex]; + switchTab(currentView.tab.server.name, newTab.name); +} + function handleGetDarkMode() { return status.config?.darkMode; } - diff --git a/src/renderer/components/MainPage.tsx b/src/renderer/components/MainPage.tsx index 6b6ef598..05aaa74f 100644 --- a/src/renderer/components/MainPage.tsx +++ b/src/renderer/components/MainPage.tsx @@ -8,7 +8,9 @@ import {DropResult} from 'react-beautiful-dnd'; import DotsVerticalIcon from 'mdi-react/DotsVerticalIcon'; import {IpcRendererEvent} from 'electron/renderer'; -import {Team} from 'types/config'; +import {TeamWithTabs} from 'types/config'; + +import {getTabViewName} from 'common/tabs/TabView'; import { FOCUS_BROWSERVIEW, @@ -18,8 +20,6 @@ import { LOAD_RETRY, LOAD_SUCCESS, LOAD_FAILED, - SHOW_NEW_SERVER_MODAL, - SWITCH_SERVER, WINDOW_CLOSE, WINDOW_MINIMIZE, WINDOW_RESTORE, @@ -28,16 +28,14 @@ import { PLAY_SOUND, MODAL_OPEN, MODAL_CLOSE, - SET_SERVER_KEY, + SET_ACTIVE_VIEW, UPDATE_MENTIONS, TOGGLE_BACK_BUTTON, - SELECT_NEXT_TAB, - SELECT_PREVIOUS_TAB, - ADD_SERVER, FOCUS_THREE_DOT_MENU, GET_FULL_SCREEN_STATUS, CLOSE_TEAMS_DROPDOWN, OPEN_TEAMS_DROPDOWN, + SWITCH_TAB, } from 'common/communication'; import restoreButton from '../../assets/titlebar/chrome-restore.svg'; @@ -61,22 +59,21 @@ enum Status { } type Props = { - teams: Team[]; - showAddServerButton: boolean; - moveTabs: (originalOrder: number, newOrder: number) => number | undefined; + teams: TeamWithTabs[]; + moveTabs: (teamName: string, originalOrder: number, newOrder: number) => number | undefined; openMenu: () => void; darkMode: boolean; appName: string; }; type State = { - key: number; + activeServerName?: string; + activeTabName?: string; sessionsExpired: Record; unreadCounts: Record; mentionCounts: Record; - targetURL: string; maximized: boolean; - tabStatus: Map; + tabViewStatus: Map; darkMode: boolean; modalOpen?: boolean; fullScreen?: boolean; @@ -84,7 +81,7 @@ type State = { isMenuOpen: boolean; }; -type TabStatus = { +type TabViewStatus = { status: Status; extra?: { url: string; @@ -102,40 +99,39 @@ export default class MainPage extends React.PureComponent { this.topBar = React.createRef(); this.threeDotMenu = React.createRef(); + const firstServer = this.props.teams.find((team) => team.order === 0); + const firstTab = firstServer?.tabs.find((tab) => tab.order === (firstServer.lastActiveTab || 0)) || firstServer?.tabs[0]; + this.state = { - key: this.props.teams.length ? this.props.teams.findIndex((team) => team.order === 0) : 0, + activeServerName: firstServer?.name, + activeTabName: firstTab?.name, sessionsExpired: {}, unreadCounts: {}, mentionCounts: {}, - targetURL: '', maximized: false, - tabStatus: new Map(this.props.teams.map((server) => [server.name, {status: Status.LOADING}])), + tabViewStatus: new Map(this.props.teams.map((team) => team.tabs.map((tab) => getTabViewName(team.name, tab.name))).flat().map((tabViewName) => [tabViewName, {status: Status.LOADING}])), darkMode: this.props.darkMode, isMenuOpen: false, }; } - getTabStatus() { - if (this.props.teams.length) { - const tab = this.props.teams[this.state.key]; - if (tab) { - const tabname = tab.name; - return this.state.tabStatus.get(tabname); - } + getTabViewStatus() { + if (!this.state.activeServerName || !this.state.activeTabName) { + return undefined; } - return {status: Status.NOSERVERS}; + return this.state.tabViewStatus.get(getTabViewName(this.state.activeServerName, this.state.activeTabName)) ?? {status: Status.NOSERVERS}; } - updateTabStatus(server: string, newStatusValue: TabStatus) { - const status = new Map(this.state.tabStatus); - status.set(server, newStatusValue); - this.setState({tabStatus: status}); + updateTabStatus(tabViewName: string, newStatusValue: TabViewStatus) { + const status = new Map(this.state.tabViewStatus); + status.set(tabViewName, newStatusValue); + this.setState({tabViewStatus: status}); } componentDidMount() { // set page on retry - window.ipcRenderer.on(LOAD_RETRY, (_, server, retry, err, loadUrl) => { - console.log(`${server}: failed to load ${err}, but retrying`); + window.ipcRenderer.on(LOAD_RETRY, (_, viewName, retry, err, loadUrl) => { + console.log(`${viewName}: failed to load ${err}, but retrying`); const statusValue = { status: Status.RETRY, extra: { @@ -144,15 +140,15 @@ export default class MainPage extends React.PureComponent { url: loadUrl, }, }; - this.updateTabStatus(server, statusValue); + this.updateTabStatus(viewName, statusValue); }); - window.ipcRenderer.on(LOAD_SUCCESS, (_, server) => { - this.updateTabStatus(server, {status: Status.DONE}); + window.ipcRenderer.on(LOAD_SUCCESS, (_, viewName) => { + this.updateTabStatus(viewName, {status: Status.DONE}); }); - window.ipcRenderer.on(LOAD_FAILED, (_, server, err, loadUrl) => { - console.log(`${server}: failed to load ${err}`); + window.ipcRenderer.on(LOAD_FAILED, (_, viewName, err, loadUrl) => { + console.log(`${viewName}: failed to load ${err}`); const statusValue = { status: Status.FAILED, extra: { @@ -160,7 +156,7 @@ export default class MainPage extends React.PureComponent { url: loadUrl, }, }; - this.updateTabStatus(server, statusValue); + this.updateTabStatus(viewName, statusValue); }); window.ipcRenderer.on(DARK_MODE_CHANGE, (_, darkMode) => { @@ -168,26 +164,8 @@ export default class MainPage extends React.PureComponent { }); // can't switch tabs sequentially for some reason... - window.ipcRenderer.on(SET_SERVER_KEY, (event, key) => { - const nextIndex = this.props.teams.findIndex((team) => team.order === key); - this.handleSetServerKey(nextIndex); - }); - window.ipcRenderer.on(SELECT_NEXT_TAB, () => { - const currentOrder = this.props.teams[this.state.key].order; - const nextOrder = ((currentOrder + 1) % this.props.teams.length); - const nextIndex = this.props.teams.findIndex((team) => team.order === nextOrder); - const team = this.props.teams[nextIndex]; - this.handleSelect(team.name, nextIndex); - }); - - window.ipcRenderer.on(SELECT_PREVIOUS_TAB, () => { - const currentOrder = this.props.teams[this.state.key].order; - - // js modulo operator returns a negative number if result is negative, so we have to ensure it's positive - const nextOrder = ((this.props.teams.length + (currentOrder - 1)) % this.props.teams.length); - const nextIndex = this.props.teams.findIndex((team) => team.order === nextOrder); - const team = this.props.teams[nextIndex]; - this.handleSelect(team.name, nextIndex); + window.ipcRenderer.on(SET_ACTIVE_VIEW, (event, serverName, tabName) => { + this.setState({activeServerName: serverName, activeTabName: tabName}); }); window.ipcRenderer.on(MAXIMIZE_CHANGE, this.handleMaximizeState); @@ -197,10 +175,6 @@ export default class MainPage extends React.PureComponent { window.ipcRenderer.invoke(GET_FULL_SCREEN_STATUS).then((fullScreenStatus) => this.handleFullScreenState(fullScreenStatus)); - window.ipcRenderer.on(ADD_SERVER, () => { - this.addServer(); - }); - window.ipcRenderer.on(PLAY_SOUND, (_event, soundName) => { playSound(soundName); }); @@ -217,18 +191,17 @@ export default class MainPage extends React.PureComponent { this.setState({showExtraBar}); }); - window.ipcRenderer.on(UPDATE_MENTIONS, (_event, team, mentions, unreads, isExpired) => { - const key = this.props.teams.findIndex((server) => server.name === team); + window.ipcRenderer.on(UPDATE_MENTIONS, (_event, view, mentions, unreads, isExpired) => { const {unreadCounts, mentionCounts, sessionsExpired} = this.state; const newMentionCounts = {...mentionCounts}; - newMentionCounts[key] = mentions || 0; + newMentionCounts[view] = mentions || 0; const newUnreads = {...unreadCounts}; - newUnreads[key] = unreads || false; + newUnreads[view] = unreads || false; const expired = {...sessionsExpired}; - expired[key] = isExpired || false; + expired[view] = isExpired || false; this.setState({unreadCounts: newUnreads, mentionCounts: newMentionCounts, sessionsExpired: expired}); }); @@ -258,14 +231,8 @@ export default class MainPage extends React.PureComponent { this.setState({fullScreen: isFullScreen}); } - handleSetServerKey = (key: number) => { - const newKey = ((this.props.teams.length + key) % this.props.teams.length) || 0; - this.setState({key: newKey}); - } - - handleSelect = (name: string, key: number) => { - window.ipcRenderer.send(SWITCH_SERVER, name); - this.handleSetServerKey(key); + handleSelectTab = (name: string) => { + window.ipcRenderer.send(SWITCH_TAB, this.state.activeServerName, name); } handleDragAndDrop = async (dropResult: DropResult) => { @@ -274,12 +241,20 @@ export default class MainPage extends React.PureComponent { if (addedIndex === undefined || removedIndex === addedIndex) { return; } - const teamIndex = this.props.moveTabs(removedIndex, addedIndex < this.props.teams.length ? addedIndex : this.props.teams.length - 1); + if (!this.state.activeServerName) { + return; + } + const currentTabs = this.props.teams.find((team) => team.name === this.state.activeServerName)?.tabs; + if (!currentTabs) { + // TODO: figure out something here + return; + } + const teamIndex = this.props.moveTabs(this.state.activeServerName, removedIndex, addedIndex < currentTabs.length ? addedIndex : currentTabs.length - 1); if (!teamIndex) { return; } - const name = this.props.teams[teamIndex].name; - this.handleSelect(name, teamIndex); + const name = currentTabs[teamIndex].name; + this.handleSelectTab(name); } handleClose = (e: React.MouseEvent) => { @@ -312,17 +287,18 @@ export default class MainPage extends React.PureComponent { window.ipcRenderer.send(DOUBLE_CLICK_ON_WINDOW); } - addServer = () => { - window.ipcRenderer.send(SHOW_NEW_SERVER_MODAL); - } - focusOnWebView = () => { window.ipcRenderer.send(FOCUS_BROWSERVIEW); window.ipcRenderer.send(CLOSE_TEAMS_DROPDOWN); } render() { - if (!this.props.teams.length) { + if (!this.state.activeServerName || !this.state.activeTabName) { + return null; + } + const currentTabs = this.props.teams.find((team) => team.name === this.state.activeServerName)?.tabs; + if (!currentTabs) { + // TODO: figure out something here return null; } @@ -330,14 +306,13 @@ export default class MainPage extends React.PureComponent { @@ -424,7 +399,7 @@ export default class MainPage extends React.PureComponent { 0} isMenuOpen={this.state.isMenuOpen} @@ -439,11 +414,10 @@ export default class MainPage extends React.PureComponent { const views = () => { let component; - const tabStatus = this.getTabStatus(); + const tabStatus = this.getTabViewStatus(); if (!tabStatus) { - const tab = this.props.teams[this.state.key]; - if (tab) { - console.error(`Not tabStatus for ${this.props.teams[this.state.key].name}`); + if (this.state.activeTabName) { + console.error(`Not tabStatus for ${this.state.activeTabName}`); } else { console.error('No tab status, tab doesn\'t exist anymore'); } @@ -463,7 +437,7 @@ export default class MainPage extends React.PureComponent { case Status.FAILED: component = ( { const teams = this.state.teams || []; - teams.push(team); + teams.push(getDefaultTeamWithTabsFromTeam(team)); window.timers.setImmediate(this.saveSetting, CONFIG_TYPE_SERVERS, {key: 'teams', data: teams}); this.setState({ teams, diff --git a/src/renderer/components/TabBar.tsx b/src/renderer/components/TabBar.tsx index bc5e2f04..91b8b474 100644 --- a/src/renderer/components/TabBar.tsx +++ b/src/renderer/components/TabBar.tsx @@ -5,32 +5,26 @@ import React from 'react'; import {Nav, NavItem, NavLink} from 'react-bootstrap'; import {DragDropContext, Draggable, DraggingStyle, Droppable, DropResult, NotDraggingStyle} from 'react-beautiful-dnd'; -import PlusIcon from 'mdi-react/PlusIcon'; import classNames from 'classnames'; -import {Team} from 'types/config'; +import {Tab} from 'types/config'; -import {GET_CONFIGURATION} from 'common/communication'; +import {getTabViewName} from 'common/tabs/TabView'; type Props = { - activeKey: number; + activeTabName: string; + activeServerName: string; id: string; isDarkMode: boolean; onSelect: (name: string, index: number) => void; - teams: Team[]; + tabs: Tab[]; sessionsExpired: Record; unreadCounts: Record; mentionCounts: Record; - showAddServerButton: boolean; - onAddServer: () => void; onDrop: (result: DropResult) => void; tabsDisabled?: boolean; }; -type State = { - hasGPOTeams: boolean; -}; - function getStyle(style?: DraggingStyle | NotDraggingStyle) { if (style?.transform) { const axisLockX = `${style.transform.slice(0, style.transform.indexOf(','))}, 0px)`; @@ -42,31 +36,19 @@ function getStyle(style?: DraggingStyle | NotDraggingStyle) { return style; } -export default class TabBar extends React.PureComponent { // need "this" - constructor(props: Props) { - super(props); - this.state = { - hasGPOTeams: false, - }; - } - - componentDidMount() { - window.ipcRenderer.invoke(GET_CONFIGURATION).then((config) => { - this.setState({hasGPOTeams: config.registryTeams && config.registryTeams.length > 0}); - }); - } - +export default class TabBar extends React.PureComponent { render() { - const orderedTabs = this.props.teams.concat().sort((a, b) => a.order - b.order); - const tabs = orderedTabs.map((team, orderedIndex) => { - const index = this.props.teams.indexOf(team); + const orderedTabs = this.props.tabs.concat().sort((a, b) => a.order - b.order); + const tabs = orderedTabs.map((tab, orderedIndex) => { + const index = this.props.tabs.indexOf(tab); + const tabName = getTabViewName(this.props.activeServerName, tab.name); - const sessionExpired = this.props.sessionsExpired[index]; - const hasUnreads = this.props.unreadCounts[index]; + const sessionExpired = this.props.sessionsExpired[tabName]; + const hasUnreads = this.props.unreadCounts[tabName]; let mentionCount = 0; - if (this.props.mentionCounts[index] > 0) { - mentionCount = this.props.mentionCounts[index]; + if (this.props.mentionCounts[tabName] > 0) { + mentionCount = this.props.mentionCounts[tabName]; } let badgeDiv: React.ReactNode; @@ -98,9 +80,9 @@ export default class TabBar extends React.PureComponent { // need as='li' id={`teamTabItem${index}`} draggable={false} - title={team.name} + title={tab.name} className={classNames('teamTabItem', { - active: this.props.activeKey === index, + active: this.props.activeTabName === tab.name, dragging: snapshot.isDragging, })} {...provided.draggableProps} @@ -110,15 +92,15 @@ export default class TabBar extends React.PureComponent { // need { - this.props.onSelect(team.name, index); + this.props.onSelect(tab.name, index); }} >
- {team.name} + {tab.name} { badgeDiv }
@@ -128,49 +110,11 @@ export default class TabBar extends React.PureComponent { // need ); }); - if (this.props.showAddServerButton === true) { - tabs.push( - - {(provided) => ( - - { - this.props.onAddServer(); - }} - > -
- -
-
-
- )} -
, - ); - } - // TODO: Replace with products - tabs.length = 0; return ( diff --git a/src/renderer/dropdown.tsx b/src/renderer/dropdown.tsx index 025c1ab7..ea80a7fc 100644 --- a/src/renderer/dropdown.tsx +++ b/src/renderer/dropdown.tsx @@ -6,18 +6,21 @@ import ReactDOM from 'react-dom'; import classNames from 'classnames'; import {DragDropContext, Draggable, DraggingStyle, Droppable, DropResult, NotDraggingStyle} from 'react-beautiful-dnd'; -import {Team} from 'types/config'; +import {Team, TeamWithTabs} from 'types/config'; import {CLOSE_TEAMS_DROPDOWN, REQUEST_TEAMS_DROPDOWN_INFO, SEND_DROPDOWN_MENU_SIZE, SHOW_NEW_SERVER_MODAL, SWITCH_SERVER, UPDATE_TEAMS, UPDATE_TEAMS_DROPDOWN} from 'common/communication'; +import {getTabViewName} from 'common/tabs/TabView'; + import './css/dropdown.scss'; import './css/compass-icons.css'; type State = { - teams?: Team[]; - orderedTeams?: Team[]; + teams?: TeamWithTabs[]; + orderedTeams?: TeamWithTabs[]; activeTeam?: string; darkMode?: boolean; + enableServerManagement?: boolean; unreads?: Map; mentions?: Map; expired?: Map; @@ -47,12 +50,13 @@ class TeamDropdown extends React.PureComponent, State> { handleMessageEvent = (event: MessageEvent) => { if (event.data.type === UPDATE_TEAMS_DROPDOWN) { - const {teams, activeTeam, darkMode, hasGPOTeams, unreads, mentions, expired} = event.data.data; + const {teams, activeTeam, darkMode, enableServerManagement, hasGPOTeams, unreads, mentions, expired} = event.data.data; this.setState({ teams, - orderedTeams: teams.concat().sort((a: Team, b: Team) => a.order - b.order), + orderedTeams: teams.concat().sort((a: TeamWithTabs, b: TeamWithTabs) => a.order - b.order), activeTeam, darkMode, + enableServerManagement, hasGPOTeams, unreads, mentions, @@ -168,10 +172,13 @@ class TeamDropdown extends React.PureComponent, State> { > {this.state.orderedTeams?.map((team, orderedIndex) => { const index = this.state.teams?.indexOf(team); - - const sessionExpired = this.state.expired?.get(team.name); - const hasUnreads = this.state.unreads?.get(team.name); - const mentionCount = this.state.mentions?.get(team.name); + const {sessionExpired, hasUnreads, mentionCount} = team.tabs.reduce((counts, tab) => { + const tabName = getTabViewName(team.name, tab.name); + counts.sessionExpired = this.state.expired?.get(tabName) || counts.sessionExpired; + counts.hasUnreads = this.state.unreads?.get(tabName) || counts.hasUnreads; + counts.mentionCount += this.state.mentions?.get(tabName) || 0; + return counts; + }, {sessionExpired: false, hasUnreads: false, mentionCount: 0}); let badgeDiv: React.ReactNode; if (sessionExpired) { @@ -250,13 +257,15 @@ class TeamDropdown extends React.PureComponent, State> {
- + {this.state.enableServerManagement && + + } ); } diff --git a/src/renderer/index.tsx b/src/renderer/index.tsx index 1bbb0957..1db0d5e5 100644 --- a/src/renderer/index.tsx +++ b/src/renderer/index.tsx @@ -46,12 +46,15 @@ class Root extends React.PureComponent, State> { this.setState({config}); } - moveTabs = (originalOrder: number, newOrder: number): number | undefined => { + moveTabs = (teamName: string, originalOrder: number, newOrder: number): number | undefined => { if (!this.state.config) { throw new Error('No config'); } const teams = this.state.config.teams.concat(); - const tabOrder = teams.map((team, index) => { + const currentTeamIndex = teams.findIndex((team) => team.name === teamName); + const tabs = teams[currentTeamIndex].tabs.concat(); + + const tabOrder = tabs.map((team, index) => { return { index, order: team.order, @@ -66,8 +69,9 @@ class Root extends React.PureComponent, State> { if (order === newOrder) { teamIndex = t.index; } - teams[t.index].order = order; + tabs[t.index].order = order; }); + teams[currentTeamIndex].tabs = tabs; this.setState({ config: { ...this.state.config, @@ -118,7 +122,6 @@ class Root extends React.PureComponent, State> { return ( ; showTrayIcon: boolean; trayIconTheme: string; minimizeToTray: boolean; @@ -54,7 +83,7 @@ export type ConfigV1 = { export type ConfigV0 = {version: 0; url: string}; -export type AnyConfig = ConfigV2 | ConfigV1 | ConfigV0; +export type AnyConfig = ConfigV3 | ConfigV2 | ConfigV1 | ConfigV0; export type BuildConfig = { defaultTeams?: Team[]; @@ -70,7 +99,7 @@ export type RegistryConfig = { enableAutoUpdater: boolean; } -export type CombinedConfig = ConfigV2 & BuildConfig & { +export type CombinedConfig = ConfigV3 & BuildConfig & { registryTeams: Team[]; appName: string; } diff --git a/src/types/utils.ts b/src/types/utils.ts index 57920561..2720832a 100644 --- a/src/types/utils.ts +++ b/src/types/utils.ts @@ -4,7 +4,6 @@ export type ServerFromURL = { name: string; url: URL; - index: number; } export type Boundaries = {