[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
This commit is contained in:
Devin Binnie
2021-07-20 09:05:53 -04:00
committed by GitHub
parent 29a049e8ae
commit d3599fc500
30 changed files with 636 additions and 361 deletions

View File

@@ -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';

View File

@@ -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',

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -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<ConfigV3>(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:

View File

@@ -0,0 +1,32 @@
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import urlUtils from 'common/utils/url';
export class MattermostServer {
name: string;
url: URL;
constructor(name: string, serverUrl: string) {
this.name = name;
this.url = urlUtils.parseURL(serverUrl)!;
if (!this.url) {
throw new Error('Invalid url for creating a server');
}
}
getServerInfo = () => {
// does the server have a subpath?
const normalizedPath = this.url.pathname.toLowerCase();
const subpath = normalizedPath.endsWith('/') ? normalizedPath : `${normalizedPath}/`;
return {origin: this.url.origin, subpath, url: this.url.toString()};
}
sameOrigin = (otherURL: string) => {
const parsedUrl = urlUtils.parseURL(otherURL);
return parsedUrl && this.url.origin === parsedUrl.origin;
}
equals = (otherServer: MattermostServer) => {
return (this.name === otherServer.name) && (this.url.toString() === otherServer.url.toString());
}
}

View File

@@ -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');
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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}`;
}

View File

@@ -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,