Migrate app to TypeScript (#1637)

* Initial setup and migrated src/common

* WIP

* WIP

* WIP

* Main module basically finished

* Renderer process migrated

* Added CI step and some fixes

* Fixed remainder of issues and added proper ESLint config

* Fixed a couple issues

* Progress!

* Some more fixes

* Fixed a test

* Fix build step

* PR feedback
This commit is contained in:
Devin Binnie 2021-06-28 09:51:23 -04:00 committed by GitHub
parent 422673a740
commit 1b3d0eac8f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
115 changed files with 16246 additions and 9921 deletions

View file

@ -108,6 +108,7 @@ jobs:
apt_opts: "--no-install-recommends" apt_opts: "--no-install-recommends"
- restore_cache: - restore_cache:
key: lint-{{ arch }}-{{ .Branch }}-{{ checksum "package-lock.json" }} key: lint-{{ arch }}-{{ .Branch }}-{{ checksum "package-lock.json" }}
- run: npm run check-types
- run: ELECTRON_DISABLE_SANDBOX=1 xvfb-run npm run test - run: ELECTRON_DISABLE_SANDBOX=1 xvfb-run npm run test
- run: mkdir -p /tmp/test-results - run: mkdir -p /tmp/test-results
- run: cp test-results.xml /tmp/test-results/ - run: cp test-results.xml /tmp/test-results/

View file

@ -74,58 +74,58 @@
"scripts/manipulate_windows_zip.js", "scripts/manipulate_windows_zip.js",
"scripts/check_build_config.js", "scripts/check_build_config.js",
"LICENSE.txt", "LICENSE.txt",
"src/utils/util.js", "src/utils/util.ts",
"src/main.js", "src/main/main.ts",
"src/browser/js/contextMenu.js", "src/main/contextMenu.ts",
"src/browser/updater.jsx", "src/renderer/updater.tsx",
"src/browser/js/badge.js", "src/main/badge.ts",
"src/browser/webview/mattermost.js", "src/common/deepmerge.ts",
"src/browser/components/RemoveServerModal.jsx", "src/common/config/index.ts",
"src/browser/components/MainPage.jsx", "src/common/config/buildConfig.ts",
"src/browser/components/HoveringURL.jsx", "src/common/config/pastDefaultPreferences.ts",
"src/browser/components/AutoSaveIndicator.jsx", "src/common/config/upgradePreferences.ts",
"src/browser/components/MattermostView.jsx", "src/common/config/RegistryConfig.ts",
"src/browser/components/TabBar.jsx", "src/common/osVersion.ts",
"src/browser/components/DestructiveConfirmModal.jsx", "src/common/config/defaultPreferences.ts",
"src/browser/components/ErrorView.jsx", "src/common/JsonFileManager.ts",
"src/browser/components/UpdaterPage.jsx", "src/main/certificateStore.ts",
"src/browser/components/PermissionRequestDialog.jsx", "src/main/mainWindow.ts",
"src/browser/components/Finder.jsx", "src/main/allowProtocolDialog.ts",
"src/browser/components/SettingsPage.jsx", "src/main/permissionRequestHandler.ts",
"src/browser/components/TeamListItem.jsx", "src/main/squirrelStartup.ts",
"src/browser/components/UpdaterPage/UpdaterPage.stories.jsx", "src/main/autoLaunch.ts",
"src/browser/components/Button/Button.stories.jsx", "src/main/PermissionManager.ts",
"src/browser/components/TeamList.jsx", "src/main/AutoLauncher.ts",
"src/browser/components/LoginModal.jsx", "src/main/AppStateManager.ts",
"src/browser/components/NewTeamModal.jsx", "src/main/menus/tray.ts",
"src/browser/settings.jsx", "src/main/CriticalErrorHandler.ts",
"src/browser/index.jsx", "src/main/cookieManager.ts",
"src/common/deepmerge.js", "src/main/utils.ts",
"src/common/config/index.js", "src/main/downloadURL.ts",
"src/common/config/buildConfig.js", "src/main/autoUpdater.ts",
"src/common/config/pastDefaultPreferences.js", "src/main/SpellChecker.ts",
"src/common/config/upgradePreferences.js", "src/main/menus/app.ts",
"src/common/config/RegistryConfig.js", "src/main/preload/mattermost.js",
"src/common/osVersion.js", "src/renderer/components/RemoveServerModal.tsx",
"src/common/config/defaultPreferences.js", "src/renderer/components/MainPage.tsx",
"src/common/JsonFileManager.js", "src/renderer/components/HoveringURL.tsx",
"src/main/certificateStore.js", "src/renderer/components/AutoSaveIndicator.tsx",
"src/main/mainWindow.js", "src/renderer/components/MattermostView.tsx",
"src/main/allowProtocolDialog.js", "src/renderer/components/TabBar.tsx",
"src/main/permissionRequestHandler.js", "src/renderer/components/DestructiveConfirmModal.tsx",
"src/main/squirrelStartup.js", "src/renderer/components/ErrorView.tsx",
"src/main/autoLaunch.js", "src/renderer/components/UpdaterPage.tsx",
"src/main/PermissionManager.js", "src/renderer/components/PermissionRequestDialog.tsx",
"src/main/AutoLauncher.js", "src/renderer/components/Finder.tsx",
"src/main/AppStateManager.js", "src/renderer/components/SettingsPage.tsx",
"src/main/menus/tray.js", "src/renderer/components/TeamListItem.tsx",
"src/main/CriticalErrorHandler.js", "src/renderer/components/UpdaterPage/UpdaterPage.stories.tsx",
"src/main/cookieManager.js", "src/renderer/components/Button/Button.stories.tsx",
"src/main/utils.js", "src/renderer/components/TeamList.tsx",
"src/main/downloadURL.js", "src/renderer/components/LoginModal.tsx",
"src/main/autoUpdater.js", "src/renderer/components/NewTeamModal.tsx",
"src/main/SpellChecker.js", "src/renderer/settings.tsx",
"src/main/menus/app.js" "src/renderer/index.tsx"
], ],
"rules": { "rules": {
"header/header": [ "header/header": [

View file

@ -13,6 +13,10 @@ module.exports = (api) => { // eslint-disable-line import/no-commonjs
}, },
}], }],
'@babel/preset-react', '@babel/preset-react',
['@babel/typescript', {
allExtensions: true,
isTSX: true,
}],
], ],
plugins: ['@babel/plugin-proposal-object-rest-spread', '@babel/plugin-proposal-class-properties'], plugins: ['@babel/plugin-proposal-object-rest-spread', '@babel/plugin-proposal-class-properties'],
}; };

22537
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -38,9 +38,9 @@
"watch:main": "node scripts/watch_main_and_preload.js", "watch:main": "node scripts/watch_main_and_preload.js",
"watch:renderer": "webpack-dev-server --config webpack.config.renderer.js", "watch:renderer": "webpack-dev-server --config webpack.config.renderer.js",
"test": "npm-run-all lint:js test:unit test:e2e", "test": "npm-run-all lint:js test:unit test:e2e",
"test:e2e": "npm-run-all test:e2e:build test:e2e:run", "test:e2e": "cross-env NODE_ENV=test npm-run-all build test:e2e:build test:e2e:run",
"test:e2e:build": "cross-env NODE_ENV=test npm run build", "test:e2e:build": "webpack-cli --bail --config webpack.config.test.js",
"test:e2e:run": "cross-env NODE_ENV=test electron-mocha -r @babel/register --reporter mocha-circleci-reporter --recursive test/specs", "test:e2e:run": "electron-mocha -r @babel/register --reporter mocha-circleci-reporter dist/tests/e2e_bundle.js",
"test:unit": "npm-run-all test:unit:build test:unit:run", "test:unit": "npm-run-all test:unit:build test:unit:run",
"test:unit:build": "cross-env NODE_ENV=test webpack-cli --bail --config webpack.config.test.js", "test:unit:build": "cross-env NODE_ENV=test webpack-cli --bail --config webpack.config.test.js",
"test:unit:run": "cross-env NODE_ENV=test mocha --reporter mocha-circleci-reporter dist/tests/test_bundle.js", "test:unit:run": "cross-env NODE_ENV=test mocha --reporter mocha-circleci-reporter dist/tests/test_bundle.js",
@ -48,22 +48,35 @@
"package:windows": "cross-env NODE_ENV=production npm-run-all check-build-config build-prod && electron-builder --win --x64 --ia32 --publish=never", "package:windows": "cross-env NODE_ENV=production npm-run-all check-build-config build-prod && electron-builder --win --x64 --ia32 --publish=never",
"package:mac": "cross-env NODE_ENV=production npm-run-all check-build-config build-prod && electron-builder --mac --x64 --arm64 --publish=never", "package:mac": "cross-env NODE_ENV=production npm-run-all check-build-config build-prod && electron-builder --mac --x64 --arm64 --publish=never",
"package:linux": "cross-env NODE_ENV=production npm-run-all check-build-config build-prod && electron-builder --linux --x64 --ia32 --publish=never", "package:linux": "cross-env NODE_ENV=production npm-run-all check-build-config build-prod && electron-builder --linux --x64 --ia32 --publish=never",
"lint:js": "eslint --ignore-path .gitignore --ignore-pattern node_modules --ext .js --ext .jsx .", "lint:js": "eslint --ignore-path .gitignore --ignore-pattern node_modules --ext .js --ext .jsx --ext .ts --ext .tsx .",
"lint:js-quiet": "eslint --ignore-path .gitignore --ignore-pattern node_modules --ext .js --ext .jsx . --quiet", "lint:js-quiet": "eslint --ignore-path .gitignore --ignore-pattern node_modules --ext .js --ext .jsx --ext .ts --ext .tsx . --quiet",
"fix:js": "eslint --ignore-path .gitignore --ignore-pattern node_modules --quiet --ext .js --ext .jsx . --fix", "fix:js": "eslint --ignore-path .gitignore --ignore-pattern node_modules --quiet --ext .js --ext .jsx --ext .ts --ext .tsx . --fix",
"check-build-config": "node -r @babel/register scripts/check_build_config.js" "check-build-config": "npm-run-all check-build-config:build check-build-config:run",
"check-build-config:build": "babel ./src/common/config/buildConfig.ts -o ./dist/buildConfig.js",
"check-build-config:run": "node -r @babel/register scripts/check_build_config.js",
"check-types": "tsc"
}, },
"devDependencies": { "devDependencies": {
"@babel/cli": "^7.14.5",
"@babel/core": "^7.2.0", "@babel/core": "^7.2.0",
"@babel/plugin-proposal-class-properties": "^7.8.3", "@babel/plugin-proposal-class-properties": "^7.8.3",
"@babel/plugin-proposal-object-rest-spread": "^7.2.0", "@babel/plugin-proposal-object-rest-spread": "^7.2.0",
"@babel/preset-env": "^7.2.0", "@babel/preset-env": "^7.2.0",
"@babel/preset-react": "^7.10.4", "@babel/preset-react": "^7.10.4",
"@babel/register": "^7.0.0", "@babel/register": "^7.0.0",
"@storybook/addon-actions": "^4.0.11", "@storybook/addon-actions": "^6.2.9",
"@storybook/react": "^4.0.11", "@storybook/react": "^6.2.9",
"@typescript-eslint/eslint-plugin": "4.15.0", "@types/auto-launch": "^5.0.1",
"@typescript-eslint/parser": "4.15.0", "@types/electron-devtools-installer": "^2.2.0",
"@types/hapi__joi": "^17.1.6",
"@types/react": "^17.0.11",
"@types/react-bootstrap": "^0.32.25",
"@types/react-dom": "^17.0.8",
"@types/underscore": "^1.11.2",
"@types/valid-url": "^1.0.3",
"@types/winreg": "^1.2.30",
"@typescript-eslint/eslint-plugin": "4.28.0",
"@typescript-eslint/parser": "4.28.0",
"7zip-bin": "^4.1.0", "7zip-bin": "^4.1.0",
"awesome-node-loader": "^1.1.1", "awesome-node-loader": "^1.1.1",
"babel-eslint": "^10.0.3", "babel-eslint": "^10.0.3",
@ -91,12 +104,14 @@
"file-loader": "^2.0.0", "file-loader": "^2.0.0",
"image-webpack-loader": "5.0.0", "image-webpack-loader": "5.0.0",
"mdi-react": "^6.2.0", "mdi-react": "^6.2.0",
"mini-css-extract-plugin": "1.6.0",
"mocha": "^5.2.0", "mocha": "^5.2.0",
"mocha-circleci-reporter": "0.0.3", "mocha-circleci-reporter": "0.0.3",
"npm-run-all": "^4.1.5", "npm-run-all": "^4.1.5",
"shebang-loader": "^0.0.1",
"spectron": "^14.0.0", "spectron": "^14.0.0",
"style-loader": "^0.23.1", "style-loader": "^0.23.1",
"typescript": "4.1.3", "typescript": "^4.3.4",
"url-loader": "^1.1.2", "url-loader": "^1.1.2",
"webpack": "^4.44.2", "webpack": "^4.44.2",
"webpack-cli": "^3.1.2", "webpack-cli": "^3.1.2",
@ -108,7 +123,7 @@
"auto-launch": "^5.0.5", "auto-launch": "^5.0.5",
"bootstrap": "^3.3.7", "bootstrap": "^3.3.7",
"brace-expansion": "^2.0.0", "brace-expansion": "^2.0.0",
"classnames": "^2.2.6", "classnames": "^2.3.1",
"electron-context-menu": "^2.5.0", "electron-context-menu": "^2.5.0",
"electron-devtools-installer": "^3.1.1", "electron-devtools-installer": "^3.1.1",
"electron-is-dev": "^2.0.0", "electron-is-dev": "^2.0.0",
@ -116,9 +131,9 @@
"electron-updater": "4.3.8", "electron-updater": "4.3.8",
"font-awesome": "^4.7.0", "font-awesome": "^4.7.0",
"prop-types": "^15.6.2", "prop-types": "^15.6.2",
"react": "^16.6.3", "react": "^16.14.0",
"react-bootstrap": "~0.32.4", "react-bootstrap": "~0.32.4",
"react-dom": "^16.6.3", "react-dom": "^16.14.0",
"react-smooth-dnd": "github:mattermost/react-smooth-dnd#af6b471295007274560a375799622c1cd52d678a", "react-smooth-dnd": "github:mattermost/react-smooth-dnd#af6b471295007274560a375799622c1cd52d678a",
"react-transition-group": "^2.5.0", "react-transition-group": "^2.5.0",
"semver": "^5.5.0", "semver": "^5.5.0",

View file

@ -1,7 +1,7 @@
// Copyright (c) 2015-2016 Yuya Ochiai // Copyright (c) 2015-2016 Yuya Ochiai
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. // Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information. // See LICENSE.txt for license information.
const buildConfig = require('../src/common/config/buildConfig'); const buildConfig = require('../dist/buildConfig');
function validateBuildConfig(config) { function validateBuildConfig(config) {
if (config.enableServerManagement === false && config.defaultTeams && config.defaultTeams.length === 0) { if (config.enableServerManagement === false && config.defaultTeams && config.defaultTeams.length === 0) {

View file

@ -3,18 +3,21 @@
// See LICENSE.txt for license information. // See LICENSE.txt for license information.
import fs from 'fs'; import fs from 'fs';
export default class JsonFileManager { export default class JsonFileManager<T> {
constructor(file) { jsonFile: string;
json: T;
constructor(file: string) {
this.jsonFile = file; this.jsonFile = file;
try { try {
this.json = JSON.parse(fs.readFileSync(file, 'utf-8')); this.json = JSON.parse(fs.readFileSync(file, 'utf-8'));
} catch (err) { } catch (err) {
this.json = {}; this.json = {} as T;
} }
} }
writeToFile() { writeToFile(): void {
fs.writeFile(this.jsonFile, JSON.stringify(this.json, null, 2), (err) => { fs.writeFile(this.jsonFile, JSON.stringify(this.json, undefined, 2), (err) => {
if (err) { if (err) {
// No real point in bringing electron-log into this otherwise electron-free file // No real point in bringing electron-log into this otherwise electron-free file
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
@ -23,17 +26,17 @@ export default class JsonFileManager {
}); });
} }
setJson(json) { setJson(json: T): void {
this.json = json; this.json = json;
this.writeToFile(); this.writeToFile();
} }
setValue(key, value) { setValue(key: keyof T, value: T[keyof T]): void {
this.json[key] = value; this.json[key] = value;
this.writeToFile(); this.writeToFile();
} }
getValue(key) { getValue(key: keyof T): T[keyof T] {
return this.json[key]; return this.json[key];
} }
} }

View file

@ -6,6 +6,8 @@ import {EventEmitter} from 'events';
import log from 'electron-log'; import log from 'electron-log';
import WindowsRegistry from 'winreg-utf8'; import WindowsRegistry from 'winreg-utf8';
import {RegistryConfig as RegistryConfigType, Team} from 'types/config';
const REGISTRY_HIVE_LIST = [WindowsRegistry.HKLM, WindowsRegistry.HKCU]; const REGISTRY_HIVE_LIST = [WindowsRegistry.HKLM, WindowsRegistry.HKCU];
const BASE_REGISTRY_KEY_PATH = '\\Software\\Policies\\Mattermost'; const BASE_REGISTRY_KEY_PATH = '\\Software\\Policies\\Mattermost';
export const REGISTRY_READ_EVENT = 'registry-read'; export const REGISTRY_READ_EVENT = 'registry-read';
@ -14,6 +16,9 @@ export const REGISTRY_READ_EVENT = 'registry-read';
* Handles loading config data from the Windows registry set manually or by GPO * Handles loading config data from the Windows registry set manually or by GPO
*/ */
export default class RegistryConfig extends EventEmitter { export default class RegistryConfig extends EventEmitter {
initialized: boolean;
data: Partial<RegistryConfigType>;
constructor() { constructor() {
super(); super();
this.initialized = false; this.initialized = false;
@ -33,7 +38,7 @@ export default class RegistryConfig extends EventEmitter {
try { try {
const servers = await this.getServersListFromRegistry(); const servers = await this.getServersListFromRegistry();
if (servers.length) { if (servers.length) {
this.data.teams.push(...servers); this.data.teams!.push(...servers);
} }
} catch (error) { } catch (error) {
log.warn('[RegistryConfig] Nothing retrieved for \'DefaultServerList\'', error); log.warn('[RegistryConfig] Nothing retrieved for \'DefaultServerList\'', error);
@ -70,12 +75,12 @@ export default class RegistryConfig extends EventEmitter {
*/ */
async getServersListFromRegistry() { async getServersListFromRegistry() {
const defaultServers = await this.getRegistryEntry(`${BASE_REGISTRY_KEY_PATH}\\DefaultServerList`); const defaultServers = await this.getRegistryEntry(`${BASE_REGISTRY_KEY_PATH}\\DefaultServerList`);
return defaultServers.flat(2).reduce((servers, server, index) => { return defaultServers.flat(2).reduce((servers: Team[], server, index) => {
if (server) { if (server) {
servers.push({ servers.push({
name: server.name, name: (server as WindowsRegistry.RegistryItem).name,
url: server.value, url: (server as WindowsRegistry.RegistryItem).value,
order: server.order || index, order: index,
}); });
} }
return servers; return servers;
@ -106,7 +111,7 @@ export default class RegistryConfig extends EventEmitter {
* @param {string} key Path to the registry key to return * @param {string} key Path to the registry key to return
* @param {string} name Name of specific entry in the registry key to retrieve (optional) * @param {string} name Name of specific entry in the registry key to retrieve (optional)
*/ */
async getRegistryEntry(key, name) { async getRegistryEntry(key: string, name?: string) {
const results = []; const results = [];
for (const hive of REGISTRY_HIVE_LIST) { for (const hive of REGISTRY_HIVE_LIST) {
results.push(this.getRegistryEntryValues(hive, key, name)); results.push(this.getRegistryEntryValues(hive, key, name));
@ -121,18 +126,18 @@ export default class RegistryConfig extends EventEmitter {
* @param {WindowsRegistry} regKey A configured instance of the WindowsRegistry class * @param {WindowsRegistry} regKey A configured instance of the WindowsRegistry class
* @param {string} name Name of the specific entry to retrieve (optional) * @param {string} name Name of the specific entry to retrieve (optional)
*/ */
getRegistryEntryValues(hive, key, name) { getRegistryEntryValues(hive: string, key: string, name?: string) {
const registry = new WindowsRegistry({hive, key, utf8: true}); const registry = new WindowsRegistry({hive, key, utf8: true});
return new Promise((resolve, reject) => { return new Promise<string | WindowsRegistry.RegistryItem[] | undefined>((resolve, reject) => {
try { try {
registry.values((error, results) => { registry.values((error: Error, results: WindowsRegistry.RegistryItem[]) => {
if (error || !results || results.length === 0) { if (error || !results || results.length === 0) {
resolve(); resolve(undefined);
return; return;
} }
if (name) { // looking for a single entry value if (name) { // looking for a single entry value
const registryItem = results.find((item) => item.name === name); const registryItem = results.find((item) => item.name === name);
resolve(registryItem && registryItem.value ? registryItem.value : null); resolve(registryItem && registryItem.value ? registryItem.value : undefined);
} else { // looking for an entry list } else { // looking for an entry list
resolve(results); resolve(results);
} }

View file

@ -2,6 +2,8 @@
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. // Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information. // See LICENSE.txt for license information.
import {BuildConfig} from 'types/config';
// For detailed guides, please refer to https://docs.mattermost.com/deployment/desktop-app-deployment.html // For detailed guides, please refer to https://docs.mattermost.com/deployment/desktop-app-deployment.html
/** /**
@ -17,7 +19,7 @@
* when "enableServerManagement is set to false * when "enableServerManagement is set to false
* @prop {[]} managedResources - Defines which paths are managed * @prop {[]} managedResources - Defines which paths are managed
*/ */
const buildConfig = { const buildConfig: BuildConfig = {
defaultTeams: [/* defaultTeams: [/*
{ {
name: 'example', name: 'example',

View file

@ -7,7 +7,9 @@
* @param {number} version - Scheme version. (Not application version) * @param {number} version - Scheme version. (Not application version)
*/ */
const getDefaultDownloadLocation = () => { import {ConfigV2} from 'types/config';
export const getDefaultDownloadLocation = () => {
switch (process.platform) { switch (process.platform) {
case 'darwin': case 'darwin':
return `/Users/${process.env.USER || process.env.USERNAME}/Downloads`; return `/Users/${process.env.USER || process.env.USERNAME}/Downloads`;
@ -18,7 +20,7 @@ const getDefaultDownloadLocation = () => {
} }
}; };
const defaultPreferences = { const defaultPreferences: ConfigV2 = {
version: 2, version: 2,
teams: [], teams: [],
showTrayIcon: true, showTrayIcon: true,

View file

@ -9,11 +9,21 @@ import {EventEmitter} from 'events';
import {ipcMain, nativeTheme, app} from 'electron'; import {ipcMain, nativeTheme, app} from 'electron';
import log from 'electron-log'; import log from 'electron-log';
import * as Validator from '../../main/Validator'; import {
AnyConfig,
BuildConfig,
CombinedConfig,
Config as ConfigType,
LocalConfiguration,
RegistryConfig as RegistryConfigType,
Team,
} from 'types/config';
import {UPDATE_TEAMS, GET_CONFIGURATION, UPDATE_CONFIGURATION, GET_LOCAL_CONFIGURATION} from 'common/communication'; import {UPDATE_TEAMS, GET_CONFIGURATION, UPDATE_CONFIGURATION, GET_LOCAL_CONFIGURATION} from 'common/communication';
import defaultPreferences from './defaultPreferences'; import * as Validator from '../../main/Validator';
import defaultPreferences, {getDefaultDownloadLocation} from './defaultPreferences';
import upgradeConfigData from './upgradePreferences'; import upgradeConfigData from './upgradePreferences';
import buildConfig from './buildConfig'; import buildConfig from './buildConfig';
import RegistryConfig, {REGISTRY_READ_EVENT} from './RegistryConfig'; import RegistryConfig, {REGISTRY_READ_EVENT} from './RegistryConfig';
@ -21,14 +31,25 @@ import RegistryConfig, {REGISTRY_READ_EVENT} from './RegistryConfig';
/** /**
* Handles loading and merging all sources of configuration as well as saving user provided config * Handles loading and merging all sources of configuration as well as saving user provided config
*/ */
export default class Config extends EventEmitter { export default class Config extends EventEmitter {
constructor(configFilePath) { configFilePath: string;
registryConfig?: RegistryConfig;
combinedData?: CombinedConfig;
registryConfigData?: Partial<RegistryConfigType>;
defaultConfigData?: ConfigType;
buildConfigData?: BuildConfig;
localConfigData?: ConfigType;
constructor(configFilePath: string) {
super(); super();
this.configFilePath = configFilePath; this.configFilePath = configFilePath;
} }
// separating constructor from init so main can setup event listeners // separating constructor from init so main can setup event listeners
init = () => { init = (): void => {
this.registryConfig = new RegistryConfig(); this.registryConfig = new RegistryConfig();
this.registryConfig.once(REGISTRY_READ_EVENT, this.loadRegistry); this.registryConfig.once(REGISTRY_READ_EVENT, this.loadRegistry);
this.registryConfig.init(); this.registryConfig.init();
@ -40,7 +61,7 @@ export default class Config extends EventEmitter {
* @param {object} registryData Team configuration from the registry and if teams can be managed by user * @param {object} registryData Team configuration from the registry and if teams can be managed by user
*/ */
loadRegistry = (registryData) => { loadRegistry = (registryData: Partial<RegistryConfigType>): void => {
this.registryConfigData = registryData; this.registryConfigData = registryData;
this.reload(); this.reload();
ipcMain.handle(GET_CONFIGURATION, this.handleGetConfiguration); ipcMain.handle(GET_CONFIGURATION, this.handleGetConfiguration);
@ -59,11 +80,11 @@ export default class Config extends EventEmitter {
* @emits {update} emitted once all data has been loaded and merged * @emits {update} emitted once all data has been loaded and merged
* @emits {synchronize} emitted when requested by a call to method; used to notify other config instances of changes * @emits {synchronize} emitted when requested by a call to method; used to notify other config instances of changes
*/ */
reload = () => { reload = (): void => {
this.defaultConfigData = this.loadDefaultConfigData(); this.defaultConfigData = this.loadDefaultConfigData();
this.buildConfigData = this.loadBuildConfigData(); this.buildConfigData = this.loadBuildConfigData();
this.localConfigData = this.loadLocalConfigFile(); const loadedConfig = this.loadLocalConfigFile();
this.localConfigData = this.checkForConfigUpdates(this.localConfigData); this.localConfigData = this.checkForConfigUpdates(loadedConfig);
this.regenerateCombinedConfigData(); this.regenerateCombinedConfigData();
this.emit('update', this.combinedData); this.emit('update', this.combinedData);
@ -76,9 +97,9 @@ export default class Config extends EventEmitter {
* @param {string} key name of config property to be saved * @param {string} key name of config property to be saved
* @param {*} data value to save for provided key * @param {*} data value to save for provided key
*/ */
set = (key, data) => { set = (key: keyof ConfigType, data: ConfigType[keyof ConfigType]): void => {
if (key) { if (key && this.localConfigData) {
this.localConfigData[key] = data; this.localConfigData = Object.assign({}, this.localConfigData, {[key]: data});
this.regenerateCombinedConfigData(); this.regenerateCombinedConfigData();
this.saveLocalConfigData(); this.saveLocalConfigData();
} }
@ -89,13 +110,9 @@ export default class Config extends EventEmitter {
* *
* @param {array} properties an array of config properties to save * @param {array} properties an array of config properties to save
*/ */
setMultiple = (event, properties = []) => { setMultiple = (event: Electron.IpcMainEvent, properties: Array<{key: keyof ConfigType; data: ConfigType[keyof ConfigType]}> = []): Partial<ConfigType> | undefined => {
if (properties.length) { if (properties.length) {
properties.forEach(({key, data}) => { this.localConfigData = Object.assign({}, this.localConfigData, ...properties.map(({key, data}) => ({[key]: data})));
if (key) {
this.localConfigData[key] = data;
}
});
this.regenerateCombinedConfigData(); this.regenerateCombinedConfigData();
this.saveLocalConfigData(); this.saveLocalConfigData();
} }
@ -103,7 +120,7 @@ export default class Config extends EventEmitter {
return this.localConfigData; //this is the only part that changes return this.localConfigData; //this is the only part that changes
} }
setRegistryConfigData = (registryConfigData = {teams: []}) => { setRegistryConfigData = (registryConfigData = {teams: []}): void => {
this.registryConfigData = Object.assign({}, registryConfigData); this.registryConfigData = Object.assign({}, registryConfigData);
this.reload(); this.reload();
} }
@ -113,7 +130,7 @@ export default class Config extends EventEmitter {
* *
* @param {object} configData a new, config data object to completely replace the existing config data * @param {object} configData a new, config data object to completely replace the existing config data
*/ */
replace = (configData) => { replace = (configData: ConfigType) => {
const newConfigData = configData; const newConfigData = configData;
this.localConfigData = Object.assign({}, this.localConfigData, newConfigData); this.localConfigData = Object.assign({}, this.localConfigData, newConfigData);
@ -129,11 +146,15 @@ export default class Config extends EventEmitter {
* @emits {synchronize} emitted once all data has been saved; used to notify other config instances of changes * @emits {synchronize} emitted once all data has been saved; used to notify other config instances of changes
* @emits {error} emitted if saving local config data to file fails * @emits {error} emitted if saving local config data to file fails
*/ */
saveLocalConfigData = () => { saveLocalConfigData = (): void => {
if (!this.localConfigData) {
return;
}
try { try {
this.writeFile(this.configFilePath, this.localConfigData, (error) => { this.writeFile(this.configFilePath, this.localConfigData, (error: NodeJS.ErrnoException | null) => {
if (error) { if (error) {
throw new Error(error); throw new Error(error.message);
} }
this.emit('update', this.combinedData); this.emit('update', this.combinedData);
this.emit('synchronize'); this.emit('synchronize');
@ -149,13 +170,13 @@ export default class Config extends EventEmitter {
return this.combinedData; return this.combinedData;
} }
get localData() { get localData() {
return this.localConfigData; return this.localConfigData ?? defaultPreferences;
} }
get defaultData() { get defaultData() {
return this.defaultConfigData; return this.defaultConfigData ?? defaultPreferences;
} }
get buildData() { get buildData() {
return this.buildConfigData; return this.buildConfigData ?? buildConfig;
} }
get registryData() { get registryData() {
return this.registryConfigData; return this.registryConfigData;
@ -164,52 +185,55 @@ export default class Config extends EventEmitter {
// convenience getters // convenience getters
get version() { get version() {
return this.combinedData.version; return this.combinedData?.version ?? defaultPreferences.version;
} }
get teams() { get teams() {
return this.combinedData.teams; return this.combinedData?.teams ?? defaultPreferences.teams;
} }
get darkMode() { get darkMode() {
return this.combinedData.darkMode; return this.combinedData?.darkMode ?? defaultPreferences.darkMode;
} }
get localTeams() { get localTeams() {
return this.localConfigData.teams; return this.localConfigData?.teams ?? defaultPreferences.version;
} }
get predefinedTeams() { get predefinedTeams() {
return [...this.buildConfigData.defaultTeams, ...this.registryConfigData.teams]; return [...this.buildConfigData?.defaultTeams ?? [], ...this.registryConfigData?.teams ?? []];
} }
get enableHardwareAcceleration() { get enableHardwareAcceleration() {
return this.combinedData.enableHardwareAcceleration; return this.combinedData?.enableHardwareAcceleration ?? defaultPreferences.enableHardwareAcceleration;
} }
get enableServerManagement() { get enableServerManagement() {
return this.combinedData.enableServerManagement; return this.combinedData?.enableServerManagement ?? buildConfig.enableServerManagement;
} }
get enableAutoUpdater() { get enableAutoUpdater() {
return this.combinedData.enableAutoUpdater; return this.combinedData?.enableAutoUpdater ?? buildConfig.enableAutoUpdater;
} }
get autostart() { get autostart() {
return this.combinedData.autostart; return this.combinedData?.autostart ?? defaultPreferences.autostart;
} }
get notifications() { get notifications() {
return this.combinedData.notifications; return this.combinedData?.notifications ?? defaultPreferences.notifications;
} }
get showUnreadBadge() { get showUnreadBadge() {
return this.combinedData.showUnreadBadge; return this.combinedData?.showUnreadBadge ?? defaultPreferences.showUnreadBadge;
} }
get useSpellChecker() { get useSpellChecker() {
return this.combinedData.useSpellChecker; return this.combinedData?.useSpellChecker ?? defaultPreferences.useSpellChecker;
} }
get spellCheckerLocale() { get spellCheckerLocale() {
return this.combinedData.spellCheckerLocale; return this.combinedData?.spellCheckerLocale ?? defaultPreferences.spellCheckerLocale;
} }
get showTrayIcon() { get showTrayIcon() {
return this.combinedData.showTrayIcon; return this.combinedData?.showTrayIcon ?? defaultPreferences.showTrayIcon;
} }
get trayIconTheme() { get trayIconTheme() {
return this.combinedData.trayIconTheme; return this.combinedData?.trayIconTheme ?? defaultPreferences.trayIconTheme;
}
get downloadLocation() {
return this.combinedData?.downloadLocation ?? getDefaultDownloadLocation();
} }
get helpLink() { get helpLink() {
return this.combinedData.helpLink; return this.combinedData?.helpLink;
} }
// initialization/processing methods // initialization/processing methods
@ -231,22 +255,21 @@ export default class Config extends EventEmitter {
/** /**
* Loads and returns locally stored config data from the filesystem or returns app defaults if no file is found * Loads and returns locally stored config data from the filesystem or returns app defaults if no file is found
*/ */
loadLocalConfigFile = () => { loadLocalConfigFile = (): AnyConfig => {
let configData = {}; let configData: AnyConfig;
try { try {
configData = this.readFileSync(this.configFilePath); configData = this.readFileSync(this.configFilePath);
// validate based on config file version // validate based on config file version
if (configData.version > 1) { switch (configData.version) {
configData = Validator.validateV2ConfigData(configData); case 2:
} else { configData = Validator.validateV2ConfigData(configData)!;
switch (configData.version) { break;
case 1: case 1:
configData = Validator.validateV1ConfigData(configData); configData = Validator.validateV1ConfigData(configData)!;
break; break;
default: default:
configData = Validator.validateV0ConfigData(configData); configData = Validator.validateV0ConfigData(configData)!;
}
} }
if (!configData) { if (!configData) {
throw new Error('Provided configuration file does not validate, using defaults instead.'); throw new Error('Provided configuration file does not validate, using defaults instead.');
@ -255,12 +278,6 @@ export default class Config extends EventEmitter {
log.warn('Failed to load configuration file from the filesystem. Using defaults.'); log.warn('Failed to load configuration file from the filesystem. Using defaults.');
configData = this.copy(this.defaultConfigData); configData = this.copy(this.defaultConfigData);
// add default team to teams if one exists and there arent currently any teams
if (!configData.teams.length && this.defaultConfigData.defaultTeam) {
configData.teams.push(this.defaultConfigData.defaultTeam);
}
delete configData.defaultTeam;
this.writeFileSync(this.configFilePath, configData); this.writeFileSync(this.configFilePath, configData);
} }
return configData; return configData;
@ -271,18 +288,21 @@ export default class Config extends EventEmitter {
* *
* @param {*} data locally stored data * @param {*} data locally stored data
*/ */
checkForConfigUpdates = (data) => { checkForConfigUpdates = (data: AnyConfig) => {
let configData = data; let configData = data;
try { if (this.defaultConfigData) {
if (configData.version !== this.defaultConfigData.version) { try {
configData = upgradeConfigData(configData); if (configData.version !== this.defaultConfigData.version) {
this.writeFileSync(this.configFilePath, configData); configData = upgradeConfigData(configData);
log.info(`Configuration updated to version ${this.defaultConfigData.version} successfully.`); this.writeFileSync(this.configFilePath, configData);
log.info(`Configuration updated to version ${this.defaultConfigData.version} successfully.`);
}
} catch (error) {
log.error(`Failed to update configuration to version ${this.defaultConfigData.version}.`);
} }
} catch (error) {
log.error(`Failed to update configuration to version ${this.defaultConfigData.version}.`);
} }
return configData;
return configData as ConfigType;
} }
/** /**
@ -293,38 +313,37 @@ export default class Config extends EventEmitter {
this.combinedData = Object.assign({}, this.defaultConfigData, this.localConfigData, this.buildConfigData, this.registryConfigData); this.combinedData = Object.assign({}, this.defaultConfigData, this.localConfigData, this.buildConfigData, this.registryConfigData);
// remove unecessary data pulled from default and build config // remove unecessary data pulled from default and build config
delete this.combinedData.defaultTeam; delete this.combinedData!.defaultTeams;
delete this.combinedData.defaultTeams;
// IMPORTANT: properly combine teams from all sources // IMPORTANT: properly combine teams from all sources
let combinedTeams = []; let combinedTeams = [];
// - start by adding default teams from buildConfig, if any // - start by adding default teams from buildConfig, if any
if (this.buildConfigData.defaultTeams && this.buildConfigData.defaultTeams.length) { if (this.buildConfigData?.defaultTeams?.length) {
combinedTeams.push(...this.buildConfigData.defaultTeams); combinedTeams.push(...this.buildConfigData.defaultTeams);
} }
// - add registry defined teams, if any // - add registry defined teams, if any
if (this.registryConfigData.teams && this.registryConfigData.teams.length) { if (this.registryConfigData?.teams?.length) {
combinedTeams.push(...this.registryConfigData.teams); combinedTeams.push(...this.registryConfigData.teams);
} }
// - add locally defined teams only if server management is enabled // - add locally defined teams only if server management is enabled
if (this.enableServerManagement) { if (this.localConfigData && this.enableServerManagement) {
combinedTeams.push(...this.localConfigData.teams); combinedTeams.push(...this.localConfigData.teams || []);
} }
combinedTeams = this.filterOutDuplicateTeams(combinedTeams); combinedTeams = this.filterOutDuplicateTeams(combinedTeams);
combinedTeams = this.sortUnorderedTeams(combinedTeams); combinedTeams = this.sortUnorderedTeams(combinedTeams);
this.combinedData.teams = combinedTeams; if (this.combinedData) {
this.combinedData.localTeams = this.localConfigData.teams; this.combinedData.teams = combinedTeams;
this.combinedData.buildTeams = this.buildConfigData.defaultTeams; this.combinedData.registryTeams = this.registryConfigData?.teams || [];
this.combinedData.registryTeams = this.registryConfigData.teams; if (process.platform === 'darwin' || process.platform === 'win32') {
if (process.platform === 'darwin' || process.platform === 'win32') { this.combinedData.darkMode = nativeTheme.shouldUseDarkColors;
this.combinedData.darkMode = nativeTheme.shouldUseDarkColors; }
this.combinedData.appName = app.name;
} }
this.combinedData.appName = app.name;
} }
/** /**
@ -332,7 +351,7 @@ export default class Config extends EventEmitter {
* *
* @param {array} teams array of teams to check for duplicates * @param {array} teams array of teams to check for duplicates
*/ */
filterOutDuplicateTeams = (teams) => { filterOutDuplicateTeams = (teams: Team[]) => {
let newTeams = teams; let newTeams = teams;
const uniqueURLs = new Set(); const uniqueURLs = new Set();
newTeams = newTeams.filter((team) => { newTeams = newTeams.filter((team) => {
@ -345,7 +364,7 @@ export default class Config extends EventEmitter {
* Returns the provided array fo teams with existing teams filtered out * Returns the provided array fo teams with existing teams filtered out
* @param {array} teams array of teams to check for already defined teams * @param {array} teams array of teams to check for already defined teams
*/ */
filterOutPredefinedTeams = (teams) => { filterOutPredefinedTeams = (teams: Team[]) => {
let newTeams = teams; let newTeams = teams;
// filter out predefined teams // filter out predefined teams
@ -360,17 +379,17 @@ export default class Config extends EventEmitter {
* Apply a default sort order to the team list, if no order is specified. * Apply a default sort order to the team list, if no order is specified.
* @param {array} teams to sort * @param {array} teams to sort
*/ */
sortUnorderedTeams = (teams) => { sortUnorderedTeams = (teams: Team[]) => {
// We want to preserve the array order of teams in the config, otherwise a lot of bugs will occur // 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})); const mappedTeams = teams.map((team, index) => ({team, originalOrder: index}));
// Make a best pass at interpreting sort order. If an order is not specified, assume it is 0. // Make a best pass at interpreting sort order. If an order is not specified, assume it is 0.
// //
const newTeams = mappedTeams.sort((x, y) => { const newTeams = mappedTeams.sort((x, y) => {
if (x.team.order == null) { if (!x.team.order) {
x.team.order = 0; x.team.order = 0;
} }
if (y.team.order == null) { if (!y.team.order) {
y.team.order = 0; y.team.order = 0;
} }
@ -390,11 +409,15 @@ export default class Config extends EventEmitter {
// helper functions // helper functions
readFileSync = (filePath) => { readFileSync = (filePath: string) => {
return JSON.parse(fs.readFileSync(filePath, 'utf8')); return JSON.parse(fs.readFileSync(filePath, 'utf8'));
} }
writeFile = (filePath, configData, callback) => { writeFile = (filePath: string, configData: Partial<ConfigType>, callback: fs.NoParamCallback) => {
if (!this.defaultConfigData) {
return;
}
if (configData.version !== this.defaultConfigData.version) { if (configData.version !== this.defaultConfigData.version) {
throw new Error('version ' + configData.version + ' is not equal to ' + this.defaultConfigData.version); throw new Error('version ' + configData.version + ' is not equal to ' + this.defaultConfigData.version);
} }
@ -402,7 +425,11 @@ export default class Config extends EventEmitter {
fs.writeFile(filePath, json, 'utf8', callback); fs.writeFile(filePath, json, 'utf8', callback);
} }
writeFileSync = (filePath, config) => { writeFileSync = (filePath: string, config: Partial<ConfigType>) => {
if (!this.defaultConfigData) {
return;
}
if (config.version !== this.defaultConfigData.version) { if (config.version !== this.defaultConfigData.version) {
throw new Error('version ' + config.version + ' is not equal to ' + this.defaultConfigData.version); throw new Error('version ' + config.version + ' is not equal to ' + this.defaultConfigData.version);
} }
@ -416,15 +443,15 @@ export default class Config extends EventEmitter {
fs.writeFileSync(filePath, json, 'utf8'); fs.writeFileSync(filePath, json, 'utf8');
} }
merge = (base, target) => { merge = <T, T2>(base: T, target: T2) => {
return Object.assign({}, base, target); return Object.assign({}, base, target);
} }
copy = (data) => { copy = <T>(data: T) => {
return Object.assign({}, data); return Object.assign({}, data);
} }
handleGetConfiguration = (event, option) => { handleGetConfiguration = (event: Electron.IpcMainInvokeEvent, option: keyof CombinedConfig) => {
const config = {...this.combinedData}; const config = {...this.combinedData};
if (option) { if (option) {
return config[option]; return config[option];
@ -432,19 +459,19 @@ export default class Config extends EventEmitter {
return config; return config;
} }
handleGetLocalConfiguration = (event, option) => { handleGetLocalConfiguration = (event: Electron.IpcMainInvokeEvent, option: keyof ConfigType) => {
const config = {...this.localConfigData}; const config: Partial<LocalConfiguration> = {...this.localConfigData};
config.appName = app.name; config.appName = app.name;
config.enableServerManagement = this.combinedData.enableServerManagement; config.enableServerManagement = this.combinedData?.enableServerManagement;
if (option) { if (option) {
return config[option]; return config[option];
} }
return config; return config;
} }
handleUpdateTeams = (event, newTeams) => { handleUpdateTeams = (event: Electron.IpcMainInvokeEvent, newTeams: Team[]) => {
this.set('teams', newTeams); this.set('teams', newTeams);
return this.combinedData.teams; return this.combinedData!.teams;
} }
/** /**
@ -452,7 +479,7 @@ export default class Config extends EventEmitter {
* @emits 'darkModeChange' * @emits 'darkModeChange'
*/ */
handleUpdateTheme = () => { handleUpdateTheme = () => {
if (this.combinedData.darkMode !== nativeTheme.shouldUseDarkColors) { if (this.combinedData && this.combinedData.darkMode !== nativeTheme.shouldUseDarkColors) {
this.combinedData.darkMode = nativeTheme.shouldUseDarkColors; this.combinedData.darkMode = nativeTheme.shouldUseDarkColors;
this.emit('darkModeChange', this.combinedData.darkMode); this.emit('darkModeChange', this.combinedData.darkMode);
} }
@ -463,6 +490,10 @@ export default class Config extends EventEmitter {
* @emits 'darkModeChange' * @emits 'darkModeChange'
*/ */
toggleDarkModeManually = () => { toggleDarkModeManually = () => {
if (!this.combinedData) {
return;
}
this.set('darkMode', !this.combinedData.darkMode); this.set('darkMode', !this.combinedData.darkMode);
this.emit('darkModeChange', this.combinedData.darkMode); this.emit('darkModeChange', this.combinedData.darkMode);
} }

View file

@ -1,12 +1,14 @@
// Copyright (c) 2015-2016 Yuya Ochiai // Copyright (c) 2015-2016 Yuya Ochiai
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. // Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information. // See LICENSE.txt for license information.
import {ConfigV0, ConfigV1} from 'types/config';
import defaultPreferences from './defaultPreferences'; import defaultPreferences from './defaultPreferences';
const pastDefaultPreferences = { const pastDefaultPreferences = {
0: { 0: {
url: '', url: '',
}, } as ConfigV0,
1: { 1: {
version: 1, version: 1,
teams: [], teams: [],
@ -23,9 +25,8 @@ const pastDefaultPreferences = {
enableHardwareAcceleration: true, enableHardwareAcceleration: true,
autostart: true, autostart: true,
spellCheckerLocale: 'en-US', spellCheckerLocale: 'en-US',
}, } as ConfigV1,
2: defaultPreferences,
}; };
pastDefaultPreferences[`${defaultPreferences.version}`] = defaultPreferences;
export default pastDefaultPreferences; export default pastDefaultPreferences;

View file

@ -1,42 +0,0 @@
// Copyright (c) 2015-2016 Yuya Ochiai
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import pastDefaultPreferences from './pastDefaultPreferences';
function deepCopy(object) {
return JSON.parse(JSON.stringify(object));
}
function upgradeV0toV1(configV0) {
const config = deepCopy(pastDefaultPreferences['1']);
if (config.version !== 1) {
throw new Error('pastDefaultPreferences[\'1\'].version is not equal to 1');
}
config.teams.push({
name: 'Primary team',
url: configV0.url,
});
return config;
}
function upgradeV1toV2(configV1) {
const config = deepCopy(configV1);
config.version = 2;
config.teams.forEach((value, index) => {
value.order = index;
});
config.darkMode = false;
return config;
}
export default function upgradeToLatest(config) {
const configVersion = config.version ? config.version : 0;
switch (configVersion) {
case 1:
return upgradeToLatest(upgradeV1toV2(config));
case 0:
return upgradeToLatest(upgradeV0toV1(config));
default:
return config;
}
}

View file

@ -0,0 +1,42 @@
// 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 pastDefaultPreferences from './pastDefaultPreferences';
function deepCopy<T>(object: T): T {
return JSON.parse(JSON.stringify(object));
}
function upgradeV0toV1(configV0: ConfigV0) {
const config = deepCopy(pastDefaultPreferences[1]);
config.teams.push({
name: 'Primary team',
url: configV0.url,
});
return config;
}
function upgradeV1toV2(configV1: ConfigV1) {
const config: ConfigV2 = Object.assign({}, deepCopy<ConfigV2>(pastDefaultPreferences[2]), configV1);
config.version = 2;
config.teams = configV1.teams.map((value, index) => {
return {
...value,
order: index,
};
});
return config;
}
export default function upgradeToLatest(config: AnyConfig): ConfigV2 {
switch (config.version) {
case 2:
return config as ConfigV2;
case 1:
return upgradeToLatest(upgradeV1toV2(config as ConfigV1));
default:
return upgradeToLatest(upgradeV0toV1(config as ConfigV0));
}
}

View file

@ -3,6 +3,6 @@
// See LICENSE.txt for license information. // See LICENSE.txt for license information.
import deepmerge from 'deepmerge'; import deepmerge from 'deepmerge';
export default function deepMergeProxy(x, y, options) { export default function deepMergeProxy<T>(x: Partial<T>, y: Partial<T>, options: deepmerge.Options) {
return deepmerge(x, y, options); // due to webpack conversion return deepmerge(x, y, options); // due to webpack conversion
} }

View file

@ -9,7 +9,7 @@ const releaseSplit = os.release().split('.');
export default { export default {
major: parseInt(releaseSplit[0], 10), major: parseInt(releaseSplit[0], 10),
minor: parseInt(releaseSplit[1], 10), minor: parseInt(releaseSplit[1], 10),
isLowerThanOrEqualWindows8_1() { isLowerThanOrEqualWindows8_1(): boolean {
if (process.platform !== 'win32') { if (process.platform !== 'win32') {
return false; return false;
} }

View file

@ -1,6 +1,8 @@
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. // Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information. // See LICENSE.txt for license information.
import {Team} from 'types/config';
import {ServerFromURL} from 'types/utils';
import {isHttpsUri, isHttpUri, isUri} from 'valid-url'; import {isHttpsUri, isHttpUri, isUri} from 'valid-url';
import buildConfig from '../config/buildConfig'; import buildConfig from '../config/buildConfig';
@ -19,34 +21,31 @@ const customLoginRegexPaths = [
/^\/login\/sso\/saml$/i, /^\/login\/sso\/saml$/i,
]; ];
function getDomain(inputURL) { function getDomain(inputURL: URL | string) {
const parsedURL = parseURL(inputURL); const parsedURL = parseURL(inputURL);
return parsedURL.origin; return parsedURL?.origin;
} }
function isValidURL(testURL) { function isValidURL(testURL: string) {
return Boolean(isHttpUri(testURL) || isHttpsUri(testURL)) && parseURL(testURL) !== null; return Boolean(isHttpUri(testURL) || isHttpsUri(testURL)) && parseURL(testURL) !== null;
} }
function isValidURI(testURL) { function isValidURI(testURL: string) {
return Boolean(isUri(testURL)); return Boolean(isUri(testURL));
} }
function parseURL(inputURL) { function parseURL(inputURL: URL | string) {
if (!inputURL) {
return null;
}
if (inputURL instanceof URL) { if (inputURL instanceof URL) {
return inputURL; return inputURL;
} }
try { try {
return new URL(inputURL.replace(/([^:]\/)\/+/g, '$1')); return new URL(inputURL.replace(/([^:]\/)\/+/g, '$1'));
} catch (e) { } catch (e) {
return null; return undefined;
} }
} }
function getHost(inputURL) { function getHost(inputURL: URL | string) {
const parsedURL = parseURL(inputURL); const parsedURL = parseURL(inputURL);
if (parsedURL) { if (parsedURL) {
return parsedURL.origin; return parsedURL.origin;
@ -57,7 +56,7 @@ function getHost(inputURL) {
// isInternalURL determines if the target url is internal to the application. // isInternalURL determines if the target url is internal to the application.
// - currentURL is the current url inside the webview // - currentURL is the current url inside the webview
// - basename is the global export from the Mattermost application defining the subpath, if any // - basename is the global export from the Mattermost application defining the subpath, if any
function isInternalURL(targetURL, currentURL, basename = '/') { function isInternalURL(targetURL: URL, currentURL: URL, basename = '/') {
if (targetURL.host !== currentURL.host) { if (targetURL.host !== currentURL.host) {
return false; return false;
} }
@ -69,16 +68,16 @@ function isInternalURL(targetURL, currentURL, basename = '/') {
return true; return true;
} }
function getServerInfo(serverUrl) { function getServerInfo(serverUrl: URL | string) {
const parsedServer = parseURL(serverUrl); const parsedServer = parseURL(serverUrl);
if (!parsedServer) { if (!parsedServer) {
return null; return undefined;
} }
// does the server have a subpath? // does the server have a subpath?
const pn = parsedServer.pathname.toLowerCase(); const pn = parsedServer.pathname.toLowerCase();
const subpath = pn.endsWith('/') ? pn.toLowerCase() : `${pn}/`; const subpath = pn.endsWith('/') ? pn.toLowerCase() : `${pn}/`;
return {origin: parsedServer.origin, subpath, url: parsedServer}; return {subpath, url: parsedServer};
} }
function getManagedResources() { function getManagedResources() {
@ -89,20 +88,23 @@ function getManagedResources() {
return buildConfig.managedResources || []; return buildConfig.managedResources || [];
} }
function isAdminUrl(serverUrl, inputUrl) { function isAdminUrl(serverUrl: URL | string, inputUrl: URL | string) {
const parsedURL = parseURL(inputUrl); const parsedURL = parseURL(inputUrl);
const server = getServerInfo(serverUrl); const server = getServerInfo(serverUrl);
if (!parsedURL || !server || (!equalUrlsIgnoringSubpath(server, parsedURL))) { if (!parsedURL || !server || (!equalUrlsIgnoringSubpath(server.url, parsedURL))) {
return null; return null;
} }
return (parsedURL.pathname.toLowerCase().startsWith(`${server.subpath}/admin_console/`) || return (parsedURL.pathname.toLowerCase().startsWith(`${server.subpath}/admin_console/`) ||
parsedURL.pathname.toLowerCase().startsWith('/admin_console/')); parsedURL.pathname.toLowerCase().startsWith('/admin_console/'));
} }
function isTeamUrl(serverUrl, inputUrl, withApi) { function isTeamUrl(serverUrl: URL | string, inputUrl: URL | string, withApi?: boolean) {
if (!serverUrl || !inputUrl) {
return false;
}
const parsedURL = parseURL(inputUrl); const parsedURL = parseURL(inputUrl);
const server = getServerInfo(serverUrl); const server = getServerInfo(serverUrl);
if (!parsedURL || !server || (!equalUrlsIgnoringSubpath(server, parsedURL))) { if (!parsedURL || !server || (!equalUrlsIgnoringSubpath(server.url, parsedURL))) {
return null; return null;
} }
@ -128,19 +130,19 @@ function isTeamUrl(serverUrl, inputUrl, withApi) {
parsedURL.pathname.toLowerCase().startsWith(`/${testPath}/`)))); parsedURL.pathname.toLowerCase().startsWith(`/${testPath}/`))));
} }
function isPluginUrl(serverUrl, inputURL) { function isPluginUrl(serverUrl: URL | string, inputURL: URL | string) {
const server = getServerInfo(serverUrl); const server = getServerInfo(serverUrl);
const parsedURL = parseURL(inputURL); const parsedURL = parseURL(inputURL);
if (!parsedURL || !server) { if (!parsedURL || !server) {
return false; return false;
} }
return ( return (
equalUrlsIgnoringSubpath(server, parsedURL) && equalUrlsIgnoringSubpath(server.url, parsedURL) &&
(parsedURL.pathname.toLowerCase().startsWith(`${server.subpath}plugins/`) || (parsedURL.pathname.toLowerCase().startsWith(`${server.subpath}plugins/`) ||
parsedURL.pathname.toLowerCase().startsWith('/plugins/'))); parsedURL.pathname.toLowerCase().startsWith('/plugins/')));
} }
function isManagedResource(serverUrl, inputURL) { function isManagedResource(serverUrl: URL | string, inputURL: URL | string) {
const server = getServerInfo(serverUrl); const server = getServerInfo(serverUrl);
const parsedURL = parseURL(inputURL); const parsedURL = parseURL(inputURL);
if (!parsedURL || !server) { if (!parsedURL || !server) {
@ -150,19 +152,22 @@ function isManagedResource(serverUrl, inputURL) {
const managedResources = getManagedResources(); const managedResources = getManagedResources();
return ( return (
equalUrlsIgnoringSubpath(server, parsedURL) && managedResources && managedResources.length && equalUrlsIgnoringSubpath(server.url, parsedURL) && managedResources && managedResources.length &&
managedResources.some((managedResource) => (parsedURL.pathname.toLowerCase().startsWith(`${server.subpath}${managedResource}/`) || parsedURL.pathname.toLowerCase().startsWith(`/${managedResource}/`)))); managedResources.some((managedResource) => (parsedURL.pathname.toLowerCase().startsWith(`${server.subpath}${managedResource}/`) || parsedURL.pathname.toLowerCase().startsWith(`/${managedResource}/`))));
} }
function getServer(inputURL, teams, ignoreScheme = false) { function getServer(inputURL: URL | string, teams: Team[], ignoreScheme = false): ServerFromURL | undefined {
const parsedURL = parseURL(inputURL); const parsedURL = parseURL(inputURL);
if (!parsedURL) { if (!parsedURL) {
return null; return undefined;
} }
let parsedServerUrl; let parsedServerUrl;
let secondOption = null; let secondOption;
for (let i = 0; i < teams.length; i++) { for (let i = 0; i < teams.length; i++) {
parsedServerUrl = parseURL(teams[i].url); parsedServerUrl = parseURL(teams[i].url);
if (!parsedServerUrl) {
continue;
}
// check server and subpath matches (without subpath pathname is \ so it always matches) // check server and subpath matches (without subpath pathname is \ so it always matches)
if (equalUrlsWithSubpath(parsedServerUrl, parsedURL, ignoreScheme)) { if (equalUrlsWithSubpath(parsedServerUrl, parsedURL, ignoreScheme)) {
@ -180,21 +185,21 @@ function getServer(inputURL, teams, ignoreScheme = false) {
} }
// next two functions are defined to clarify intent // next two functions are defined to clarify intent
function equalUrlsWithSubpath(url1, url2, ignoreScheme) { function equalUrlsWithSubpath(url1: URL, url2: URL, ignoreScheme?: boolean) {
if (ignoreScheme) { if (ignoreScheme) {
return url1.host === url2.host && url2.pathname.toLowerCase().startsWith(url1.pathname.toLowerCase()); return url1.host === url2.host && url2.pathname.toLowerCase().startsWith(url1.pathname.toLowerCase());
} }
return url1.origin === url2.origin && url2.pathname.toLowerCase().startsWith(url1.pathname.toLowerCase()); return url1.origin === url2.origin && url2.pathname.toLowerCase().startsWith(url1.pathname.toLowerCase());
} }
function equalUrlsIgnoringSubpath(url1, url2, ignoreScheme) { function equalUrlsIgnoringSubpath(url1: URL, url2: URL, ignoreScheme?: boolean) {
if (ignoreScheme) { if (ignoreScheme) {
return url1.host.toLowerCase() === url2.host.toLowerCase(); return url1.host.toLowerCase() === url2.host.toLowerCase();
} }
return url1.origin.toLowerCase() === url2.origin.toLowerCase(); return url1.origin.toLowerCase() === url2.origin.toLowerCase();
} }
function isTrustedURL(url, teams) { function isTrustedURL(url: URL | string, teams: Team[]) {
const parsedURL = parseURL(url); const parsedURL = parseURL(url);
if (!parsedURL) { if (!parsedURL) {
return false; return false;
@ -202,8 +207,8 @@ function isTrustedURL(url, teams) {
return getServer(parsedURL, teams) !== null; return getServer(parsedURL, teams) !== null;
} }
function isCustomLoginURL(url, server, teams) { function isCustomLoginURL(url: URL | string, server: ServerFromURL, teams: Team[]): boolean {
const subpath = (server === null || typeof server === 'undefined') ? '' : server.url.pathname; const subpath = server ? server.url.pathname : '';
const parsedURL = parseURL(url); const parsedURL = parseURL(url);
if (!parsedURL) { if (!parsedURL) {
return false; return false;
@ -212,7 +217,7 @@ function isCustomLoginURL(url, server, teams) {
return false; return false;
} }
const urlPath = parsedURL.pathname; const urlPath = parsedURL.pathname;
if ((subpath !== '' || subpath !== '/') && urlPath.startsWith(subpath)) { if (subpath !== '' && subpath !== '/' && urlPath.startsWith(subpath)) {
const replacement = subpath.endsWith('/') ? '/' : ''; const replacement = subpath.endsWith('/') ? '/' : '';
const replacedPath = urlPath.replace(subpath, replacement); const replacedPath = urlPath.replace(subpath, replacement);
for (const regexPath of customLoginRegexPaths) { for (const regexPath of customLoginRegexPaths) {

View file

@ -2,7 +2,7 @@
// See LICENSE.txt for license information. // See LICENSE.txt for license information.
// Copyright (c) 2015-2016 Yuya Ochiai // Copyright (c) 2015-2016 Yuya Ochiai
import electron, {BrowserWindow} from 'electron'; import electron from 'electron';
import {DEVELOPMENT, PRODUCTION} from './constants'; import {DEVELOPMENT, PRODUCTION} from './constants';
@ -27,26 +27,9 @@ function runMode() {
return process.env.NODE_ENV === PRODUCTION ? PRODUCTION : DEVELOPMENT; return process.env.NODE_ENV === PRODUCTION ? PRODUCTION : DEVELOPMENT;
} }
// workaround until electron 12 hits, since fromWebContents return a null value if using a webcontent from browserview
function browserWindowFromWebContents(content) {
let window;
if (content.type === 'browserview') {
for (const win of BrowserWindow.getAllWindows()) {
for (const view of win.getBrowserViews()) {
if (view.webContents.id === content.id) {
window = win;
}
}
}
} else {
window = BrowserWindow.fromWebContents(content);
}
return window;
}
const DEFAULT_MAX = 20; const DEFAULT_MAX = 20;
function shorten(string, max) { function shorten(string: string, max?: number) {
const maxLength = (max && max >= 4) ? max : DEFAULT_MAX; const maxLength = (max && max >= 4) ? max : DEFAULT_MAX;
if (string.length >= maxLength) { if (string.length >= maxLength) {
return `${string.slice(0, maxLength - 3)}...`; return `${string.slice(0, maxLength - 3)}...`;
@ -57,6 +40,5 @@ function shorten(string, max) {
export default { export default {
getDisplayBoundaries, getDisplayBoundaries,
runMode, runMode,
browserWindowFromWebContents,
shorten, shorten,
}; };

View file

@ -2,12 +2,14 @@
// See LICENSE.txt for license information. // See LICENSE.txt for license information.
// Copyright (c) 2015-2016 Yuya Ochiai // Copyright (c) 2015-2016 Yuya Ochiai
import {AppState} from 'types/appState';
import JsonFileManager from '../common/JsonFileManager'; import JsonFileManager from '../common/JsonFileManager';
import * as Validator from './Validator'; import * as Validator from './Validator';
export default class AppVersionManager extends JsonFileManager { export default class AppVersionManager extends JsonFileManager<AppState> {
constructor(file) { constructor(file: string) {
super(file); super(file);
// ensure data loaded from file is valid // ensure data loaded from file is valid
@ -33,7 +35,7 @@ export default class AppVersionManager extends JsonFileManager {
} }
set updateCheckedDate(date) { set updateCheckedDate(date) {
this.setValue('updateCheckedDate', date.toISOString()); this.setValue('updateCheckedDate', date?.toISOString());
} }
get updateCheckedDate() { get updateCheckedDate() {

View file

@ -8,6 +8,8 @@ import isDev from 'electron-is-dev';
import log from 'electron-log'; import log from 'electron-log';
export default class AutoLauncher { export default class AutoLauncher {
appLauncher: AutoLaunch;
constructor() { constructor() {
this.appLauncher = new AutoLaunch({ this.appLauncher = new AutoLaunch({
name: app.name, name: app.name,

View file

@ -7,7 +7,7 @@ import fs from 'fs';
import os from 'os'; import os from 'os';
import path from 'path'; import path from 'path';
import {app, dialog} from 'electron'; import {app, BrowserWindow, dialog} from 'electron';
import log from 'electron-log'; import log from 'electron-log';
@ -15,15 +15,17 @@ const BUTTON_OK = 'OK';
const BUTTON_SHOW_DETAILS = 'Show Details'; const BUTTON_SHOW_DETAILS = 'Show Details';
const BUTTON_REOPEN = 'Reopen'; const BUTTON_REOPEN = 'Reopen';
function createErrorReport(err) { function createErrorReport(err: Error) {
// eslint-disable-next-line no-undef // eslint-disable-next-line no-undef
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
return `Application: ${app.name} ${app.getVersion()} [commit: ${__HASH_VERSION__}]\n` + return `Application: ${app.name} ${app.getVersion()} [commit: ${__HASH_VERSION__}]\n` +
`Platform: ${os.type()} ${os.release()} ${os.arch()}\n` + `Platform: ${os.type()} ${os.release()} ${os.arch()}\n` +
`${err.stack}`; `${err.stack}`;
} }
function openDetachedExternal(url) { function openDetachedExternal(url: string) {
const spawnOption = {detached: true, stdio: 'ignore'}; const spawnOption = {detached: true, stdio: 'ignore' as any};
switch (process.platform) { switch (process.platform) {
case 'win32': case 'win32':
return spawn('cmd', ['/C', 'start', url], spawnOption); return spawn('cmd', ['/C', 'start', url], spawnOption);
@ -32,20 +34,21 @@ function openDetachedExternal(url) {
case 'linux': case 'linux':
return spawn('xdg-open', [url], spawnOption); return spawn('xdg-open', [url], spawnOption);
default: default:
return null; return undefined;
} }
} }
export default class CriticalErrorHandler { export default class CriticalErrorHandler {
constructor() { mainWindow?: BrowserWindow;
this.mainWindow = null;
}
setMainWindow(mainWindow) { setMainWindow(mainWindow: BrowserWindow) {
this.mainWindow = mainWindow; this.mainWindow = mainWindow;
} }
windowUnresponsiveHandler() { windowUnresponsiveHandler() {
if (!this.mainWindow) {
return;
}
dialog.showMessageBox(this.mainWindow, { dialog.showMessageBox(this.mainWindow, {
type: 'warning', type: 'warning',
title: app.name, title: app.name,
@ -59,7 +62,7 @@ export default class CriticalErrorHandler {
}); });
} }
processUncaughtExceptionHandler(err) { processUncaughtExceptionHandler(err: Error) {
const file = path.join(app.getPath('userData'), `uncaughtException-${Date.now()}.txt`); const file = path.join(app.getPath('userData'), `uncaughtException-${Date.now()}.txt`);
const report = createErrorReport(err); const report = createErrorReport(err);
fs.writeFileSync(file, report.replace(new RegExp('\\n', 'g'), os.EOL)); fs.writeFileSync(file, report.replace(new RegExp('\\n', 'g'), os.EOL));
@ -69,9 +72,11 @@ export default class CriticalErrorHandler {
if (process.platform === 'darwin') { if (process.platform === 'darwin') {
buttons.reverse(); buttons.reverse();
} }
const bindWindow = this.mainWindow && this.mainWindow.isVisible() ? this.mainWindow : null; if (!this.mainWindow?.isVisible) {
return;
}
dialog.showMessageBox( dialog.showMessageBox(
bindWindow, this.mainWindow,
{ {
type: 'error', type: 'error',
title: app.name, title: app.name,
@ -102,7 +107,7 @@ export default class CriticalErrorHandler {
app.exit(-1); app.exit(-1);
}); });
} else { } else {
log.err(`Window wasn't ready to handle the error: ${err}\ntrace: ${err.stack}`); log.error(`Window wasn't ready to handle the error: ${err}\ntrace: ${err.stack}`);
throw err; throw err;
} }
} }

View file

@ -4,9 +4,11 @@
import urlUtils from 'common/utils/url'; import urlUtils from 'common/utils/url';
export class MattermostServer { export class MattermostServer {
constructor(name, serverUrl) { name: string;
url: URL;
constructor(name: string, serverUrl: string) {
this.name = name; this.name = name;
this.url = urlUtils.parseURL(serverUrl); this.url = urlUtils.parseURL(serverUrl)!;
if (!this.url) { if (!this.url) {
throw new Error('Invalid url for creating a server'); throw new Error('Invalid url for creating a server');
} }
@ -19,12 +21,12 @@ export class MattermostServer {
return {origin: this.url.origin, subpath, url: this.url.toString()}; return {origin: this.url.origin, subpath, url: this.url.toString()};
} }
sameOrigin = (otherURL) => { sameOrigin = (otherURL: string) => {
const parsedUrl = urlUtils.parseURL(otherURL); const parsedUrl = urlUtils.parseURL(otherURL);
return parsedUrl && this.url.origin === parsedUrl.origin; return parsedUrl && this.url.origin === parsedUrl.origin;
} }
equals = (otherServer) => { equals = (otherServer: MattermostServer) => {
return (this.name === otherServer.name) && (this.url.toString() === otherServer.url.toString()); return (this.name === otherServer.name) && (this.url.toString() === otherServer.url.toString());
} }
} }

View file

@ -1,17 +1,18 @@
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. // Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information. // See LICENSE.txt for license information.
import {Args} from 'types/args';
import yargs from 'yargs'; import yargs from 'yargs';
import {protocols} from '../../electron-builder.json'; import {protocols} from '../../electron-builder.json';
import * as Validator from './Validator'; import * as Validator from './Validator';
export default function parse(args) { export default function parse(args: string[]) {
return validateArgs(parseArgs(triageArgs(args))); return validateArgs(parseArgs(triageArgs(args)));
} }
function triageArgs(args) { function triageArgs(args: string[]) {
// ensure any args following a possible deeplink are discarded // ensure any args following a possible deeplink are discarded
if (protocols && protocols[0] && protocols[0].schemes && protocols[0].schemes[0]) { if (protocols && protocols[0] && protocols[0].schemes && protocols[0].schemes[0]) {
const scheme = protocols[0].schemes[0].toLowerCase(); const scheme = protocols[0].schemes[0].toLowerCase();
@ -23,7 +24,7 @@ function triageArgs(args) {
return args; return args;
} }
function parseArgs(args) { function parseArgs(args: string[]) {
return yargs. return yargs.
alias('dataDir', 'd').string('dataDir').describe('dataDir', 'Set the path to where user data is stored.'). alias('dataDir', 'd').string('dataDir').describe('dataDir', 'Set the path to where user data is stored.').
alias('disableDevMode', 'p').boolean('disableDevMode').describe('disableDevMode', 'Disable development mode. Allows for testing as if it was Production.'). alias('disableDevMode', 'p').boolean('disableDevMode').describe('disableDevMode', 'Disable development mode. Allows for testing as if it was Production.').
@ -32,6 +33,6 @@ function parseArgs(args) {
parse(args); parse(args);
} }
function validateArgs(args) { function validateArgs(args: Args) {
return Validator.validateArgs(args) || {}; return Validator.validateArgs(args) || {};
} }

View file

@ -1,7 +1,7 @@
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. // Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information. // See LICENSE.txt for license information.
import EventEmitter from 'events'; import {EventEmitter} from 'events';
import electron from 'electron'; import electron from 'electron';
import log from 'electron-log'; import log from 'electron-log';
@ -12,12 +12,21 @@ const {app} = electron;
* Monitors system idle time, listens for system events and fires status updates as needed * Monitors system idle time, listens for system events and fires status updates as needed
*/ */
export default class UserActivityMonitor extends EventEmitter { export default class UserActivityMonitor extends EventEmitter {
isActive: boolean;
idleTime: number;
lastSetActive?: number;
systemIdleTimeIntervalID: number;
config: {
updateFrequencyMs: number;
inactiveThresholdMs: number;
statusUpdateThresholdMs: number;
};
constructor() { constructor() {
super(); super();
this.isActive = true; this.isActive = true;
this.idleTime = 0; this.idleTime = 0;
this.lastSetActive = null;
this.systemIdleTimeIntervalID = -1; this.systemIdleTimeIntervalID = -1;
this.config = { this.config = {
@ -58,13 +67,14 @@ export default class UserActivityMonitor extends EventEmitter {
this.config = Object.assign({}, this.config, config); this.config = Object.assign({}, this.config, config);
// TODO: Node typings don't map Timeout to number, but then clearInterval requires a number?
this.systemIdleTimeIntervalID = setInterval(() => { this.systemIdleTimeIntervalID = setInterval(() => {
try { try {
this.updateIdleTime(electron.powerMonitor.getSystemIdleTime()); this.updateIdleTime(electron.powerMonitor.getSystemIdleTime());
} catch (err) { } catch (err) {
log.error('Error getting system idle time:', err); log.error('Error getting system idle time:', err);
} }
}, this.config.updateFrequencyMs); }, this.config.updateFrequencyMs) as unknown as number;
} }
/** /**
@ -80,7 +90,7 @@ export default class UserActivityMonitor extends EventEmitter {
* @param {integer} idleTime * @param {integer} idleTime
* @private * @private
*/ */
updateIdleTime(idleTime) { updateIdleTime(idleTime: number) {
this.idleTime = idleTime; this.idleTime = idleTime;
if (idleTime * 1000 > this.config.inactiveThresholdMs) { // eslint-disable-line no-magic-numbers if (idleTime * 1000 > this.config.inactiveThresholdMs) { // eslint-disable-line no-magic-numbers
this.setActivityState(false); this.setActivityState(false);
@ -110,7 +120,7 @@ export default class UserActivityMonitor extends EventEmitter {
this.sendStatusUpdate(false); this.sendStatusUpdate(false);
this.lastSetActive = now; this.lastSetActive = now;
} else if (!isActive) { } else if (!isActive) {
this.lastSetActive = null; delete this.lastSetActive;
} }
} }

View file

@ -4,6 +4,13 @@ import log from 'electron-log';
import Joi from '@hapi/joi'; import Joi from '@hapi/joi';
import {Args} from 'types/args';
import {ConfigV0, ConfigV1, ConfigV2} from 'types/config';
import {SavedWindowState} from 'types/mainWindow';
import {AppState} from 'types/appState';
import {ComparableCertificate} from 'types/certificate';
import {PermissionType, TrustedOrigin} from 'types/trustedOrigin';
import urlUtils from 'common/utils/url'; import urlUtils from 'common/utils/url';
const defaultOptions = { const defaultOptions = {
@ -14,14 +21,14 @@ const defaultWindowHeight = 700;
const minWindowWidth = 400; const minWindowWidth = 400;
const minWindowHeight = 240; const minWindowHeight = 240;
const argsSchema = Joi.object({ const argsSchema = Joi.object<Args>({
hidden: Joi.boolean(), hidden: Joi.boolean(),
disableDevMode: Joi.boolean(), disableDevMode: Joi.boolean(),
dataDir: Joi.string(), dataDir: Joi.string(),
version: Joi.boolean(), version: Joi.boolean(),
}); });
const boundsInfoSchema = Joi.object({ const boundsInfoSchema = Joi.object<SavedWindowState>({
x: Joi.number().integer().default(0), x: Joi.number().integer().default(0),
y: Joi.number().integer().default(0), y: Joi.number().integer().default(0),
width: Joi.number().integer().min(minWindowWidth).required().default(defaultWindowWidth), width: Joi.number().integer().min(minWindowWidth).required().default(defaultWindowWidth),
@ -30,17 +37,17 @@ const boundsInfoSchema = Joi.object({
fullscreen: Joi.boolean().default(false), fullscreen: Joi.boolean().default(false),
}); });
const appStateSchema = Joi.object({ const appStateSchema = Joi.object<AppState>({
lastAppVersion: Joi.string(), lastAppVersion: Joi.string(),
skippedVersion: Joi.string(), skippedVersion: Joi.string(),
updateCheckedDate: Joi.string(), updateCheckedDate: Joi.string(),
}); });
const configDataSchemaV0 = Joi.object({ const configDataSchemaV0 = Joi.object<ConfigV0>({
url: Joi.string().required(), url: Joi.string().required(),
}); });
const configDataSchemaV1 = Joi.object({ const configDataSchemaV1 = Joi.object<ConfigV1>({
version: Joi.number().min(1).default(1), version: Joi.number().min(1).default(1),
teams: Joi.array().items(Joi.object({ teams: Joi.array().items(Joi.object({
name: Joi.string().required(), name: Joi.string().required(),
@ -61,7 +68,7 @@ const configDataSchemaV1 = Joi.object({
spellCheckerLocale: Joi.string().regex(/^[a-z]{2}-[A-Z]{2}$/).default('en-US'), spellCheckerLocale: Joi.string().regex(/^[a-z]{2}-[A-Z]{2}$/).default('en-US'),
}); });
const configDataSchemaV2 = Joi.object({ const configDataSchemaV2 = Joi.object<ConfigV2>({
version: Joi.number().min(2).default(2), version: Joi.number().min(2).default(2),
teams: Joi.array().items(Joi.object({ teams: Joi.array().items(Joi.object({
name: Joi.string().required(), name: Joi.string().required(),
@ -88,13 +95,13 @@ const configDataSchemaV2 = Joi.object({
// eg. data['community.mattermost.com'] = { data: 'certificate data', issuerName: 'COMODO RSA Domain Validation Secure Server CA'}; // eg. data['community.mattermost.com'] = { data: 'certificate data', issuerName: 'COMODO RSA Domain Validation Secure Server CA'};
const certificateStoreSchema = Joi.object().pattern( const certificateStoreSchema = Joi.object().pattern(
Joi.string().uri(), Joi.string().uri(),
Joi.object({ Joi.object<ComparableCertificate>({
data: Joi.string(), data: Joi.string(),
issuerName: Joi.string(), issuerName: Joi.string(),
}), }),
); );
const originPermissionsSchema = Joi.object().keys({ const originPermissionsSchema = Joi.object<TrustedOrigin>().keys({
canBasicAuth: Joi.boolean().default(false), // we can add more permissions later if we want canBasicAuth: Joi.boolean().default(false), // we can add more permissions later if we want
}); });
@ -108,27 +115,27 @@ const trustedOriginsSchema = Joi.object({}).pattern(
const allowedProtocolsSchema = Joi.array().items(Joi.string().regex(/^[a-z-]+:$/i)); const allowedProtocolsSchema = Joi.array().items(Joi.string().regex(/^[a-z-]+:$/i));
// validate bounds_info.json // validate bounds_info.json
export function validateArgs(data) { export function validateArgs(data: Args) {
return validateAgainstSchema(data, argsSchema); return validateAgainstSchema(data, argsSchema);
} }
// validate bounds_info.json // validate bounds_info.json
export function validateBoundsInfo(data) { export function validateBoundsInfo(data: SavedWindowState) {
return validateAgainstSchema(data, boundsInfoSchema); return validateAgainstSchema(data, boundsInfoSchema);
} }
// validate app_state.json // validate app_state.json
export function validateAppState(data) { export function validateAppState(data: AppState) {
return validateAgainstSchema(data, appStateSchema); return validateAgainstSchema(data, appStateSchema);
} }
// validate v.0 config.json // validate v.0 config.json
export function validateV0ConfigData(data) { export function validateV0ConfigData(data: ConfigV0) {
return validateAgainstSchema(data, configDataSchemaV0); return validateAgainstSchema(data, configDataSchemaV0);
} }
// validate v.1 config.json // validate v.1 config.json
export function validateV1ConfigData(data) { export function validateV1ConfigData(data: ConfigV1) {
if (Array.isArray(data.teams) && data.teams.length) { if (Array.isArray(data.teams) && data.teams.length) {
// first replace possible backslashes with forward slashes // first replace possible backslashes with forward slashes
let teams = data.teams.map(({name, url}) => { let teams = data.teams.map(({name, url}) => {
@ -148,7 +155,7 @@ export function validateV1ConfigData(data) {
return validateAgainstSchema(data, configDataSchemaV1); return validateAgainstSchema(data, configDataSchemaV1);
} }
export function validateV2ConfigData(data) { export function validateV2ConfigData(data: ConfigV2) {
if (Array.isArray(data.teams) && data.teams.length) { if (Array.isArray(data.teams) && data.teams.length) {
// first replace possible backslashes with forward slashes // first replace possible backslashes with forward slashes
let teams = data.teams.map(({name, url, order}) => { let teams = data.teams.map(({name, url, order}) => {
@ -169,39 +176,39 @@ export function validateV2ConfigData(data) {
} }
// validate certificate.json // validate certificate.json
export function validateCertificateStore(data) { export function validateCertificateStore(data: string | Record<string, ComparableCertificate>) {
const jsonData = (typeof data === 'object' ? data : JSON.parse(data)); const jsonData = (typeof data === 'object' ? data : JSON.parse(data));
return validateAgainstSchema(jsonData, certificateStoreSchema); return validateAgainstSchema(jsonData, certificateStoreSchema);
} }
// validate allowedProtocols.json // validate allowedProtocols.json
export function validateAllowedProtocols(data) { export function validateAllowedProtocols(data: string[]) {
return validateAgainstSchema(data, allowedProtocolsSchema); return validateAgainstSchema(data, allowedProtocolsSchema);
} }
export function validateTrustedOriginsStore(data) { export function validateTrustedOriginsStore(data: string | Record<PermissionType, TrustedOrigin>) {
const jsonData = (typeof data === 'object' ? data : JSON.parse(data)); const jsonData: Record<PermissionType, TrustedOrigin> = (typeof data === 'object' ? data : JSON.parse(data));
return validateAgainstSchema(jsonData, trustedOriginsSchema); return validateAgainstSchema(jsonData, trustedOriginsSchema);
} }
export function validateOriginPermissions(data) { export function validateOriginPermissions(data: string | TrustedOrigin) {
const jsonData = (typeof data === 'object' ? data : JSON.parse(data)); const jsonData: TrustedOrigin = (typeof data === 'object' ? data : JSON.parse(data));
return validateAgainstSchema(jsonData, originPermissionsSchema); return validateAgainstSchema(jsonData, originPermissionsSchema);
} }
function validateAgainstSchema(data, schema) { function validateAgainstSchema<T>(data: T, schema: Joi.ObjectSchema<T> | Joi.ArraySchema): T | null {
if (typeof data !== 'object') { if (typeof data !== 'object') {
log.error(`Input 'data' is not an object we can validate: ${typeof data}`); log.error(`Input 'data' is not an object we can validate: ${typeof data}`);
return false; return null;
} }
if (!schema) { if (!schema) {
log.error('No schema provided to validate'); log.error('No schema provided to validate');
return false; return null;
} }
const {error, value} = schema.validate(data, defaultOptions); const {error, value} = schema.validate(data, defaultOptions);
if (error) { if (error) {
log.error(`Validation failed due to: ${error}`); log.error(`Validation failed due to: ${error}`);
return false; return null;
} }
return value; return value;
} }

View file

@ -16,9 +16,9 @@ import * as Validator from './Validator';
import {getMainWindow} from './windows/windowManager'; import {getMainWindow} from './windows/windowManager';
const allowedProtocolFile = path.resolve(app.getPath('userData'), 'allowedProtocols.json'); const allowedProtocolFile = path.resolve(app.getPath('userData'), 'allowedProtocols.json');
let allowedProtocols = []; let allowedProtocols: string[] = [];
function addScheme(scheme) { function addScheme(scheme: string) {
const proto = `${scheme}:`; const proto = `${scheme}:`;
if (!allowedProtocols.includes(proto)) { if (!allowedProtocols.includes(proto)) {
allowedProtocols.push(proto); allowedProtocols.push(proto);
@ -41,12 +41,16 @@ function init() {
}); });
} }
function handleDialogEvent(protocol, URL) { function handleDialogEvent(protocol: string, URL: string) {
if (allowedProtocols.indexOf(protocol) !== -1) { if (allowedProtocols.indexOf(protocol) !== -1) {
shell.openExternal(URL); shell.openExternal(URL);
return; return;
} }
dialog.showMessageBox(getMainWindow(), { const mainWindow = getMainWindow();
if (!mainWindow) {
return;
}
dialog.showMessageBox(mainWindow, {
title: 'Non http(s) protocol', title: 'Non http(s) protocol',
message: `${protocol} link requires an external application.`, message: `${protocol} link requires an external application.`,
detail: `The requested link is ${URL} . Do you want to continue?`, detail: `The requested link is ${URL} . Do you want to continue?`,
@ -63,7 +67,7 @@ function handleDialogEvent(protocol, URL) {
switch (response) { switch (response) {
case 1: { case 1: {
allowedProtocols.push(protocol); allowedProtocols.push(protocol);
function handleError(err) { function handleError(err: NodeJS.ErrnoException | null) {
if (err) { if (err) {
log.error(err); log.error(err);
} }

View file

@ -9,13 +9,13 @@ import {UPDATE_MENTIONS, UPDATE_TRAY, UPDATE_BADGE, SESSION_EXPIRED} from 'commo
import * as WindowManager from './windows/windowManager'; import * as WindowManager from './windows/windowManager';
const status = { const status = {
unreads: new Map(), unreads: new Map<string, boolean>(),
mentions: new Map(), mentions: new Map<string, number>(),
expired: new Map(), expired: new Map<string, boolean>(),
emitter: new events.EventEmitter(), emitter: new events.EventEmitter(),
}; };
const emitMentions = (serverName) => { const emitMentions = (serverName: string) => {
const newMentions = getMentions(serverName); const newMentions = getMentions(serverName);
const newUnreads = getUnreads(serverName); const newUnreads = getUnreads(serverName);
const isExpired = getIsExpired(serverName); const isExpired = getIsExpired(serverName);
@ -24,11 +24,11 @@ const emitMentions = (serverName) => {
emitStatus(); emitStatus();
}; };
const emitTray = (expired, mentions, unreads) => { const emitTray = (expired?: boolean, mentions?: number, unreads?: boolean) => {
status.emitter.emit(UPDATE_TRAY, expired, Boolean(mentions), unreads); status.emitter.emit(UPDATE_TRAY, expired, Boolean(mentions), unreads);
}; };
const emitBadge = (expired, mentions, unreads) => { const emitBadge = (expired?: boolean, mentions?: number, unreads?: boolean) => {
status.emitter.emit(UPDATE_BADGE, expired, mentions, unreads); status.emitter.emit(UPDATE_BADGE, expired, mentions, unreads);
}; };
@ -40,7 +40,7 @@ export const emitStatus = () => {
emitBadge(expired, mentions, unreads); emitBadge(expired, mentions, unreads);
}; };
export const updateMentions = (serverName, mentions, unreads) => { export const updateMentions = (serverName: string, mentions: number, unreads?: boolean) => {
if (typeof unreads !== 'undefined') { if (typeof unreads !== 'undefined') {
status.unreads.set(serverName, Boolean(unreads)); status.unreads.set(serverName, Boolean(unreads));
} }
@ -48,20 +48,20 @@ export const updateMentions = (serverName, mentions, unreads) => {
emitMentions(serverName); emitMentions(serverName);
}; };
export const updateUnreads = (serverName, unreads) => { export const updateUnreads = (serverName: string, unreads: boolean) => {
status.unreads.set(serverName, Boolean(unreads)); status.unreads.set(serverName, Boolean(unreads));
emitMentions(serverName); emitMentions(serverName);
}; };
export const getUnreads = (serverName) => { export const getUnreads = (serverName: string) => {
return status.unreads.get(serverName) || false; return status.unreads.get(serverName) || false;
}; };
export const getMentions = (serverName) => { export const getMentions = (serverName: string) => {
return status.mentions.get(serverName) || 0; // this might be undefined as a way to tell that we don't know as it might need to login still. return status.mentions.get(serverName) || 0; // this might be undefined as a way to tell that we don't know as it might need to login still.
}; };
export const getIsExpired = (serverName) => { export const getIsExpired = (serverName: string) => {
return status.expired.get(serverName) || false; return status.expired.get(serverName) || false;
}; };
@ -101,11 +101,11 @@ export const anyExpired = () => {
}; };
// add any other event emitter methods if needed // add any other event emitter methods if needed
export const on = (event, listener) => { export const on = (event: string, listener: (...args: any[]) => void) => {
status.emitter.on(event, listener); status.emitter.on(event, listener);
}; };
export const setSessionExpired = (serverName, expired) => { export const setSessionExpired = (serverName: string, expired: boolean) => {
const isExpired = Boolean(expired); const isExpired = Boolean(expired);
const old = status.expired.get(serverName); const old = status.expired.get(serverName);
status.expired.set(serverName, isExpired); status.expired.set(serverName, isExpired);

View file

@ -1,90 +0,0 @@
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import log from 'electron-log';
import {BASIC_AUTH_PERMISSION} from 'common/permissions';
import urlUtils from 'common/utils/url';
import * as WindowManager from './windows/windowManager';
import {addModal} from './views/modalManager';
import {getLocalURLString, getLocalPreload} from './utils';
const modalPreload = getLocalPreload('modalPreload.js');
const loginModalHtml = getLocalURLString('loginModal.html');
const permissionModalHtml = getLocalURLString('permissionModal.html');
export class AuthManager {
constructor(config, trustedOriginsStore) {
this.config = config;
this.trustedOriginsStore = trustedOriginsStore;
this.loginCallbackMap = new Map();
config.on('update', this.handleConfigUpdate);
}
handleConfigUpdate = (newConfig) => {
this.config = newConfig;
}
handleAppLogin = (event, webContents, request, authInfo, callback) => {
event.preventDefault();
const parsedURL = new URL(request.url);
const server = urlUtils.getServer(parsedURL, this.config.teams);
this.loginCallbackMap.set(request.url, typeof callback === 'undefined' ? null : callback); // if callback is undefined set it to null instead so we know we have set it up with no value
if (urlUtils.isTrustedURL(request.url, this.config.teams) || urlUtils.isCustomLoginURL(parsedURL, server, this.config.teams) || this.trustedOriginsStore.checkPermission(request.url, BASIC_AUTH_PERMISSION)) {
this.popLoginModal(request, authInfo);
} else {
this.popPermissionModal(request, authInfo, BASIC_AUTH_PERMISSION);
}
}
popLoginModal = (request, authInfo) => {
const modalPromise = addModal(`login-${request.url}`, loginModalHtml, modalPreload, {request, authInfo}, WindowManager.getMainWindow());
modalPromise.then((data) => {
const {username, password} = data;
this.handleLoginCredentialsEvent(request, username, password);
}).catch((err) => {
if (err) {
log.error('Error processing login request', err);
}
this.handleCancelLoginEvent(request);
});
}
popPermissionModal = (request, authInfo, permission) => {
const modalPromise = addModal(`permission-${request.url}`, permissionModalHtml, modalPreload, {url: request.url, permission}, WindowManager.getMainWindow());
modalPromise.then(() => {
this.handlePermissionGranted(request.url, permission);
this.addToLoginQueue(request, authInfo);
}).catch((err) => {
if (err) {
log.error('Error processing permission request', err);
}
this.handleCancelLoginEvent(request);
});
}
handleLoginCredentialsEvent = (request, username, password) => {
const callback = this.loginCallbackMap.get(request.url);
if (typeof callback === 'undefined') {
log.error(`Failed to retrieve login callback for ${request.url}`);
return;
}
if (callback != null) {
callback(username, password);
}
this.loginCallbackMap.delete(request.url);
}
handleCancelLoginEvent = (request) => {
log.info(`Cancelling request for ${request ? request.url : 'unknown'}`);
this.handleLoginCredentialsEvent(request); // we use undefined to cancel the request
}
handlePermissionGranted(url, permission) {
this.trustedOriginsStore.addPermission(url, permission);
this.trustedOriginsStore.save();
}
}

119
src/main/authManager.ts Normal file
View file

@ -0,0 +1,119 @@
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {AuthenticationResponseDetails, AuthInfo, WebContents} from 'electron';
import log from 'electron-log';
import {CombinedConfig} from 'types/config';
import {PermissionType} from 'types/trustedOrigin';
import {LoginModalData} from 'types/auth';
import {BASIC_AUTH_PERMISSION} from 'common/permissions';
import urlUtils from 'common/utils/url';
import * as WindowManager from './windows/windowManager';
import {addModal} from './views/modalManager';
import {getLocalURLString, getLocalPreload} from './utils';
import TrustedOriginsStore from './trustedOrigins';
const modalPreload = getLocalPreload('modalPreload.js');
const loginModalHtml = getLocalURLString('loginModal.html');
const permissionModalHtml = getLocalURLString('permissionModal.html');
type LoginModalResult = {
username: string;
password: string;
};
export class AuthManager {
config: CombinedConfig;
trustedOriginsStore: TrustedOriginsStore;
loginCallbackMap: Map<string, ((username?: string, password?: string) => void) | undefined>;
constructor(config: CombinedConfig, trustedOriginsStore: TrustedOriginsStore) {
this.config = config;
this.trustedOriginsStore = trustedOriginsStore;
this.loginCallbackMap = new Map();
}
handleConfigUpdate = (newConfig: CombinedConfig) => {
this.config = newConfig;
}
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);
if (!server) {
return;
}
this.loginCallbackMap.set(request.url, callback); // if callback is undefined set it to null instead so we know we have set it up with no value
if (urlUtils.isTrustedURL(request.url, this.config.teams) || urlUtils.isCustomLoginURL(parsedURL, server, this.config.teams) || this.trustedOriginsStore.checkPermission(request.url, BASIC_AUTH_PERMISSION)) {
this.popLoginModal(request, authInfo);
} else {
this.popPermissionModal(request, authInfo, BASIC_AUTH_PERMISSION);
}
}
popLoginModal = (request: AuthenticationResponseDetails, authInfo: AuthInfo) => {
const mainWindow = WindowManager.getMainWindow();
if (!mainWindow) {
return;
}
const modalPromise = addModal<LoginModalData, LoginModalResult>(`login-${request.url}`, loginModalHtml, modalPreload, {request, authInfo}, mainWindow);
if (modalPromise) {
modalPromise.then((data) => {
const {username, password} = data;
this.handleLoginCredentialsEvent(request, username, password);
}).catch((err) => {
if (err) {
log.error('Error processing login request', err);
}
this.handleCancelLoginEvent(request);
});
}
}
popPermissionModal = (request: AuthenticationResponseDetails, authInfo: AuthInfo, permission: PermissionType) => {
const mainWindow = WindowManager.getMainWindow();
if (!mainWindow) {
return;
}
const modalPromise = addModal(`permission-${request.url}`, permissionModalHtml, modalPreload, {url: request.url, permission}, mainWindow);
if (modalPromise) {
modalPromise.then(() => {
this.handlePermissionGranted(request.url, permission);
this.popLoginModal(request, authInfo);
}).catch((err) => {
if (err) {
log.error('Error processing permission request', err);
}
this.handleCancelLoginEvent(request);
});
}
}
handleLoginCredentialsEvent = (request: AuthenticationResponseDetails, username?: string, password?: string) => {
const callback = this.loginCallbackMap.get(request.url);
if (typeof callback === 'undefined') {
log.error(`Failed to retrieve login callback for ${request.url}`);
return;
}
if (callback != null) {
callback(username, password);
}
this.loginCallbackMap.delete(request.url);
}
handleCancelLoginEvent = (request: AuthenticationResponseDetails) => {
log.info(`Cancelling request for ${request ? request.url : 'unknown'}`);
this.handleLoginCredentialsEvent(request); // we use undefined to cancel the request
}
handlePermissionGranted(url: string, permission: PermissionType) {
this.trustedOriginsStore.addPermission(url, permission);
this.trustedOriginsStore.save();
}
}

View file

@ -2,9 +2,13 @@
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. // Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information. // See LICENSE.txt for license information.
// TODO: This needs to be rebuilt anyways, skipping TS migration for now
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-nocheck
import path from 'path'; import path from 'path';
import {app, BrowserWindow, dialog, ipcMain, shell} from 'electron'; import {app, BrowserWindow, BrowserWindowConstructorOptions, dialog, ipcMain, IpcMainEvent, shell} from 'electron';
import log from 'electron-log'; import log from 'electron-log';
import {autoUpdater, CancellationToken} from 'electron-updater'; import {autoUpdater, CancellationToken} from 'electron-updater';
@ -18,18 +22,18 @@ autoUpdater.log.transports.file.level = 'info';
let updaterModal = null; let updaterModal = null;
function createEventListener(win, eventName) { function createEventListener(win: BrowserWindow, eventName: string) {
return (event) => { return (event: IpcMainEvent) => {
if (event.sender === win.webContents) { if (event.sender === win.webContents) {
win.emit(eventName); win.emit(eventName);
} }
}; };
} }
function createUpdaterModal(parentWindow, options) { function createUpdaterModal(parentWindow: BrowserWindow, options: {linuxAppIcon: string; notifyOnly: boolean}) {
const windowWidth = 480; const windowWidth = 480;
const windowHeight = 280; const windowHeight = 280;
const windowOptions = { const windowOptions: BrowserWindowConstructorOptions = {
title: `${app.name} Updater`, title: `${app.name} Updater`,
parent: parentWindow, parent: parentWindow,
modal: true, modal: true,
@ -67,7 +71,7 @@ function createUpdaterModal(parentWindow, options) {
return modal; return modal;
} }
function isUpdateApplicable(now, skippedVersion, updateInfo) { function isUpdateApplicable(now: Date, skippedVersion, updateInfo) {
const releaseTime = new Date(updateInfo.releaseDate).getTime(); const releaseTime = new Date(updateInfo.releaseDate).getTime();
// 48 hours after a new version is added to releases.mattermost.com, user receives a “New update is available” dialog // 48 hours after a new version is added to releases.mattermost.com, user receives a “New update is available” dialog
@ -83,7 +87,7 @@ function isUpdateApplicable(now, skippedVersion, updateInfo) {
return true; return true;
} }
function downloadAndInstall(cancellationToken) { function downloadAndInstall(cancellationToken?: CancellationToken) {
autoUpdater.on('update-downloaded', () => { autoUpdater.on('update-downloaded', () => {
global.willAppQuit = true; global.willAppQuit = true;
autoUpdater.quitAndInstall(); autoUpdater.quitAndInstall();
@ -150,7 +154,7 @@ function initialize(appState, mainWindow, notifyOnly = false) {
}); });
} }
function shouldCheckForUpdatesOnStart(updateCheckedDate) { function shouldCheckForUpdatesOnStart(updateCheckedDate: Date) {
if (updateCheckedDate) { if (updateCheckedDate) {
if (Date.now() - updateCheckedDate.getTime() < UPDATER_INTERVAL_IN_MS) { if (Date.now() - updateCheckedDate.getTime() < UPDATER_INTERVAL_IN_MS) {
return false; return false;
@ -167,6 +171,8 @@ function checkForUpdates(isManual = false) {
} }
class AutoUpdaterConfig { class AutoUpdaterConfig {
data: {notifyOnly?: boolean};
constructor() { constructor() {
this.data = {}; this.data = {};
} }

View file

@ -1,3 +1,4 @@
// Copyright (c) 2015-2016 Yuya Ochiai
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. // Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information. // See LICENSE.txt for license information.
@ -10,9 +11,9 @@ import * as AppState from './appState';
const MAX_WIN_COUNT = 99; const MAX_WIN_COUNT = 99;
let showUnreadBadgeSetting; let showUnreadBadgeSetting: boolean;
function showBadgeWindows(sessionExpired, showUnreadBadge, mentionCount) { function showBadgeWindows(sessionExpired: boolean, mentionCount: number, showUnreadBadge: boolean) {
let description = 'You have no unread messages'; let description = 'You have no unread messages';
let text; let text;
if (sessionExpired) { if (sessionExpired) {
@ -28,7 +29,7 @@ function showBadgeWindows(sessionExpired, showUnreadBadge, mentionCount) {
WindowManager.setOverlayIcon(text, description, mentionCount > 99); WindowManager.setOverlayIcon(text, description, mentionCount > 99);
} }
function showBadgeOSX(sessionExpired, showUnreadBadge, mentionCount) { function showBadgeOSX(sessionExpired: boolean, mentionCount: number, showUnreadBadge: boolean) {
let badge = ''; let badge = '';
if (sessionExpired) { if (sessionExpired) {
badge = '•'; badge = '•';
@ -40,28 +41,28 @@ function showBadgeOSX(sessionExpired, showUnreadBadge, mentionCount) {
app.dock.setBadge(badge); app.dock.setBadge(badge);
} }
function showBadgeLinux(sessionExpired, showUnreadBadge, mentionCount) { function showBadgeLinux(sessionExpired: boolean, mentionCount: number) {
if (app.isUnityRunning()) { if (app.isUnityRunning()) {
const countExpired = sessionExpired ? 1 : 0; const countExpired = sessionExpired ? 1 : 0;
app.setBadgeCount(mentionCount + countExpired); app.setBadgeCount(mentionCount + countExpired);
} }
} }
function showBadge(sessionExpired, mentionCount, showUnreadBadge) { function showBadge(sessionExpired: boolean, mentionCount: number, showUnreadBadge: boolean) {
switch (process.platform) { switch (process.platform) {
case 'win32': case 'win32':
showBadgeWindows(sessionExpired, showUnreadBadge, mentionCount); showBadgeWindows(sessionExpired, mentionCount, showUnreadBadge);
break; break;
case 'darwin': case 'darwin':
showBadgeOSX(sessionExpired, showUnreadBadge, mentionCount); showBadgeOSX(sessionExpired, mentionCount, showUnreadBadge);
break; break;
case 'linux': case 'linux':
showBadgeLinux(sessionExpired, showUnreadBadge, mentionCount); showBadgeLinux(sessionExpired, mentionCount);
break; break;
} }
} }
export function setUnreadBadgeSetting(showUnreadBadge) { export function setUnreadBadgeSetting(showUnreadBadge: boolean) {
showUnreadBadgeSetting = showUnreadBadge; showUnreadBadgeSetting = showUnreadBadge;
AppState.emitStatus(); AppState.emitStatus();
} }

View file

@ -1,6 +1,9 @@
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. // Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information. // See LICENSE.txt for license information.
import log from 'electron-log'; import log from 'electron-log';
import {Certificate, WebContents} from 'electron';
import {CertificateModalData} from 'types/certificate';
import * as WindowManager from './windows/windowManager'; import * as WindowManager from './windows/windowManager';
@ -10,12 +13,18 @@ import {getLocalURLString, getLocalPreload} from './utils';
const modalPreload = getLocalPreload('modalPreload.js'); const modalPreload = getLocalPreload('modalPreload.js');
const html = getLocalURLString('certificateModal.html'); const html = getLocalURLString('certificateModal.html');
type CertificateModalResult = {
cert: Certificate;
}
export class CertificateManager { export class CertificateManager {
certificateRequestCallbackMap: Map<string, (certificate?: Certificate | undefined) => void>;
constructor() { constructor() {
this.certificateRequestCallbackMap = new Map(); this.certificateRequestCallbackMap = new Map();
} }
handleSelectCertificate = (event, webContents, url, list, callback) => { handleSelectCertificate = (event: Event, webContents: WebContents, url: string, list: Certificate[], callback: (certificate?: Certificate | undefined) => void) => {
if (list.length > 1) { if (list.length > 1) {
event.preventDefault(); // prevent the app from getting the first certificate available event.preventDefault(); // prevent the app from getting the first certificate available
@ -27,20 +36,26 @@ export class CertificateManager {
} }
} }
popCertificateModal = (url, list) => { popCertificateModal = (url: string, list: Certificate[]) => {
const modalPromise = addModal(`certificate-${url}`, html, modalPreload, {url, list}, WindowManager.getMainWindow()); const mainWindow = WindowManager.getMainWindow();
modalPromise.then((data) => { if (!mainWindow) {
const {cert} = data; return;
this.handleSelectedCertificate(url, cert); }
}).catch((err) => { const modalPromise = addModal<CertificateModalData, CertificateModalResult>(`certificate-${url}`, html, modalPreload, {url, list}, mainWindow);
if (err) { if (modalPromise) {
log.error('Error processing certificate selection', err); modalPromise.then((data) => {
} const {cert} = data;
this.handleSelectedCertificate(url); this.handleSelectedCertificate(url, cert);
}); }).catch((err) => {
if (err) {
log.error('Error processing certificate selection', err);
}
this.handleSelectedCertificate(url);
});
}
} }
handleSelectedCertificate = (server, cert) => { handleSelectedCertificate = (server: string, cert?: Certificate) => {
const callback = this.certificateRequestCallbackMap.get(server); const callback = this.certificateRequestCallbackMap.get(server);
if (!callback) { if (!callback) {
log.error(`there was no callback associated with: ${server}`); log.error(`there was no callback associated with: ${server}`);

View file

@ -1,68 +0,0 @@
// Copyright (c) 2015-2016 Yuya Ochiai
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
'use strict';
import fs from 'fs';
import urlUtils from 'common/utils/url';
import * as Validator from './Validator';
function comparableCertificate(certificate) {
return {
data: certificate.data.toString(),
issuerName: certificate.issuerName,
};
}
function areEqual(certificate0, certificate1) {
if (certificate0.data !== certificate1.data) {
return false;
}
if (certificate0.issuerName !== certificate1.issuerName) {
return false;
}
return true;
}
function CertificateStore(storeFile) {
this.storeFile = storeFile;
let storeStr;
try {
storeStr = fs.readFileSync(storeFile, 'utf-8');
const result = Validator.validateCertificateStore(storeStr);
if (!result) {
throw new Error('Provided certificate store file does not validate, using defaults instead.');
}
this.data = result;
} catch (e) {
this.data = {};
}
}
CertificateStore.prototype.save = function save() {
fs.writeFileSync(this.storeFile, JSON.stringify(this.data, null, ' '));
};
CertificateStore.prototype.add = function add(targetURL, certificate) {
this.data[urlUtils.getHost(targetURL)] = comparableCertificate(certificate);
};
CertificateStore.prototype.isExisting = function isExisting(targetURL) {
return Object.prototype.hasOwnProperty.call(this.data, urlUtils.getHost(targetURL));
};
CertificateStore.prototype.isTrusted = function isTrusted(targetURL, certificate) {
const host = urlUtils.getHost(targetURL);
if (!this.isExisting(targetURL)) {
return false;
}
return areEqual(this.data[host], comparableCertificate(certificate));
};
export default {
load(storeFile) {
return new CertificateStore(storeFile);
},
};

View file

@ -0,0 +1,71 @@
// Copyright (c) 2015-2016 Yuya Ochiai
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
'use strict';
import fs from 'fs';
import {Certificate} from 'electron';
import {ComparableCertificate} from 'types/certificate';
import urlUtils from 'common/utils/url';
import * as Validator from './Validator';
function comparableCertificate(certificate: Certificate): ComparableCertificate {
return {
data: certificate.data.toString(),
issuerName: certificate.issuerName,
};
}
function areEqual(certificate0: ComparableCertificate, certificate1: ComparableCertificate) {
if (certificate0.data !== certificate1.data) {
return false;
}
if (certificate0.issuerName !== certificate1.issuerName) {
return false;
}
return true;
}
export default class CertificateStore {
storeFile: string;
data: Record<string, ComparableCertificate>;
constructor(storeFile: string) {
this.storeFile = storeFile;
let storeStr;
try {
storeStr = fs.readFileSync(storeFile, 'utf-8');
const result = Validator.validateCertificateStore(storeStr);
if (!result) {
throw new Error('Provided certificate store file does not validate, using defaults instead.');
}
this.data = result;
} catch (e) {
this.data = {};
}
}
save = () => {
fs.writeFileSync(this.storeFile, JSON.stringify(this.data, null, ' '));
};
add = (targetURL: string, certificate: Certificate) => {
this.data[urlUtils.getHost(targetURL)] = comparableCertificate(certificate);
};
isExisting = (targetURL: string) => {
return Object.prototype.hasOwnProperty.call(this.data, urlUtils.getHost(targetURL));
};
isTrusted = (targetURL: string, certificate: Certificate) => {
const host = urlUtils.getHost(targetURL);
if (!this.isExisting(targetURL)) {
return false;
}
return areEqual(this.data[host], comparableCertificate(certificate));
};
}

View file

@ -1,18 +1,19 @@
// Copyright (c) 2015-2016 Yuya Ochiai
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. // Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information. // See LICENSE.txt for license information.
// Copyright (c) 2015-2016 Yuya Ochiai
import electronContextMenu from 'electron-context-menu'; import {BrowserView, BrowserWindow, ContextMenuParams, Event, WebContents} from 'electron';
import electronContextMenu, {Options} from 'electron-context-menu';
import urlUtils from 'common/utils/url'; import urlUtils from 'common/utils/url';
const defaultMenuOptions = { const defaultMenuOptions = {
shouldShowMenu: (e, p) => { shouldShowMenu: (e: Event, p: ContextMenuParams) => {
const isInternalLink = p.linkURL.endsWith('#') && p.linkURL.slice(0, -1) === p.pageURL; const isInternalLink = p.linkURL.endsWith('#') && p.linkURL.slice(0, -1) === p.pageURL;
let isInternalSrc; let isInternalSrc;
try { try {
const srcurl = urlUtils.parseURL(p.srcURL); const srcurl = urlUtils.parseURL(p.srcURL);
isInternalSrc = srcurl.protocol === 'file:'; isInternalSrc = srcurl?.protocol === 'file:';
} catch (err) { } catch (err) {
isInternalSrc = false; isInternalSrc = false;
} }
@ -27,8 +28,12 @@ const defaultMenuOptions = {
}; };
export default class ContextMenu { export default class ContextMenu {
constructor(options, view) { view: BrowserWindow | BrowserView;
const providedOptions = options || {}; menuOptions: Options;
menuDispose?: () => void;
constructor(options: Options, view: BrowserWindow | BrowserView) {
const providedOptions: Options = options || {};
this.menuOptions = Object.assign({}, defaultMenuOptions, providedOptions); this.menuOptions = Object.assign({}, defaultMenuOptions, providedOptions);
this.view = view; this.view = view;
@ -39,7 +44,7 @@ export default class ContextMenu {
dispose = () => { dispose = () => {
if (this.menuDispose) { if (this.menuDispose) {
this.menuDispose(); this.menuDispose();
this.menuDispose = null; delete this.menuDispose;
} }
} }
@ -50,7 +55,7 @@ export default class ContextMenu {
* Work-around issue with passing `WebContents` to `electron-context-menu` in Electron 11 * Work-around issue with passing `WebContents` to `electron-context-menu` in Electron 11
* @see https://github.com/sindresorhus/electron-context-menu/issues/123 * @see https://github.com/sindresorhus/electron-context-menu/issues/123
*/ */
const options = {window: {webContents: this.view.webContents, inspectElement: this.view.webContents.inspectElement.bind(this.view.webContents), isDestroyed: this.view.webContents.isDestroyed.bind(this.view.webContents), off: this.view.webContents.off.bind(this.view.webContents)}, ...this.menuOptions}; const options = {window: {webContents: this.view.webContents, inspectElement: this.view.webContents.inspectElement.bind(this.view.webContents), isDestroyed: this.view.webContents.isDestroyed.bind(this.view.webContents), off: this.view.webContents.off.bind(this.view.webContents)} as unknown as WebContents, ...this.menuOptions};
this.menuDispose = electronContextMenu(options); this.menuDispose = electronContextMenu(options);
} }
} }

View file

@ -1,16 +1,16 @@
// Copyright (c) 2015-2016 Yuya Ochiai // Copyright (c) 2015-2016 Yuya Ochiai
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. // Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information. // See LICENSE.txt for license information.
import {app} from 'electron'; import {app, Session} from 'electron';
import log from 'electron-log'; import log from 'electron-log';
function flushCookiesStore(session) { function flushCookiesStore(session: Session) {
session.cookies.flushStore().catch((err) => { session.cookies.flushStore().catch((err) => {
log.error(`There was a problem flushing cookies:\n${err}`); log.error(`There was a problem flushing cookies:\n${err}`);
}); });
} }
export default function initCookieManager(session) { export default function initCookieManager(session: Session) {
// Somehow cookies are not immediately saved to disk. // Somehow cookies are not immediately saved to disk.
// So manually flush cookie store to disk on closing the app. // So manually flush cookie store to disk on closing the app.
// https://github.com/electron/electron/issues/8416 // https://github.com/electron/electron/issues/8416

View file

@ -1,65 +0,0 @@
// Copyright (c) 2015-2016 Yuya Ochiai
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import fs from 'fs';
import path from 'path';
import zlib from 'zlib';
import electron from 'electron';
const {app, dialog} = electron;
import * as WindowManager from './windows/windowManager';
export default function downloadURL(URL, callback) {
const {net} = electron;
const request = net.request(URL);
request.setHeader('Accept-Encoding', 'gzip,deflate');
request.on('response', (response) => {
const file = getAttachmentName(response.headers);
const dialogOptions = {
defaultPath: path.join(app.getPath('downloads'), file),
};
dialog.showSaveDialog(
WindowManager.getMainWindow(true),
dialogOptions,
).then(
(filename) => {
if (filename) {
saveResponseBody(response, filename, callback);
}
},
).catch((err) => {
callback(err);
});
}).on('error', callback);
request.end();
}
function getAttachmentName(headers) {
if (headers['content-disposition']) {
const contentDisposition = headers['content-disposition'][0];
const matched = contentDisposition.match(/filename="(.*)"/);
if (matched) {
return path.basename(matched[1]);
}
}
return '';
}
function saveResponseBody(response, filename, callback) {
const output = fs.createWriteStream(filename);
output.on('close', callback);
switch (response.headers['content-encoding']) {
case 'gzip':
response.pipe(zlib.createGunzip()).pipe(output).on('error', callback);
break;
case 'deflate':
response.pipe(zlib.createInflate()).pipe(output).on('error', callback);
break;
default:
response.pipe(output).on('error', callback);
break;
}
}

View file

@ -1,18 +1,23 @@
// Copyright (c) 2015-2016 Yuya Ochiai
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. // Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information. // See LICENSE.txt for license information.
// Copyright (c) 2015-2016 Yuya Ochiai
/* eslint-disable max-lines */
import fs from 'fs'; import fs from 'fs';
import path from 'path'; import path from 'path';
import electron from 'electron'; import electron, {BrowserWindow, IpcMainEvent, IpcMainInvokeEvent, Rectangle} from 'electron';
import isDev from 'electron-is-dev'; import isDev from 'electron-is-dev';
import installExtension, {REACT_DEVELOPER_TOOLS} from 'electron-devtools-installer'; import installExtension, {REACT_DEVELOPER_TOOLS} from 'electron-devtools-installer';
import log from 'electron-log'; import log from 'electron-log';
import 'airbnb-js-shims/target/es2015'; import 'airbnb-js-shims/target/es2015';
import Utils from 'common/utils/util'; import {Team} from 'types/config';
import urlUtils from 'common/utils/url';
import {MentionData} from 'types/notification';
import {Boundaries} from 'types/utils';
import { import {
SWITCH_SERVER, SWITCH_SERVER,
@ -33,6 +38,10 @@ import {
} from 'common/communication'; } from 'common/communication';
import Config from 'common/config'; import Config from 'common/config';
import Utils from 'common/utils/util';
import urlUtils from 'common/utils/url';
import {protocols} from '../../electron-builder.json'; import {protocols} from '../../electron-builder.json';
import AutoLauncher from './AutoLauncher'; import AutoLauncher from './AutoLauncher';
@ -76,13 +85,13 @@ const certificateErrorCallbacks = new Map();
// Keep a global reference of the window object, if you don't, the window will // Keep a global reference of the window object, if you don't, the window will
// be closed automatically when the JavaScript object is garbage collected. // be closed automatically when the JavaScript object is garbage collected.
let certificateStore = null; let certificateStore: CertificateStore;
let trustedOriginsStore = null; let trustedOriginsStore;
let scheme = null; let scheme: string;
let appVersion = null; let appVersion = null;
let config = null; let config: Config;
let authManager = null; let authManager: AuthManager;
let certificateManager = null; let certificateManager: CertificateManager;
/** /**
* Main entry point for the application, ensures that everything initializes in the proper order * Main entry point for the application, ensures that everything initializes in the proper order
@ -140,7 +149,7 @@ function initializeArgs() {
} }
async function initializeConfig() { async function initializeConfig() {
const loadConfig = new Promise((resolve) => { const loadConfig = new Promise<void>((resolve) => {
config = new Config(app.getPath('userData') + '/config.json'); config = new Config(app.getPath('userData') + '/config.json');
config.once('update', (configData) => { config.once('update', (configData) => {
config.on('update', handleConfigUpdate); config.on('update', handleConfigUpdate);
@ -169,7 +178,11 @@ function initializeAppEventListeners() {
} }
function initializeBeforeAppReady() { function initializeBeforeAppReady() {
certificateStore = CertificateStore.load(path.resolve(app.getPath('userData'), 'certificate.json')); if (!config || !config.data) {
log.error('No config loaded');
return;
}
certificateStore = new CertificateStore(path.resolve(app.getPath('userData'), 'certificate.json'));
trustedOriginsStore = new TrustedOriginsStore(path.resolve(app.getPath('userData'), 'trustedOrigins.json')); trustedOriginsStore = new TrustedOriginsStore(path.resolve(app.getPath('userData'), 'trustedOrigins.json'));
trustedOriginsStore.load(); trustedOriginsStore.load();
@ -196,7 +209,7 @@ function initializeBeforeAppReady() {
allowProtocolDialog.init(); allowProtocolDialog.init();
authManager = new AuthManager(config, trustedOriginsStore); authManager = new AuthManager(config.data, trustedOriginsStore);
certificateManager = new CertificateManager(); certificateManager = new CertificateManager();
if (isDev) { if (isDev) {
@ -237,7 +250,10 @@ function initializeInterCommunicationEventListeners() {
// config event handlers // config event handlers
// //
function handleConfigUpdate(newConfig) { function handleConfigUpdate(newConfig: Config) {
if (!newConfig.data) {
return;
}
if (process.platform === 'win32' || process.platform === 'linux') { if (process.platform === 'win32' || process.platform === 'linux') {
const appLauncher = new AutoLauncher(); const appLauncher = new AutoLauncher();
const autoStartTask = config.autostart ? appLauncher.enable() : appLauncher.disable(); const autoStartTask = config.autostart ? appLauncher.enable() : appLauncher.disable();
@ -247,6 +263,7 @@ function handleConfigUpdate(newConfig) {
log.error('error:', err); log.error('error:', err);
}); });
WindowManager.setConfig(newConfig.data); WindowManager.setConfig(newConfig.data);
authManager.handleConfigUpdate(newConfig.data);
setUnreadBadgeSetting(newConfig.data && newConfig.data.showUnreadBadge); setUnreadBadgeSetting(newConfig.data && newConfig.data.showUnreadBadge);
} }
@ -254,6 +271,10 @@ function handleConfigUpdate(newConfig) {
} }
function handleConfigSynchronize() { function handleConfigSynchronize() {
if (!config.data) {
return;
}
// TODO: send this to server manager // TODO: send this to server manager
WindowManager.setConfig(config.data); WindowManager.setConfig(config.data);
setUnreadBadgeSetting(config.data.showUnreadBadge); setUnreadBadgeSetting(config.data.showUnreadBadge);
@ -267,7 +288,7 @@ function handleConfigSynchronize() {
function handleReloadConfig() { function handleReloadConfig() {
config.reload(); config.reload();
WindowManager.setConfig(config.data); WindowManager.setConfig(config.data!);
} }
function handleAppVersion() { function handleAppVersion() {
@ -277,7 +298,7 @@ function handleAppVersion() {
}; };
} }
function handleDarkModeChange(darkMode) { function handleDarkModeChange(darkMode: boolean) {
refreshTrayImages(config.trayIconTheme); refreshTrayImages(config.trayIconTheme);
WindowManager.sendToRenderer(DARK_MODE_CHANGE, darkMode); WindowManager.sendToRenderer(DARK_MODE_CHANGE, darkMode);
WindowManager.updateLoadingScreenDarkMode(darkMode); WindowManager.updateLoadingScreenDarkMode(darkMode);
@ -288,11 +309,13 @@ function handleDarkModeChange(darkMode) {
// //
// activate first app instance, subsequent instances will quit themselves // activate first app instance, subsequent instances will quit themselves
function handleAppSecondInstance(event, argv) { function handleAppSecondInstance(event: Event, argv: string[]) {
// Protocol handler for win32 // Protocol handler for win32
// argv: An array of the second instances (command line / deep linked) arguments // argv: An array of the second instances (command line / deep linked) arguments
const deeplinkingUrl = getDeeplinkingURL(argv); const deeplinkingUrl = getDeeplinkingURL(argv);
openDeepLink(deeplinkingUrl); if (deeplinkingUrl) {
openDeepLink(deeplinkingUrl);
}
} }
function handleAppWindowAllClosed() { function handleAppWindowAllClosed() {
@ -303,9 +326,9 @@ function handleAppWindowAllClosed() {
} }
} }
function handleAppBrowserWindowCreated(error, newWindow) { function handleAppBrowserWindowCreated(event: Event, newWindow: BrowserWindow) {
// Screen cannot be required before app is ready // Screen cannot be required before app is ready
resizeScreen(electron.screen, newWindow); resizeScreen(newWindow);
} }
function handleAppActivate() { function handleAppActivate() {
@ -318,18 +341,18 @@ function handleAppBeforeQuit() {
global.willAppQuit = true; global.willAppQuit = true;
} }
function handleQuit(e, reason, stack) { function handleQuit(e: IpcMainEvent, reason: string, stack: string) {
log.error(`Exiting App. Reason: ${reason}`); log.error(`Exiting App. Reason: ${reason}`);
log.info(`Stacktrace:\n${stack}`); log.info(`Stacktrace:\n${stack}`);
handleAppBeforeQuit(); handleAppBeforeQuit();
app.quit(); app.quit();
} }
function handleSelectCertificate(event, webContents, url, list, callback) { function handleSelectCertificate(event: electron.Event, webContents: electron.WebContents, url: string, list: electron.Certificate[], callback: (certificate?: electron.Certificate | undefined) => void) {
certificateManager.handleSelectCertificate(event, webContents, url, list, callback); certificateManager.handleSelectCertificate(event, webContents, url, list, callback);
} }
function handleAppCertificateError(event, webContents, url, error, certificate, callback) { function handleAppCertificateError(event: electron.Event, webContents: electron.WebContents, url: string, error: string, certificate: electron.Certificate, callback: (isTrusted: boolean) => void) {
const parsedURL = new URL(url); const parsedURL = new URL(url);
if (!parsedURL) { if (!parsedURL) {
return; return;
@ -355,6 +378,9 @@ function handleAppCertificateError(event, webContents, url, error, certificate,
// TODO: should we move this to window manager or provide a handler for dialogs? // TODO: should we move this to window manager or provide a handler for dialogs?
const mainWindow = WindowManager.getMainWindow(); const mainWindow = WindowManager.getMainWindow();
if (!mainWindow) {
return;
}
dialog.showMessageBox(mainWindow, { dialog.showMessageBox(mainWindow, {
title: 'Certificate Error', title: 'Certificate Error',
message: 'There is a configuration issue with this Mattermost server, or someone is trying to intercept your connection. You also may need to sign into the Wi-Fi you are connected to using your web browser.', message: 'There is a configuration issue with this Mattermost server, or someone is trying to intercept your connection. You also may need to sign into the Wi-Fi you are connected to using your web browser.',
@ -395,15 +421,15 @@ function handleAppCertificateError(event, webContents, url, error, certificate,
} }
} }
function handleAppLogin(event, webContents, request, authInfo, callback) { function handleAppLogin(event: electron.Event, webContents: electron.WebContents, request: electron.AuthenticationResponseDetails, authInfo: electron.AuthInfo, callback: (username?: string | undefined, password?: string | undefined) => void) {
authManager.handleAppLogin(event, webContents, request, authInfo, callback); authManager.handleAppLogin(event, webContents, request, authInfo, callback);
} }
function handleAppGPUProcessCrashed(event, killed) { function handleAppGPUProcessCrashed(event: electron.Event, killed: boolean) {
log.error(`The GPU process has crashed (killed = ${killed})`); log.error(`The GPU process has crashed (killed = ${killed})`);
} }
function openDeepLink(deeplinkingUrl) { function openDeepLink(deeplinkingUrl: string) {
try { try {
WindowManager.showMainWindow(deeplinkingUrl); WindowManager.showMainWindow(deeplinkingUrl);
} catch (err) { } catch (err) {
@ -427,7 +453,7 @@ function handleAppWillFinishLaunching() {
}); });
} }
function handleSwitchServer(event, serverName) { function handleSwitchServer(event: IpcMainEvent, serverName: string) {
WindowManager.switchServer(serverName); WindowManager.switchServer(serverName);
} }
@ -436,7 +462,11 @@ function handleNewServerModal() {
const modalPreload = getLocalPreload('modalPreload.js'); const modalPreload = getLocalPreload('modalPreload.js');
const modalPromise = addModal('newServer', html, modalPreload, {}, WindowManager.getMainWindow()); const mainWindow = WindowManager.getMainWindow();
if (!mainWindow) {
return;
}
const modalPromise = addModal<unknown, Team>('newServer', html, modalPreload, {}, mainWindow);
if (modalPromise) { if (modalPromise) {
modalPromise.then((data) => { modalPromise.then((data) => {
const teams = config.teams; const teams = config.teams;
@ -506,7 +536,7 @@ function initializeAfterAppReady() {
WindowManager.showSettingsWindow(); WindowManager.showSettingsWindow();
} }
criticalErrorHandler.setMainWindow(WindowManager.getMainWindow()); criticalErrorHandler.setMainWindow(WindowManager.getMainWindow()!);
// listen for status updates and pass on to renderer // listen for status updates and pass on to renderer
userActivityMonitor.on('status', (status) => { userActivityMonitor.on('status', (status) => {
@ -519,7 +549,7 @@ function initializeAfterAppReady() {
if (shouldShowTrayIcon()) { if (shouldShowTrayIcon()) {
setupTray(config.trayIconTheme); setupTray(config.trayIconTheme);
} }
setupBadge(config.showUnreadBadge); setupBadge();
session.defaultSession.on('will-download', (event, item, webContents) => { session.defaultSession.on('will-download', (event, item, webContents) => {
const filename = item.getFilename(); const filename = item.getFilename();
@ -533,13 +563,13 @@ function initializeAfterAppReady() {
} }
item.setSaveDialogOptions({ item.setSaveDialogOptions({
title: filename, title: filename,
defaultPath: path.resolve(config.combinedData.downloadLocation, filename), defaultPath: path.resolve(config.downloadLocation, filename),
filters, filters,
}); });
item.on('done', (doneEvent, state) => { item.on('done', (doneEvent, state) => {
if (state === 'completed') { if (state === 'completed') {
displayDownloadCompleted(filename, item.savePath, urlUtils.getServer(webContents.getURL(), config.teams)); displayDownloadCompleted(filename, item.savePath, urlUtils.getServer(webContents.getURL(), config.teams)!);
} }
}); });
}); });
@ -584,7 +614,7 @@ function initializeAfterAppReady() {
// ipc communication event handlers // ipc communication event handlers
// //
function handleMentionNotification(event, title, body, channel, teamId, silent, data) { function handleMentionNotification(event: IpcMainEvent, title: string, body: string, channel: {id: string}, teamId: string, silent: boolean, data: MentionData) {
displayMention(title, body, channel, teamId, silent, event.sender, data); displayMention(title, body, channel, teamId, silent, event.sender, data);
} }
@ -605,23 +635,21 @@ function handleCloseAppMenu() {
WindowManager.focusBrowserView(); WindowManager.focusBrowserView();
} }
function handleUpdateMenuEvent(event, menuConfig) { function handleUpdateMenuEvent(event: IpcMainEvent, menuConfig: Config) {
// TODO: this might make sense to move to window manager? so it updates the window referenced if needed.
const mainWindow = WindowManager.getMainWindow();
const aMenu = appMenu.createMenu(menuConfig); const aMenu = appMenu.createMenu(menuConfig);
Menu.setApplicationMenu(aMenu); Menu.setApplicationMenu(aMenu);
aMenu.addListener('menu-will-close', handleCloseAppMenu); aMenu.addListener('menu-will-close', handleCloseAppMenu);
// set up context menu for tray icon // set up context menu for tray icon
if (shouldShowTrayIcon()) { if (shouldShowTrayIcon()) {
const tMenu = trayMenu.createMenu(menuConfig.data); const tMenu = trayMenu.createMenu(menuConfig.data!);
setTrayMenu(tMenu, mainWindow); setTrayMenu(tMenu);
} }
} }
async function handleSelectDownload(event, startFrom) { async function handleSelectDownload(event: IpcMainInvokeEvent, startFrom: string) {
const message = 'Specify the folder where files will download'; const message = 'Specify the folder where files will download';
const result = await dialog.showOpenDialog({defaultPath: startFrom || config.data.downloadLocation, const result = await dialog.showOpenDialog({defaultPath: startFrom || config.downloadLocation,
message, message,
properties: properties:
['openDirectory', 'createDirectory', 'dontAddToRecent', 'promptToCreate']}); ['openDirectory', 'createDirectory', 'dontAddToRecent', 'promptToCreate']});
@ -632,7 +660,7 @@ async function handleSelectDownload(event, startFrom) {
// helper functions // helper functions
// //
function getDeeplinkingURL(args) { function getDeeplinkingURL(args: string[]) {
if (Array.isArray(args) && args.length) { if (Array.isArray(args) && args.length) {
// deeplink urls should always be the last argument, but may not be the first (i.e. Windows with the app already running) // deeplink urls should always be the last argument, but may not be the first (i.e. Windows with the app already running)
const url = args[args.length - 1]; const url = args[args.length - 1];
@ -640,14 +668,14 @@ function getDeeplinkingURL(args) {
return url; return url;
} }
} }
return null; return undefined;
} }
function shouldShowTrayIcon() { function shouldShowTrayIcon() {
return config.showTrayIcon || process.platform === 'win32'; return config.showTrayIcon || process.platform === 'win32';
} }
function wasUpdated(lastAppVersion) { function wasUpdated(lastAppVersion?: string) {
return lastAppVersion !== app.getVersion(); return lastAppVersion !== app.getVersion();
} }
@ -662,7 +690,7 @@ function clearAppCache() {
} }
} }
function isWithinDisplay(state, display) { function isWithinDisplay(state: Rectangle, display: Boundaries) {
const startsWithinDisplay = !(state.x > display.maxX || state.y > display.maxY || state.x < display.minX || state.y < display.minY); const startsWithinDisplay = !(state.x > display.maxX || state.y > display.maxY || state.x < display.minX || state.y < display.minY);
if (!startsWithinDisplay) { if (!startsWithinDisplay) {
return false; return false;
@ -674,7 +702,7 @@ function isWithinDisplay(state, display) {
return !(midX > display.maxX || midY > display.maxY); return !(midX > display.maxX || midY > display.maxY);
} }
function getValidWindowPosition(state) { function getValidWindowPosition(state: Rectangle) {
// Check if the previous position is out of the viewable area // Check if the previous position is out of the viewable area
// (e.g. because the screen has been plugged off) // (e.g. because the screen has been plugged off)
const boundaries = Utils.getDisplayBoundaries(); const boundaries = Utils.getDisplayBoundaries();
@ -688,7 +716,7 @@ function getValidWindowPosition(state) {
return {x: state.x, y: state.y}; return {x: state.x, y: state.y};
} }
function resizeScreen(screen, browserWindow) { function resizeScreen(browserWindow: BrowserWindow) {
function handle() { function handle() {
const position = browserWindow.getPosition(); const position = browserWindow.getPosition();
const size = browserWindow.getSize(); const size = browserWindow.getSize();

View file

@ -3,14 +3,15 @@
// See LICENSE.txt for license information. // See LICENSE.txt for license information.
'use strict'; 'use strict';
import {app, Menu, session, shell, webContents} from 'electron'; 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, SELECT_NEXT_TAB, SELECT_PREVIOUS_TAB} from 'common/communication';
import Config from 'common/config';
import * as WindowManager from '../windows/windowManager'; import * as WindowManager from '../windows/windowManager';
function createTemplate(config) { function createTemplate(config: Config) {
const separatorItem = { const separatorItem: MenuItemConstructorOptions = {
type: 'separator', type: 'separator',
}; };
@ -39,7 +40,7 @@ function createTemplate(config) {
}, },
}); });
if (config.data.enableServerManagement === true) { if (config.data?.enableServerManagement === true) {
platformAppMenu.push({ platformAppMenu.push({
label: 'Sign in to Another Server', label: 'Sign in to Another Server',
click() { click() {
@ -53,7 +54,7 @@ function createTemplate(config) {
separatorItem, { separatorItem, {
role: 'hide', role: 'hide',
}, { }, {
role: 'hideothers', role: 'hideOthers',
}, { }, {
role: 'unhide', role: 'unhide',
}, separatorItem, { }, separatorItem, {
@ -139,7 +140,7 @@ function createTemplate(config) {
} }
return 'Ctrl+Shift+I'; return 'Ctrl+Shift+I';
})(), })(),
click(item, focusedWindow) { click(item: Electron.MenuItem, focusedWindow?: WebContents) {
if (focusedWindow) { if (focusedWindow) {
// toggledevtools opens it in the last known position, so sometimes it goes below the browserview // toggledevtools opens it in the last known position, so sometimes it goes below the browserview
if (focusedWindow.isDevToolsOpened()) { if (focusedWindow.isDevToolsOpened()) {
@ -193,7 +194,7 @@ function createTemplate(config) {
}], }],
}); });
const teams = config.data.teams || []; const teams = config.data?.teams || [];
const windowMenu = { const windowMenu = {
label: '&Window', label: '&Window',
submenu: [{ submenu: [{
@ -209,7 +210,7 @@ function createTemplate(config) {
label: team.name, label: team.name,
accelerator: `CmdOrCtrl+${i + 1}`, accelerator: `CmdOrCtrl+${i + 1}`,
click() { click() {
WindowManager.switchServer(team.name, true); WindowManager.switchServer(team.name);
}, },
}; };
}), separatorItem, { }), separatorItem, {
@ -230,17 +231,19 @@ function createTemplate(config) {
}; };
template.push(windowMenu); template.push(windowMenu);
const submenu = []; const submenu = [];
if (config.data.helpLink) { if (config.data?.helpLink) {
submenu.push({ submenu.push({
label: 'Learn More...', label: 'Learn More...',
click() { click() {
shell.openExternal(config.data.helpLink); shell.openExternal(config.data!.helpLink);
}, },
}); });
submenu.push(separatorItem); submenu.push(separatorItem);
} }
submenu.push({ submenu.push({
// eslint-disable-next-line no-undef // eslint-disable-next-line no-undef
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
label: `Version ${app.getVersion()} commit: ${__HASH_VERSION__}`, label: `Version ${app.getVersion()} commit: ${__HASH_VERSION__}`,
enabled: false, enabled: false,
}); });
@ -249,8 +252,9 @@ function createTemplate(config) {
return template; return template;
} }
function createMenu(config) { function createMenu(config: Config) {
return Menu.buildFromTemplate(createTemplate(config)); // TODO: Electron is enforcing certain variables that it doesn't need
return Menu.buildFromTemplate(createTemplate(config) as Array<MenuItemConstructorOptions | MenuItem>);
} }
export default { export default {

View file

@ -3,18 +3,19 @@
// See LICENSE.txt for license information. // See LICENSE.txt for license information.
'use strict'; 'use strict';
import {Menu} from 'electron'; import {Menu, MenuItem, MenuItemConstructorOptions} from 'electron';
import {CombinedConfig} from 'types/config';
import * as WindowManager from '../windows/windowManager'; import * as WindowManager from '../windows/windowManager';
function createTemplate(config) { function createTemplate(config: CombinedConfig) {
const teams = config.teams; const teams = config.teams;
const template = [ const template = [
...teams.slice(0, 9).sort((teamA, teamB) => teamA.order - teamB.order).map((team) => { ...teams.slice(0, 9).sort((teamA, teamB) => teamA.order - teamB.order).map((team) => {
return { return {
label: team.name, label: team.name,
click: () => { click: () => {
WindowManager.switchServer(team.name, true); WindowManager.switchServer(team.name);
}, },
}; };
}), { }), {
@ -33,8 +34,9 @@ function createTemplate(config) {
return template; return template;
} }
function createMenu(config) { function createMenu(config: CombinedConfig) {
return Menu.buildFromTemplate(createTemplate(config)); // TODO: Electron is enforcing certain variables that it doesn't need
return Menu.buildFromTemplate(createTemplate(config) as Array<MenuItemConstructorOptions | MenuItem>);
} }
export default { export default {

View file

@ -3,6 +3,7 @@
import path from 'path'; import path from 'path';
import {app, Notification} from 'electron'; import {app, Notification} from 'electron';
import {ServerFromURL} from 'types/utils';
const assetsDir = path.resolve(app.getAppPath(), 'assets'); const assetsDir = path.resolve(app.getAppPath(), 'assets');
const appIconURL = path.resolve(assetsDir, 'appicon_48.png'); const appIconURL = path.resolve(assetsDir, 'appicon_48.png');
@ -11,11 +12,12 @@ const defaultOptions = {
title: 'Download Complete', title: 'Download Complete',
silent: false, silent: false,
icon: appIconURL, icon: appIconURL,
urgency: 'normal', urgency: 'normal' as Notification['urgency'],
body: '',
}; };
export class DownloadNotification extends Notification { export class DownloadNotification extends Notification {
constructor(fileName, serverInfo) { constructor(fileName: string, serverInfo: ServerFromURL) {
const options = {...defaultOptions}; const options = {...defaultOptions};
if (process.platform === 'win32') { if (process.platform === 'win32') {
options.icon = appIconURL; options.icon = appIconURL;

View file

@ -4,6 +4,8 @@
import path from 'path'; import path from 'path';
import {app, Notification} from 'electron'; import {app, Notification} from 'electron';
import {MentionOptions} from 'types/notification';
import osVersion from 'common/osVersion'; import osVersion from 'common/osVersion';
const assetsDir = path.resolve(app.getAppPath(), 'assets'); const assetsDir = path.resolve(app.getAppPath(), 'assets');
@ -13,23 +15,28 @@ const defaultOptions = {
title: 'Someone mentioned you', title: 'Someone mentioned you',
silent: false, silent: false,
icon: appIconURL, icon: appIconURL,
urgency: 'normal', urgency: 'normal' as Notification['urgency'],
}; };
export const DEFAULT_WIN7 = 'Ding'; export const DEFAULT_WIN7 = 'Ding';
export class Mention extends Notification { export class Mention extends Notification {
constructor(customOptions, channel, teamId) { customSound: boolean;
channel: {id: string}; // TODO: Channel from mattermost-redux
teamId: string;
constructor(customOptions: MentionOptions, channel: {id: string}, teamId: string) {
super({...defaultOptions, ...customOptions});
const options = {...defaultOptions, ...customOptions}; const options = {...defaultOptions, ...customOptions};
if (process.platform === 'darwin') { if (process.platform === 'darwin') {
// Notification Center shows app's icon, so there were two icons on the notification. // Notification Center shows app's icon, so there were two icons on the notification.
Reflect.deleteProperty(options, 'icon'); Reflect.deleteProperty(options, 'icon');
} }
const isWin7 = (process.platform === 'win32' && osVersion.isLowerThanOrEqualWindows8_1() && DEFAULT_WIN7); const isWin7 = (process.platform === 'win32' && osVersion.isLowerThanOrEqualWindows8_1() && DEFAULT_WIN7);
const customSound = !options.silent && ((options.data && options.data.soundName !== 'None' && options.data.soundName) || isWin7); const customSound = Boolean(!options.silent && ((options.data && options.data.soundName !== 'None' && options.data.soundName) || isWin7));
if (customSound) { if (customSound) {
options.silent = true; options.silent = true;
} }
super(options);
this.customSound = customSound; this.customSound = customSound;
this.channel = channel; this.channel = channel;
this.teamId = teamId; this.teamId = teamId;

View file

@ -4,6 +4,9 @@
import {shell, Notification} from 'electron'; import {shell, Notification} from 'electron';
import log from 'electron-log'; import log from 'electron-log';
import {MentionData} from 'types/notification';
import {ServerFromURL} from 'types/utils';
import {PLAY_SOUND} from 'common/communication'; import {PLAY_SOUND} from 'common/communication';
import * as windowManager from '../windows/windowManager'; import * as windowManager from '../windows/windowManager';
@ -13,7 +16,7 @@ import {DownloadNotification} from './Download';
const currentNotifications = new Map(); const currentNotifications = new Map();
export function displayMention(title, body, channel, teamId, silent, webcontents, data) { export function displayMention(title: string, body: string, channel: {id: string}, teamId: string, silent: boolean, webcontents: Electron.WebContents, data: MentionData) {
if (!Notification.isSupported()) { if (!Notification.isSupported()) {
log.error('notification not supported'); log.error('notification not supported');
return; return;
@ -49,14 +52,14 @@ export function displayMention(title, body, channel, teamId, silent, webcontents
mention.on('click', () => { mention.on('click', () => {
if (serverName) { if (serverName) {
windowManager.switchServer(serverName, true); windowManager.switchServer(serverName);
webcontents.send('notification-clicked', {channel, teamId}); webcontents.send('notification-clicked', {channel, teamId});
} }
}); });
mention.show(); mention.show();
} }
export function displayDownloadCompleted(fileName, path, serverInfo) { export function displayDownloadCompleted(fileName: string, path: string, serverInfo: ServerFromURL) {
if (!Notification.isSupported()) { if (!Notification.isSupported()) {
log.error('notification not supported'); log.error('notification not supported');
return; return;

View file

@ -1,6 +1,6 @@
// Copyright (c) 2015-2016 Yuya Ochiai
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. // Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information. // See LICENSE.txt for license information.
// Copyright (c) 2015-2016 Yuya Ochiai
'use strict'; 'use strict';

View file

@ -11,12 +11,12 @@ import * as AppState from '../appState';
const assetsDir = path.resolve(app.getAppPath(), 'assets'); const assetsDir = path.resolve(app.getAppPath(), 'assets');
let trayImages; let trayImages: Record<string, Electron.NativeImage>;
let trayIcon; let trayIcon: Tray;
let lastStatus = 'normal'; let lastStatus = 'normal';
let lastMessage = app.name; let lastMessage = app.name;
export function refreshTrayImages(trayIconTheme) { export function refreshTrayImages(trayIconTheme: string) {
const winTheme = nativeTheme.shouldUseDarkColors ? 'dark' : 'light'; const winTheme = nativeTheme.shouldUseDarkColors ? 'dark' : 'light';
switch (process.platform) { switch (process.platform) {
@ -69,7 +69,7 @@ export function refreshTrayImages(trayIconTheme) {
return trayImages; return trayImages;
} }
export function setupTray(icontheme) { export function setupTray(icontheme: string) {
refreshTrayImages(icontheme); refreshTrayImages(icontheme);
trayIcon = new Tray(trayImages.normal); trayIcon = new Tray(trayImages.normal);
if (process.platform === 'darwin') { if (process.platform === 'darwin') {
@ -103,7 +103,7 @@ export function setupTray(icontheme) {
}); });
} }
function setTray(status, message) { function setTray(status: string, message: string) {
lastStatus = status; lastStatus = status;
lastMessage = message; lastMessage = message;
trayIcon.setImage(trayImages[status]); trayIcon.setImage(trayImages[status]);
@ -116,17 +116,8 @@ export function destroyTray() {
} }
} }
export function setTrayMenu(tMenu, mainWindow) { export function setTrayMenu(tMenu: Electron.Menu) {
if (process.platform === 'darwin' || process.platform === 'linux') { if (trayIcon) {
// store the information, if the tray was initialized, for checking in the settings, if the application
// was restarted after setting "Show icon on menu bar"
if (trayIcon) {
trayIcon.setContextMenu(tMenu);
mainWindow.trayWasVisible = true;
} else {
mainWindow.trayWasVisible = false;
}
} else if (trayIcon) {
trayIcon.setContextMenu(tMenu); trayIcon.setContextMenu(tMenu);
} }
} }

View file

@ -7,12 +7,16 @@ import fs from 'fs';
import log from 'electron-log'; import log from 'electron-log';
import urlUtils from '../common/utils/url'; import {TrustedOrigin, PermissionType} from 'types/trustedOrigin';
import urlUtils from 'common/utils/url';
import * as Validator from './Validator'; import * as Validator from './Validator';
export default class TrustedOriginsStore { export default class TrustedOriginsStore {
constructor(storeFile) { storeFile: string;
data?: Map<string, TrustedOrigin>;
constructor(storeFile: string) {
this.storeFile = storeFile; this.storeFile = storeFile;
} }
@ -40,18 +44,24 @@ export default class TrustedOriginsStore {
} }
// don't use this, is for ease of mocking it on testing // don't use this, is for ease of mocking it on testing
saveToFile(stringMap) { saveToFile(stringMap: string) {
fs.writeFileSync(this.storeFile, stringMap); fs.writeFileSync(this.storeFile, stringMap);
} }
save = () => { save = () => {
if (!this.data) {
return;
}
this.saveToFile(JSON.stringify(Object.fromEntries((this.data.entries())), null, ' ')); this.saveToFile(JSON.stringify(Object.fromEntries((this.data.entries())), null, ' '));
}; };
// if permissions or targetUrl are invalid, this function will throw an error // if permissions or targetUrl are invalid, this function will throw an error
// this function stablishes all the permissions at once, overwriting whatever was before // this function stablishes all the permissions at once, overwriting whatever was before
// to enable just one permission use addPermission instead. // to enable just one permission use addPermission instead.
set = (targetURL, permissions) => { set = (targetURL: string, permissions: Record<PermissionType, boolean>) => {
if (!this.data) {
return;
}
const validPermissions = Validator.validateOriginPermissions(permissions); const validPermissions = Validator.validateOriginPermissions(permissions);
if (!validPermissions) { if (!validPermissions) {
throw new Error(`Invalid permissions set for trusting ${targetURL}`); throw new Error(`Invalid permissions set for trusting ${targetURL}`);
@ -60,30 +70,28 @@ export default class TrustedOriginsStore {
}; };
// enables usage of `targetURL` for `permission` // enables usage of `targetURL` for `permission`
addPermission = (targetURL, permission) => { addPermission = (targetURL: string, permission: PermissionType) => {
const origin = urlUtils.getHost(targetURL); const origin = urlUtils.getHost(targetURL);
const currentPermissions = this.data.get(origin) || {}; this.set(origin, {[permission]: true});
currentPermissions[permission] = true;
this.set(origin, currentPermissions);
} }
delete = (targetURL) => { delete = (targetURL: string) => {
let host; let host;
try { try {
host = urlUtils.getHost(targetURL); host = urlUtils.getHost(targetURL);
this.data.delete(host); this.data?.delete(host);
} catch { } catch {
return false; return false;
} }
return true; return true;
} }
isExisting = (targetURL) => { isExisting = (targetURL: string) => {
return (typeof this.data.get(urlUtils.getHost(targetURL)) !== 'undefined'); return this.data?.has(urlUtils.getHost(targetURL)) || false;
}; };
// if user hasn't set his preferences, it will return null (falsy) // if user hasn't set his preferences, it will return null (falsy)
checkPermission = (targetURL, permission) => { checkPermission = (targetURL: string, permission: PermissionType) => {
if (!permission) { if (!permission) {
log.error(`Missing permission request on ${targetURL}`); log.error(`Missing permission request on ${targetURL}`);
return null; return null;
@ -96,7 +104,7 @@ export default class TrustedOriginsStore {
return null; return null;
} }
const urlPermissions = this.data.get(origin); const urlPermissions = this.data?.get(origin);
return urlPermissions ? urlPermissions[permission] : null; return urlPermissions ? urlPermissions[permission] : undefined;
} }
} }

View file

@ -2,16 +2,18 @@
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. // Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information. // See LICENSE.txt for license information.
import electron, {app} from 'electron'; import electron, {app, BrowserWindow} from 'electron';
import path from 'path'; import path from 'path';
import {Args} from 'types/args';
import {PRODUCTION} from 'common/utils/constants'; import {PRODUCTION} from 'common/utils/constants';
import Utils from 'common/utils/util'; import Utils from 'common/utils/util';
const TAB_BAR_HEIGHT = 40; const TAB_BAR_HEIGHT = 40;
const BACK_BAR_HEIGHT = 36; const BACK_BAR_HEIGHT = 36;
export function shouldBeHiddenOnStartup(parsedArgv) { export function shouldBeHiddenOnStartup(parsedArgv: Args) {
if (parsedArgv.hidden) { if (parsedArgv.hidden) {
return true; return true;
} }
@ -23,12 +25,12 @@ export function shouldBeHiddenOnStartup(parsedArgv) {
return false; return false;
} }
export function getWindowBoundaries(win, hasBackBar = false) { export function getWindowBoundaries(win: BrowserWindow, hasBackBar = false) {
const {width, height} = win.getContentBounds(); const {width, height} = win.getContentBounds();
return getAdjustedWindowBoundaries(width, height, hasBackBar); return getAdjustedWindowBoundaries(width, height, hasBackBar);
} }
export function getAdjustedWindowBoundaries(width, height, hasBackBar = false) { export function getAdjustedWindowBoundaries(width: number, height: number, hasBackBar = false) {
return { return {
x: 0, x: 0,
y: TAB_BAR_HEIGHT + (hasBackBar ? BACK_BAR_HEIGHT : 0), y: TAB_BAR_HEIGHT + (hasBackBar ? BACK_BAR_HEIGHT : 0),
@ -37,12 +39,12 @@ export function getAdjustedWindowBoundaries(width, height, hasBackBar = false) {
}; };
} }
export function getLocalURLString(urlPath, query, isMain) { export function getLocalURLString(urlPath: string, query?: Map<string, string>, isMain?: boolean) {
const localURL = getLocalURL(urlPath, query, isMain); const localURL = getLocalURL(urlPath, query, isMain);
return localURL.href; return localURL.href;
} }
export function getLocalURL(urlPath, query, isMain) { export function getLocalURL(urlPath: string, query?: Map<string, string>, isMain?: boolean) {
let pathname; let pathname;
const processPath = isMain ? '' : '/renderer'; const processPath = isMain ? '' : '/renderer';
const mode = Utils.runMode(); const mode = Utils.runMode();
@ -57,7 +59,7 @@ export function getLocalURL(urlPath, query, isMain) {
const localUrl = new URL(`${protocol}://${hostname}${port}`); const localUrl = new URL(`${protocol}://${hostname}${port}`);
localUrl.pathname = pathname; localUrl.pathname = pathname;
if (query) { if (query) {
query.forEach((value, key) => { query.forEach((value: string, key: string) => {
localUrl.searchParams.append(encodeURIComponent(key), encodeURIComponent(value)); localUrl.searchParams.append(encodeURIComponent(key), encodeURIComponent(value));
}); });
} }
@ -65,7 +67,7 @@ export function getLocalURL(urlPath, query, isMain) {
return localUrl; return localUrl;
} }
export function getLocalPreload(file) { export function getLocalPreload(file: string) {
if (Utils.runMode() === PRODUCTION) { if (Utils.runMode() === PRODUCTION) {
return path.join(electron.app.getAppPath(), `${file}`); return path.join(electron.app.getAppPath(), `${file}`);
} }

View file

@ -1,7 +1,8 @@
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. // Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information. // See LICENSE.txt for license information.
import {BrowserView, app, ipcMain} from 'electron'; import {BrowserView, app, ipcMain, BrowserWindow} from 'electron';
import {BrowserViewConstructorOptions, Event, Input} from 'electron/main';
import log from 'electron-log'; import log from 'electron-log';
import {EventEmitter} from 'events'; import {EventEmitter} from 'events';
@ -21,6 +22,8 @@ import {
LOADSCREEN_END, LOADSCREEN_END,
} from 'common/communication'; } from 'common/communication';
import {MattermostServer} from 'main/MattermostServer';
import ContextMenu from '../contextMenu'; import ContextMenu from '../contextMenu';
import {getWindowBoundaries, getLocalPreload, composeUserAgent} from '../utils'; import {getWindowBoundaries, getLocalPreload, composeUserAgent} from '../utils';
import * as WindowManager from '../windows/windowManager'; import * as WindowManager from '../windows/windowManager';
@ -28,49 +31,67 @@ import * as appState from '../appState';
import {removeWebContentsListeners} from './webContentEvents'; import {removeWebContentsListeners} from './webContentEvents';
const READY = 1; enum Status {
const WAITING_MM = 2; LOADING,
const LOADING = 0; READY,
const ERROR = -1; WAITING_MM,
ERROR = -1,
}
const ASTERISK_GROUP = 3; const ASTERISK_GROUP = 3;
const MENTIONS_GROUP = 2; const MENTIONS_GROUP = 2;
export class MattermostView extends EventEmitter { export class MattermostView extends EventEmitter {
constructor(server, win, options) { server: MattermostServer;
window: BrowserWindow;
view: BrowserView;
isVisible: boolean;
options: BrowserViewConstructorOptions;
removeLoading?: number;
/**
* for backward compatibility when reading the title.
* null means we have yet to figure out if it uses it or not but we consider it false until proven wrong
*/
usesAsteriskForUnreads?: boolean;
faviconMemoize: Map<string, boolean>;
currentFavicon?: string;
isInitialized: boolean;
hasBeenShown: boolean;
altLastPressed?: boolean;
contextMenu: ContextMenu;
status?: Status;
retryLoad?: NodeJS.Timeout;
maxRetries: number;
constructor(server: MattermostServer, win: BrowserWindow, options: BrowserViewConstructorOptions) {
super(); super();
this.server = server; this.server = server;
this.window = win; this.window = win;
const preload = getLocalPreload('preload.js'); const preload = getLocalPreload('preload.js');
const spellcheck = ((!options || typeof options.spellcheck === 'undefined') ? true : options.spellcheck);
this.options = { this.options = {
webPreferences: { webPreferences: {
contextIsolation: process.env.NODE_ENV !== 'test', contextIsolation: process.env.NODE_ENV !== 'test',
preload, preload,
spellcheck,
additionalArguments: [ additionalArguments: [
`version=${app.version}`, `version=${app.getVersion()}`,
`appName=${app.name}`, `appName=${app.name}`,
], ],
enableRemoteModule: process.env.NODE_ENV === 'test', enableRemoteModule: process.env.NODE_ENV === 'test',
nodeIntegration: process.env.NODE_ENV === 'test', nodeIntegration: process.env.NODE_ENV === 'test',
...options.webPreferences,
}, },
...options, ...options,
}; };
this.isVisible = false; this.isVisible = false;
this.view = new BrowserView(this.options); this.view = new BrowserView(this.options);
this.removeLoading = null;
this.resetLoadingStatus(); this.resetLoadingStatus();
/**
* for backward compatibility when reading the title.
* null means we have yet to figure out if it uses it or not but we consider it false until proven wrong
*/
this.usesAsteriskForUnreads = null;
this.faviconMemoize = new Map(); this.faviconMemoize = new Map();
this.currentFavicon = null;
log.info(`BrowserView created for server ${this.server.name}`); log.info(`BrowserView created for server ${this.server.name}`);
this.isInitialized = false; this.isInitialized = false;
@ -82,24 +103,40 @@ export class MattermostView extends EventEmitter {
} }
this.contextMenu = new ContextMenu({}, this.view); this.contextMenu = new ContextMenu({}, this.view);
this.maxRetries = MAX_SERVER_RETRIES;
} }
// use the same name as the server // 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) // TODO: we'll need unique identifiers if we have multiple instances of the same server in different tabs (1:N relationships)
get name() { get name() {
return this.server.name; return this.server?.name;
} }
resetLoadingStatus = () => { resetLoadingStatus = () => {
if (this.status !== LOADING) { // if it's already loading, don't touch anything if (this.status !== Status.LOADING) { // if it's already loading, don't touch anything
this.retryLoad = null; delete this.retryLoad;
this.status = LOADING; this.status = Status.LOADING;
this.maxRetries = MAX_SERVER_RETRIES; this.maxRetries = MAX_SERVER_RETRIES;
} }
} }
load = (someURL) => { load = (someURL?: URL | string) => {
const loadURL = (typeof someURL === 'undefined') ? `${this.server.url.toString()}` : urlUtils.parseURL(someURL).toString(); if (!this.server) {
return;
}
let loadURL: string;
if (someURL) {
const parsedURL = urlUtils.parseURL(someURL);
if (parsedURL) {
loadURL = parsedURL.toString();
} else {
log.error('Cannot parse provided url, using current server url', someURL);
loadURL = this.server.url.toString();
}
} else {
loadURL = this.server.url.toString();
}
log.info(`[${Util.shorten(this.server.name)}] Loading ${loadURL}`); log.info(`[${Util.shorten(this.server.name)}] Loading ${loadURL}`);
const loading = this.view.webContents.loadURL(loadURL, {userAgent: composeUserAgent()}); const loading = this.view.webContents.loadURL(loadURL, {userAgent: composeUserAgent()});
loading.then(this.loadSuccess(loadURL)).catch((err) => { loading.then(this.loadSuccess(loadURL)).catch((err) => {
@ -107,7 +144,7 @@ export class MattermostView extends EventEmitter {
}); });
} }
retry = (loadURL) => { retry = (loadURL: string) => {
return () => { return () => {
// window was closed while retrying // window was closed while retrying
if (!this.view || !this.view.webContents) { if (!this.view || !this.view.webContents) {
@ -121,43 +158,43 @@ export class MattermostView extends EventEmitter {
WindowManager.sendToRenderer(LOAD_FAILED, this.server.name, err.toString(), loadURL.toString()); WindowManager.sendToRenderer(LOAD_FAILED, this.server.name, err.toString(), loadURL.toString());
this.emit(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}.`); log.info(`[${Util.shorten(this.server.name)}] Couldn't stablish a connection with ${loadURL}: ${err}.`);
this.status = ERROR; this.status = Status.ERROR;
} }
}); });
}; };
} }
loadRetry = (loadURL, err) => { loadRetry = (loadURL: string, err: any) => {
this.retryLoad = setTimeout(this.retry(loadURL), RELOAD_INTERVAL); this.retryLoad = setTimeout(this.retry(loadURL), RELOAD_INTERVAL);
WindowManager.sendToRenderer(LOAD_RETRY, this.server.name, Date.now() + RELOAD_INTERVAL, err.toString(), loadURL.toString()); 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`); log.info(`[${Util.shorten(this.server.name)}] failed loading ${loadURL}: ${err}, retrying in ${RELOAD_INTERVAL / SECOND} seconds`);
} }
loadSuccess = (loadURL) => { loadSuccess = (loadURL: string) => {
return () => { return () => {
log.info(`[${Util.shorten(this.server.name)}] finished loading ${loadURL}`); log.info(`[${Util.shorten(this.server.name)}] finished loading ${loadURL}`);
WindowManager.sendToRenderer(LOAD_SUCCESS, this.server.name); WindowManager.sendToRenderer(LOAD_SUCCESS, this.server.name);
this.maxRetries = MAX_SERVER_RETRIES; this.maxRetries = MAX_SERVER_RETRIES;
if (this.status === LOADING) { if (this.status === Status.LOADING) {
ipcMain.on(UNREAD_RESULT, this.handleFaviconIsUnread); ipcMain.on(UNREAD_RESULT, this.handleFaviconIsUnread);
this.handleTitleUpdate(null, this.view.webContents.getTitle()); this.updateMentionsFromTitle(this.view.webContents.getTitle());
this.findUnreadState(null); this.findUnreadState(null);
} }
this.status = WAITING_MM; this.status = Status.WAITING_MM;
this.removeLoading = setTimeout(this.setInitialized, MAX_LOADING_SCREEN_SECONDS, true); this.removeLoading = setTimeout(this.setInitialized, MAX_LOADING_SCREEN_SECONDS, true);
this.emit(LOAD_SUCCESS, this.server.name, loadURL.toString()); this.emit(LOAD_SUCCESS, this.server.name, loadURL);
this.view.webContents.send(SET_SERVER_NAME, this.server.name); 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.setBounds(getWindowBoundaries(this.window, !(urlUtils.isTeamUrl(this.server.url || '', this.view.webContents.getURL()) || urlUtils.isAdminUrl(this.server.url || '', this.view.webContents.getURL()))));
}; };
} }
show = (requestedVisibility) => { show = (requestedVisibility?: boolean) => {
this.hasBeenShown = true; this.hasBeenShown = true;
const request = typeof requestedVisibility === 'undefined' ? true : requestedVisibility; const request = typeof requestedVisibility === 'undefined' ? true : requestedVisibility;
if (request && !this.isVisible) { if (request && !this.isVisible) {
this.window.addBrowserView(this.view); 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.server.url || '', this.view.webContents.getURL()) || urlUtils.isAdminUrl(this.server.url || '', this.view.webContents.getURL()))));
if (this.status === READY) { if (this.status === Status.READY) {
this.focus(); this.focus();
} }
} else if (!request && this.isVisible) { } else if (!request && this.isVisible) {
@ -173,15 +210,12 @@ export class MattermostView extends EventEmitter {
hide = () => this.show(false); hide = () => this.show(false);
setBounds = (boundaries) => { setBounds = (boundaries: Electron.Rectangle) => {
// todo: review this, as it might not work properly with devtools/minimizing/resizing // todo: review this, as it might not work properly with devtools/minimizing/resizing
this.view.setBounds(boundaries); this.view.setBounds(boundaries);
} }
destroy = () => { destroy = () => {
if (this.retryLoad) {
clearTimeout(this.retryLoad);
}
removeWebContentsListeners(this.view.webContents.id); removeWebContentsListeners(this.view.webContents.id);
if (this.window) { if (this.window) {
this.window.removeBrowserView(this.view); this.window.removeBrowserView(this.view);
@ -189,12 +223,17 @@ export class MattermostView extends EventEmitter {
// workaround to eliminate zombie processes // workaround to eliminate zombie processes
// https://github.com/mattermost/desktop/pull/1519 // https://github.com/mattermost/desktop/pull/1519
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
this.view.webContents.destroy(); this.view.webContents.destroy();
this.window = null;
this.server = null;
this.isVisible = false; this.isVisible = false;
clearTimeout(this.retryLoad); if (this.retryLoad) {
clearTimeout(this.retryLoad);
}
if (this.removeLoading) {
clearTimeout(this.removeLoading);
}
} }
focus = () => { focus = () => {
@ -206,22 +245,22 @@ export class MattermostView extends EventEmitter {
} }
isReady = () => { isReady = () => {
return this.status !== LOADING; return this.status !== Status.LOADING;
} }
needsLoadingScreen = () => { needsLoadingScreen = () => {
return !(this.status === READY || this.status === ERROR); return !(this.status === Status.READY || this.status === Status.ERROR);
} }
setInitialized = (timedout) => { setInitialized = (timedout?: boolean) => {
this.status = READY; this.status = Status.READY;
if (timedout) { if (timedout) {
log.info(`${this.server.name} timeout expired will show the browserview`); log.info(`${this.server.name} timeout expired will show the browserview`);
this.emit(LOADSCREEN_END, this.server.name); this.emit(LOADSCREEN_END, this.server.name);
} }
clearTimeout(this.removeLoading); clearTimeout(this.removeLoading);
this.removeLoading = null; delete this.removeLoading;
} }
openDevTools = () => { openDevTools = () => {
@ -229,15 +268,15 @@ export class MattermostView extends EventEmitter {
} }
getWebContents = () => { getWebContents = () => {
if (this.status === READY) { if (this.status === Status.READY) {
return this.view.webContents; return this.view.webContents;
} else if (this.window) { } else if (this.window) {
return this.window.webContents; // if it's not ready you are looking at the renderer process return this.window.webContents; // if it's not ready you are looking at the renderer process
} }
return WindowManager.getMainWindow.webContents; return WindowManager.getMainWindow()?.webContents;
} }
handleInputEvents = (_, input) => { handleInputEvents = (_: Event, input: Input) => {
// Handler for pressing the Alt key to focus the 3-dot menu // Handler for pressing the Alt key to focus the 3-dot menu
if (input.key === 'Alt' && input.type === 'keyUp' && this.altLastPressed) { if (input.key === 'Alt' && input.type === 'keyUp' && this.altLastPressed) {
this.altLastPressed = false; this.altLastPressed = false;
@ -253,8 +292,8 @@ export class MattermostView extends EventEmitter {
} }
} }
handleDidNavigate = (event, url) => { handleDidNavigate = (event: Event, url: string) => {
const isUrlTeamUrl = urlUtils.isTeamUrl(this.server.url, url) || urlUtils.isAdminUrl(this.server.url, url); const isUrlTeamUrl = urlUtils.isTeamUrl(this.server.url || '', url) || urlUtils.isAdminUrl(this.server.url || '', url);
if (isUrlTeamUrl) { if (isUrlTeamUrl) {
this.setBounds(getWindowBoundaries(this.window)); this.setBounds(getWindowBoundaries(this.window));
WindowManager.sendToRenderer(TOGGLE_BACK_BUTTON, false); WindowManager.sendToRenderer(TOGGLE_BACK_BUTTON, false);
@ -266,15 +305,19 @@ export class MattermostView extends EventEmitter {
} }
} }
handleUpdateTarget = (e, url) => { handleUpdateTarget = (e: Event, url: string) => {
if (!this.server.sameOrigin(url)) { if (!url || !this.server.sameOrigin(url)) {
this.emit(UPDATE_TARGET_URL, url); this.emit(UPDATE_TARGET_URL, url);
} }
} }
titleParser = /(\((\d+)\) )?(\*)?/g titleParser = /(\((\d+)\) )?(\*)?/g
handleTitleUpdate = (e, title) => { handleTitleUpdate = (e: Event, title: string) => {
this.updateMentionsFromTitle(title);
}
updateMentionsFromTitle = (title: string) => {
//const title = this.view.webContents.getTitle(); //const title = this.view.webContents.getTitle();
const resultsIterator = title.matchAll(this.titleParser); const resultsIterator = title.matchAll(this.titleParser);
const results = resultsIterator.next(); // we are only interested in the first set const results = resultsIterator.next(); // we are only interested in the first set
@ -293,13 +336,13 @@ export class MattermostView extends EventEmitter {
appState.updateMentions(this.server.name, mentions, unreads); appState.updateMentions(this.server.name, mentions, unreads);
} }
handleFaviconUpdate = (e, favicons) => { handleFaviconUpdate = (e: Event, favicons: string[]) => {
if (!this.usesAsteriskForUnreads) { if (!this.usesAsteriskForUnreads) {
// if unread state is stored for that favicon, retrieve value. // if unread state is stored for that favicon, retrieve value.
// if not, get related info from preload and store it for future changes // if not, get related info from preload and store it for future changes
this.currentFavicon = favicons[0]; this.currentFavicon = favicons[0];
if (this.faviconMemoize.has(favicons[0])) { if (this.faviconMemoize.has(favicons[0])) {
appState.updateUnreads(this.server.name, this.faviconMemoize.get(favicons[0])); appState.updateUnreads(this.server.name, Boolean(this.faviconMemoize.get(favicons[0])));
} else { } else {
this.findUnreadState(favicons[0]); this.findUnreadState(favicons[0]);
} }
@ -307,7 +350,7 @@ export class MattermostView extends EventEmitter {
} }
// if favicon is null, it will affect appState, but won't be memoized // if favicon is null, it will affect appState, but won't be memoized
findUnreadState = (favicon) => { findUnreadState = (favicon: string | null) => {
try { try {
this.view.webContents.send(IS_UNREAD, favicon, this.server.name); this.view.webContents.send(IS_UNREAD, favicon, this.server.name);
} catch (err) { } catch (err) {
@ -318,12 +361,12 @@ export class MattermostView extends EventEmitter {
// if favicon is null, it means it is the initial load, // 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. // so don't memoize as we don't have the favicons and there is no rush to find out.
handleFaviconIsUnread = (e, favicon, serverName, result) => { handleFaviconIsUnread = (e: Event, favicon: string, serverName: string, result: boolean) => {
if (this.server && serverName === this.server.name) { if (this.server && serverName === this.server.name) {
if (favicon) { if (favicon) {
this.faviconMemoize.set(favicon, result); this.faviconMemoize.set(favicon, result);
} }
if (favicon === null || favicon === this.currentFavicon) { if (!favicon || favicon === this.currentFavicon) {
appState.updateUnreads(serverName, result); appState.updateUnreads(serverName, result);
} }
} }

View file

@ -1,23 +1,24 @@
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. // Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information. // See LICENSE.txt for license information.
import {ipcMain} from 'electron'; import {BrowserWindow, ipcMain} from 'electron';
import {IpcMainEvent, IpcMainInvokeEvent} from 'electron/main';
import {RETRIEVE_MODAL_INFO, MODAL_CANCEL, MODAL_RESULT, MODAL_OPEN, MODAL_CLOSE} from 'common/communication.js'; import {RETRIEVE_MODAL_INFO, MODAL_CANCEL, MODAL_RESULT, MODAL_OPEN, MODAL_CLOSE} from 'common/communication';
import * as WindowManager from '../windows/windowManager'; import * as WindowManager from '../windows/windowManager';
import {ModalView} from './modalView'; import {ModalView} from './modalView';
let modalQueue = []; let modalQueue: Array<ModalView<any, any>> = [];
// TODO: add a queue/add differentiation, in case we need to put a modal first in line // TODO: add a queue/add differentiation, in case we need to put a modal first in line
// should we return the original promise if called multiple times with the same key? // should we return the original promise if called multiple times with the same key?
export function addModal(key, html, preload, data, win) { export function addModal<T, T2>(key: string, html: string, preload: string, data: T, win: BrowserWindow) {
const foundModal = modalQueue.find((modal) => modal.key === key); const foundModal = modalQueue.find((modal) => modal.key === key);
if (!foundModal) { if (!foundModal) {
const modalPromise = new Promise((resolve, reject) => { const modalPromise = new Promise((resolve: (value: T2) => void, reject) => {
const mv = new ModalView(key, html, preload, data, resolve, reject, win); const mv = new ModalView<T, T2>(key, html, preload, data, resolve, reject, win);
modalQueue.push(mv); modalQueue.push(mv);
}); });
@ -34,7 +35,7 @@ ipcMain.handle(RETRIEVE_MODAL_INFO, handleInfoRequest);
ipcMain.on(MODAL_RESULT, handleModalResult); ipcMain.on(MODAL_RESULT, handleModalResult);
ipcMain.on(MODAL_CANCEL, handleModalCancel); ipcMain.on(MODAL_CANCEL, handleModalCancel);
function findModalByCaller(event) { function findModalByCaller(event: IpcMainInvokeEvent) {
if (modalQueue.length) { if (modalQueue.length) {
const requestModal = modalQueue.find((modal) => { const requestModal = modalQueue.find((modal) => {
return (modal.view && modal.view.webContents && modal.view.webContents.id === event.sender.id); return (modal.view && modal.view.webContents && modal.view.webContents.id === event.sender.id);
@ -44,7 +45,7 @@ function findModalByCaller(event) {
return null; return null;
} }
function handleInfoRequest(event) { function handleInfoRequest(event: IpcMainInvokeEvent) {
const requestModal = findModalByCaller(event); const requestModal = findModalByCaller(event);
if (requestModal) { if (requestModal) {
return requestModal.handleInfoRequest(); return requestModal.handleInfoRequest();
@ -53,12 +54,11 @@ function handleInfoRequest(event) {
} }
export function showModal() { export function showModal() {
let noWindow;
const withDevTools = process.env.MM_DEBUG_MODALS || false; const withDevTools = process.env.MM_DEBUG_MODALS || false;
modalQueue.forEach((modal, index) => { modalQueue.forEach((modal, index) => {
if (index === 0) { if (index === 0) {
WindowManager.sendToRenderer(MODAL_OPEN); WindowManager.sendToRenderer(MODAL_OPEN);
modal.show(noWindow, withDevTools); modal.show(undefined, Boolean(withDevTools));
} else { } else {
WindowManager.sendToRenderer(MODAL_CLOSE); WindowManager.sendToRenderer(MODAL_CLOSE);
modal.hide(); modal.hide();
@ -66,7 +66,7 @@ export function showModal() {
}); });
} }
function handleModalResult(event, data) { function handleModalResult(event: IpcMainEvent, data: unknown) {
const requestModal = findModalByCaller(event); const requestModal = findModalByCaller(event);
if (requestModal) { if (requestModal) {
requestModal.resolve(data); requestModal.resolve(data);
@ -80,7 +80,7 @@ function handleModalResult(event, data) {
} }
} }
function handleModalCancel(event, data) { function handleModalCancel(event: IpcMainEvent, data: unknown) {
const requestModal = findModalByCaller(event); const requestModal = findModalByCaller(event);
if (requestModal) { if (requestModal) {
requestModal.reject(data); requestModal.reject(data);

View file

@ -1,18 +1,31 @@
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. // Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information. // See LICENSE.txt for license information.
import {BrowserView} from 'electron'; import {BrowserView, BrowserWindow} from 'electron';
import log from 'electron-log'; import log from 'electron-log';
import ContextMenu from '../contextMenu'; import ContextMenu from '../contextMenu';
import {getWindowBoundaries} from '../utils'; import {getWindowBoundaries} from '../utils';
const ACTIVE = 'active'; enum Status {
const SHOWING = 'showing'; ACTIVE,
const DONE = 'done'; SHOWING,
DONE
}
export class ModalView { export class ModalView<T, T2> {
constructor(key, html, preload, data, onResolve, onReject, currentWindow) { key: string;
html: string;
data: T;
view: BrowserView;
onReject: (value: T2) => void;
onResolve: (value: T2) => void;
window: BrowserWindow;
windowAttached?: BrowserWindow;
status: Status;
contextMenu: ContextMenu;
constructor(key: string, html: string, preload: string, data: T, onResolve: (value: T2) => void, onReject: (value: T2) => void, currentWindow: BrowserWindow) {
this.key = key; this.key = key;
this.html = html; this.html = html;
this.data = data; this.data = data;
@ -26,8 +39,8 @@ export class ModalView {
this.onReject = onReject; this.onReject = onReject;
this.onResolve = onResolve; this.onResolve = onResolve;
this.window = currentWindow; this.window = currentWindow;
this.windowAttached = null;
this.status = ACTIVE; this.status = Status.ACTIVE;
try { try {
this.view.webContents.loadURL(this.html); this.view.webContents.loadURL(this.html);
} catch (e) { } catch (e) {
@ -38,7 +51,7 @@ export class ModalView {
this.contextMenu = new ContextMenu({}, this.view); this.contextMenu = new ContextMenu({}, this.view);
} }
show = (win, withDevTools) => { show = (win?: BrowserWindow, withDevTools?: boolean) => {
if (this.windowAttached) { if (this.windowAttached) {
// we'll reatach // we'll reatach
this.windowAttached.removeBrowserView(this.view); this.windowAttached.removeBrowserView(this.view);
@ -53,7 +66,7 @@ export class ModalView {
horizontal: true, horizontal: true,
vertical: true, vertical: true,
}); });
this.status = SHOWING; this.status = Status.SHOWING;
if (this.view.webContents.isLoading()) { if (this.view.webContents.isLoading()) {
this.view.webContents.once('did-finish-load', () => { this.view.webContents.once('did-finish-load', () => {
this.view.webContents.focus(); this.view.webContents.focus();
@ -77,10 +90,12 @@ export class ModalView {
// workaround to eliminate zombie processes // workaround to eliminate zombie processes
// https://github.com/mattermost/desktop/pull/1519 // https://github.com/mattermost/desktop/pull/1519
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
this.view.webContents.destroy(); this.view.webContents.destroy();
this.windowAttached = null; delete this.windowAttached;
this.status = ACTIVE; this.status = Status.ACTIVE;
} }
} }
@ -88,21 +103,21 @@ export class ModalView {
return this.data; return this.data;
} }
reject = (data) => { reject = (data: T2) => {
if (this.onReject) { if (this.onReject) {
this.onReject(data); this.onReject(data);
} }
this.hide(); this.hide();
this.status = DONE; this.status = Status.DONE;
} }
resolve = (data) => { resolve = (data: T2) => {
if (this.onResolve) { if (this.onResolve) {
this.onResolve(data); this.onResolve(data);
} }
this.hide(); this.hide();
this.status = DONE; this.status = Status.DONE;
} }
isActive = () => this.status !== DONE; isActive = () => this.status !== Status.DONE;
} }

View file

@ -1,7 +1,10 @@
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. // Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information. // See LICENSE.txt for license information.
import log from 'electron-log'; import log from 'electron-log';
import {BrowserView, dialog} from 'electron'; import {BrowserView, BrowserWindow, dialog} from 'electron';
import {BrowserViewConstructorOptions} from 'electron/main';
import {CombinedConfig, Team} from 'types/config';
import {SECOND} from 'common/utils/constants'; import {SECOND} from 'common/utils/constants';
import { import {
@ -26,16 +29,23 @@ const URL_VIEW_DURATION = 10 * SECOND;
const URL_VIEW_HEIGHT = 36; const URL_VIEW_HEIGHT = 36;
export class ViewManager { export class ViewManager {
constructor(config, mainWindow) { configServers: Team[];
viewOptions: BrowserViewConstructorOptions;
views: Map<string, MattermostView>;
currentView?: string;
urlView?: BrowserView;
urlViewCancel?: () => void;
mainWindow: BrowserWindow;
loadingScreen?: BrowserView;
constructor(config: CombinedConfig, mainWindow: BrowserWindow) {
this.configServers = config.teams; this.configServers = config.teams;
this.viewOptions = {spellcheck: config.useSpellChecker}; this.viewOptions = {webPreferences: {spellcheck: config.useSpellChecker}};
this.views = new Map(); // keep in mind that this doesn't need to hold server order, only tabs on the renderer need that. this.views = new Map(); // keep in mind that this doesn't need to hold server order, only tabs on the renderer need that.
this.currentView = null;
this.urlView = null;
this.mainWindow = mainWindow; this.mainWindow = mainWindow;
} }
updateMainWindow = (mainWindow) => { updateMainWindow = (mainWindow: BrowserWindow) => {
this.mainWindow = mainWindow; this.mainWindow = mainWindow;
} }
@ -43,7 +53,7 @@ export class ViewManager {
return this.configServers; return this.configServers;
} }
loadServer = (server) => { loadServer = (server: Team) => {
const srv = new MattermostServer(server.name, server.url); const srv = new MattermostServer(server.name, server.url);
const view = new MattermostView(srv, this.mainWindow, this.viewOptions); const view = new MattermostView(srv, this.mainWindow, this.viewOptions);
this.views.set(server.name, view); this.views.set(server.name, view);
@ -61,7 +71,7 @@ export class ViewManager {
this.configServers.forEach((server) => this.loadServer(server)); this.configServers.forEach((server) => this.loadServer(server));
} }
reloadConfiguration = (configServers) => { reloadConfiguration = (configServers: Team[]) => {
this.configServers = configServers.concat(); this.configServers = configServers.concat();
const oldviews = this.views; const oldviews = this.views;
this.views = new Map(); this.views = new Map();
@ -72,11 +82,11 @@ export class ViewManager {
if (recycle && recycle.isVisible) { if (recycle && recycle.isVisible) {
setFocus = recycle.name; setFocus = recycle.name;
} }
if (recycle && recycle.server.name === server.name && recycle.server.url.toString() === urlUtils.parseURL(server.url).toString()) { if (recycle && recycle.server.name === server.name && recycle.server.url.toString() === urlUtils.parseURL(server.url)!.toString()) {
oldviews.delete(recycle.name); oldviews.delete(recycle.name);
this.views.set(recycle.name, recycle); this.views.set(recycle.name, recycle);
} else { } else {
this.loadServer(server, this.mainWindow); this.loadServer(server);
} }
}); });
oldviews.forEach((unused) => { oldviews.forEach((unused) => {
@ -98,12 +108,12 @@ export class ViewManager {
} }
} }
showByName = (name) => { showByName = (name: string) => {
const newView = this.views.get(name); const newView = this.views.get(name);
if (newView.isVisible) {
return;
}
if (newView) { if (newView) {
if (newView.isVisible) {
return;
}
if (this.currentView && this.currentView !== name) { if (this.currentView && this.currentView !== name) {
const previous = this.getCurrentView(); const previous = this.getCurrentView();
if (previous) { if (previous) {
@ -116,6 +126,10 @@ export class ViewManager {
this.showLoadingScreen(); this.showLoadingScreen();
} }
const serverInfo = this.configServers.find((candidate) => candidate.name === newView.server.name); 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); newView.window.webContents.send(SET_SERVER_KEY, serverInfo.order);
if (newView.isReady()) { if (newView.isReady()) {
// if view is not ready, the renderer will have something to display instead. // if view is not ready, the renderer will have something to display instead.
@ -148,18 +162,22 @@ export class ViewManager {
view.focus(); view.focus();
} }
} }
activateView = (viewName) => { activateView = (viewName: string) => {
if (this.currentView === viewName) { if (this.currentView === viewName) {
this.showByName(this.currentView); this.showByName(this.currentView);
} }
const view = this.views.get(viewName); const view = this.views.get(viewName);
if (!view) {
log.error(`Couldn't find a view with the name ${viewName}`);
return;
}
addWebContentsEventListeners(view, this.getServers); addWebContentsEventListeners(view, this.getServers);
} }
finishLoading = (server) => { finishLoading = (server: string) => {
const view = this.views.get(server); const view = this.views.get(server);
if (view && this.getCurrentView() === view) { if (view && this.getCurrentView() === view) {
this.showByName(this.currentView); this.showByName(this.currentView!);
this.fadeLoadingScreen(); this.fadeLoadingScreen();
} }
} }
@ -169,19 +187,23 @@ export class ViewManager {
} }
getCurrentView() { getCurrentView() {
return this.views.get(this.currentView); if (this.currentView) {
return this.views.get(this.currentView);
}
return undefined;
} }
openViewDevTools = () => { openViewDevTools = () => {
const view = this.getCurrentView(); const view = this.getCurrentView();
if (view) { if (view) {
view.openDevTools({mode: 'detach'}); view.openDevTools();
} else { } else {
log.error(`couldn't find ${this.currentView}`); log.error(`couldn't find ${this.currentView}`);
} }
} }
findByWebContent(webContentId) { findByWebContent(webContentId: number) {
let found = null; let found = null;
let serverName; let serverName;
let view; let view;
@ -198,7 +220,7 @@ export class ViewManager {
return found; return found;
} }
showURLView = (url) => { showURLView = (url: URL | string) => {
if (this.urlViewCancel) { if (this.urlViewCancel) {
this.urlViewCancel(); this.urlViewCancel();
} }
@ -213,9 +235,8 @@ export class ViewManager {
const query = new Map([['url', urlString]]); const query = new Map([['url', urlString]]);
const localURL = getLocalURLString('urlView.html', query); const localURL = getLocalURLString('urlView.html', query);
urlView.webContents.loadURL(localURL); urlView.webContents.loadURL(localURL);
const currentWindow = this.getCurrentView().window; this.mainWindow.addBrowserView(urlView);
currentWindow.addBrowserView(urlView); const boundaries = this.mainWindow.getBounds();
const boundaries = currentWindow.getBounds();
urlView.setBounds({ urlView.setBounds({
x: 0, x: 0,
y: boundaries.height - URL_VIEW_HEIGHT, y: boundaries.height - URL_VIEW_HEIGHT,
@ -224,11 +245,13 @@ export class ViewManager {
}); });
const hideView = () => { const hideView = () => {
this.urlViewCancel = null; delete this.urlViewCancel;
currentWindow.removeBrowserView(urlView); this.mainWindow.removeBrowserView(urlView);
// workaround to eliminate zombie processes // workaround to eliminate zombie processes
// https://github.com/mattermost/desktop/pull/1519 // https://github.com/mattermost/desktop/pull/1519
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
urlView.webContents.destroy(); urlView.webContents.destroy();
}; };
@ -263,12 +286,12 @@ export class ViewManager {
this.createLoadingScreen(); this.createLoadingScreen();
} }
this.loadingScreen.webContents.send(TOGGLE_LOADING_SCREEN_VISIBILITY, true); this.loadingScreen!.webContents.send(TOGGLE_LOADING_SCREEN_VISIBILITY, true);
if (this.mainWindow.getBrowserViews().includes(this.loadingScreen)) { if (this.mainWindow.getBrowserViews().includes(this.loadingScreen!)) {
this.mainWindow.setTopBrowserView(this.loadingScreen); this.mainWindow.setTopBrowserView(this.loadingScreen!);
} else { } else {
this.mainWindow.addBrowserView(this.loadingScreen); this.mainWindow.addBrowserView(this.loadingScreen!);
} }
this.setLoadingScreenBounds(); this.setLoadingScreenBounds();
@ -286,7 +309,7 @@ export class ViewManager {
} }
} }
setServerInitialized = (server) => { setServerInitialized = (server: string) => {
const view = this.views.get(server); const view = this.views.get(server);
if (view) { if (view) {
view.setInitialized(); view.setInitialized();
@ -296,30 +319,40 @@ export class ViewManager {
} }
} }
updateLoadingScreenDarkMode = (darkMode) => { updateLoadingScreenDarkMode = (darkMode: boolean) => {
if (this.loadingScreen) { if (this.loadingScreen) {
this.loadingScreen.webContents.send(GET_LOADING_SCREEN_DATA, {darkMode}); this.loadingScreen.webContents.send(GET_LOADING_SCREEN_DATA, {darkMode});
} }
} }
deeplinkSuccess = (serverName) => { deeplinkSuccess = (serverName: string) => {
const view = this.views.get(serverName); const view = this.views.get(serverName);
if (!view) {
return;
}
this.showByName(serverName); this.showByName(serverName);
view.removeListener(LOAD_FAILED, this.deeplinkFailed); view.removeListener(LOAD_FAILED, this.deeplinkFailed);
}; };
deeplinkFailed = (serverName, err, url) => { deeplinkFailed = (serverName: string, err: string, url: string) => {
const view = this.views.get(serverName);
log.error(`[${serverName}] failed to load deeplink ${url}: ${err}`); log.error(`[${serverName}] failed to load deeplink ${url}: ${err}`);
const view = this.views.get(serverName);
if (!view) {
return;
}
view.removeListener(LOAD_SUCCESS, this.deeplinkSuccess); view.removeListener(LOAD_SUCCESS, this.deeplinkSuccess);
} }
handleDeepLink = (url) => { handleDeepLink = (url: string | URL) => {
if (url) { if (url) {
const parsedURL = urlUtils.parseURL(url); const parsedURL = urlUtils.parseURL(url)!;
const server = urlUtils.getServer(parsedURL, this.configServers, true); const server = urlUtils.getServer(parsedURL, this.configServers, true);
if (server) { if (server) {
const view = this.views.get(server.name); const view = this.views.get(server.name);
if (!view) {
log.error(`Couldn't find a view matching the name ${server.name}`);
return;
}
// attempting to change parsedURL protocol results in it not being modified. // attempting to change parsedURL protocol results in it not being modified.
const urlWithSchema = `${view.server.url.origin}${parsedURL.pathname}${parsedURL.search}`; const urlWithSchema = `${view.server.url.origin}${parsedURL.pathname}${parsedURL.search}`;
@ -333,7 +366,7 @@ export class ViewManager {
} }
}; };
sendToAllViews = (channel, ...args) => { sendToAllViews = (channel: string, ...args: any[]) => {
this.views.forEach((view) => view.view.webContents.send(channel, ...args)); this.views.forEach((view) => view.view.webContents.send(channel, ...args));
} }
} }

View file

@ -1,12 +1,12 @@
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. // Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information. // See LICENSE.txt for license information.
import {BrowserWindow, shell} from 'electron'; import {BrowserWindow, shell, WebContents} from 'electron';
import log from 'electron-log'; import log from 'electron-log';
import {DEVELOPMENT, PRODUCTION} from 'common/utils/constants'; import {Team} from 'types/config';
import urlUtils from 'common/utils/url'; import urlUtils from 'common/utils/url';
import Utils from 'common/utils/util';
import * as WindowManager from '../windows/windowManager'; import * as WindowManager from '../windows/windowManager';
@ -15,26 +15,32 @@ import {protocols} from '../../../electron-builder.json';
import allowProtocolDialog from '../allowProtocolDialog'; import allowProtocolDialog from '../allowProtocolDialog';
import {composeUserAgent} from '../utils'; import {composeUserAgent} from '../utils';
const customLogins = {}; import {MattermostView} from './MattermostView';
const listeners = {};
let popupWindow = null;
function isTrustedPopupWindow(webContents) { type CustomLogin = {
inProgress: boolean;
}
const customLogins: Record<number, CustomLogin> = {};
const listeners: Record<number, () => void> = {};
let popupWindow: BrowserWindow | undefined;
function isTrustedPopupWindow(webContents: WebContents) {
if (!webContents) { if (!webContents) {
return false; return false;
} }
if (!popupWindow) { if (!popupWindow) {
return false; return false;
} }
return Utils.browserWindowFromWebContents(webContents) === popupWindow; return BrowserWindow.fromWebContents(webContents) === popupWindow;
} }
const scheme = protocols && protocols[0] && protocols[0].schemes && protocols[0].schemes[0]; const scheme = protocols && protocols[0] && protocols[0].schemes && protocols[0].schemes[0];
const generateWillNavigate = (getServersFunction) => { const generateWillNavigate = (getServersFunction: () => Team[]) => {
return (event, url) => { return (event: Event & {sender: WebContents}, url: string) => {
const contentID = event.sender.id; const contentID = event.sender.id;
const parsedURL = urlUtils.parseURL(url); const parsedURL = urlUtils.parseURL(url)!;
const configServers = getServersFunction(); const configServers = getServersFunction();
const server = urlUtils.getServer(parsedURL, configServers); const server = urlUtils.getServer(parsedURL, configServers);
@ -42,7 +48,7 @@ const generateWillNavigate = (getServersFunction) => {
return; return;
} }
if (urlUtils.isCustomLoginURL(parsedURL, server, configServers)) { if (server && urlUtils.isCustomLoginURL(parsedURL, server, configServers)) {
return; return;
} }
if (parsedURL.protocol === 'mailto:') { if (parsedURL.protocol === 'mailto:') {
@ -51,30 +57,24 @@ const generateWillNavigate = (getServersFunction) => {
if (customLogins[contentID].inProgress) { if (customLogins[contentID].inProgress) {
return; return;
} }
const mode = Utils.runMode();
if (((mode === DEVELOPMENT || mode === PRODUCTION) &&
(parsedURL.path === 'renderer/index.html' || parsedURL.path === 'renderer/settings.html'))) {
log.info('loading settings page');
return;
}
log.info(`Prevented desktop from navigating to: ${url}`); log.info(`Prevented desktop from navigating to: ${url}`);
event.preventDefault(); event.preventDefault();
}; };
}; };
const generateDidStartNavigation = (getServersFunction) => { const generateDidStartNavigation = (getServersFunction: () => Team[]) => {
return (event, url) => { return (event: Event & {sender: WebContents}, url: string) => {
const serverList = getServersFunction(); const serverList = getServersFunction();
const contentID = event.sender.id; const contentID = event.sender.id;
const parsedURL = urlUtils.parseURL(url); const parsedURL = urlUtils.parseURL(url)!;
const server = urlUtils.getServer(parsedURL, serverList); const server = urlUtils.getServer(parsedURL, serverList);
if (!urlUtils.isTrustedURL(parsedURL, serverList)) { if (!urlUtils.isTrustedURL(parsedURL, serverList)) {
return; return;
} }
if (urlUtils.isCustomLoginURL(parsedURL, server, serverList)) { if (server && urlUtils.isCustomLoginURL(parsedURL, server, serverList)) {
customLogins[contentID].inProgress = true; customLogins[contentID].inProgress = true;
} else if (customLogins[contentID].inProgress) { } else if (customLogins[contentID].inProgress) {
customLogins[contentID].inProgress = false; customLogins[contentID].inProgress = false;
@ -82,8 +82,8 @@ const generateDidStartNavigation = (getServersFunction) => {
}; };
}; };
const generateNewWindowListener = (getServersFunction, spellcheck) => { const generateNewWindowListener = (getServersFunction: () => Team[], spellcheck?: boolean) => {
return (event, url) => { return (event: Event, url: string) => {
const parsedURL = urlUtils.parseURL(url); const parsedURL = urlUtils.parseURL(url);
if (!parsedURL) { if (!parsedURL) {
event.preventDefault(); event.preventDefault();
@ -146,14 +146,14 @@ const generateNewWindowListener = (getServersFunction, spellcheck) => {
log.info(`${url} is an admin console page, preventing to open a new window`); log.info(`${url} is an admin console page, preventing to open a new window`);
return; return;
} }
if (popupWindow && !popupWindow.closed && popupWindow.getURL() === url) { if (popupWindow && popupWindow.webContents.getURL() === url) {
log.info(`Popup window already open at provided url: ${url}`); log.info(`Popup window already open at provided url: ${url}`);
return; return;
} }
// TODO: move popups to its own and have more than one. // TODO: move popups to its own and have more than one.
if (urlUtils.isPluginUrl(server.url, parsedURL) || urlUtils.isManagedResource(server.url, parsedURL)) { if (urlUtils.isPluginUrl(server.url, parsedURL) || urlUtils.isManagedResource(server.url, parsedURL)) {
if (!popupWindow || popupWindow.closed) { if (!popupWindow) {
popupWindow = new BrowserWindow({ popupWindow = new BrowserWindow({
backgroundColor: '#fff', // prevents blurry text: https://electronjs.org/docs/faq#the-font-looks-blurry-what-is-this-and-what-can-i-do backgroundColor: '#fff', // prevents blurry text: https://electronjs.org/docs/faq#the-font-looks-blurry-what-is-this-and-what-can-i-do
//parent: WindowManager.getMainWindow(), //parent: WindowManager.getMainWindow(),
@ -167,10 +167,10 @@ const generateNewWindowListener = (getServersFunction, spellcheck) => {
}, },
}); });
popupWindow.once('ready-to-show', () => { popupWindow.once('ready-to-show', () => {
popupWindow.show(); popupWindow!.show();
}); });
popupWindow.once('closed', () => { popupWindow.once('closed', () => {
popupWindow = null; popupWindow = undefined;
}); });
} }
@ -187,13 +187,13 @@ const generateNewWindowListener = (getServersFunction, spellcheck) => {
}; };
}; };
export const removeWebContentsListeners = (id) => { export const removeWebContentsListeners = (id: number) => {
if (listeners[id]) { if (listeners[id]) {
listeners[id](); listeners[id]();
} }
}; };
export const addWebContentsEventListeners = (mmview, getServersFunction) => { export const addWebContentsEventListeners = (mmview: MattermostView, getServersFunction: () => Team[]) => {
const contents = mmview.view.webContents; const contents = mmview.view.webContents;
// initialize custom login tracking // initialize custom login tracking
@ -206,7 +206,7 @@ export const addWebContentsEventListeners = (mmview, getServersFunction) => {
} }
const willNavigate = generateWillNavigate(getServersFunction); const willNavigate = generateWillNavigate(getServersFunction);
contents.on('will-navigate', willNavigate); contents.on('will-navigate', willNavigate as (e: Event, u: string) => void); // TODO: Electron types don't include sender for some reason
// handle custom login requests (oath, saml): // handle custom login requests (oath, saml):
// 1. are we navigating to a supported local custom login path from the `/login` page? // 1. are we navigating to a supported local custom login path from the `/login` page?
@ -214,9 +214,9 @@ export const addWebContentsEventListeners = (mmview, getServersFunction) => {
// 2. are we finished with the custom login process? // 2. are we finished with the custom login process?
// - indicate custom login is NOT in progress // - indicate custom login is NOT in progress
const didStartNavigation = generateDidStartNavigation(getServersFunction); const didStartNavigation = generateDidStartNavigation(getServersFunction);
contents.on('did-start-navigation', didStartNavigation); contents.on('did-start-navigation', didStartNavigation as (e: Event, u: string) => void);
const spellcheck = mmview.options.webPreferences.spellcheck; const spellcheck = mmview.options.webPreferences?.spellcheck;
const newWindow = generateNewWindowListener(getServersFunction, spellcheck); const newWindow = generateNewWindowListener(getServersFunction, spellcheck);
contents.on('new-window', newWindow); contents.on('new-window', newWindow);
@ -227,8 +227,8 @@ export const addWebContentsEventListeners = (mmview, getServersFunction) => {
const removeListeners = () => { const removeListeners = () => {
try { try {
contents.removeListener('will-navigate', willNavigate); contents.removeListener('will-navigate', willNavigate as (e: Event, u: string) => void);
contents.removeListener('did-start-navigation', didStartNavigation); contents.removeListener('did-start-navigation', didStartNavigation as (e: Event, u: string) => void);
contents.removeListener('new-window', newWindow); contents.removeListener('new-window', newWindow);
contents.removeListener('page-title-updated', mmview.handleTitleUpdate); contents.removeListener('page-title-updated', mmview.handleTitleUpdate);
contents.removeListener('page-favicon-updated', mmview.handleFaviconUpdate); contents.removeListener('page-favicon-updated', mmview.handleFaviconUpdate);

View file

@ -6,19 +6,24 @@ import fs from 'fs';
import path from 'path'; import path from 'path';
import os from 'os'; import os from 'os';
import {app, BrowserWindow, ipcMain} from 'electron'; import {app, BrowserWindow, BrowserWindowConstructorOptions, ipcMain} from 'electron';
import log from 'electron-log'; import log from 'electron-log';
import {CombinedConfig} from 'types/config';
import {SavedWindowState} from 'types/mainWindow';
import {SELECT_NEXT_TAB, SELECT_PREVIOUS_TAB, GET_FULL_SCREEN_STATUS} from 'common/communication'; import {SELECT_NEXT_TAB, SELECT_PREVIOUS_TAB, GET_FULL_SCREEN_STATUS} from 'common/communication';
import * as Validator from '../Validator'; import * as Validator from '../Validator';
import ContextMenu from '../contextMenu'; import ContextMenu from '../contextMenu';
import {getLocalPreload, getLocalURLString} from '../utils'; import {getLocalPreload, getLocalURLString} from '../utils';
function saveWindowState(file, window) { function saveWindowState(file: string, window: BrowserWindow) {
const windowState = window.getBounds(); const windowState: SavedWindowState = {
windowState.maximized = window.isMaximized(); ...window.getBounds(),
windowState.fullscreen = window.isFullScreen(); maximized: window.isMaximized(),
fullscreen: window.isFullScreen(),
};
try { try {
fs.writeFileSync(file, JSON.stringify(windowState)); fs.writeFileSync(file, JSON.stringify(windowState));
} catch (e) { } catch (e) {
@ -31,7 +36,7 @@ function isFramelessWindow() {
return os.platform() === 'darwin' || (os.platform() === 'win32' && os.release().startsWith('10')); return os.platform() === 'darwin' || (os.platform() === 'win32' && os.release().startsWith('10'));
} }
function createMainWindow(config, options) { function createMainWindow(config: CombinedConfig, options: {linuxAppIcon: string}) {
const defaultWindowWidth = 1000; const defaultWindowWidth = 1000;
const defaultWindowHeight = 700; const defaultWindowHeight = 700;
const minimumWindowWidth = 400; const minimumWindowWidth = 400;
@ -40,26 +45,23 @@ function createMainWindow(config, options) {
// Create the browser window. // Create the browser window.
const preload = getLocalPreload('mainWindow.js'); const preload = getLocalPreload('mainWindow.js');
const boundsInfoPath = path.join(app.getPath('userData'), 'bounds-info.json'); const boundsInfoPath = path.join(app.getPath('userData'), 'bounds-info.json');
let windowOptions; let savedWindowState;
try { try {
windowOptions = JSON.parse(fs.readFileSync(boundsInfoPath, 'utf-8')); savedWindowState = JSON.parse(fs.readFileSync(boundsInfoPath, 'utf-8'));
windowOptions = Validator.validateBoundsInfo(windowOptions); savedWindowState = Validator.validateBoundsInfo(savedWindowState);
if (!windowOptions) { if (!savedWindowState) {
throw new Error('Provided bounds info file does not validate, using defaults instead.'); throw new Error('Provided bounds info file does not validate, using defaults instead.');
} }
} catch (e) { } catch (e) {
// Follow Electron's defaults, except for window dimensions which targets 1024x768 screen resolution. // Follow Electron's defaults, except for window dimensions which targets 1024x768 screen resolution.
windowOptions = {width: defaultWindowWidth, height: defaultWindowHeight}; savedWindowState = {width: defaultWindowWidth, height: defaultWindowHeight};
} }
const {maximized: windowIsMaximized} = windowOptions; const {maximized: windowIsMaximized} = savedWindowState;
const spellcheck = (typeof config.useSpellChecker === 'undefined' ? true : config.useSpellChecker); const spellcheck = (typeof config.useSpellChecker === 'undefined' ? true : config.useSpellChecker);
if (process.platform === 'linux') { const windowOptions: BrowserWindowConstructorOptions = Object.assign({}, savedWindowState, {
windowOptions.icon = options.linuxAppIcon;
}
Object.assign(windowOptions, {
title: app.name, title: app.name,
fullscreenable: true, fullscreenable: true,
show: false, // don't start the window until it is ready and only if it isn't hidden show: false, // don't start the window until it is ready and only if it isn't hidden
@ -67,8 +69,8 @@ function createMainWindow(config, options) {
minWidth: minimumWindowWidth, minWidth: minimumWindowWidth,
minHeight: minimumWindowHeight, minHeight: minimumWindowHeight,
frame: !isFramelessWindow(), frame: !isFramelessWindow(),
fullscreen: windowOptions.fullscreen, fullscreen: savedWindowState.fullscreen,
titleBarStyle: 'hidden', titleBarStyle: 'hidden' as const,
trafficLightPosition: {x: 12, y: 24}, trafficLightPosition: {x: 12, y: 24},
backgroundColor: '#fff', // prevents blurry text: https://electronjs.org/docs/faq#the-font-looks-blurry-what-is-this-and-what-can-i-do backgroundColor: '#fff', // prevents blurry text: https://electronjs.org/docs/faq#the-font-looks-blurry-what-is-this-and-what-can-i-do
webPreferences: { webPreferences: {
@ -81,10 +83,18 @@ function createMainWindow(config, options) {
}, },
}); });
if (process.platform === 'linux') {
windowOptions.icon = options.linuxAppIcon;
}
const mainWindow = new BrowserWindow(windowOptions); const mainWindow = new BrowserWindow(windowOptions);
mainWindow.setMenuBarVisibility(false); mainWindow.setMenuBarVisibility(false);
ipcMain.handle(GET_FULL_SCREEN_STATUS, () => mainWindow.isFullScreen()); try {
ipcMain.handle(GET_FULL_SCREEN_STATUS, () => mainWindow.isFullScreen());
} catch (e) {
log.error('Tried to register second handler, skipping');
}
const localURL = getLocalURLString('index.html'); const localURL = getLocalURLString('index.html');
mainWindow.loadURL(localURL).catch( mainWindow.loadURL(localURL).catch(
@ -122,7 +132,7 @@ function createMainWindow(config, options) {
saveWindowState(boundsInfoPath, mainWindow); saveWindowState(boundsInfoPath, mainWindow);
} else { // Minimize or hide the window for close button. } else { // Minimize or hide the window for close button.
event.preventDefault(); event.preventDefault();
function hideWindow(window) { function hideWindow(window: BrowserWindow) {
window.blur(); // To move focus to the next top-level window in Windows window.blur(); // To move focus to the next top-level window in Windows
window.hide(); window.hide();
} }

View file

@ -3,15 +3,15 @@
import {BrowserWindow} from 'electron'; import {BrowserWindow} from 'electron';
import log from 'electron-log'; import log from 'electron-log';
import {CombinedConfig} from 'types/config';
import ContextMenu from '../contextMenu'; import ContextMenu from '../contextMenu';
import {getLocalPreload, getLocalURLString} from '../utils'; import {getLocalPreload, getLocalURLString} from '../utils';
export function createSettingsWindow(mainWindow, config, withDevTools) { export function createSettingsWindow(mainWindow: BrowserWindow, config: CombinedConfig, withDevTools: boolean) {
const preload = getLocalPreload('mainWindow.js'); const preload = getLocalPreload('mainWindow.js');
const spellcheck = (typeof config.useSpellChecker === 'undefined' ? true : config.useSpellChecker); const spellcheck = (typeof config.useSpellChecker === 'undefined' ? true : config.useSpellChecker);
const settingsWindow = new BrowserWindow({ const settingsWindow = new BrowserWindow({
...config.data,
parent: mainWindow, parent: mainWindow,
title: 'Desktop App Settings', title: 'Desktop App Settings',
fullscreen: false, fullscreen: false,

View file

@ -2,28 +2,32 @@
// See LICENSE.txt for license information. // See LICENSE.txt for license information.
import path from 'path'; import path from 'path';
import {app, BrowserWindow, nativeImage, systemPreferences, ipcMain} from 'electron'; import {app, BrowserWindow, nativeImage, systemPreferences, ipcMain, IpcMainEvent} from 'electron';
import log from 'electron-log'; import log from 'electron-log';
import {CombinedConfig} from 'types/config';
import {MAXIMIZE_CHANGE, HISTORY, GET_LOADING_SCREEN_DATA, REACT_APP_INITIALIZED, LOADING_SCREEN_ANIMATION_FINISHED, FOCUS_THREE_DOT_MENU} from 'common/communication'; import {MAXIMIZE_CHANGE, HISTORY, GET_LOADING_SCREEN_DATA, REACT_APP_INITIALIZED, LOADING_SCREEN_ANIMATION_FINISHED, FOCUS_THREE_DOT_MENU} from 'common/communication';
import urlUtils from 'common/utils/url'; import urlUtils from 'common/utils/url';
import {getAdjustedWindowBoundaries} from '../utils'; import {getAdjustedWindowBoundaries} from '../utils';
import {ViewManager} from '../views/viewManager'; import {ViewManager} from '../views/viewManager';
import {CriticalErrorHandler} from '../CriticalErrorHandler'; import CriticalErrorHandler from '../CriticalErrorHandler';
import {createSettingsWindow} from './settingsWindow'; import {createSettingsWindow} from './settingsWindow';
import createMainWindow from './mainWindow'; import createMainWindow from './mainWindow';
// singleton module to manage application's windows // singleton module to manage application's windows
const status = { type WindowManagerStatus = {
mainWindow: null, mainWindow?: BrowserWindow;
settingsWindow: null, settingsWindow?: BrowserWindow;
config: null, config?: CombinedConfig;
viewManager: null, viewManager?: ViewManager;
}; };
const status: WindowManagerStatus = {};
const assetsDir = path.resolve(app.getAppPath(), 'assets'); const assetsDir = path.resolve(app.getAppPath(), 'assets');
ipcMain.on(HISTORY, handleHistory); ipcMain.on(HISTORY, handleHistory);
@ -31,12 +35,12 @@ ipcMain.handle(GET_LOADING_SCREEN_DATA, handleLoadingScreenDataRequest);
ipcMain.on(REACT_APP_INITIALIZED, handleReactAppInitialized); ipcMain.on(REACT_APP_INITIALIZED, handleReactAppInitialized);
ipcMain.on(LOADING_SCREEN_ANIMATION_FINISHED, handleLoadingScreenAnimationFinished); ipcMain.on(LOADING_SCREEN_ANIMATION_FINISHED, handleLoadingScreenAnimationFinished);
export function setConfig(data) { export function setConfig(data: CombinedConfig) {
if (data) { if (data) {
status.config = data; status.config = data;
} }
if (status.viewManager) { if (status.viewManager && status.config) {
status.viewManager.reloadConfiguration(status.config.teams); status.viewManager.reloadConfiguration(status.config.teams || []);
} }
} }
@ -47,17 +51,20 @@ export function showSettingsWindow() {
if (!status.mainWindow) { if (!status.mainWindow) {
showMainWindow(); showMainWindow();
} }
const withDevTools = process.env.MM_DEBUG_SETTINGS || false; const withDevTools = Boolean(process.env.MM_DEBUG_SETTINGS) || false;
status.settingsWindow = createSettingsWindow(status.mainWindow, status.config, withDevTools); if (!status.config) {
return;
}
status.settingsWindow = createSettingsWindow(status.mainWindow!, status.config, withDevTools);
status.settingsWindow.on('closed', () => { status.settingsWindow.on('closed', () => {
status.settingsWindow = null; delete status.settingsWindow;
focusBrowserView(); focusBrowserView();
}); });
} }
} }
export function showMainWindow(deeplinkingURL) { export function showMainWindow(deeplinkingURL?: string | URL) {
if (status.mainWindow) { if (status.mainWindow) {
if (status.mainWindow.isVisible()) { if (status.mainWindow.isVisible()) {
status.mainWindow.focus(); status.mainWindow.focus();
@ -65,6 +72,9 @@ export function showMainWindow(deeplinkingURL) {
status.mainWindow.show(); status.mainWindow.show();
} }
} else { } else {
if (!status.config) {
return;
}
status.mainWindow = createMainWindow(status.config, { status.mainWindow = createMainWindow(status.config, {
linuxAppIcon: path.join(assetsDir, 'linux', 'app_icon.png'), linuxAppIcon: path.join(assetsDir, 'linux', 'app_icon.png'),
}); });
@ -77,14 +87,13 @@ export function showMainWindow(deeplinkingURL) {
// window handlers // window handlers
status.mainWindow.on('closed', () => { status.mainWindow.on('closed', () => {
log.warn('main window closed'); log.warn('main window closed');
status.mainWindow = null; delete status.mainWindow;
}); });
status.mainWindow.on('unresponsive', () => { status.mainWindow.on('unresponsive', () => {
const criticalErrorHandler = new CriticalErrorHandler(); const criticalErrorHandler = new CriticalErrorHandler();
criticalErrorHandler.setMainWindow(status.mainWindow); criticalErrorHandler.setMainWindow(status.mainWindow!);
criticalErrorHandler.windowUnresponsiveHandler(); criticalErrorHandler.windowUnresponsiveHandler();
}); });
status.mainWindow.on('crashed', handleMainWindowWebContentsCrashed);
status.mainWindow.on('maximize', handleMaximizeMainWindow); status.mainWindow.on('maximize', handleMaximizeMainWindow);
status.mainWindow.on('unmaximize', handleUnmaximizeMainWindow); status.mainWindow.on('unmaximize', handleUnmaximizeMainWindow);
status.mainWindow.on('resize', handleResizeMainWindow); status.mainWindow.on('resize', handleResizeMainWindow);
@ -103,24 +112,18 @@ export function showMainWindow(deeplinkingURL) {
initializeViewManager(); initializeViewManager();
if (deeplinkingURL) { if (deeplinkingURL) {
status.viewManager.handleDeepLink(deeplinkingURL); status.viewManager!.handleDeepLink(deeplinkingURL);
} }
} }
export function getMainWindow(ensureCreated) { export function getMainWindow(ensureCreated?: boolean) {
if (ensureCreated && status.mainWindow === null) { if (ensureCreated && !status.mainWindow) {
showMainWindow(); showMainWindow();
} }
return status.mainWindow; return status.mainWindow;
} }
export function on(event, listener) { export const on = status.mainWindow?.on;
return status.mainWindow.on(event, listener);
}
function handleMainWindowWebContentsCrashed() {
throw new Error('webContents \'crashed\' event has been emitted');
}
function handleMaximizeMainWindow() { function handleMaximizeMainWindow() {
sendToRenderer(MAXIMIZE_CHANGE, true); sendToRenderer(MAXIMIZE_CHANGE, true);
@ -131,8 +134,11 @@ function handleUnmaximizeMainWindow() {
} }
function handleResizeMainWindow() { function handleResizeMainWindow() {
if (!(status.viewManager && status.mainWindow)) {
return;
}
const currentView = status.viewManager.getCurrentView(); const currentView = status.viewManager.getCurrentView();
let bounds; let bounds: Partial<Electron.Rectangle>;
// Workaround for linux maximizing/minimizing, which doesn't work properly because of these bugs: // Workaround for linux maximizing/minimizing, which doesn't work properly because of these bugs:
// https://github.com/electron/electron/issues/28699 // https://github.com/electron/electron/issues/28699
@ -146,7 +152,7 @@ function handleResizeMainWindow() {
const setBoundsFunction = () => { const setBoundsFunction = () => {
if (currentView) { 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.server.url, currentView.view.webContents.getURL())));
} }
}; };
@ -160,17 +166,17 @@ function handleResizeMainWindow() {
status.viewManager.setLoadingScreenBounds(); status.viewManager.setLoadingScreenBounds();
} }
export function sendToRenderer(channel, ...args) { export function sendToRenderer(channel: string, ...args: any[]) {
if (!status.mainWindow) { if (!status.mainWindow) {
showMainWindow(); showMainWindow();
} }
status.mainWindow.webContents.send(channel, ...args); status.mainWindow!.webContents.send(channel, ...args);
if (status.settingsWindow && status.settingsWindow.isVisible()) { if (status.settingsWindow && status.settingsWindow.isVisible()) {
status.settingsWindow.webContents.send(channel, ...args); status.settingsWindow.webContents.send(channel, ...args);
} }
} }
export function sendToAll(channel, ...args) { export function sendToAll(channel: string, ...args: any[]) {
sendToRenderer(channel, ...args); sendToRenderer(channel, ...args);
if (status.settingsWindow) { if (status.settingsWindow) {
status.settingsWindow.webContents.send(channel, ...args); status.settingsWindow.webContents.send(channel, ...args);
@ -179,7 +185,7 @@ export function sendToAll(channel, ...args) {
// TODO: should we include popups? // TODO: should we include popups?
} }
export function sendToMattermostViews(channel, ...args) { export function sendToMattermostViews(channel: string, ...args: any[]) {
if (status.viewManager) { if (status.viewManager) {
status.viewManager.sendToAllViews(channel, ...args); status.viewManager.sendToAllViews(channel, ...args);
} }
@ -190,16 +196,16 @@ export function restoreMain() {
if (!status.mainWindow) { if (!status.mainWindow) {
showMainWindow(); showMainWindow();
} }
if (!status.mainWindow.isVisible() || status.mainWindow.isMinimized()) { if (!status.mainWindow!.isVisible() || status.mainWindow!.isMinimized()) {
if (status.mainWindow.isMinimized()) { if (status.mainWindow!.isMinimized()) {
status.mainWindow.restore(); status.mainWindow!.restore();
} else { } else {
status.mainWindow.show(); status.mainWindow!.show();
} }
if (status.settingsWindow) { if (status.settingsWindow) {
status.settingsWindow.focus(); status.settingsWindow.focus();
} else { } else {
status.mainWindow.focus(); status.mainWindow!.focus();
} }
if (process.platform === 'darwin') { if (process.platform === 'darwin') {
app.dock.show(); app.dock.show();
@ -207,31 +213,36 @@ export function restoreMain() {
} else if (status.settingsWindow) { } else if (status.settingsWindow) {
status.settingsWindow.focus(); status.settingsWindow.focus();
} else { } else {
status.mainWindow.focus(); status.mainWindow!.focus();
} }
} }
export function flashFrame(flash) { export function flashFrame(flash: boolean) {
if (process.platform === 'linux' || process.platform === 'win32') { if (process.platform === 'linux' || process.platform === 'win32') {
status.mainWindow.flashFrame(flash); status.mainWindow?.flashFrame(flash);
if (status.settingsWindow) { if (status.settingsWindow) {
// main might be hidden behind the settings // main might be hidden behind the settings
status.settingsWindow.flashFrame(flash); status.settingsWindow.flashFrame(flash);
} }
} }
if (process.platform === 'darwin' && status.config.notifications.bounceIcon) { if (process.platform === 'darwin' && status.config?.notifications.bounceIcon) {
app.dock.bounce(status.config.notifications.bounceIconType); app.dock.bounce(status.config?.notifications.bounceIconType);
} }
} }
function drawBadge(text, small) { function drawBadge(text: string, small: boolean) {
const scale = 2; // should rely display dpi const scale = 2; // should rely display dpi
const size = (small ? 20 : 16) * scale; const size = (small ? 20 : 16) * scale;
const canvas = document.createElement('canvas'); const canvas = document.createElement('canvas');
canvas.setAttribute('width', size); canvas.setAttribute('width', `${size}`);
canvas.setAttribute('height', size); canvas.setAttribute('height', `${size}`);
const ctx = canvas.getContext('2d'); const ctx = canvas.getContext('2d');
if (!ctx) {
log.error('Could not create canvas context');
return null;
}
// circle // circle
ctx.fillStyle = '#FF1744'; // Material Red A400 ctx.fillStyle = '#FF1744'; // Material Red A400
ctx.beginPath(); ctx.beginPath();
@ -248,7 +259,7 @@ function drawBadge(text, small) {
return canvas.toDataURL(); return canvas.toDataURL();
} }
function createDataURL(text, small) { function createDataURL(text: string, small: boolean) {
const win = status.mainWindow; const win = status.mainWindow;
if (!win) { if (!win) {
return null; return null;
@ -263,26 +274,28 @@ function createDataURL(text, small) {
return win.webContents.executeJavaScript(code); return win.webContents.executeJavaScript(code);
} }
export async function setOverlayIcon(badgeText, description, small) { export async function setOverlayIcon(badgeText: string | undefined, description: string, small: boolean) {
if (process.platform === 'win32') { if (process.platform === 'win32') {
let overlay = null; let overlay = null;
if (status.mainWindow && badgeText) { if (status.mainWindow) {
try { if (badgeText) {
const dataUrl = await createDataURL(badgeText, small); try {
overlay = nativeImage.createFromDataURL(dataUrl); const dataUrl = await createDataURL(badgeText, small);
} catch (err) { overlay = nativeImage.createFromDataURL(dataUrl);
log.error(`Couldn't generate a badge: ${err}`); } catch (err) {
log.error(`Couldn't generate a badge: ${err}`);
}
} }
status.mainWindow.setOverlayIcon(overlay, description);
} }
status.mainWindow.setOverlayIcon(overlay, description);
} }
} }
export function isMainWindow(window) { export function isMainWindow(window: BrowserWindow) {
return status.mainWindow && status.mainWindow === window; return status.mainWindow && status.mainWindow === window;
} }
export function handleDoubleClick(e, windowType) { export function handleDoubleClick(e: IpcMainEvent, windowType?: string) {
let action = 'Maximize'; let action = 'Maximize';
if (process.platform === 'darwin') { if (process.platform === 'darwin') {
action = systemPreferences.getUserDefault('AppleActionOnDoubleClick', 'string'); action = systemPreferences.getUserDefault('AppleActionOnDoubleClick', 'string');
@ -311,16 +324,16 @@ export function handleDoubleClick(e, windowType) {
} }
function initializeViewManager() { function initializeViewManager() {
if (!status.viewManager) { if (!status.viewManager && status.config && status.mainWindow) {
status.viewManager = new ViewManager(status.config, status.mainWindow); status.viewManager = new ViewManager(status.config, status.mainWindow);
status.viewManager.load(); status.viewManager.load();
status.viewManager.showInitial(); status.viewManager.showInitial();
} }
} }
export function switchServer(serverName) { export function switchServer(serverName: string) {
showMainWindow(); showMainWindow();
status.viewManager.showByName(serverName); status.viewManager?.showByName(serverName);
} }
export function focusBrowserView() { export function focusBrowserView() {
@ -346,11 +359,11 @@ export function focusThreeDotMenu() {
function handleLoadingScreenDataRequest() { function handleLoadingScreenDataRequest() {
return { return {
darkMode: status.config.darkMode, darkMode: status.config?.darkMode || false,
}; };
} }
function handleReactAppInitialized(_, server) { function handleReactAppInitialized(e: IpcMainEvent, server: string) {
if (status.viewManager) { if (status.viewManager) {
status.viewManager.setServerInitialized(server); status.viewManager.setServerInitialized(server);
} }
@ -362,27 +375,19 @@ function handleLoadingScreenAnimationFinished() {
} }
} }
export function updateLoadingScreenDarkMode(darkMode) { export function updateLoadingScreenDarkMode(darkMode: boolean) {
if (status.viewManager) { if (status.viewManager) {
status.viewManager.updateLoadingScreenDarkMode(darkMode); status.viewManager.updateLoadingScreenDarkMode(darkMode);
} }
} }
export function getServerNameByWebContentsId(webContentsId) { export function getServerNameByWebContentsId(webContentsId: number) {
if (status.viewManager) { return status.viewManager?.findByWebContent(webContentsId);
return status.viewManager.findByWebContent(webContentsId);
}
return null;
} }
export function close() { export function close() {
const focused = BrowserWindow.getFocusedWindow(); const focused = BrowserWindow.getFocusedWindow();
if (focused.id === status.mainWindow.id) { focused?.close();
// TODO: figure out logic for closing
focused.close();
} else {
focused.close();
}
} }
export function maximize() { export function maximize() {
const focused = BrowserWindow.getFocusedWindow(); const focused = BrowserWindow.getFocusedWindow();
@ -404,21 +409,21 @@ export function restore() {
} }
export function reload() { export function reload() {
const currentView = status.viewManager.getCurrentView(); const currentView = status.viewManager?.getCurrentView();
if (currentView) { if (currentView) {
status.viewManager.showLoadingScreen(); status.viewManager?.showLoadingScreen();
currentView.reload(); currentView.reload();
} }
} }
export function sendToFind() { export function sendToFind() {
const currentView = status.viewManager.getCurrentView(); const currentView = status.viewManager?.getCurrentView();
if (currentView) { if (currentView) {
currentView.view.webContents.sendInputEvent({type: 'keyDown', keyCode: 'F', modifiers: ['CmdOrCtrl', 'Shift']}); currentView.view.webContents.sendInputEvent({type: 'keyDown', keyCode: 'F', modifiers: [process.platform === 'darwin' ? 'cmd' : 'ctrl', 'shift']});
} }
} }
export function handleHistory(event, offset) { export function handleHistory(event: IpcMainEvent, offset: number) {
if (status.viewManager) { if (status.viewManager) {
const activeView = status.viewManager.getCurrentView(); const activeView = status.viewManager.getCurrentView();
if (activeView && activeView.view.webContents.canGoToOffset(offset)) { if (activeView && activeView.view.webContents.canGoToOffset(offset)) {

View file

@ -1,35 +1,42 @@
// Copyright (c) 2015-2016 Yuya Ochiai
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. // Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information. // See LICENSE.txt for license information.
// Copyright (c) 2015-2016 Yuya Ochiai
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types';
import {Alert} from 'react-bootstrap'; import {Alert} from 'react-bootstrap';
const baseClassName = 'AutoSaveIndicator'; const baseClassName = 'AutoSaveIndicator';
const leaveClassName = `${baseClassName}-Leave`; const leaveClassName = `${baseClassName}-Leave`;
const SAVING_STATE_SAVING = 'saving'; export enum SavingState {
const SAVING_STATE_SAVED = 'saved'; SAVING_STATE_SAVING = 'saving',
const SAVING_STATE_ERROR = 'error'; SAVING_STATE_SAVED = 'saved',
const SAVING_STATE_DONE = 'done'; SAVING_STATE_ERROR = 'error',
SAVING_STATE_DONE = 'done',
}
function getClassNameAndMessage(savingState, errorMessage) { function getClassNameAndMessage(savingState: SavingState, errorMessage?: string) {
switch (savingState) { switch (savingState) {
case SAVING_STATE_SAVING: case SavingState.SAVING_STATE_SAVING:
return {className: baseClassName, message: 'Saving...'}; return {className: baseClassName, message: 'Saving...'};
case SAVING_STATE_SAVED: case SavingState.SAVING_STATE_SAVED:
return {className: baseClassName, message: 'Saved'}; return {className: baseClassName, message: 'Saved'};
case SAVING_STATE_ERROR: case SavingState.SAVING_STATE_ERROR:
return {className: `${baseClassName}`, message: errorMessage}; return {className: `${baseClassName}`, message: errorMessage};
case SAVING_STATE_DONE: case SavingState.SAVING_STATE_DONE:
return {className: `${baseClassName} ${leaveClassName}`, message: 'Saved'}; return {className: `${baseClassName} ${leaveClassName}`, message: 'Saved'};
default: default:
return {className: `${baseClassName} ${leaveClassName}`, message: ''}; return {className: `${baseClassName} ${leaveClassName}`, message: ''};
} }
} }
export default function AutoSaveIndicator(props) { type Props = {
id?: string;
savingState: SavingState;
errorMessage?: string;
};
export default function AutoSaveIndicator(props: Props) {
const {savingState, errorMessage, ...rest} = props; const {savingState, errorMessage, ...rest} = props;
const {className, message} = getClassNameAndMessage(savingState, errorMessage); const {className, message} = getClassNameAndMessage(savingState, errorMessage);
return ( return (
@ -42,15 +49,3 @@ export default function AutoSaveIndicator(props) {
</Alert> </Alert>
); );
} }
AutoSaveIndicator.propTypes = {
savingState: PropTypes.string.isRequired,
errorMessage: PropTypes.string,
};
Object.assign(AutoSaveIndicator, {
SAVING_STATE_SAVING,
SAVING_STATE_SAVED,
SAVING_STATE_ERROR,
SAVING_STATE_DONE,
});

View file

@ -1,6 +1,6 @@
// Copyright (c) 2015-2016 Yuya Ochiai
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. // Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information. // See LICENSE.txt for license information.
// Copyright (c) 2015-2016 Yuya Ochiai
import React from 'react'; import React from 'react';
import {storiesOf} from '@storybook/react'; import {storiesOf} from '@storybook/react';

View file

@ -1,12 +1,21 @@
// Copyright (c) 2015-2016 Yuya Ochiai
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. // Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information. // See LICENSE.txt for license information.
// Copyright (c) 2015-2016 Yuya Ochiai
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types';
import {Button, Modal} from 'react-bootstrap'; import {Button, Modal} from 'react-bootstrap';
export default function DestructiveConfirmationModal(props) { type Props = {
title: string;
body: React.ReactNode;
acceptLabel: string;
cancelLabel: string;
onHide: () => void;
onAccept: React.MouseEventHandler<Button>;
onCancel: React.MouseEventHandler<Button>;
};
export default function DestructiveConfirmationModal(props: Props) {
const { const {
title, title,
body, body,
@ -14,9 +23,13 @@ export default function DestructiveConfirmationModal(props) {
cancelLabel, cancelLabel,
onAccept, onAccept,
onCancel, onCancel,
onHide,
...rest} = props; ...rest} = props;
return ( return (
<Modal {...rest}> <Modal
onHide={onHide}
{...rest}
>
<Modal.Header closeButton={true}> <Modal.Header closeButton={true}>
<Modal.Title>{title}</Modal.Title> <Modal.Title>{title}</Modal.Title>
</Modal.Header> </Modal.Header>
@ -34,12 +47,3 @@ export default function DestructiveConfirmationModal(props) {
</Modal> </Modal>
); );
} }
DestructiveConfirmationModal.propTypes = {
title: PropTypes.string.isRequired,
body: PropTypes.node.isRequired,
acceptLabel: PropTypes.string.isRequired,
cancelLabel: PropTypes.string.isRequired,
onAccept: PropTypes.func.isRequired,
onCancel: PropTypes.func.isRequired,
};

View file

@ -1,14 +1,21 @@
// Copyright (c) 2015-2016 Yuya Ochiai
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. // Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information. // See LICENSE.txt for license information.
// Copyright (c) 2015-2016 Yuya Ochiai
// ErrorCode: https://code.google.com/p/chromium/codesearch#chromium/src/net/base/net_error_list.h // ErrorCode: https://code.google.com/p/chromium/codesearch#chromium/src/net/base/net_error_list.h
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types';
import {Grid, Row, Col} from 'react-bootstrap'; import {Grid, Row, Col} from 'react-bootstrap';
export default function ErrorView(props) { type Props = {
errorInfo?: string;
url?: string;
id?: string;
active?: boolean;
appName?: string;
};
export default function ErrorView(props: Props) {
const classNames = ['container', 'ErrorView']; const classNames = ['container', 'ErrorView'];
if (!props.active) { if (!props.active) {
classNames.push('ErrorView-hidden'); classNames.push('ErrorView-hidden');
@ -58,7 +65,7 @@ export default function ErrorView(props) {
target='_blank' target='_blank'
rel='noreferrer' rel='noreferrer'
> >
{props.errorInfo.validatedURL} {props.url}
</a>{' from a browser window.'}</li> </a>{' from a browser window.'}</li>
</ul> </ul>
<br/> <br/>
@ -78,11 +85,3 @@ export default function ErrorView(props) {
</Grid> </Grid>
); );
} }
ErrorView.propTypes = {
errorInfo: PropTypes.string,
url: PropTypes.string,
id: PropTypes.string,
active: PropTypes.bool,
appName: PropTypes.string,
};

View file

@ -2,10 +2,15 @@
// See LICENSE.txt for license information. // See LICENSE.txt for license information.
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types';
import {Row, Button} from 'react-bootstrap'; import {Row, Button} from 'react-bootstrap';
export default class ExtraBar extends React.PureComponent { type Props = {
darkMode?: boolean;
goBack?: () => void;
show?: boolean;
};
export default class ExtraBar extends React.PureComponent<Props> {
handleBack = () => { handleBack = () => {
if (this.props.goBack) { if (this.props.goBack) {
this.props.goBack(); this.props.goBack();
@ -42,9 +47,3 @@ export default class ExtraBar extends React.PureComponent {
); );
} }
} }
ExtraBar.propTypes = {
darkMode: PropTypes.bool,
goBack: PropTypes.func,
show: PropTypes.bool,
};

View file

@ -3,11 +3,10 @@
import React from 'react'; import React from 'react';
import classNames from 'classnames'; import classNames from 'classnames';
import PropTypes from 'prop-types';
import useAnimationEnd from '../../hooks/useAnimationEnd.js'; import useAnimationEnd from '../../hooks/useAnimationEnd';
import LoadingIcon from './LoadingIcon.jsx'; import LoadingIcon from './LoadingIcon';
const LOADING_STATE = { const LOADING_STATE = {
INITIALIZING: 'initializing', // animation graphics are hidden INITIALIZING: 'initializing', // animation graphics are hidden
@ -18,6 +17,12 @@ const LOADING_STATE = {
const ANIMATION_COMPLETION_DELAY = 500; const ANIMATION_COMPLETION_DELAY = 500;
type Props = {
loading: boolean;
darkMode: boolean;
onLoadAnimationComplete?: () => void;
}
/** /**
* A function component for rendering the animated MM logo loading sequence * A function component for rendering the animated MM logo loading sequence
* @param {boolean} loading - Prop that indicates whether currently loading or not * @param {boolean} loading - Prop that indicates whether currently loading or not
@ -27,7 +32,7 @@ const ANIMATION_COMPLETION_DELAY = 500;
function LoadingAnimation({ function LoadingAnimation({
loading = false, loading = false,
darkMode = false, darkMode = false,
onLoadAnimationComplete = null}, onLoadAnimationComplete = undefined}: Props,
) { ) {
const loadingIconContainerRef = React.useRef(null); const loadingIconContainerRef = React.useRef(null);
const [animationState, setAnimationState] = React.useState(LOADING_STATE.INITIALIZING); const [animationState, setAnimationState] = React.useState(LOADING_STATE.INITIALIZING);
@ -55,14 +60,14 @@ function LoadingAnimation({
}, [loadingAnimationComplete]); }, [loadingAnimationComplete]);
// listen for end of the css logo animation sequence // listen for end of the css logo animation sequence
useAnimationEnd(loadingIconContainerRef, () => { useAnimationEnd<HTMLDivElement>(loadingIconContainerRef, () => {
setTimeout(() => { setTimeout(() => {
setLoadingAnimationComplete(true); setLoadingAnimationComplete(true);
}, ANIMATION_COMPLETION_DELAY); }, ANIMATION_COMPLETION_DELAY);
}, 'LoadingAnimation__compass-shrink'); }, 'LoadingAnimation__compass-shrink');
// listen for end of final css logo fade/shrink animation sequence // listen for end of final css logo fade/shrink animation sequence
useAnimationEnd(loadingIconContainerRef, () => { useAnimationEnd<HTMLDivElement>(loadingIconContainerRef, () => {
if (onLoadAnimationComplete) { if (onLoadAnimationComplete) {
onLoadAnimationComplete(); onLoadAnimationComplete();
} }
@ -84,10 +89,4 @@ function LoadingAnimation({
); );
} }
LoadingAnimation.propTypes = {
loading: PropTypes.bool,
darkMode: PropTypes.bool,
onLoadAnimationComplete: PropTypes.func,
};
export default LoadingAnimation; export default LoadingAnimation;

View file

@ -1,4 +1,4 @@
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. // Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information. // See LICENSE.txt for license information.
export {default} from './LoadingAnimation.jsx'; export {default} from './LoadingAnimation';

View file

@ -3,19 +3,24 @@
import React from 'react'; import React from 'react';
import classNames from 'classnames'; import classNames from 'classnames';
import PropTypes from 'prop-types';
import useTransitionEnd from '../hooks/useTransitionEnd.js'; import useTransitionEnd from '../hooks/useTransitionEnd';
import LoadingAnimation from './LoadingAnimation'; import LoadingAnimation from './LoadingAnimation';
type Props = {
loading?: boolean;
darkMode?: boolean;
onFadeOutComplete?: () => void;
};
/** /**
* A function component for rendering the desktop app loading screen * A function component for rendering the desktop app loading screen
* @param {boolean} loading - Prop that indicates whether currently loading or not * @param {boolean} loading - Prop that indicates whether currently loading or not
* @param {boolean} darkMode - Prop that indicates if dark mode is enabled * @param {boolean} darkMode - Prop that indicates if dark mode is enabled
* @param {() => void} onFadeOutComplete - Function to call when the loading animation is completely finished * @param {() => void} onFadeOutComplete - Function to call when the loading animation is completely finished
*/ */
function LoadingScreen({loading = false, darkMode = false, onFadeOutComplete = () => null}) { function LoadingScreen({loading = false, darkMode = false, onFadeOutComplete = () => null}: Props) {
const loadingScreenRef = React.useRef(null); const loadingScreenRef = React.useRef(null);
const [loadingIsComplete, setLoadingIsComplete] = React.useState(true); const [loadingIsComplete, setLoadingIsComplete] = React.useState(true);
@ -35,10 +40,10 @@ function LoadingScreen({loading = false, darkMode = false, onFadeOutComplete = (
setLoadAnimationIsComplete(true); setLoadAnimationIsComplete(true);
} }
useTransitionEnd(loadingScreenRef, React.useCallback(() => { useTransitionEnd<HTMLDivElement>(loadingScreenRef, React.useCallback(() => {
setFadeOutIsComplete(true); setFadeOutIsComplete(true);
onFadeOutComplete(); onFadeOutComplete();
}), ['opacity']); }, []), ['opacity']);
function loadingInProgress() { function loadingInProgress() {
return !(loadingIsComplete && loadAnimationIsComplete && fadeOutIsComplete); return !(loadingIsComplete && loadAnimationIsComplete && fadeOutIsComplete);
@ -69,10 +74,4 @@ function LoadingScreen({loading = false, darkMode = false, onFadeOutComplete = (
return loadingInProgress() ? loadingScreen : null; return loadingInProgress() ? loadingScreen : null;
} }
LoadingScreen.propTypes = {
loading: PropTypes.bool,
darkMode: PropTypes.bool,
onFadeOutComplete: PropTypes.func,
};
export default LoadingScreen; export default LoadingScreen;

View file

@ -1,11 +1,14 @@
// Copyright (c) 2015-2016 Yuya Ochiai
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. // Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information. // See LICENSE.txt for license information.
// Copyright (c) 2015-2016 Yuya Ochiai
import React, {Fragment} from 'react'; import React, {Fragment} from 'react';
import PropTypes from 'prop-types';
import {Grid, Row} from 'react-bootstrap'; import {Grid, Row} from 'react-bootstrap';
import DotsVerticalIcon from 'mdi-react/DotsVerticalIcon'; import DotsVerticalIcon from 'mdi-react/DotsVerticalIcon';
import {IpcRendererEvent} from 'electron/renderer';
import {DropResult} from 'react-smooth-dnd';
import {Team} from 'types/config';
import { import {
FOCUS_BROWSERVIEW, FOCUS_BROWSERVIEW,
@ -42,18 +45,54 @@ import closeButton from '../../assets/titlebar/chrome-close.svg';
import {playSound} from '../notificationSounds'; import {playSound} from '../notificationSounds';
import TabBar from './TabBar.jsx'; import TabBar from './TabBar';
import ExtraBar from './ExtraBar.jsx'; import ExtraBar from './ExtraBar';
import ErrorView from './ErrorView.jsx'; import ErrorView from './ErrorView';
const LOADING = 1; enum Status {
const DONE = 2; LOADING = 1,
const RETRY = -1; DONE = 2,
const FAILED = 0; RETRY = -1,
const NOSERVERS = -2; FAILED = 0,
NOSERVERS = -2,
}
export default class MainPage extends React.PureComponent { type Props = {
constructor(props) { teams: Team[];
showAddServerButton: boolean;
moveTabs: (originalOrder: number, newOrder: number) => Promise<number | undefined>;
openMenu: () => void;
darkMode: boolean;
appName: string;
};
type State = {
key: number;
sessionsExpired: Record<string, boolean>;
unreadCounts: Record<string, number>;
mentionCounts: Record<string, number>;
targetURL: string;
maximized: boolean;
tabStatus: Map<string, TabStatus>;
darkMode: boolean;
modalOpen?: boolean;
fullScreen?: boolean;
showExtraBar?: boolean;
};
type TabStatus = {
status: Status;
extra?: {
url: string;
error: string;
};
}
export default class MainPage extends React.PureComponent<Props, State> {
topBar: React.RefObject<HTMLDivElement>;
threeDotMenu: React.RefObject<HTMLButtonElement>;
constructor(props: Props) {
super(props); super(props);
this.topBar = React.createRef(); this.topBar = React.createRef();
@ -66,7 +105,7 @@ export default class MainPage extends React.PureComponent {
mentionCounts: {}, mentionCounts: {},
targetURL: '', targetURL: '',
maximized: false, maximized: false,
tabStatus: new Map(this.props.teams.map((server) => [server.name, {status: LOADING, extra: null}])), tabStatus: new Map(this.props.teams.map((server) => [server.name, {status: Status.LOADING}])),
darkMode: this.props.darkMode, darkMode: this.props.darkMode,
}; };
} }
@ -79,10 +118,10 @@ export default class MainPage extends React.PureComponent {
return this.state.tabStatus.get(tabname); return this.state.tabStatus.get(tabname);
} }
} }
return {status: NOSERVERS}; return {status: Status.NOSERVERS};
} }
updateTabStatus(server, newStatusValue) { updateTabStatus(server: string, newStatusValue: TabStatus) {
const status = new Map(this.state.tabStatus); const status = new Map(this.state.tabStatus);
status.set(server, newStatusValue); status.set(server, newStatusValue);
this.setState({tabStatus: status}); this.setState({tabStatus: status});
@ -93,7 +132,7 @@ export default class MainPage extends React.PureComponent {
window.ipcRenderer.on(LOAD_RETRY, (_, server, retry, err, loadUrl) => { window.ipcRenderer.on(LOAD_RETRY, (_, server, retry, err, loadUrl) => {
console.log(`${server}: failed to load ${err}, but retrying`); console.log(`${server}: failed to load ${err}, but retrying`);
const statusValue = { const statusValue = {
status: RETRY, status: Status.RETRY,
extra: { extra: {
retry, retry,
error: err, error: err,
@ -104,13 +143,13 @@ export default class MainPage extends React.PureComponent {
}); });
window.ipcRenderer.on(LOAD_SUCCESS, (_, server) => { window.ipcRenderer.on(LOAD_SUCCESS, (_, server) => {
this.updateTabStatus(server, {status: DONE}); this.updateTabStatus(server, {status: Status.DONE});
}); });
window.ipcRenderer.on(LOAD_FAILED, (_, server, err, loadUrl) => { window.ipcRenderer.on(LOAD_FAILED, (_, server, err, loadUrl) => {
console.log(`${server}: failed to load ${err}`); console.log(`${server}: failed to load ${err}`);
const statusValue = { const statusValue = {
status: FAILED, status: Status.FAILED,
extra: { extra: {
error: err, error: err,
url: loadUrl, url: loadUrl,
@ -198,44 +237,50 @@ export default class MainPage extends React.PureComponent {
} }
} }
handleMaximizeState = (_, maximized) => { handleMaximizeState = (_: IpcRendererEvent, maximized: boolean) => {
this.setState({maximized}); this.setState({maximized});
} }
handleFullScreenState = (isFullScreen) => { handleFullScreenState = (isFullScreen: boolean) => {
this.setState({fullScreen: isFullScreen}); this.setState({fullScreen: isFullScreen});
} }
handleSetServerKey = (key) => { handleSetServerKey = (key: number) => {
const newKey = (this.props.teams.length + key) % this.props.teams.length; const newKey = (this.props.teams.length + key) % this.props.teams.length;
this.setState({key: newKey}); this.setState({key: newKey});
} }
handleSelect = (name, key) => { handleSelect = (name: string, key: number) => {
window.ipcRenderer.send(SWITCH_SERVER, name); window.ipcRenderer.send(SWITCH_SERVER, name);
this.handleSetServerKey(key); this.handleSetServerKey(key);
} }
handleDragAndDrop = async (dropResult) => { handleDragAndDrop = async (dropResult: DropResult) => {
const {removedIndex, addedIndex} = dropResult; const {removedIndex, addedIndex} = dropResult;
if (removedIndex === null || addedIndex === null) {
return;
}
if (removedIndex !== addedIndex) { if (removedIndex !== addedIndex) {
const teamIndex = await this.props.moveTabs(removedIndex, addedIndex < this.props.teams.length ? addedIndex : this.props.teams.length - 1); const teamIndex = await this.props.moveTabs(removedIndex, addedIndex < this.props.teams.length ? addedIndex : this.props.teams.length - 1);
if (!teamIndex) {
return;
}
const name = this.props.teams[teamIndex].name; const name = this.props.teams[teamIndex].name;
this.handleSelect(name, teamIndex); this.handleSelect(name, teamIndex);
} }
} }
handleClose = (e) => { handleClose = (e: React.MouseEvent<HTMLDivElement>) => {
e.stopPropagation(); // since it is our button, the event goes into MainPage's onclick event, getting focus back. e.stopPropagation(); // since it is our button, the event goes into MainPage's onclick event, getting focus back.
window.ipcRenderer.send(WINDOW_CLOSE); window.ipcRenderer.send(WINDOW_CLOSE);
} }
handleMinimize = (e) => { handleMinimize = (e: React.MouseEvent<HTMLDivElement>) => {
e.stopPropagation(); e.stopPropagation();
window.ipcRenderer.send(WINDOW_MINIMIZE); window.ipcRenderer.send(WINDOW_MINIMIZE);
} }
handleMaximize = (e) => { handleMaximize = (e: React.MouseEvent<HTMLDivElement>) => {
e.stopPropagation(); e.stopPropagation();
window.ipcRenderer.send(WINDOW_MAXIMIZE); window.ipcRenderer.send(WINDOW_MAXIMIZE);
} }
@ -246,7 +291,7 @@ export default class MainPage extends React.PureComponent {
openMenu = () => { openMenu = () => {
if (window.process.platform !== 'darwin') { if (window.process.platform !== 'darwin') {
this.threeDotMenu.current.blur(); this.threeDotMenu.current?.blur();
} }
this.props.openMenu(); this.props.openMenu();
} }
@ -263,10 +308,6 @@ export default class MainPage extends React.PureComponent {
window.ipcRenderer.send(FOCUS_BROWSERVIEW); window.ipcRenderer.send(FOCUS_BROWSERVIEW);
} }
setInputRef = (ref) => {
this.inputRef = ref;
}
render() { render() {
const tabsRow = ( const tabsRow = (
<TabBar <TabBar
@ -352,7 +393,7 @@ export default class MainPage extends React.PureComponent {
> >
<div <div
ref={this.topBar} ref={this.topBar}
className={`topBar-bg${this.state.unfocused ? ' unfocused' : ''}`} className={'topBar-bg'}
> >
<button <button
className='three-dot-menu' className='three-dot-menu'
@ -383,32 +424,29 @@ export default class MainPage extends React.PureComponent {
return null; return null;
} }
switch (tabStatus.status) { switch (tabStatus.status) {
case NOSERVERS: // TODO: substitute with https://mattermost.atlassian.net/browse/MM-25003 case Status.NOSERVERS: // TODO: substitute with https://mattermost.atlassian.net/browse/MM-25003
component = ( component = (
<ErrorView <ErrorView
id={'NoServers'} id={'NoServers'}
className='errorView'
errorInfo={'No Servers configured'} errorInfo={'No Servers configured'}
url={tabStatus.extra ? tabStatus.extra.url : ''} url={tabStatus.extra ? tabStatus.extra.url : ''}
active={true} active={true}
retry={null}
appName={this.props.appName} appName={this.props.appName}
/>); />);
break; break;
case FAILED: case Status.FAILED:
component = ( component = (
<ErrorView <ErrorView
id={this.state.key + '-fail'} id={this.state.key + '-fail'}
className='errorView' errorInfo={tabStatus.extra?.error}
errorInfo={tabStatus.extra ? tabStatus.extra.error : null}
url={tabStatus.extra ? tabStatus.extra.url : ''} url={tabStatus.extra ? tabStatus.extra.url : ''}
active={true} active={true}
appName={this.props.appName} appName={this.props.appName}
/>); />);
break; break;
case LOADING: case Status.LOADING:
case RETRY: case Status.RETRY:
case DONE: case Status.DONE:
component = null; component = null;
} }
return component; return component;
@ -441,12 +479,3 @@ export default class MainPage extends React.PureComponent {
); );
} }
} }
MainPage.propTypes = {
teams: PropTypes.array.isRequired,
showAddServerButton: PropTypes.bool.isRequired,
moveTabs: PropTypes.func.isRequired,
openMenu: PropTypes.func.isRequired,
darkMode: PropTypes.bool.isRequired,
appName: PropTypes.string.isRequired,
};

View file

@ -1,19 +1,42 @@
// Copyright (c) 2015-2016 Yuya Ochiai
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. // Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information. // See LICENSE.txt for license information.
// Copyright (c) 2015-2016 Yuya Ochiai
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types';
import {Modal, Button, FormGroup, FormControl, ControlLabel, HelpBlock} from 'react-bootstrap'; import {Modal, Button, FormGroup, FormControl, ControlLabel, HelpBlock} from 'react-bootstrap';
import {TeamWithIndex} from 'types/config';
import urlUtils from 'common/utils/url'; import urlUtils from 'common/utils/url';
export default class NewTeamModal extends React.PureComponent { type Props = {
onClose: () => void;
onSave?: (team: TeamWithIndex) => void;
team?: TeamWithIndex;
editMode?: boolean;
show?: boolean;
restoreFocus?: boolean;
currentOrder?: number;
setInputRef?: (inputRef: HTMLInputElement) => void;
};
type State = {
teamName: string;
teamUrl: string;
teamIndex?: number;
teamOrder: number;
saveStarted: boolean;
}
export default class NewTeamModal extends React.PureComponent<Props, State> {
wasShown?: boolean;
teamNameInputRef?: HTMLInputElement;
static defaultProps = { static defaultProps = {
restoreFocus: true, restoreFocus: true,
}; };
constructor(props) { constructor(props: Props) {
super(props); super(props);
this.wasShown = false; this.wasShown = false;
@ -29,7 +52,7 @@ export default class NewTeamModal extends React.PureComponent {
this.setState({ this.setState({
teamName: this.props.team ? this.props.team.name : '', teamName: this.props.team ? this.props.team.name : '',
teamUrl: this.props.team ? this.props.team.url : '', teamUrl: this.props.team ? this.props.team.url : '',
teamIndex: this.props.team ? this.props.team.index : false, teamIndex: this.props.team?.index,
teamOrder: this.props.team ? this.props.team.order : (this.props.currentOrder || 0), teamOrder: this.props.team ? this.props.team.order : (this.props.currentOrder || 0),
saveStarted: false, saveStarted: false,
}); });
@ -46,7 +69,7 @@ export default class NewTeamModal extends React.PureComponent {
return this.getTeamNameValidationError() === null ? null : 'error'; return this.getTeamNameValidationError() === null ? null : 'error';
} }
handleTeamNameChange = (e) => { handleTeamNameChange = (e: React.ChangeEvent<FormControl & HTMLInputElement>) => {
this.setState({ this.setState({
teamName: e.target.value, teamName: e.target.value,
}); });
@ -72,7 +95,7 @@ export default class NewTeamModal extends React.PureComponent {
return this.getTeamUrlValidationError() === null ? null : 'error'; return this.getTeamUrlValidationError() === null ? null : 'error';
} }
handleTeamUrlChange = (e) => { handleTeamUrlChange = (e: React.ChangeEvent<FormControl & HTMLInputElement>) => {
this.setState({ this.setState({
teamUrl: e.target.value, teamUrl: e.target.value,
}); });
@ -102,10 +125,10 @@ export default class NewTeamModal extends React.PureComponent {
saveStarted: true, saveStarted: true,
}, () => { }, () => {
if (this.validateForm()) { if (this.validateForm()) {
this.props.onSave({ this.props.onSave?.({
url: this.state.teamUrl, url: this.state.teamUrl,
name: this.state.teamName, name: this.state.teamName,
index: this.state.teamIndex, index: this.state.teamIndex!,
order: this.state.teamOrder, order: this.state.teamOrder,
}); });
} }
@ -139,7 +162,7 @@ export default class NewTeamModal extends React.PureComponent {
show={this.props.show} show={this.props.show}
id='newServerModal' id='newServerModal'
enforceFocus={true} enforceFocus={true}
onEntered={() => this.teamNameInputRef.focus()} onEntered={() => this.teamNameInputRef?.focus()}
onHide={this.props.onClose} onHide={this.props.onClose}
restoreFocus={this.props.restoreFocus} restoreFocus={this.props.restoreFocus}
onKeyDown={(e) => { onKeyDown={(e) => {
@ -231,14 +254,3 @@ export default class NewTeamModal extends React.PureComponent {
); );
} }
} }
NewTeamModal.propTypes = {
onClose: PropTypes.func,
onSave: PropTypes.func,
team: PropTypes.object,
editMode: PropTypes.bool,
show: PropTypes.bool,
restoreFocus: PropTypes.bool,
currentOrder: PropTypes.number,
setInputRef: PropTypes.func,
};

View file

@ -1,14 +1,21 @@
// Copyright (c) 2015-2016 Yuya Ochiai
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. // Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information. // See LICENSE.txt for license information.
// Copyright (c) 2015-2016 Yuya Ochiai
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types'; import {Button, Modal} from 'react-bootstrap';
import {Modal} from 'react-bootstrap';
import DestructiveConfirmationModal from './DestructiveConfirmModal.jsx'; import DestructiveConfirmationModal from './DestructiveConfirmModal';
export default function RemoveServerModal(props) { type Props = {
show: boolean;
serverName: string;
onHide: () => void;
onAccept: React.MouseEventHandler<Button>;
onCancel: React.MouseEventHandler<Button>;
}
export default function RemoveServerModal(props: Props) {
const {serverName, ...rest} = props; const {serverName, ...rest} = props;
return ( return (
<DestructiveConfirmationModal <DestructiveConfirmationModal
@ -30,7 +37,3 @@ export default function RemoveServerModal(props) {
/> />
); );
} }
RemoveServerModal.propTypes = {
serverName: PropTypes.string.isRequired,
};

View file

@ -1,38 +1,79 @@
// Copyright (c) 2015-2016 Yuya Ochiai
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. // Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information. // See LICENSE.txt for license information.
// Copyright (c) 2015-2016 Yuya Ochiai
/* eslint-disable max-lines */
import 'renderer/css/settings.css'; import 'renderer/css/settings.css';
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types';
import {Checkbox, Col, FormGroup, Grid, HelpBlock, Navbar, Radio, Row, Button} from 'react-bootstrap'; import {Checkbox, Col, FormGroup, Grid, HelpBlock, Navbar, Radio, Row, Button} from 'react-bootstrap';
import {debounce} from 'underscore'; import {debounce} from 'underscore';
import {CombinedConfig, LocalConfiguration, Team} from 'types/config';
import {DeepPartial} from 'types/utils';
import {GET_LOCAL_CONFIGURATION, UPDATE_CONFIGURATION, DOUBLE_CLICK_ON_WINDOW, GET_DOWNLOAD_LOCATION, SWITCH_SERVER, ADD_SERVER, RELOAD_CONFIGURATION} from 'common/communication'; import {GET_LOCAL_CONFIGURATION, UPDATE_CONFIGURATION, DOUBLE_CLICK_ON_WINDOW, GET_DOWNLOAD_LOCATION, SWITCH_SERVER, ADD_SERVER, RELOAD_CONFIGURATION} from 'common/communication';
import TeamList from './TeamList.jsx'; import TeamList from './TeamList';
import AutoSaveIndicator from './AutoSaveIndicator.jsx'; import AutoSaveIndicator, {SavingState} from './AutoSaveIndicator';
const CONFIG_TYPE_SERVERS = 'servers'; const CONFIG_TYPE_SERVERS = 'servers';
const CONFIG_TYPE_APP_OPTIONS = 'appOptions'; const CONFIG_TYPE_APP_OPTIONS = 'appOptions';
function backToIndex(serverName) { type ConfigType = typeof CONFIG_TYPE_SERVERS | typeof CONFIG_TYPE_APP_OPTIONS;
type State = DeepPartial<CombinedConfig> & {
ready: boolean;
maximized?: boolean;
teams?: Team[];
showAddTeamForm: boolean;
trayWasVisible?: boolean;
firstRun?: boolean;
savingState: SavingStateItems;
userOpenedDownloadDialog: boolean;
}
type SavingStateItems = {
appOptions: SavingState;
servers: SavingState;
};
type SaveQueueItem = {
configType: ConfigType;
key: keyof CombinedConfig;
data: CombinedConfig[keyof CombinedConfig];
}
function backToIndex(serverName: string) {
window.ipcRenderer.send(SWITCH_SERVER, serverName); window.ipcRenderer.send(SWITCH_SERVER, serverName);
window.close(); window.close();
} }
export default class SettingsPage extends React.PureComponent { export default class SettingsPage extends React.PureComponent<Record<string, never>, State> {
constructor(props) { trayIconThemeRef: React.RefObject<FormGroup>;
downloadLocationRef: React.RefObject<HTMLInputElement>;
showTrayIconRef: React.RefObject<Checkbox>;
autostartRef: React.RefObject<Checkbox>;
minimizeToTrayRef: React.RefObject<Checkbox>;
flashWindowRef: React.RefObject<Checkbox>;
bounceIconRef: React.RefObject<Checkbox>;
showUnreadBadgeRef: React.RefObject<Checkbox>;
useSpellCheckerRef: React.RefObject<Checkbox>;
enableHardwareAccelerationRef: React.RefObject<Checkbox>;
saveQueue: SaveQueueItem[];
constructor(props: Record<string, never>) {
super(props); super(props);
this.state = { this.state = {
ready: false, ready: false,
teams: [], teams: [],
showAddTeamForm: false, showAddTeamForm: false,
savingState: { savingState: {
appOptions: AutoSaveIndicator.SAVING_STATE_DONE, appOptions: SavingState.SAVING_STATE_DONE,
servers: AutoSaveIndicator.SAVING_STATE_DONE, servers: SavingState.SAVING_STATE_DONE,
}, },
userOpenedDownloadDialog: false, userOpenedDownloadDialog: false,
}; };
@ -67,26 +108,26 @@ export default class SettingsPage extends React.PureComponent {
getConfig = () => { getConfig = () => {
window.ipcRenderer.invoke(GET_LOCAL_CONFIGURATION).then((config) => { window.ipcRenderer.invoke(GET_LOCAL_CONFIGURATION).then((config) => {
this.setState({ready: true, maximized: false, ...this.convertConfigDataToState(config)}); this.setState({ready: true, maximized: false, ...this.convertConfigDataToState(config) as Omit<State, 'ready'>});
}); });
} }
convertConfigDataToState = (configData, currentState = {}) => { convertConfigDataToState = (configData: Partial<LocalConfiguration>, currentState: Partial<State> = {}) => {
const newState = Object.assign({}, configData); const newState = Object.assign({} as State, configData);
newState.showAddTeamForm = currentState.showAddTeamForm || false; newState.showAddTeamForm = currentState.showAddTeamForm || false;
newState.trayWasVisible = currentState.trayWasVisible || false; newState.trayWasVisible = currentState.trayWasVisible || false;
if (newState.teams.length === 0 && currentState.firstRun !== false) { if (newState.teams?.length === 0 && currentState.firstRun !== false) {
newState.firstRun = false; newState.firstRun = false;
newState.showAddTeamForm = true; newState.showAddTeamForm = true;
} }
newState.savingState = currentState.savingState || { newState.savingState = currentState.savingState || {
appOptions: AutoSaveIndicator.SAVING_STATE_DONE, appOptions: SavingState.SAVING_STATE_DONE,
servers: AutoSaveIndicator.SAVING_STATE_DONE, servers: SavingState.SAVING_STATE_DONE,
}; };
return newState; return newState;
} }
saveSetting = (configType, {key, data}) => { saveSetting = (configType: ConfigType, {key, data}: {key: keyof CombinedConfig; data: CombinedConfig[keyof CombinedConfig]}) => {
this.saveQueue.push({ this.saveQueue.push({
configType, configType,
key, key,
@ -115,25 +156,25 @@ export default class SettingsPage extends React.PureComponent {
Object.entries(queuedUpdateCounts).forEach(([configType, count]) => { Object.entries(queuedUpdateCounts).forEach(([configType, count]) => {
if (count > 0) { if (count > 0) {
savingState[configType] = AutoSaveIndicator.SAVING_STATE_SAVING; savingState[configType as keyof SavingStateItems] = SavingState.SAVING_STATE_SAVING;
} else if (count === 0 && savingState[configType] === AutoSaveIndicator.SAVING_STATE_SAVING) { } else if (count === 0 && savingState[configType as keyof SavingStateItems] === SavingState.SAVING_STATE_SAVING) {
savingState[configType] = AutoSaveIndicator.SAVING_STATE_SAVED; savingState[configType as keyof SavingStateItems] = SavingState.SAVING_STATE_SAVED;
this.resetSaveState(configType); this.resetSaveState(configType as keyof SavingStateItems);
} }
}); });
this.setState({savingState}); this.setState({savingState});
} }
resetSaveState = debounce((configType) => { resetSaveState = debounce((configType: keyof SavingStateItems) => {
if (this.state.savingState[configType] !== AutoSaveIndicator.SAVING_STATE_SAVING) { if (this.state.savingState[configType] !== SavingState.SAVING_STATE_SAVING) {
const savingState = Object.assign({}, this.state.savingState); const savingState = Object.assign({}, this.state.savingState);
savingState[configType] = AutoSaveIndicator.SAVING_STATE_DONE; savingState[configType] = SavingState.SAVING_STATE_DONE;
this.setState({savingState}); this.setState({savingState});
} }
}, 2000); }, 2000);
handleTeamsChange = (teams) => { handleTeamsChange = (teams: Team[]) => {
window.timers.setImmediate(this.saveSetting, CONFIG_TYPE_SERVERS, {key: 'teams', data: teams}); window.timers.setImmediate(this.saveSetting, CONFIG_TYPE_SERVERS, {key: 'teams', data: teams});
this.setState({ this.setState({
showAddTeamForm: false, showAddTeamForm: false,
@ -145,7 +186,7 @@ export default class SettingsPage extends React.PureComponent {
} }
handleChangeShowTrayIcon = () => { handleChangeShowTrayIcon = () => {
const shouldShowTrayIcon = !this.showTrayIconRef.current.props.checked; const shouldShowTrayIcon = !this.showTrayIconRef.current?.props.checked;
window.timers.setImmediate(this.saveSetting, CONFIG_TYPE_APP_OPTIONS, {key: 'showTrayIcon', data: shouldShowTrayIcon}); window.timers.setImmediate(this.saveSetting, CONFIG_TYPE_APP_OPTIONS, {key: 'showTrayIcon', data: shouldShowTrayIcon});
this.setState({ this.setState({
showTrayIcon: shouldShowTrayIcon, showTrayIcon: shouldShowTrayIcon,
@ -158,7 +199,7 @@ export default class SettingsPage extends React.PureComponent {
} }
} }
handleChangeTrayIconTheme = (theme) => { handleChangeTrayIconTheme = (theme: string) => {
window.timers.setImmediate(this.saveSetting, CONFIG_TYPE_APP_OPTIONS, {key: 'trayIconTheme', data: theme}); window.timers.setImmediate(this.saveSetting, CONFIG_TYPE_APP_OPTIONS, {key: 'trayIconTheme', data: theme});
this.setState({ this.setState({
trayIconTheme: theme, trayIconTheme: theme,
@ -166,14 +207,14 @@ export default class SettingsPage extends React.PureComponent {
} }
handleChangeAutoStart = () => { handleChangeAutoStart = () => {
window.timers.setImmediate(this.saveSetting, CONFIG_TYPE_APP_OPTIONS, {key: 'autostart', data: !this.autostartRef.current.props.checked}); window.timers.setImmediate(this.saveSetting, CONFIG_TYPE_APP_OPTIONS, {key: 'autostart', data: !this.autostartRef.current?.props.checked});
this.setState({ this.setState({
autostart: !this.autostartRef.current.props.checked, autostart: !this.autostartRef.current?.props.checked,
}); });
} }
handleChangeMinimizeToTray = () => { handleChangeMinimizeToTray = () => {
const shouldMinimizeToTray = this.state.showTrayIcon && !this.minimizeToTrayRef.current.props.checked; const shouldMinimizeToTray = this.state.showTrayIcon && !this.minimizeToTrayRef.current?.props.checked;
window.timers.setImmediate(this.saveSetting, CONFIG_TYPE_APP_OPTIONS, {key: 'minimizeToTray', data: shouldMinimizeToTray}); window.timers.setImmediate(this.saveSetting, CONFIG_TYPE_APP_OPTIONS, {key: 'minimizeToTray', data: shouldMinimizeToTray});
this.setState({ this.setState({
@ -185,10 +226,10 @@ export default class SettingsPage extends React.PureComponent {
this.setState({ this.setState({
showAddTeamForm: !this.state.showAddTeamForm, showAddTeamForm: !this.state.showAddTeamForm,
}); });
document.activeElement.blur(); (document.activeElement as HTMLElement).blur();
} }
setShowTeamFormVisibility = (val) => { setShowTeamFormVisibility = (val: boolean) => {
this.setState({ this.setState({
showAddTeamForm: val, showAddTeamForm: val,
}); });
@ -199,13 +240,13 @@ export default class SettingsPage extends React.PureComponent {
key: 'notifications', key: 'notifications',
data: { data: {
...this.state.notifications, ...this.state.notifications,
flashWindow: this.flashWindowRef.current.props.checked ? 0 : 2, flashWindow: this.flashWindowRef.current?.props.checked ? 0 : 2,
}, },
}); });
this.setState({ this.setState({
notifications: { notifications: {
...this.state.notifications, ...this.state.notifications,
flashWindow: this.flashWindowRef.current.props.checked ? 0 : 2, flashWindow: this.flashWindowRef.current?.props.checked ? 0 : 2,
}, },
}); });
} }
@ -215,18 +256,18 @@ export default class SettingsPage extends React.PureComponent {
key: 'notifications', key: 'notifications',
data: { data: {
...this.state.notifications, ...this.state.notifications,
bounceIcon: !this.bounceIconRef.current.props.checked, bounceIcon: !this.bounceIconRef.current?.props.checked,
}, },
}); });
this.setState({ this.setState({
notifications: { notifications: {
...this.state.notifications, ...this.state.notifications,
bounceIcon: !this.bounceIconRef.current.props.checked, bounceIcon: !this.bounceIconRef.current?.props.checked,
}, },
}); });
} }
handleBounceIconType = (event) => { handleBounceIconType = (event: React.ChangeEvent<Radio & HTMLInputElement>) => {
window.timers.setImmediate(this.saveSetting, CONFIG_TYPE_APP_OPTIONS, { window.timers.setImmediate(this.saveSetting, CONFIG_TYPE_APP_OPTIONS, {
key: 'notifications', key: 'notifications',
data: { data: {
@ -237,33 +278,33 @@ export default class SettingsPage extends React.PureComponent {
this.setState({ this.setState({
notifications: { notifications: {
...this.state.notifications, ...this.state.notifications,
bounceIconType: event.target.value, bounceIconType: event.target.value as 'critical' | 'informational',
}, },
}); });
} }
handleShowUnreadBadge = () => { handleShowUnreadBadge = () => {
window.timers.setImmediate(this.saveSetting, CONFIG_TYPE_APP_OPTIONS, {key: 'showUnreadBadge', data: !this.showUnreadBadgeRef.current.props.checked}); window.timers.setImmediate(this.saveSetting, CONFIG_TYPE_APP_OPTIONS, {key: 'showUnreadBadge', data: !this.showUnreadBadgeRef.current?.props.checked});
this.setState({ this.setState({
showUnreadBadge: !this.showUnreadBadgeRef.current.props.checked, showUnreadBadge: !this.showUnreadBadgeRef.current?.props.checked,
}); });
} }
handleChangeUseSpellChecker = () => { handleChangeUseSpellChecker = () => {
window.timers.setImmediate(this.saveSetting, CONFIG_TYPE_APP_OPTIONS, {key: 'useSpellChecker', data: !this.useSpellCheckerRef.current.props.checked}); window.timers.setImmediate(this.saveSetting, CONFIG_TYPE_APP_OPTIONS, {key: 'useSpellChecker', data: !this.useSpellCheckerRef.current?.props.checked});
this.setState({ this.setState({
useSpellChecker: !this.useSpellCheckerRef.current.props.checked, useSpellChecker: !this.useSpellCheckerRef.current?.props.checked,
}); });
} }
handleChangeEnableHardwareAcceleration = () => { handleChangeEnableHardwareAcceleration = () => {
window.timers.setImmediate(this.saveSetting, CONFIG_TYPE_APP_OPTIONS, {key: 'enableHardwareAcceleration', data: !this.enableHardwareAccelerationRef.current.props.checked}); window.timers.setImmediate(this.saveSetting, CONFIG_TYPE_APP_OPTIONS, {key: 'enableHardwareAcceleration', data: !this.enableHardwareAccelerationRef.current?.props.checked});
this.setState({ this.setState({
enableHardwareAcceleration: !this.enableHardwareAccelerationRef.current.props.checked, enableHardwareAcceleration: !this.enableHardwareAccelerationRef.current?.props.checked,
}); });
} }
saveDownloadLocation = (location) => { saveDownloadLocation = (location: string) => {
if (!location) { if (!location) {
return; return;
} }
@ -273,7 +314,7 @@ export default class SettingsPage extends React.PureComponent {
window.timers.setImmediate(this.saveSetting, CONFIG_TYPE_APP_OPTIONS, {key: 'downloadLocation', data: location}); window.timers.setImmediate(this.saveSetting, CONFIG_TYPE_APP_OPTIONS, {key: 'downloadLocation', data: location});
} }
handleChangeDownloadLocation = (e) => { handleChangeDownloadLocation = (e: React.ChangeEvent<HTMLInputElement>) => {
this.saveDownloadLocation(e.target.value); this.saveDownloadLocation(e.target.value);
} }
@ -285,8 +326,8 @@ export default class SettingsPage extends React.PureComponent {
this.setState({userOpenedDownloadDialog: false}); this.setState({userOpenedDownloadDialog: false});
} }
updateTeam = (index, newData) => { updateTeam = (index: number, newData: Team) => {
const teams = this.state.teams; const teams = this.state.teams || [];
teams[index] = newData; teams[index] = newData;
window.timers.setImmediate(this.saveSetting, CONFIG_TYPE_SERVERS, {key: 'teams', data: teams}); window.timers.setImmediate(this.saveSetting, CONFIG_TYPE_SERVERS, {key: 'teams', data: teams});
this.setState({ this.setState({
@ -294,8 +335,8 @@ export default class SettingsPage extends React.PureComponent {
}); });
} }
addServer = (team) => { addServer = (team: Team) => {
const teams = this.state.teams; const teams = this.state.teams || [];
teams.push(team); teams.push(team);
window.timers.setImmediate(this.saveSetting, CONFIG_TYPE_SERVERS, {key: 'teams', data: teams}); window.timers.setImmediate(this.saveSetting, CONFIG_TYPE_SERVERS, {key: 'teams', data: teams});
this.setState({ this.setState({
@ -303,12 +344,6 @@ export default class SettingsPage extends React.PureComponent {
}); });
} }
openMenu = () => {
// @eslint-ignore
this.threeDotMenu.current.blur();
this.props.openMenu();
}
handleDoubleClick = () => { handleDoubleClick = () => {
window.ipcRenderer.send(DOUBLE_CLICK_ON_WINDOW, 'settings'); window.ipcRenderer.send(DOUBLE_CLICK_ON_WINDOW, 'settings');
} }
@ -317,7 +352,7 @@ export default class SettingsPage extends React.PureComponent {
const settingsPage = { const settingsPage = {
navbar: { navbar: {
backgroundColor: '#fff', backgroundColor: '#fff',
position: 'relative', position: 'relative' as const,
}, },
close: { close: {
textDecoration: 'none', textDecoration: 'none',
@ -329,7 +364,7 @@ export default class SettingsPage extends React.PureComponent {
color: '#bbb', color: '#bbb',
}, },
heading: { heading: {
textAlign: 'center', textAlign: 'center' as const,
fontSize: '24px', fontSize: '24px',
margin: '0', margin: '0',
padding: '1em 0', padding: '1em 0',
@ -356,7 +391,7 @@ export default class SettingsPage extends React.PureComponent {
padding: '0 12px', padding: '0 12px',
borderRadius: '4px', borderRadius: '4px',
border: '1px solid #ccc', border: '1px solid #ccc',
fontWeight: '500', fontWeight: 500,
}, },
downloadLocationButton: { downloadLocationButton: {
@ -372,14 +407,12 @@ export default class SettingsPage extends React.PureComponent {
<Row> <Row>
<Col md={12}> <Col md={12}>
<TeamList <TeamList
teams={this.state.teams} teams={this.state.teams!}
showAddTeamForm={this.state.showAddTeamForm} showAddTeamForm={this.state.showAddTeamForm}
toggleAddTeamForm={this.toggleShowTeamForm}
setAddTeamFormVisibility={this.setShowTeamFormVisibility} setAddTeamFormVisibility={this.setShowTeamFormVisibility}
onTeamsChange={this.handleTeamsChange} onTeamsChange={this.handleTeamsChange}
updateTeam={this.updateTeam} updateTeam={this.updateTeam}
addServer={this.addServer} addServer={this.addServer}
allowTeamEdit={this.state.enableServerManagement}
onTeamClick={(name) => { onTeamClick={(name) => {
backToIndex(name); backToIndex(name);
}} }}
@ -576,7 +609,7 @@ export default class SettingsPage extends React.PureComponent {
name='trayIconTheme' name='trayIconTheme'
value='light' value='light'
defaultChecked={this.state.trayIconTheme === 'light' || !this.state.trayIconTheme} defaultChecked={this.state.trayIconTheme === 'light' || !this.state.trayIconTheme}
onChange={(event) => this.handleChangeTrayIconTheme('light', event)} onChange={() => this.handleChangeTrayIconTheme('light')}
> >
{'Light'} {'Light'}
</Radio> </Radio>
@ -586,7 +619,7 @@ export default class SettingsPage extends React.PureComponent {
name='trayIconTheme' name='trayIconTheme'
value='dark' value='dark'
defaultChecked={this.state.trayIconTheme === 'dark'} defaultChecked={this.state.trayIconTheme === 'dark'}
onChange={(event) => this.handleChangeTrayIconTheme('dark', event)} onChange={() => this.handleChangeTrayIconTheme('dark')}
>{'Dark'}</Radio> >{'Dark'}</Radio>
</FormGroup>, </FormGroup>,
); );
@ -719,7 +752,3 @@ export default class SettingsPage extends React.PureComponent {
); );
} }
} }
SettingsPage.propTypes = {
openMenu: PropTypes.func.isRequired,
};

View file

@ -1,17 +1,39 @@
// Copyright (c) 2015-2016 Yuya Ochiai
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. // Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information. // See LICENSE.txt for license information.
// Copyright (c) 2015-2016 Yuya Ochiai
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types';
import {Nav, NavItem} from 'react-bootstrap'; import {Nav, NavItem} from 'react-bootstrap';
import {Container, Draggable} from 'react-smooth-dnd'; import {Container, Draggable, OnDropCallback} from 'react-smooth-dnd';
import PlusIcon from 'mdi-react/PlusIcon'; import PlusIcon from 'mdi-react/PlusIcon';
import {Team} from 'types/config';
import {GET_CONFIGURATION} from 'common/communication'; import {GET_CONFIGURATION} from 'common/communication';
export default class TabBar extends React.PureComponent { // need "this" type Props = {
constructor(props) { activeKey: number;
id: string;
isDarkMode: boolean;
onSelect: (name: string, index: number) => void;
teams: Team[];
sessionsExpired: Record<string, boolean>;
unreadCounts: Record<string, number>;
mentionCounts: Record<string, number>;
showAddServerButton: boolean;
onAddServer: () => void;
onDrop: OnDropCallback;
tabsDisabled?: boolean;
};
type State = {
hasGPOTeams: boolean;
};
export default class TabBar extends React.PureComponent<Props, State> { // need "this"
container?: React.RefObject<Container>;
constructor(props: Props) {
super(props); super(props);
this.state = { this.state = {
hasGPOTeams: false, hasGPOTeams: false,
@ -37,7 +59,7 @@ export default class TabBar extends React.PureComponent { // need "this"
mentionCount = this.props.mentionCounts[index]; mentionCount = this.props.mentionCounts[index];
} }
let badgeDiv; let badgeDiv: React.ReactNode;
if (sessionExpired) { if (sessionExpired) {
badgeDiv = ( badgeDiv = (
<div className='TabBar-expired'/> <div className='TabBar-expired'/>
@ -63,7 +85,6 @@ export default class TabBar extends React.PureComponent { // need "this"
draggable={false} draggable={false}
ref={id} ref={id}
active={this.props.activeKey === index} active={this.props.activeKey === index}
activeKey={this.props.activeKey}
onMouseDown={() => { onMouseDown={() => {
this.props.onSelect(team.name, index); this.props.onSelect(team.name, index);
}} }}
@ -98,7 +119,6 @@ export default class TabBar extends React.PureComponent { // need "this"
eventKey='addServerButton' eventKey='addServerButton'
draggable={false} draggable={false}
title='Add new server' title='Add new server'
activeKey={this.props.activeKey}
onSelect={() => { onSelect={() => {
this.props.onAddServer(); this.props.onAddServer();
}} }}
@ -111,7 +131,7 @@ export default class TabBar extends React.PureComponent { // need "this"
); );
} }
const navContainer = (ref) => ( const navContainer = (ref: React.RefObject<Nav>) => (
<Nav <Nav
ref={ref} ref={ref}
className={`smooth-dnd-container TabBar${this.props.isDarkMode ? ' darkMode' : ''}`} className={`smooth-dnd-container TabBar${this.props.isDarkMode ? ' darkMode' : ''}`}
@ -136,18 +156,3 @@ export default class TabBar extends React.PureComponent { // need "this"
); );
} }
} }
TabBar.propTypes = {
activeKey: PropTypes.number,
id: PropTypes.string,
isDarkMode: PropTypes.bool,
onSelect: PropTypes.func,
teams: PropTypes.array,
sessionsExpired: PropTypes.object,
unreadCounts: PropTypes.object,
mentionCounts: PropTypes.object,
showAddServerButton: PropTypes.bool,
onAddServer: PropTypes.func,
onDrop: PropTypes.func,
tabsDisabled: PropTypes.bool,
};

View file

@ -1,17 +1,34 @@
// Copyright (c) 2015-2016 Yuya Ochiai
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. // Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information. // See LICENSE.txt for license information.
// Copyright (c) 2015-2016 Yuya Ochiai
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types';
import {ListGroup} from 'react-bootstrap'; import {ListGroup} from 'react-bootstrap';
import TeamListItem from './TeamListItem.jsx'; import {Team, TeamWithIndex} from 'types/config';
import NewTeamModal from './NewTeamModal.jsx';
import RemoveServerModal from './RemoveServerModal.jsx';
export default class TeamList extends React.PureComponent { import TeamListItem from './TeamListItem';
constructor(props) { import NewTeamModal from './NewTeamModal';
import RemoveServerModal from './RemoveServerModal';
type Props = {
onTeamClick: (teamName: string) => void;
onTeamsChange: (teams: Team[]) => void;
showAddTeamForm?: boolean;
teams: Team[];
addServer: (team: Team) => void;
updateTeam: (index: number, team: Team) => void;
setAddTeamFormVisibility: (visible: boolean) => void;
};
type State = {
team: TeamWithIndex;
showEditTeamForm: boolean;
indexToRemoveServer: number;
}
export default class TeamList extends React.PureComponent<Props, State> {
constructor(props: Props) {
super(props); super(props);
this.state = { this.state = {
@ -20,13 +37,13 @@ export default class TeamList extends React.PureComponent {
team: { team: {
url: '', url: '',
name: '', name: '',
index: false, index: 0,
order: props.teams.length, order: props.teams.length,
}, },
}; };
} }
handleTeamRemove = (index) => { handleTeamRemove = (index: number) => {
console.log(index); console.log(index);
const teams = this.props.teams; const teams = this.props.teams;
const removedOrder = this.props.teams[index].order; const removedOrder = this.props.teams[index].order;
@ -39,7 +56,7 @@ export default class TeamList extends React.PureComponent {
this.props.onTeamsChange(teams); this.props.onTeamsChange(teams);
} }
handleTeamAdd = (team) => { handleTeamAdd = (team: TeamWithIndex) => {
const teams = this.props.teams; const teams = this.props.teams;
// check if team already exists and then change existing team or add new one // check if team already exists and then change existing team or add new one
@ -56,7 +73,7 @@ export default class TeamList extends React.PureComponent {
team: { team: {
url: '', url: '',
name: '', name: '',
index: false, index: 0,
order: teams.length, order: teams.length,
}, },
}); });
@ -64,7 +81,7 @@ export default class TeamList extends React.PureComponent {
this.props.onTeamsChange(teams); this.props.onTeamsChange(teams);
} }
openServerRemoveModal = (indexForServer) => { openServerRemoveModal = (indexForServer: number) => {
this.setState({indexToRemoveServer: indexForServer}); this.setState({indexToRemoveServer: indexForServer});
} }
@ -72,16 +89,16 @@ export default class TeamList extends React.PureComponent {
this.setState({indexToRemoveServer: -1}); this.setState({indexToRemoveServer: -1});
} }
handleTeamRemovePrompt = (index) => { handleTeamRemovePrompt = (index: number) => {
return () => { return () => {
document.activeElement.blur(); (document.activeElement as HTMLElement).blur();
this.openServerRemoveModal(index); this.openServerRemoveModal(index);
}; };
} }
handleTeamEditing = (team, index) => { handleTeamEditing = (team: Team, index: number) => {
return () => { return () => {
document.activeElement.blur(); (document.activeElement as HTMLElement).blur();
this.setState({ this.setState({
showEditTeamForm: true, showEditTeamForm: true,
team: { team: {
@ -119,7 +136,7 @@ export default class TeamList extends React.PureComponent {
team: { team: {
name: '', name: '',
url: '', url: '',
index: false, index: 0,
order: this.props.teams.length, order: this.props.teams.length,
}, },
}); });
@ -137,12 +154,11 @@ export default class TeamList extends React.PureComponent {
this.props.updateTeam(newTeam.index, teamData); this.props.updateTeam(newTeam.index, teamData);
} }
this.setState({ this.setState({
showNewTeamModal: false,
showEditTeamForm: false, showEditTeamForm: false,
team: { team: {
name: '', name: '',
url: '', url: '',
index: false, index: 0,
order: newTeam.order + 1, order: newTeam.order + 1,
}, },
}); });
@ -175,13 +191,3 @@ export default class TeamList extends React.PureComponent {
); );
} }
} }
TeamList.propTypes = {
onTeamClick: PropTypes.func,
onTeamsChange: PropTypes.func,
showAddTeamForm: PropTypes.bool,
teams: PropTypes.array,
addServer: PropTypes.func,
updateTeam: PropTypes.func,
setAddTeamFormVisibility: PropTypes.func,
};

View file

@ -1,11 +1,18 @@
// Copyright (c) 2015-2016 Yuya Ochiai
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. // Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information. // See LICENSE.txt for license information.
// Copyright (c) 2015-2016 Yuya Ochiai
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types';
export default class TeamListItem extends React.PureComponent { type Props = {
name: string;
onTeamEditing: () => void;
onTeamRemove: () => void;
onTeamClick: React.MouseEventHandler<HTMLDivElement>;
url: string;
};
export default class TeamListItem extends React.PureComponent<Props> {
handleTeamRemove = () => { handleTeamRemove = () => {
this.props.onTeamRemove(); this.props.onTeamRemove();
} }
@ -39,11 +46,3 @@ export default class TeamListItem extends React.PureComponent {
); );
} }
} }
TeamListItem.propTypes = {
name: PropTypes.string,
onTeamEditing: PropTypes.func,
onTeamRemove: PropTypes.func,
onTeamClick: PropTypes.func,
url: PropTypes.string,
};

View file

@ -1,12 +1,17 @@
// Copyright (c) 2015-2016 Yuya Ochiai
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. // Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information. // See LICENSE.txt for license information.
// Copyright (c) 2015-2016 Yuya Ochiai
import React from 'react'; import React from 'react';
import propTypes from 'prop-types';
import {Button, Navbar, ProgressBar} from 'react-bootstrap'; import {Button, Navbar, ProgressBar} from 'react-bootstrap';
function InstallButton(props) { type InstallButtonProps = {
notifyOnly?: boolean;
onClickInstall?: React.MouseEventHandler<Button>;
onClickDownload?: React.MouseEventHandler<Button>;
};
function InstallButton(props: InstallButtonProps) {
if (props.notifyOnly) { if (props.notifyOnly) {
return ( return (
<Button <Button
@ -23,13 +28,20 @@ function InstallButton(props) {
); );
} }
InstallButton.propTypes = { type UpdaterPageProps = {
notifyOnly: propTypes.bool.isRequired, appName: string;
onClickInstall: propTypes.func.isRequired, notifyOnly?: boolean;
onClickDownload: propTypes.func.isRequired, isDownloading?: boolean;
progress?: number;
onClickInstall?: React.MouseEventHandler<Button>;
onClickDownload?: React.MouseEventHandler<Button>;
onClickReleaseNotes?: React.MouseEventHandler<HTMLAnchorElement>;
onClickRemind?: React.MouseEventHandler<Button>;
onClickSkip?: React.MouseEventHandler<Button>;
onClickCancel?: React.MouseEventHandler<Button>;
}; };
function UpdaterPage(props) { function UpdaterPage(props: UpdaterPageProps) {
let footer; let footer;
if (props.isDownloading) { if (props.isDownloading) {
footer = ( footer = (
@ -97,17 +109,4 @@ function UpdaterPage(props) {
); );
} }
UpdaterPage.propTypes = {
appName: propTypes.string.isRequired,
notifyOnly: propTypes.bool.isRequired,
isDownloading: propTypes.bool.isRequired,
progress: propTypes.number,
onClickInstall: propTypes.func.isRequired,
onClickDownload: propTypes.func.isRequired,
onClickReleaseNotes: propTypes.func.isRequired,
onClickRemind: propTypes.func.isRequired,
onClickSkip: propTypes.func.isRequired,
onClickCancel: propTypes.func.isRequired,
};
export default UpdaterPage; export default UpdaterPage;

View file

@ -1,13 +1,13 @@
// Copyright (c) 2015-2016 Yuya Ochiai
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. // Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information. // See LICENSE.txt for license information.
// Copyright (c) 2015-2016 Yuya Ochiai
import React from 'react'; import React from 'react';
import {storiesOf} from '@storybook/react'; import {storiesOf} from '@storybook/react';
import {action} from '@storybook/addon-actions'; import {action} from '@storybook/addon-actions';
import UpdaterPage from '../UpdaterPage.jsx'; import UpdaterPage from '../UpdaterPage';
import '../../css/components/UpdaterPage.css'; import '../../css/components/UpdaterPage.css';
/* /*

View file

@ -2,16 +2,20 @@
// See LICENSE.txt for license information. // See LICENSE.txt for license information.
import React, {Fragment} from 'react'; import React, {Fragment} from 'react';
import PropTypes from 'prop-types';
import {Modal, Button, Row, Col} from 'react-bootstrap'; import {Modal, Button, Row, Col} from 'react-bootstrap';
import {Certificate} from 'electron/renderer';
export default class ShowCertificateModal extends React.PureComponent { type Props = {
static propTypes = { certificate: Certificate;
certificate: PropTypes.object, onOk: () => void;
onOk: PropTypes.func.isRequired, };
};
constructor(props) { type State = {
certificate?: Certificate;
}
export default class ShowCertificateModal extends React.PureComponent<Props, State> {
constructor(props: Props) {
super(props); super(props);
this.state = { this.state = {
certificate: props.certificate, certificate: props.certificate,
@ -19,12 +23,12 @@ export default class ShowCertificateModal extends React.PureComponent {
} }
handleOk = () => { handleOk = () => {
this.setState({certificate: null}); this.setState({certificate: undefined});
this.props.onOk(); this.props.onOk();
} }
render() { render() {
const certificateSection = (descriptor) => { const certificateSection = (descriptor: React.ReactNode) => {
return ( return (
<Fragment> <Fragment>
<dt className={'certificate-key'}>{descriptor}</dt> <dt className={'certificate-key'}>{descriptor}</dt>
@ -32,7 +36,7 @@ export default class ShowCertificateModal extends React.PureComponent {
</Fragment> </Fragment>
); );
}; };
const certificateItem = (descriptor, value) => { const certificateItem = (descriptor: React.ReactNode, value: React.ReactNode) => {
const val = value ? `${value}` : <span/>; const val = value ? `${value}` : <span/>;
return ( return (
<Fragment> <Fragment>
@ -47,6 +51,7 @@ export default class ShowCertificateModal extends React.PureComponent {
<Modal <Modal
bsClass='modal' bsClass='modal'
className='show-certificate' className='show-certificate'
onHide={() => {}}
> >
<Modal.Body> <Modal.Body>
{'No certificate Selected'} {'No certificate Selected'}
@ -55,22 +60,22 @@ export default class ShowCertificateModal extends React.PureComponent {
); );
} }
const utcSeconds = (date) => { const utcSeconds = (date: number) => {
const d = new Date(0); const d = new Date(0);
d.setUTCSeconds(date); d.setUTCSeconds(date);
return d; return d;
}; };
const expiration = utcSeconds(this.state.certificate.validExpiry); const expiration = utcSeconds(this.state.certificate?.validExpiry || 0);
const creation = utcSeconds(this.state.certificate.validStart); const creation = utcSeconds(this.state.certificate?.validStart || 0);
const dateDisplayOptions = {dateStyle: 'full', timeStyle: 'full'}; const dateDisplayOptions = {dateStyle: 'full' as const, timeStyle: 'full' as const};
const dateLocale = 'en-US'; const dateLocale = 'en-US';
return ( return (
<Modal <Modal
bsClass='modal' bsClass='modal'
className='show-certificate' className='show-certificate'
show={this.state.certificate !== null} show={this.state.certificate !== null}
scrollable={'true'} onHide={() => {}}
> >
<Modal.Header className={'no-border'}> <Modal.Header className={'no-border'}>
<Modal.Title>{'Certificate information'}</Modal.Title> <Modal.Title>{'Certificate information'}</Modal.Title>
@ -79,20 +84,20 @@ export default class ShowCertificateModal extends React.PureComponent {
<p className='details'>{'Details'}</p> <p className='details'>{'Details'}</p>
<dl> <dl>
{certificateSection('Subject Name')} {certificateSection('Subject Name')}
{certificateItem('Common Name', this.state.certificate.subject.commonName)} {certificateItem('Common Name', this.state.certificate?.subject.commonName)}
</dl> </dl>
<dl> <dl>
{certificateSection('Issuer Name')} {certificateSection('Issuer Name')}
{certificateItem('Common Name', this.state.certificate.issuer.commonName)} {certificateItem('Common Name', this.state.certificate?.issuer.commonName)}
</dl> </dl>
<dl> <dl>
{certificateItem('Serial Number', this.state.certificate.serialNumber)} {certificateItem('Serial Number', this.state.certificate?.serialNumber)}
{certificateItem('Not Valid Before', creation.toLocaleString(dateLocale, dateDisplayOptions))} {certificateItem('Not Valid Before', creation.toLocaleString(dateLocale, dateDisplayOptions))}
{certificateItem('Not Valid After', expiration.toLocaleString(dateLocale, dateDisplayOptions))} {certificateItem('Not Valid After', expiration.toLocaleString(dateLocale, dateDisplayOptions))}
</dl> </dl>
<dl> <dl>
{certificateSection('Public Key Info')} {certificateSection('Public Key Info')}
{certificateItem('Algorithm', this.state.certificate.fingerprint.split('/')[0])} {certificateItem('Algorithm', this.state.certificate?.fingerprint.split('/')[0])}
</dl> </dl>
</Modal.Body> </Modal.Body>
<Modal.Footer className={'no-border'}> <Modal.Footer className={'no-border'}>
@ -100,7 +105,6 @@ export default class ShowCertificateModal extends React.PureComponent {
<Row> <Row>
<Col> <Col>
<Button <Button
variant={'primary'}
onClick={this.handleOk} onClick={this.handleOk}
className={'primary'} className={'primary'}
>{'Close'}</Button> >{'Close'}</Button>

View file

@ -2,9 +2,8 @@
// See LICENSE.txt for license information. // See LICENSE.txt for license information.
import React from 'react'; import React from 'react';
import propTypes from 'prop-types';
export default function UrlDescription(props) { export default function UrlDescription(props: {url: string}) {
if (props.url) { if (props.url) {
return ( return (
<div className='HoveringURL HoveringURL-left'> <div className='HoveringURL HoveringURL-left'>
@ -12,8 +11,6 @@ export default function UrlDescription(props) {
</div> </div>
); );
} }
}
UrlDescription.propTypes = { return null;
url: propTypes.string, }
};

View file

@ -12,18 +12,18 @@ import React from 'react';
* bubbled from all descendent elements but when false, only listens for events coming from the target element and * bubbled from all descendent elements but when false, only listens for events coming from the target element and
* ignores events bubbling up from descendent elements * ignores events bubbling up from descendent elements
*/ */
function useAnimationEnd( function useAnimationEnd<T extends Element>(
ref, ref: React.RefObject<T>,
callback, callback: (event: Event) => void,
animationName, animationName: string,
listenForEventBubbling = true, listenForEventBubbling = true,
) { ): void {
React.useEffect(() => { React.useEffect(() => {
if (!ref.current) { if (!ref.current) {
return undefined; return undefined;
} }
function handleAnimationend(event) { function handleAnimationend(event: Event & {animationName?: string}) {
if (!listenForEventBubbling && event.target !== ref.current) { if (!listenForEventBubbling && event.target !== ref.current) {
return; return;
} }
@ -36,7 +36,7 @@ function useAnimationEnd(
ref.current.addEventListener('animationend', handleAnimationend); ref.current.addEventListener('animationend', handleAnimationend);
return () => { return () => {
ref.current.removeEventListener('animationend', handleAnimationend); ref.current?.removeEventListener('animationend', handleAnimationend);
}; };
}, [ref, callback, animationName, listenForEventBubbling]); }, [ref, callback, animationName, listenForEventBubbling]);
} }

View file

@ -12,10 +12,10 @@ import React from 'react';
* bubbled from all descendent elements but when false, only listens for events coming from the target element and * bubbled from all descendent elements but when false, only listens for events coming from the target element and
* ignores events bubbling up from descendent elements * ignores events bubbling up from descendent elements
*/ */
function useTransitionend( function useTransitionend<T extends Element>(
ref, ref: React.RefObject<T>,
callback, callback: (event: Event) => void,
properties, properties: string[],
listenForEventBubbling = true, listenForEventBubbling = true,
) { ) {
React.useEffect(() => { React.useEffect(() => {
@ -23,7 +23,7 @@ function useTransitionend(
return undefined; return undefined;
} }
function handleTransitionEnd(event) { function handleTransitionEnd(event: Event & {propertyName?: string}) {
if (!listenForEventBubbling && event.target !== ref.current) { if (!listenForEventBubbling && event.target !== ref.current) {
return; return;
} }

View file

@ -1,6 +1,6 @@
// Copyright (c) 2015-2016 Yuya Ochiai
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. // Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information. // See LICENSE.txt for license information.
// Copyright (c) 2015-2016 Yuya Ochiai
import 'bootstrap/dist/css/bootstrap.min.css'; import 'bootstrap/dist/css/bootstrap.min.css';
import 'renderer/css/index.css'; import 'renderer/css/index.css';
@ -8,11 +8,18 @@ import 'renderer/css/index.css';
import React from 'react'; import React from 'react';
import ReactDOM from 'react-dom'; import ReactDOM from 'react-dom';
import {CombinedConfig, Team} from 'types/config';
import {GET_CONFIGURATION, UPDATE_TEAMS, QUIT, RELOAD_CONFIGURATION} from 'common/communication'; import {GET_CONFIGURATION, UPDATE_TEAMS, QUIT, RELOAD_CONFIGURATION} from 'common/communication';
import MainPage from './components/MainPage.jsx'; import MainPage from './components/MainPage';
class Root extends React.PureComponent {
constructor(props) { type State = {
config?: CombinedConfig;
}
class Root extends React.PureComponent<Record<string, never>, State> {
constructor(props: Record<string, never>) {
super(props); super(props);
this.state = {}; this.state = {};
} }
@ -39,7 +46,10 @@ class Root extends React.PureComponent {
this.setState({config}); this.setState({config});
} }
moveTabs = async (originalOrder, newOrder) => { moveTabs = async (originalOrder: number, newOrder: number): Promise<number | undefined> => {
if (!this.state.config) {
throw new Error('No config');
}
const teams = this.state.config.teams.concat(); const teams = this.state.config.teams.concat();
const tabOrder = teams.map((team, index) => { const tabOrder = teams.map((team, index) => {
return { return {
@ -62,12 +72,9 @@ class Root extends React.PureComponent {
return teamIndex; return teamIndex;
}; };
teamConfigChange = async (updatedTeams, callback) => { teamConfigChange = async (updatedTeams: Team[]) => {
const updatedConfig = await window.ipcRenderer.invoke(UPDATE_TEAMS, updatedTeams); await window.ipcRenderer.invoke(UPDATE_TEAMS, updatedTeams);
await this.reloadConfig(); await this.reloadConfig();
if (callback) {
callback(updatedConfig);
}
}; };
reloadConfig = async () => { reloadConfig = async () => {
@ -75,7 +82,7 @@ class Root extends React.PureComponent {
this.setState({config}); this.setState({config});
}; };
requestConfig = async (exitOnError) => { requestConfig = async (exitOnError?: boolean) => {
// todo: should we block? // todo: should we block?
try { try {
const configRequest = await window.ipcRenderer.invoke(GET_CONFIGURATION); const configRequest = await window.ipcRenderer.invoke(GET_CONFIGURATION);
@ -115,6 +122,8 @@ class Root extends React.PureComponent {
} }
window.ipcRenderer.invoke('get-app-version').then(({name, version}) => { window.ipcRenderer.invoke('get-app-version').then(({name, version}) => {
// eslint-disable-next-line no-undef // eslint-disable-next-line no-undef
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
console.log(`Starting ${name} v${version} commit: ${__HASH_VERSION__}`); console.log(`Starting ${name} v${version} commit: ${__HASH_VERSION__}`);
}); });

View file

@ -3,10 +3,11 @@
import React from 'react'; import React from 'react';
import ReactDOM from 'react-dom'; import ReactDOM from 'react-dom';
import {Certificate} from 'electron/renderer';
import {MODAL_CANCEL, MODAL_RESULT, RETRIEVE_MODAL_INFO} from 'common/communication.js'; import {MODAL_CANCEL, MODAL_RESULT, RETRIEVE_MODAL_INFO} from 'common/communication';
import SelectCertificateModal from './certificateModal.jsx'; import SelectCertificateModal from './certificateModal';
import 'bootstrap/dist/css/bootstrap.min.css'; import 'bootstrap/dist/css/bootstrap.min.css';
import 'renderer/css/modals.css'; import 'renderer/css/modals.css';
@ -16,7 +17,7 @@ const handleCancel = () => {
window.postMessage({type: MODAL_CANCEL}, window.location.href); window.postMessage({type: MODAL_CANCEL}, window.location.href);
}; };
const handleSelect = (cert) => { const handleSelect = (cert: Certificate) => {
window.postMessage({type: MODAL_RESULT, data: {cert}}, window.location.href); window.postMessage({type: MODAL_RESULT, data: {cert}}, window.location.href);
}; };

View file

@ -1,27 +1,34 @@
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. // Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information. // See LICENSE.txt for license information.
import {Certificate} from 'electron/renderer';
import React, {Fragment} from 'react'; import React, {Fragment} from 'react';
import PropTypes from 'prop-types';
import {Modal, Button, Table, Row, Col} from 'react-bootstrap'; import {Modal, Button, Table, Row, Col} from 'react-bootstrap';
import {CertificateModalData} from 'types/certificate';
import {ModalMessage} from 'types/modals';
import {MODAL_INFO} from 'common/communication'; import {MODAL_INFO} from 'common/communication';
import ShowCertificateModal from '../../components/showCertificateModal.jsx'; import ShowCertificateModal from '../../components/showCertificateModal';
export default class SelectCertificateModal extends React.PureComponent { type Props = {
static propTypes = { onSelect: (cert: Certificate) => void;
onSelect: PropTypes.func.isRequired, onCancel?: () => void;
onCancel: PropTypes.func, getCertInfo: () => void;
getCertInfo: PropTypes.func, }
}
constructor(props) { type State = {
selectedIndex?: number;
showCertificate?: Certificate;
url?: string;
list?: Certificate[];
}
export default class SelectCertificateModal extends React.PureComponent<Props, State> {
constructor(props: Props) {
super(props); super(props);
this.state = { this.state = {};
selectedIndex: null,
showCertificate: null,
};
} }
componentDidMount() { componentDidMount() {
@ -34,7 +41,7 @@ export default class SelectCertificateModal extends React.PureComponent {
window.removeEventListener('message', this.handleCertInfoMessage); window.removeEventListener('message', this.handleCertInfoMessage);
} }
handleCertInfoMessage = (event) => { handleCertInfoMessage = (event: {data: ModalMessage<CertificateModalData>}) => {
switch (event.data.type) { switch (event.data.type) {
case MODAL_INFO: { case MODAL_INFO: {
const {url, list} = event.data.data; const {url, list} = event.data.data;
@ -46,13 +53,13 @@ export default class SelectCertificateModal extends React.PureComponent {
} }
} }
selectfn = (index) => { selectfn = (index: number) => {
return (() => { return (() => {
this.setState({selectedIndex: index}); this.setState({selectedIndex: index});
}); });
}; };
renderCert = (cert, index) => { renderCert = (cert: Certificate, index: number) => {
const issuer = (cert.issuerName || (cert.issuer && cert.issuer.commonName) || ''); const issuer = (cert.issuerName || (cert.issuer && cert.issuer.commonName) || '');
const subject = (cert.subjectName || (cert.subject && cert.subject.commonName) || ''); const subject = (cert.subjectName || (cert.subject && cert.subject.commonName) || '');
const serial = cert.serialNumber || ''; const serial = cert.serialNumber || '';
@ -75,7 +82,7 @@ export default class SelectCertificateModal extends React.PureComponent {
</tr>); </tr>);
}; };
renderCerts = (certificateList) => { renderCerts = (certificateList: Certificate[]) => {
if (certificateList) { if (certificateList) {
const certs = certificateList.map(this.renderCert); const certs = certificateList.map(this.renderCert);
return ( return (
@ -88,12 +95,15 @@ export default class SelectCertificateModal extends React.PureComponent {
} }
getSelectedCert = () => { getSelectedCert = () => {
return this.state.selectedIndex === null ? null : this.state.list[this.state.selectedIndex]; if (this.state.list && this.state.selectedIndex) {
return this.state.list[this.state.selectedIndex];
}
return undefined;
}; };
handleOk = () => { handleOk = () => {
const cert = this.getSelectedCert(); const cert = this.getSelectedCert();
if (cert !== null) { if (cert) {
this.props.onSelect(cert); this.props.onSelect(cert);
} }
} }
@ -104,7 +114,7 @@ export default class SelectCertificateModal extends React.PureComponent {
} }
certificateInfoClose = () => { certificateInfoClose = () => {
this.setState({showCertificate: null}); this.setState({showCertificate: undefined});
} }
render() { render() {
@ -122,6 +132,7 @@ export default class SelectCertificateModal extends React.PureComponent {
bsClass='modal' bsClass='modal'
className='certificate-modal' className='certificate-modal'
show={Boolean(this.state.list && this.state.url)} show={Boolean(this.state.list && this.state.url)}
onHide={() => {}}
> >
<Modal.Header> <Modal.Header>
<Modal.Title >{'Select a certificate'}</Modal.Title> <Modal.Title >{'Select a certificate'}</Modal.Title>
@ -131,7 +142,6 @@ export default class SelectCertificateModal extends React.PureComponent {
<Table <Table
striped={true} striped={true}
hover={true} hover={true}
size={'sm'}
responsive={true} responsive={true}
className='certificate-list' className='certificate-list'
tabIndex={1} tabIndex={1}
@ -144,7 +154,7 @@ export default class SelectCertificateModal extends React.PureComponent {
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{this.renderCerts(this.state.list)} {this.renderCerts(this.state.list!)}
<tr/* this is to correct table height without affecting real rows *//> <tr/* this is to correct table height without affecting real rows *//>
</tbody> </tbody>
</Table> </Table>
@ -154,7 +164,6 @@ export default class SelectCertificateModal extends React.PureComponent {
<Row> <Row>
<Col sm={4}> <Col sm={4}>
<Button <Button
variant={'info'}
disabled={this.state.selectedIndex === null} disabled={this.state.selectedIndex === null}
onClick={this.handleCertificateInfo} onClick={this.handleCertificateInfo}
className={'info'} className={'info'}
@ -163,11 +172,9 @@ export default class SelectCertificateModal extends React.PureComponent {
<Col sm={8}> <Col sm={8}>
<Button <Button
onClick={this.props.onCancel} onClick={this.props.onCancel}
variant={'secondary'}
className={'secondary'} className={'secondary'}
>{'Cancel'}</Button> >{'Cancel'}</Button>
<Button <Button
variant={'primary'}
onClick={this.handleOk} onClick={this.handleOk}
disabled={this.state.selectedIndex === null} disabled={this.state.selectedIndex === null}
className={'primary'} className={'primary'}

View file

@ -4,18 +4,27 @@
import React from 'react'; import React from 'react';
import ReactDOM from 'react-dom'; import ReactDOM from 'react-dom';
import {RECEIVED_LOADING_SCREEN_DATA, GET_LOADING_SCREEN_DATA, LOADING_SCREEN_ANIMATION_FINISHED, TOGGLE_LOADING_SCREEN_VISIBILITY} from 'common/communication.js'; import {ModalMessage} from 'types/modals';
import LoadingScreen from '../../components/LoadingScreen.jsx'; import {RECEIVED_LOADING_SCREEN_DATA, GET_LOADING_SCREEN_DATA, LOADING_SCREEN_ANIMATION_FINISHED, TOGGLE_LOADING_SCREEN_VISIBILITY} from 'common/communication';
import LoadingScreen from '../../components/LoadingScreen';
import 'bootstrap/dist/css/bootstrap.min.css'; import 'bootstrap/dist/css/bootstrap.min.css';
import 'renderer/css/modals.css'; import 'renderer/css/modals.css';
import 'renderer/css/components/LoadingAnimation.css'; import 'renderer/css/components/LoadingAnimation.css';
import 'renderer/css/components/LoadingScreen.css'; import 'renderer/css/components/LoadingScreen.css';
class LoadingScreenRoot extends React.PureComponent { type Props = Record<string, never>;
constructor() {
super(); type State = {
showLoadingScreen: boolean;
darkMode: boolean;
}
class LoadingScreenRoot extends React.PureComponent<Props, State> {
constructor(props: Props) {
super(props);
this.state = { this.state = {
showLoadingScreen: true, showLoadingScreen: true,
darkMode: false, darkMode: false,
@ -32,7 +41,7 @@ class LoadingScreenRoot extends React.PureComponent {
window.removeEventListener('message', this.handleMessageEvent); window.removeEventListener('message', this.handleMessageEvent);
} }
handleMessageEvent = (event) => { handleMessageEvent = (event: {data: ModalMessage<any>}) => {
if (event.data.type === RECEIVED_LOADING_SCREEN_DATA) { if (event.data.type === RECEIVED_LOADING_SCREEN_DATA) {
this.setState({ this.setState({
darkMode: event.data.data.darkMode, darkMode: event.data.data.darkMode,

View file

@ -3,19 +3,20 @@
import React from 'react'; import React from 'react';
import ReactDOM from 'react-dom'; import ReactDOM from 'react-dom';
import {AuthenticationResponseDetails} from 'electron/renderer';
import {MODAL_CANCEL, MODAL_RESULT, RETRIEVE_MODAL_INFO} from 'common/communication.js'; import {MODAL_CANCEL, MODAL_RESULT, RETRIEVE_MODAL_INFO} from 'common/communication';
import LoginModal from './loginModal.jsx'; import LoginModal from './loginModal';
import 'bootstrap/dist/css/bootstrap.min.css'; import 'bootstrap/dist/css/bootstrap.min.css';
import 'renderer/css/modals.css'; import 'renderer/css/modals.css';
const handleLoginCancel = (request) => { const handleLoginCancel = (request: AuthenticationResponseDetails) => {
window.postMessage({type: MODAL_CANCEL, data: {request}}, window.location.href); window.postMessage({type: MODAL_CANCEL, data: {request}}, window.location.href);
}; };
const handleLogin = (request, username, password) => { const handleLogin = (request: AuthenticationResponseDetails, username: string, password: string) => {
window.postMessage({type: MODAL_RESULT, data: {request, username, password}}, window.location.href); window.postMessage({type: MODAL_RESULT, data: {request, username, password}}, window.location.href);
}; };

View file

@ -3,20 +3,34 @@
// Copyright (c) 2015-2016 Yuya Ochiai // Copyright (c) 2015-2016 Yuya Ochiai
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types';
import {Button, Col, ControlLabel, Form, FormGroup, FormControl, Modal} from 'react-bootstrap'; import {Button, Col, ControlLabel, Form, FormGroup, FormControl, Modal} from 'react-bootstrap';
import {MODAL_INFO} from 'common/communication'; import {LoginModalData} from 'types/auth';
import urlUtils from 'common/utils/url'; import {ModalMessage} from 'types/modals';
import {AuthenticationResponseDetails, AuthInfo} from 'electron/renderer';
export default class LoginModal extends React.PureComponent { import urlUtils from 'common/utils/url';
constructor(props) { import {MODAL_INFO} from 'common/communication';
type Props = {
onCancel: (request: AuthenticationResponseDetails) => void;
onLogin: (request: AuthenticationResponseDetails, username: string, password: string) => void;
getAuthInfo: () => void;
};
type State = {
username: string;
password: string;
request?: AuthenticationResponseDetails;
authInfo?: AuthInfo;
};
export default class LoginModal extends React.PureComponent<Props, State> {
constructor(props: Props) {
super(props); super(props);
this.state = { this.state = {
username: '', username: '',
password: '', password: '',
request: null,
authInfo: null,
}; };
} }
@ -30,7 +44,7 @@ export default class LoginModal extends React.PureComponent {
window.removeEventListener('message', this.handleAuthInfoMessage); window.removeEventListener('message', this.handleAuthInfoMessage);
} }
handleAuthInfoMessage = (event) => { handleAuthInfoMessage = (event: {data: ModalMessage<LoginModalData>}) => {
switch (event.data.type) { switch (event.data.type) {
case MODAL_INFO: { case MODAL_INFO: {
const {request, authInfo} = event.data.data; const {request, authInfo} = event.data.data;
@ -42,33 +56,33 @@ export default class LoginModal extends React.PureComponent {
} }
} }
handleSubmit = (event) => { handleSubmit = (event: React.MouseEvent<Button>) => {
event.preventDefault(); event.preventDefault();
this.props.onLogin(this.state.request, this.state.username, this.state.password); this.props.onLogin(this.state.request!, this.state.username, this.state.password);
this.setState({ this.setState({
username: '', username: '',
password: '', password: '',
request: null, request: undefined,
authInfo: null, authInfo: undefined,
}); });
} }
handleCancel = (event) => { handleCancel = (event: React.MouseEvent<Button>) => {
event.preventDefault(); event.preventDefault();
this.props.onCancel(this.state.request); this.props.onCancel(this.state.request!);
this.setState({ this.setState({
username: '', username: '',
password: '', password: '',
request: null, request: undefined,
authInfo: null, authInfo: undefined,
}); });
} }
setUsername = (e) => { setUsername = (e: React.ChangeEvent<FormControl & HTMLInputElement>) => {
this.setState({username: e.target.value}); this.setState({username: e.target.value});
} }
setPassword = (e) => { setPassword = (e: React.ChangeEvent<FormControl & HTMLInputElement>) => {
this.setState({password: e.target.value}); this.setState({password: e.target.value});
} }
@ -80,11 +94,14 @@ export default class LoginModal extends React.PureComponent {
theServer = `The proxy ${this.state.authInfo.host}:${this.state.authInfo.port}`; theServer = `The proxy ${this.state.authInfo.host}:${this.state.authInfo.port}`;
} else { } else {
const tmpURL = urlUtils.parseURL(this.state.request.url); const tmpURL = urlUtils.parseURL(this.state.request.url);
theServer = `The server ${tmpURL.protocol}//${tmpURL.host}`; theServer = `The server ${tmpURL?.protocol}//${tmpURL?.host}`;
} }
const message = `${theServer} requires a username and password.`; const message = `${theServer} requires a username and password.`;
return ( return (
<Modal show={Boolean(this.state.request && this.state.authInfo)}> <Modal
show={Boolean(this.state.request && this.state.authInfo)}
onHide={() => {}}
>
<Modal.Header> <Modal.Header>
<Modal.Title>{'Authentication Required'}</Modal.Title> <Modal.Title>{'Authentication Required'}</Modal.Title>
</Modal.Header> </Modal.Header>
@ -148,9 +165,3 @@ export default class LoginModal extends React.PureComponent {
); );
} }
} }
LoginModal.propTypes = {
onCancel: PropTypes.func,
onLogin: PropTypes.func,
getAuthInfo: PropTypes.func,
};

View file

@ -4,21 +4,20 @@
import 'bootstrap/dist/css/bootstrap.min.css'; import 'bootstrap/dist/css/bootstrap.min.css';
import 'renderer/css/modals.css'; import 'renderer/css/modals.css';
const queryString = window.location.search;
const urlParams = new URLSearchParams(queryString);
import React from 'react'; import React from 'react';
import ReactDOM from 'react-dom'; import ReactDOM from 'react-dom';
import {MODAL_CANCEL, MODAL_RESULT} from 'common/communication.js'; import {TeamWithIndex} from 'types/config';
import NewTeamModal from '../../components/NewTeamModal.jsx'; //'./addServer.jsx'; import {MODAL_CANCEL, MODAL_RESULT} from 'common/communication';
import NewTeamModal from '../../components/NewTeamModal'; //'./addServer.jsx';
const onClose = () => { const onClose = () => {
window.postMessage({type: MODAL_CANCEL}, window.location.href); window.postMessage({type: MODAL_CANCEL}, window.location.href);
}; };
const onSave = (data) => { const onSave = (data: TeamWithIndex) => {
window.postMessage({type: MODAL_RESULT, data}, window.location.href); window.postMessage({type: MODAL_RESULT, data}, window.location.href);
}; };
@ -29,7 +28,6 @@ const start = async () => {
onSave={onSave} onSave={onSave}
editMode={false} editMode={false}
show={true} show={true}
url={decodeURIComponent(urlParams.get('url'))}
/>, />,
document.getElementById('app'), document.getElementById('app'),
); );

View file

@ -4,9 +4,9 @@
import React from 'react'; import React from 'react';
import ReactDOM from 'react-dom'; import ReactDOM from 'react-dom';
import {MODAL_CANCEL, MODAL_RESULT, RETRIEVE_MODAL_INFO, MODAL_SEND_IPC_MESSAGE} from 'common/communication.js'; import {MODAL_CANCEL, MODAL_RESULT, RETRIEVE_MODAL_INFO, MODAL_SEND_IPC_MESSAGE} from 'common/communication';
import PermissionModal from './permissionModal.jsx'; import PermissionModal from './permissionModal';
import 'bootstrap/dist/css/bootstrap.min.css'; import 'bootstrap/dist/css/bootstrap.min.css';
import 'renderer/css/modals.css'; import 'renderer/css/modals.css';
@ -23,7 +23,7 @@ const getPermissionInfo = () => {
window.postMessage({type: RETRIEVE_MODAL_INFO}, window.location.href); window.postMessage({type: RETRIEVE_MODAL_INFO}, window.location.href);
}; };
const openExternalLink = (protocol, url) => { const openExternalLink = (protocol: string, url: string) => {
window.postMessage({type: MODAL_SEND_IPC_MESSAGE, data: {type: 'confirm-protocol', args: [protocol, url]}}, window.location.href); window.postMessage({type: MODAL_SEND_IPC_MESSAGE, data: {type: 'confirm-protocol', args: [protocol, url]}}, window.location.href);
}; };

View file

@ -3,14 +3,29 @@
import React from 'react'; import React from 'react';
import {Modal, Button} from 'react-bootstrap'; import {Modal, Button} from 'react-bootstrap';
import PropTypes from 'prop-types';
import {PermissionType} from 'types/trustedOrigin';
import {ModalMessage} from 'types/modals';
import urlUtil from 'common/utils/url'; import urlUtil from 'common/utils/url';
import {MODAL_INFO} from 'common/communication'; import {MODAL_INFO} from 'common/communication';
import {PERMISSION_DESCRIPTION} from 'common/permissions'; import {PERMISSION_DESCRIPTION} from 'common/permissions';
export default class PermissionModal extends React.PureComponent { type Props = {
constructor(props) { handleDeny: React.MouseEventHandler<Button>;
handleGrant: React.MouseEventHandler<Button>;
getPermissionInfo: () => void;
openExternalLink: (protocol: string, url: string) => void;
};
type State = {
url?: string;
permission?: PermissionType;
}
export default class PermissionModal extends React.PureComponent<Props, State> {
constructor(props: Props) {
super(props); super(props);
this.state = {}; this.state = {};
} }
@ -25,7 +40,7 @@ export default class PermissionModal extends React.PureComponent {
window.removeEventListener('message', this.handlePermissionInfoMessage); window.removeEventListener('message', this.handlePermissionInfoMessage);
} }
handlePermissionInfoMessage = (event) => { handlePermissionInfoMessage = (event: {data: ModalMessage<{url: string; permission: PermissionType}>}) => {
switch (event.data.type) { switch (event.data.type) {
case MODAL_INFO: { case MODAL_INFO: {
const {url, permission} = event.data.data; const {url, permission} = event.data.data;
@ -38,7 +53,7 @@ export default class PermissionModal extends React.PureComponent {
} }
getModalTitle() { getModalTitle() {
return `${PERMISSION_DESCRIPTION[this.state.permission]} Required`; return `${PERMISSION_DESCRIPTION[this.state.permission!]} Required`;
} }
getModalBody() { getModalBody() {
@ -46,12 +61,12 @@ export default class PermissionModal extends React.PureComponent {
const originDisplay = url ? urlUtil.getHost(url) : 'unknown origin'; const originDisplay = url ? urlUtil.getHost(url) : 'unknown origin';
const originLink = url ? originDisplay : ''; const originLink = url ? originDisplay : '';
const click = (e) => { const click = (e: React.MouseEvent<HTMLAnchorElement>) => {
e.preventDefault(); e.preventDefault();
let parseUrl; let parseUrl;
try { try {
parseUrl = urlUtil.parseURL(originLink); parseUrl = urlUtil.parseURL(originLink);
this.props.openExternalLink(parseUrl.protocol, originLink); this.props.openExternalLink(parseUrl!.protocol, originLink);
} catch (err) { } catch (err) {
console.error(`invalid url ${originLink} supplied to externallink: ${err}`); console.error(`invalid url ${originLink} supplied to externallink: ${err}`);
} }
@ -60,7 +75,7 @@ export default class PermissionModal extends React.PureComponent {
return ( return (
<div> <div>
<p> <p>
{`A site that's not included in your Mattermost server configuration requires access for ${PERMISSION_DESCRIPTION[permission]}.`} {`A site that's not included in your Mattermost server configuration requires access for ${PERMISSION_DESCRIPTION[permission!]}.`}
</p> </p>
<p> <p>
<span>{'This request originated from '}</span> <span>{'This request originated from '}</span>
@ -78,6 +93,7 @@ export default class PermissionModal extends React.PureComponent {
show={Boolean(this.state.url && this.state.permission)} show={Boolean(this.state.url && this.state.permission)}
id='requestPermissionModal' id='requestPermissionModal'
enforceFocus={true} enforceFocus={true}
onHide={() => {}}
> >
<Modal.Header> <Modal.Header>
<Modal.Title>{this.getModalTitle()}</Modal.Title> <Modal.Title>{this.getModalTitle()}</Modal.Title>
@ -100,10 +116,3 @@ export default class PermissionModal extends React.PureComponent {
); );
} }
} }
PermissionModal.propTypes = {
handleDeny: PropTypes.func,
handleGrant: PropTypes.func,
getPermissionInfo: PropTypes.func,
openExternalLink: PropTypes.func,
};

View file

@ -9,12 +9,12 @@ const urlParams = new URLSearchParams(queryString);
import React from 'react'; import React from 'react';
import ReactDOM from 'react-dom'; import ReactDOM from 'react-dom';
import UrlDescription from '../../components/urlDescription.jsx'; import UrlDescription from '../../components/urlDescription';
const start = async () => { const start = async () => {
ReactDOM.render( ReactDOM.render(
<UrlDescription <UrlDescription
url={decodeURIComponent(urlParams.get('url'))} url={decodeURIComponent(urlParams.get('url')!)}
/>, />,
document.getElementById('app'), document.getElementById('app'),
); );

View file

@ -22,7 +22,7 @@ const notificationSounds = new Map([
['Upstairs', upstairs], ['Upstairs', upstairs],
]); ]);
export const playSound = throttle((soundName) => { export const playSound = throttle((soundName: string) => {
if (soundName) { if (soundName) {
const audio = new Audio(notificationSounds.get(soundName)); const audio = new Audio(notificationSounds.get(soundName));
audio.play(); audio.play();

View file

@ -1,6 +1,6 @@
// Copyright (c) 2015-2016 Yuya Ochiai
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. // Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information. // See LICENSE.txt for license information.
// Copyright (c) 2015-2016 Yuya Ochiai
import 'bootstrap/dist/css/bootstrap.min.css'; import 'bootstrap/dist/css/bootstrap.min.css';
import 'renderer/css/index.css'; import 'renderer/css/index.css';
@ -9,19 +9,11 @@ import 'renderer/css/settings.css';
import React from 'react'; import React from 'react';
import ReactDOM from 'react-dom'; import ReactDOM from 'react-dom';
import SettingsPage from './components/SettingsPage.jsx'; import SettingsPage from './components/SettingsPage';
function openMenu() {
if (window.process.platform !== 'darwin') {
window.ipcRenderer.send('open-app-menu');
}
}
const start = async () => { const start = async () => {
ReactDOM.render( ReactDOM.render(
<SettingsPage <SettingsPage/>,
openMenu={openMenu}
/>,
document.getElementById('app'), document.getElementById('app'),
); );
}; };

View file

@ -1,21 +1,29 @@
// Copyright (c) 2015-2016 Yuya Ochiai
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. // Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information. // See LICENSE.txt for license information.
// Copyright (c) 2015-2016 Yuya Ochiai
import url from 'url'; import url from 'url';
import React from 'react'; import React from 'react';
import ReactDOM from 'react-dom'; import ReactDOM from 'react-dom';
import propTypes from 'prop-types';
import {remote} from 'electron'; import {remote} from 'electron';
import UpdaterPage from './components/UpdaterPage.jsx'; import UpdaterPage from './components/UpdaterPage';
const thisURL = url.parse(location.href, true); const thisURL = url.parse(location.href, true);
const notifyOnly = thisURL.query.notifyOnly === 'true'; const notifyOnly = thisURL.query.notifyOnly === 'true';
class UpdaterPageContainer extends React.PureComponent { type Props = {
constructor(props) { notifyOnly: boolean;
initialState: State;
};
type State = {
}
class UpdaterPageContainer extends React.PureComponent<Props, State> {
constructor(props: Props) {
super(props); super(props);
this.state = props.initialState; this.state = props.initialState;
} }
@ -143,11 +151,6 @@ class UpdaterPageContainer extends React.PureComponent {
} }
} }
UpdaterPageContainer.propTypes = {
notifyOnly: propTypes.bool,
initialState: propTypes.object,
};
ReactDOM.render( ReactDOM.render(
<UpdaterPageContainer <UpdaterPageContainer
notifyOnly={notifyOnly} notifyOnly={notifyOnly}

8
src/types/appState.ts Normal file
View file

@ -0,0 +1,8 @@
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
export type AppState = {
lastAppVersion?: string;
skippedVersion?: string;
updateCheckedDate?: string;
};

4
src/types/args.ts Normal file
View file

@ -0,0 +1,4 @@
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
export type Args = typeof global.args;

9
src/types/auth.ts Normal file
View file

@ -0,0 +1,9 @@
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {AuthenticationResponseDetails, AuthInfo} from 'electron/common';
export type LoginModalData = {
request: AuthenticationResponseDetails;
authInfo: AuthInfo;
}

14
src/types/certificate.ts Normal file
View file

@ -0,0 +1,14 @@
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Certificate} from 'electron/common';
export type ComparableCertificate = {
data: string;
issuerName: string;
}
export type CertificateModalData = {
url: string;
list: Certificate[];
}

81
src/types/config.ts Normal file
View file

@ -0,0 +1,81 @@
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
export type Team = {
name: string;
url: string;
order: number;
}
export type TeamWithIndex = Team & {index: number};
export type Config = ConfigV2;
export type ConfigV2 = {
version: 2;
teams: Team[];
showTrayIcon: boolean;
trayIconTheme: string;
minimizeToTray: boolean;
notifications: {
flashWindow: number;
bounceIcon: boolean;
bounceIconType: 'critical' | 'informational';
};
showUnreadBadge: boolean;
useSpellChecker: boolean;
enableHardwareAcceleration: boolean;
autostart: boolean;
spellCheckerLocale: string;
darkMode: boolean;
downloadLocation: string;
}
export type ConfigV1 = {
version: 1;
teams: Array<{
name: string;
url: string;
}>;
showTrayIcon: boolean;
trayIconTheme: string;
minimizeToTray: boolean;
notifications: {
flashWindow: number;
bounceIcon: boolean;
bounceIconType: 'critical' | 'informational';
};
showUnreadBadge: boolean;
useSpellChecker: boolean;
enableHardwareAcceleration: boolean;
autostart: boolean;
spellCheckerLocale: string;
}
export type ConfigV0 = {version: 0; url: string};
export type AnyConfig = ConfigV2 | ConfigV1 | ConfigV0;
export type BuildConfig = {
defaultTeams?: Team[];
helpLink: string;
enableServerManagement: boolean;
enableAutoUpdater: boolean;
managedResources: string[];
}
export type RegistryConfig = {
teams: Team[];
enableServerManagement: boolean;
enableAutoUpdater: boolean;
}
export type CombinedConfig = ConfigV2 & BuildConfig & {
registryTeams: Team[];
appName: string;
}
export type LocalConfiguration = Config & {
appName: string;
enableServerManagement: boolean;
}

5
src/types/external/file-types.d.ts vendored Normal file
View file

@ -0,0 +1,5 @@
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
declare module '*.mp3';
declare module '*.svg';

Some files were not shown because too many files have changed in this diff Show more