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"
- restore_cache:
key: lint-{{ arch }}-{{ .Branch }}-{{ checksum "package-lock.json" }}
- run: npm run check-types
- run: ELECTRON_DISABLE_SANDBOX=1 xvfb-run npm run test
- run: mkdir -p /tmp/test-results
- run: cp test-results.xml /tmp/test-results/

View file

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

View file

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

22541
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:renderer": "webpack-dev-server --config webpack.config.renderer.js",
"test": "npm-run-all lint:js test:unit test:e2e",
"test:e2e": "npm-run-all test:e2e:build test:e2e:run",
"test:e2e:build": "cross-env NODE_ENV=test npm run build",
"test:e2e:run": "cross-env NODE_ENV=test electron-mocha -r @babel/register --reporter mocha-circleci-reporter --recursive test/specs",
"test:e2e": "cross-env NODE_ENV=test npm-run-all build test:e2e:build test:e2e:run",
"test:e2e:build": "webpack-cli --bail --config webpack.config.test.js",
"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: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",
@ -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: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",
"lint:js": "eslint --ignore-path .gitignore --ignore-pattern node_modules --ext .js --ext .jsx .",
"lint:js-quiet": "eslint --ignore-path .gitignore --ignore-pattern node_modules --ext .js --ext .jsx . --quiet",
"fix:js": "eslint --ignore-path .gitignore --ignore-pattern node_modules --quiet --ext .js --ext .jsx . --fix",
"check-build-config": "node -r @babel/register scripts/check_build_config.js"
"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 --ext .ts --ext .tsx . --quiet",
"fix:js": "eslint --ignore-path .gitignore --ignore-pattern node_modules --quiet --ext .js --ext .jsx --ext .ts --ext .tsx . --fix",
"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": {
"@babel/cli": "^7.14.5",
"@babel/core": "^7.2.0",
"@babel/plugin-proposal-class-properties": "^7.8.3",
"@babel/plugin-proposal-object-rest-spread": "^7.2.0",
"@babel/preset-env": "^7.2.0",
"@babel/preset-react": "^7.10.4",
"@babel/register": "^7.0.0",
"@storybook/addon-actions": "^4.0.11",
"@storybook/react": "^4.0.11",
"@typescript-eslint/eslint-plugin": "4.15.0",
"@typescript-eslint/parser": "4.15.0",
"@storybook/addon-actions": "^6.2.9",
"@storybook/react": "^6.2.9",
"@types/auto-launch": "^5.0.1",
"@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",
"awesome-node-loader": "^1.1.1",
"babel-eslint": "^10.0.3",
@ -91,12 +104,14 @@
"file-loader": "^2.0.0",
"image-webpack-loader": "5.0.0",
"mdi-react": "^6.2.0",
"mini-css-extract-plugin": "1.6.0",
"mocha": "^5.2.0",
"mocha-circleci-reporter": "0.0.3",
"npm-run-all": "^4.1.5",
"shebang-loader": "^0.0.1",
"spectron": "^14.0.0",
"style-loader": "^0.23.1",
"typescript": "4.1.3",
"typescript": "^4.3.4",
"url-loader": "^1.1.2",
"webpack": "^4.44.2",
"webpack-cli": "^3.1.2",
@ -108,7 +123,7 @@
"auto-launch": "^5.0.5",
"bootstrap": "^3.3.7",
"brace-expansion": "^2.0.0",
"classnames": "^2.2.6",
"classnames": "^2.3.1",
"electron-context-menu": "^2.5.0",
"electron-devtools-installer": "^3.1.1",
"electron-is-dev": "^2.0.0",
@ -116,9 +131,9 @@
"electron-updater": "4.3.8",
"font-awesome": "^4.7.0",
"prop-types": "^15.6.2",
"react": "^16.6.3",
"react": "^16.14.0",
"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-transition-group": "^2.5.0",
"semver": "^5.5.0",

View file

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

View file

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

View file

@ -6,6 +6,8 @@ import {EventEmitter} from 'events';
import log from 'electron-log';
import WindowsRegistry from 'winreg-utf8';
import {RegistryConfig as RegistryConfigType, Team} from 'types/config';
const REGISTRY_HIVE_LIST = [WindowsRegistry.HKLM, WindowsRegistry.HKCU];
const BASE_REGISTRY_KEY_PATH = '\\Software\\Policies\\Mattermost';
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
*/
export default class RegistryConfig extends EventEmitter {
initialized: boolean;
data: Partial<RegistryConfigType>;
constructor() {
super();
this.initialized = false;
@ -33,7 +38,7 @@ export default class RegistryConfig extends EventEmitter {
try {
const servers = await this.getServersListFromRegistry();
if (servers.length) {
this.data.teams.push(...servers);
this.data.teams!.push(...servers);
}
} catch (error) {
log.warn('[RegistryConfig] Nothing retrieved for \'DefaultServerList\'', error);
@ -70,12 +75,12 @@ export default class RegistryConfig extends EventEmitter {
*/
async getServersListFromRegistry() {
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) {
servers.push({
name: server.name,
url: server.value,
order: server.order || index,
name: (server as WindowsRegistry.RegistryItem).name,
url: (server as WindowsRegistry.RegistryItem).value,
order: index,
});
}
return servers;
@ -106,7 +111,7 @@ export default class RegistryConfig extends EventEmitter {
* @param {string} key Path to the registry key to return
* @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 = [];
for (const hive of REGISTRY_HIVE_LIST) {
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 {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});
return new Promise((resolve, reject) => {
return new Promise<string | WindowsRegistry.RegistryItem[] | undefined>((resolve, reject) => {
try {
registry.values((error, results) => {
registry.values((error: Error, results: WindowsRegistry.RegistryItem[]) => {
if (error || !results || results.length === 0) {
resolve();
resolve(undefined);
return;
}
if (name) { // looking for a single entry value
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
resolve(results);
}

View file

@ -2,6 +2,8 @@
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// 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
/**
@ -17,7 +19,7 @@
* when "enableServerManagement is set to false
* @prop {[]} managedResources - Defines which paths are managed
*/
const buildConfig = {
const buildConfig: BuildConfig = {
defaultTeams: [/*
{
name: 'example',

View file

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

View file

@ -9,11 +9,21 @@ import {EventEmitter} from 'events';
import {ipcMain, nativeTheme, app} from 'electron';
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 defaultPreferences from './defaultPreferences';
import * as Validator from '../../main/Validator';
import defaultPreferences, {getDefaultDownloadLocation} from './defaultPreferences';
import upgradeConfigData from './upgradePreferences';
import buildConfig from './buildConfig';
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
*/
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();
this.configFilePath = configFilePath;
}
// separating constructor from init so main can setup event listeners
init = () => {
init = (): void => {
this.registryConfig = new RegistryConfig();
this.registryConfig.once(REGISTRY_READ_EVENT, this.loadRegistry);
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
*/
loadRegistry = (registryData) => {
loadRegistry = (registryData: Partial<RegistryConfigType>): void => {
this.registryConfigData = registryData;
this.reload();
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 {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.buildConfigData = this.loadBuildConfigData();
this.localConfigData = this.loadLocalConfigFile();
this.localConfigData = this.checkForConfigUpdates(this.localConfigData);
const loadedConfig = this.loadLocalConfigFile();
this.localConfigData = this.checkForConfigUpdates(loadedConfig);
this.regenerateCombinedConfigData();
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 {*} data value to save for provided key
*/
set = (key, data) => {
if (key) {
this.localConfigData[key] = data;
set = (key: keyof ConfigType, data: ConfigType[keyof ConfigType]): void => {
if (key && this.localConfigData) {
this.localConfigData = Object.assign({}, this.localConfigData, {[key]: data});
this.regenerateCombinedConfigData();
this.saveLocalConfigData();
}
@ -89,13 +110,9 @@ export default class Config extends EventEmitter {
*
* @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) {
properties.forEach(({key, data}) => {
if (key) {
this.localConfigData[key] = data;
}
});
this.localConfigData = Object.assign({}, this.localConfigData, ...properties.map(({key, data}) => ({[key]: data})));
this.regenerateCombinedConfigData();
this.saveLocalConfigData();
}
@ -103,7 +120,7 @@ export default class Config extends EventEmitter {
return this.localConfigData; //this is the only part that changes
}
setRegistryConfigData = (registryConfigData = {teams: []}) => {
setRegistryConfigData = (registryConfigData = {teams: []}): void => {
this.registryConfigData = Object.assign({}, registryConfigData);
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
*/
replace = (configData) => {
replace = (configData: ConfigType) => {
const newConfigData = configData;
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 {error} emitted if saving local config data to file fails
*/
saveLocalConfigData = () => {
saveLocalConfigData = (): void => {
if (!this.localConfigData) {
return;
}
try {
this.writeFile(this.configFilePath, this.localConfigData, (error) => {
this.writeFile(this.configFilePath, this.localConfigData, (error: NodeJS.ErrnoException | null) => {
if (error) {
throw new Error(error);
throw new Error(error.message);
}
this.emit('update', this.combinedData);
this.emit('synchronize');
@ -149,13 +170,13 @@ export default class Config extends EventEmitter {
return this.combinedData;
}
get localData() {
return this.localConfigData;
return this.localConfigData ?? defaultPreferences;
}
get defaultData() {
return this.defaultConfigData;
return this.defaultConfigData ?? defaultPreferences;
}
get buildData() {
return this.buildConfigData;
return this.buildConfigData ?? buildConfig;
}
get registryData() {
return this.registryConfigData;
@ -164,52 +185,55 @@ export default class Config extends EventEmitter {
// convenience getters
get version() {
return this.combinedData.version;
return this.combinedData?.version ?? defaultPreferences.version;
}
get teams() {
return this.combinedData.teams;
return this.combinedData?.teams ?? defaultPreferences.teams;
}
get darkMode() {
return this.combinedData.darkMode;
return this.combinedData?.darkMode ?? defaultPreferences.darkMode;
}
get localTeams() {
return this.localConfigData.teams;
return this.localConfigData?.teams ?? defaultPreferences.version;
}
get predefinedTeams() {
return [...this.buildConfigData.defaultTeams, ...this.registryConfigData.teams];
return [...this.buildConfigData?.defaultTeams ?? [], ...this.registryConfigData?.teams ?? []];
}
get enableHardwareAcceleration() {
return this.combinedData.enableHardwareAcceleration;
return this.combinedData?.enableHardwareAcceleration ?? defaultPreferences.enableHardwareAcceleration;
}
get enableServerManagement() {
return this.combinedData.enableServerManagement;
return this.combinedData?.enableServerManagement ?? buildConfig.enableServerManagement;
}
get enableAutoUpdater() {
return this.combinedData.enableAutoUpdater;
return this.combinedData?.enableAutoUpdater ?? buildConfig.enableAutoUpdater;
}
get autostart() {
return this.combinedData.autostart;
return this.combinedData?.autostart ?? defaultPreferences.autostart;
}
get notifications() {
return this.combinedData.notifications;
return this.combinedData?.notifications ?? defaultPreferences.notifications;
}
get showUnreadBadge() {
return this.combinedData.showUnreadBadge;
return this.combinedData?.showUnreadBadge ?? defaultPreferences.showUnreadBadge;
}
get useSpellChecker() {
return this.combinedData.useSpellChecker;
return this.combinedData?.useSpellChecker ?? defaultPreferences.useSpellChecker;
}
get spellCheckerLocale() {
return this.combinedData.spellCheckerLocale;
return this.combinedData?.spellCheckerLocale ?? defaultPreferences.spellCheckerLocale;
}
get showTrayIcon() {
return this.combinedData.showTrayIcon;
return this.combinedData?.showTrayIcon ?? defaultPreferences.showTrayIcon;
}
get trayIconTheme() {
return this.combinedData.trayIconTheme;
return this.combinedData?.trayIconTheme ?? defaultPreferences.trayIconTheme;
}
get downloadLocation() {
return this.combinedData?.downloadLocation ?? getDefaultDownloadLocation();
}
get helpLink() {
return this.combinedData.helpLink;
return this.combinedData?.helpLink;
}
// 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
*/
loadLocalConfigFile = () => {
let configData = {};
loadLocalConfigFile = (): AnyConfig => {
let configData: AnyConfig;
try {
configData = this.readFileSync(this.configFilePath);
// validate based on config file version
if (configData.version > 1) {
configData = Validator.validateV2ConfigData(configData);
} else {
switch (configData.version) {
case 2:
configData = Validator.validateV2ConfigData(configData)!;
break;
case 1:
configData = Validator.validateV1ConfigData(configData);
configData = Validator.validateV1ConfigData(configData)!;
break;
default:
configData = Validator.validateV0ConfigData(configData);
}
configData = Validator.validateV0ConfigData(configData)!;
}
if (!configData) {
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.');
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);
}
return configData;
@ -271,8 +288,9 @@ export default class Config extends EventEmitter {
*
* @param {*} data locally stored data
*/
checkForConfigUpdates = (data) => {
checkForConfigUpdates = (data: AnyConfig) => {
let configData = data;
if (this.defaultConfigData) {
try {
if (configData.version !== this.defaultConfigData.version) {
configData = upgradeConfigData(configData);
@ -282,7 +300,9 @@ export default class Config extends EventEmitter {
} catch (error) {
log.error(`Failed to update configuration to version ${this.defaultConfigData.version}.`);
}
return configData;
}
return configData as ConfigType;
}
/**
@ -293,46 +313,45 @@ export default class Config extends EventEmitter {
this.combinedData = Object.assign({}, this.defaultConfigData, this.localConfigData, this.buildConfigData, this.registryConfigData);
// 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
let combinedTeams = [];
// - 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);
}
// - add registry defined teams, if any
if (this.registryConfigData.teams && this.registryConfigData.teams.length) {
if (this.registryConfigData?.teams?.length) {
combinedTeams.push(...this.registryConfigData.teams);
}
// - add locally defined teams only if server management is enabled
if (this.enableServerManagement) {
combinedTeams.push(...this.localConfigData.teams);
if (this.localConfigData && this.enableServerManagement) {
combinedTeams.push(...this.localConfigData.teams || []);
}
combinedTeams = this.filterOutDuplicateTeams(combinedTeams);
combinedTeams = this.sortUnorderedTeams(combinedTeams);
if (this.combinedData) {
this.combinedData.teams = combinedTeams;
this.combinedData.localTeams = this.localConfigData.teams;
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') {
this.combinedData.darkMode = nativeTheme.shouldUseDarkColors;
}
this.combinedData.appName = app.name;
}
}
/**
* Returns the provided list of teams with duplicates filtered out
*
* @param {array} teams array of teams to check for duplicates
*/
filterOutDuplicateTeams = (teams) => {
filterOutDuplicateTeams = (teams: Team[]) => {
let newTeams = teams;
const uniqueURLs = new Set();
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
* @param {array} teams array of teams to check for already defined teams
*/
filterOutPredefinedTeams = (teams) => {
filterOutPredefinedTeams = (teams: Team[]) => {
let newTeams = 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.
* @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
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.
//
const newTeams = mappedTeams.sort((x, y) => {
if (x.team.order == null) {
if (!x.team.order) {
x.team.order = 0;
}
if (y.team.order == null) {
if (!y.team.order) {
y.team.order = 0;
}
@ -390,11 +409,15 @@ export default class Config extends EventEmitter {
// helper functions
readFileSync = (filePath) => {
readFileSync = (filePath: string) => {
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) {
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);
}
writeFileSync = (filePath, config) => {
writeFileSync = (filePath: string, config: Partial<ConfigType>) => {
if (!this.defaultConfigData) {
return;
}
if (config.version !== 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');
}
merge = (base, target) => {
merge = <T, T2>(base: T, target: T2) => {
return Object.assign({}, base, target);
}
copy = (data) => {
copy = <T>(data: T) => {
return Object.assign({}, data);
}
handleGetConfiguration = (event, option) => {
handleGetConfiguration = (event: Electron.IpcMainInvokeEvent, option: keyof CombinedConfig) => {
const config = {...this.combinedData};
if (option) {
return config[option];
@ -432,19 +459,19 @@ export default class Config extends EventEmitter {
return config;
}
handleGetLocalConfiguration = (event, option) => {
const config = {...this.localConfigData};
handleGetLocalConfiguration = (event: Electron.IpcMainInvokeEvent, option: keyof ConfigType) => {
const config: Partial<LocalConfiguration> = {...this.localConfigData};
config.appName = app.name;
config.enableServerManagement = this.combinedData.enableServerManagement;
config.enableServerManagement = this.combinedData?.enableServerManagement;
if (option) {
return config[option];
}
return config;
}
handleUpdateTeams = (event, newTeams) => {
handleUpdateTeams = (event: Electron.IpcMainInvokeEvent, newTeams: Team[]) => {
this.set('teams', newTeams);
return this.combinedData.teams;
return this.combinedData!.teams;
}
/**
@ -452,7 +479,7 @@ export default class Config extends EventEmitter {
* @emits 'darkModeChange'
*/
handleUpdateTheme = () => {
if (this.combinedData.darkMode !== nativeTheme.shouldUseDarkColors) {
if (this.combinedData && this.combinedData.darkMode !== nativeTheme.shouldUseDarkColors) {
this.combinedData.darkMode = nativeTheme.shouldUseDarkColors;
this.emit('darkModeChange', this.combinedData.darkMode);
}
@ -463,6 +490,10 @@ export default class Config extends EventEmitter {
* @emits 'darkModeChange'
*/
toggleDarkModeManually = () => {
if (!this.combinedData) {
return;
}
this.set('darkMode', !this.combinedData.darkMode);
this.emit('darkModeChange', this.combinedData.darkMode);
}

View file

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

View file

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

View file

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

View file

@ -2,7 +2,7 @@
// See LICENSE.txt for license information.
// Copyright (c) 2015-2016 Yuya Ochiai
import electron, {BrowserWindow} from 'electron';
import electron from 'electron';
import {DEVELOPMENT, PRODUCTION} from './constants';
@ -27,26 +27,9 @@ function runMode() {
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;
function shorten(string, max) {
function shorten(string: string, max?: number) {
const maxLength = (max && max >= 4) ? max : DEFAULT_MAX;
if (string.length >= maxLength) {
return `${string.slice(0, maxLength - 3)}...`;
@ -57,6 +40,5 @@ function shorten(string, max) {
export default {
getDisplayBoundaries,
runMode,
browserWindowFromWebContents,
shorten,
};

View file

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

View file

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

View file

@ -7,7 +7,7 @@ import fs from 'fs';
import os from 'os';
import path from 'path';
import {app, dialog} from 'electron';
import {app, BrowserWindow, dialog} from 'electron';
import log from 'electron-log';
@ -15,15 +15,17 @@ const BUTTON_OK = 'OK';
const BUTTON_SHOW_DETAILS = 'Show Details';
const BUTTON_REOPEN = 'Reopen';
function createErrorReport(err) {
function createErrorReport(err: Error) {
// 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` +
`Platform: ${os.type()} ${os.release()} ${os.arch()}\n` +
`${err.stack}`;
}
function openDetachedExternal(url) {
const spawnOption = {detached: true, stdio: 'ignore'};
function openDetachedExternal(url: string) {
const spawnOption = {detached: true, stdio: 'ignore' as any};
switch (process.platform) {
case 'win32':
return spawn('cmd', ['/C', 'start', url], spawnOption);
@ -32,20 +34,21 @@ function openDetachedExternal(url) {
case 'linux':
return spawn('xdg-open', [url], spawnOption);
default:
return null;
return undefined;
}
}
export default class CriticalErrorHandler {
constructor() {
this.mainWindow = null;
}
mainWindow?: BrowserWindow;
setMainWindow(mainWindow) {
setMainWindow(mainWindow: BrowserWindow) {
this.mainWindow = mainWindow;
}
windowUnresponsiveHandler() {
if (!this.mainWindow) {
return;
}
dialog.showMessageBox(this.mainWindow, {
type: 'warning',
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 report = createErrorReport(err);
fs.writeFileSync(file, report.replace(new RegExp('\\n', 'g'), os.EOL));
@ -69,9 +72,11 @@ export default class CriticalErrorHandler {
if (process.platform === 'darwin') {
buttons.reverse();
}
const bindWindow = this.mainWindow && this.mainWindow.isVisible() ? this.mainWindow : null;
if (!this.mainWindow?.isVisible) {
return;
}
dialog.showMessageBox(
bindWindow,
this.mainWindow,
{
type: 'error',
title: app.name,
@ -102,7 +107,7 @@ export default class CriticalErrorHandler {
app.exit(-1);
});
} 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;
}
}

View file

@ -4,9 +4,11 @@
import urlUtils from 'common/utils/url';
export class MattermostServer {
constructor(name, serverUrl) {
name: string;
url: URL;
constructor(name: string, serverUrl: string) {
this.name = name;
this.url = urlUtils.parseURL(serverUrl);
this.url = urlUtils.parseURL(serverUrl)!;
if (!this.url) {
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()};
}
sameOrigin = (otherURL) => {
sameOrigin = (otherURL: string) => {
const parsedUrl = urlUtils.parseURL(otherURL);
return parsedUrl && this.url.origin === parsedUrl.origin;
}
equals = (otherServer) => {
equals = (otherServer: MattermostServer) => {
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.
// See LICENSE.txt for license information.
import {Args} from 'types/args';
import yargs from 'yargs';
import {protocols} from '../../electron-builder.json';
import * as Validator from './Validator';
export default function parse(args) {
export default function parse(args: string[]) {
return validateArgs(parseArgs(triageArgs(args)));
}
function triageArgs(args) {
function triageArgs(args: string[]) {
// ensure any args following a possible deeplink are discarded
if (protocols && protocols[0] && protocols[0].schemes && protocols[0].schemes[0]) {
const scheme = protocols[0].schemes[0].toLowerCase();
@ -23,7 +24,7 @@ function triageArgs(args) {
return args;
}
function parseArgs(args) {
function parseArgs(args: string[]) {
return yargs.
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.').
@ -32,6 +33,6 @@ function parseArgs(args) {
parse(args);
}
function validateArgs(args) {
function validateArgs(args: Args) {
return Validator.validateArgs(args) || {};
}

View file

@ -1,7 +1,7 @@
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import EventEmitter from 'events';
import {EventEmitter} from 'events';
import electron from 'electron';
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
*/
export default class UserActivityMonitor extends EventEmitter {
isActive: boolean;
idleTime: number;
lastSetActive?: number;
systemIdleTimeIntervalID: number;
config: {
updateFrequencyMs: number;
inactiveThresholdMs: number;
statusUpdateThresholdMs: number;
};
constructor() {
super();
this.isActive = true;
this.idleTime = 0;
this.lastSetActive = null;
this.systemIdleTimeIntervalID = -1;
this.config = {
@ -58,13 +67,14 @@ export default class UserActivityMonitor extends EventEmitter {
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(() => {
try {
this.updateIdleTime(electron.powerMonitor.getSystemIdleTime());
} catch (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
* @private
*/
updateIdleTime(idleTime) {
updateIdleTime(idleTime: number) {
this.idleTime = idleTime;
if (idleTime * 1000 > this.config.inactiveThresholdMs) { // eslint-disable-line no-magic-numbers
this.setActivityState(false);
@ -110,7 +120,7 @@ export default class UserActivityMonitor extends EventEmitter {
this.sendStatusUpdate(false);
this.lastSetActive = now;
} 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 {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';
const defaultOptions = {
@ -14,14 +21,14 @@ const defaultWindowHeight = 700;
const minWindowWidth = 400;
const minWindowHeight = 240;
const argsSchema = Joi.object({
const argsSchema = Joi.object<Args>({
hidden: Joi.boolean(),
disableDevMode: Joi.boolean(),
dataDir: Joi.string(),
version: Joi.boolean(),
});
const boundsInfoSchema = Joi.object({
const boundsInfoSchema = Joi.object<SavedWindowState>({
x: Joi.number().integer().default(0),
y: Joi.number().integer().default(0),
width: Joi.number().integer().min(minWindowWidth).required().default(defaultWindowWidth),
@ -30,17 +37,17 @@ const boundsInfoSchema = Joi.object({
fullscreen: Joi.boolean().default(false),
});
const appStateSchema = Joi.object({
const appStateSchema = Joi.object<AppState>({
lastAppVersion: Joi.string(),
skippedVersion: Joi.string(),
updateCheckedDate: Joi.string(),
});
const configDataSchemaV0 = Joi.object({
const configDataSchemaV0 = Joi.object<ConfigV0>({
url: Joi.string().required(),
});
const configDataSchemaV1 = Joi.object({
const configDataSchemaV1 = Joi.object<ConfigV1>({
version: Joi.number().min(1).default(1),
teams: Joi.array().items(Joi.object({
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'),
});
const configDataSchemaV2 = Joi.object({
const configDataSchemaV2 = Joi.object<ConfigV2>({
version: Joi.number().min(2).default(2),
teams: Joi.array().items(Joi.object({
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'};
const certificateStoreSchema = Joi.object().pattern(
Joi.string().uri(),
Joi.object({
Joi.object<ComparableCertificate>({
data: 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
});
@ -108,27 +115,27 @@ const trustedOriginsSchema = Joi.object({}).pattern(
const allowedProtocolsSchema = Joi.array().items(Joi.string().regex(/^[a-z-]+:$/i));
// validate bounds_info.json
export function validateArgs(data) {
export function validateArgs(data: Args) {
return validateAgainstSchema(data, argsSchema);
}
// validate bounds_info.json
export function validateBoundsInfo(data) {
export function validateBoundsInfo(data: SavedWindowState) {
return validateAgainstSchema(data, boundsInfoSchema);
}
// validate app_state.json
export function validateAppState(data) {
export function validateAppState(data: AppState) {
return validateAgainstSchema(data, appStateSchema);
}
// validate v.0 config.json
export function validateV0ConfigData(data) {
export function validateV0ConfigData(data: ConfigV0) {
return validateAgainstSchema(data, configDataSchemaV0);
}
// validate v.1 config.json
export function validateV1ConfigData(data) {
export function validateV1ConfigData(data: ConfigV1) {
if (Array.isArray(data.teams) && data.teams.length) {
// first replace possible backslashes with forward slashes
let teams = data.teams.map(({name, url}) => {
@ -148,7 +155,7 @@ export function validateV1ConfigData(data) {
return validateAgainstSchema(data, configDataSchemaV1);
}
export function validateV2ConfigData(data) {
export function validateV2ConfigData(data: ConfigV2) {
if (Array.isArray(data.teams) && data.teams.length) {
// first replace possible backslashes with forward slashes
let teams = data.teams.map(({name, url, order}) => {
@ -169,39 +176,39 @@ export function validateV2ConfigData(data) {
}
// validate certificate.json
export function validateCertificateStore(data) {
export function validateCertificateStore(data: string | Record<string, ComparableCertificate>) {
const jsonData = (typeof data === 'object' ? data : JSON.parse(data));
return validateAgainstSchema(jsonData, certificateStoreSchema);
}
// validate allowedProtocols.json
export function validateAllowedProtocols(data) {
export function validateAllowedProtocols(data: string[]) {
return validateAgainstSchema(data, allowedProtocolsSchema);
}
export function validateTrustedOriginsStore(data) {
const jsonData = (typeof data === 'object' ? data : JSON.parse(data));
export function validateTrustedOriginsStore(data: string | Record<PermissionType, TrustedOrigin>) {
const jsonData: Record<PermissionType, TrustedOrigin> = (typeof data === 'object' ? data : JSON.parse(data));
return validateAgainstSchema(jsonData, trustedOriginsSchema);
}
export function validateOriginPermissions(data) {
const jsonData = (typeof data === 'object' ? data : JSON.parse(data));
export function validateOriginPermissions(data: string | TrustedOrigin) {
const jsonData: TrustedOrigin = (typeof data === 'object' ? data : JSON.parse(data));
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') {
log.error(`Input 'data' is not an object we can validate: ${typeof data}`);
return false;
return null;
}
if (!schema) {
log.error('No schema provided to validate');
return false;
return null;
}
const {error, value} = schema.validate(data, defaultOptions);
if (error) {
log.error(`Validation failed due to: ${error}`);
return false;
return null;
}
return value;
}

View file

@ -16,9 +16,9 @@ import * as Validator from './Validator';
import {getMainWindow} from './windows/windowManager';
const allowedProtocolFile = path.resolve(app.getPath('userData'), 'allowedProtocols.json');
let allowedProtocols = [];
let allowedProtocols: string[] = [];
function addScheme(scheme) {
function addScheme(scheme: string) {
const proto = `${scheme}:`;
if (!allowedProtocols.includes(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) {
shell.openExternal(URL);
return;
}
dialog.showMessageBox(getMainWindow(), {
const mainWindow = getMainWindow();
if (!mainWindow) {
return;
}
dialog.showMessageBox(mainWindow, {
title: 'Non http(s) protocol',
message: `${protocol} link requires an external application.`,
detail: `The requested link is ${URL} . Do you want to continue?`,
@ -63,7 +67,7 @@ function handleDialogEvent(protocol, URL) {
switch (response) {
case 1: {
allowedProtocols.push(protocol);
function handleError(err) {
function handleError(err: NodeJS.ErrnoException | null) {
if (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';
const status = {
unreads: new Map(),
mentions: new Map(),
expired: new Map(),
unreads: new Map<string, boolean>(),
mentions: new Map<string, number>(),
expired: new Map<string, boolean>(),
emitter: new events.EventEmitter(),
};
const emitMentions = (serverName) => {
const emitMentions = (serverName: string) => {
const newMentions = getMentions(serverName);
const newUnreads = getUnreads(serverName);
const isExpired = getIsExpired(serverName);
@ -24,11 +24,11 @@ const emitMentions = (serverName) => {
emitStatus();
};
const emitTray = (expired, mentions, unreads) => {
const emitTray = (expired?: boolean, mentions?: number, unreads?: boolean) => {
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);
};
@ -40,7 +40,7 @@ export const emitStatus = () => {
emitBadge(expired, mentions, unreads);
};
export const updateMentions = (serverName, mentions, unreads) => {
export const updateMentions = (serverName: string, mentions: number, unreads?: boolean) => {
if (typeof unreads !== 'undefined') {
status.unreads.set(serverName, Boolean(unreads));
}
@ -48,20 +48,20 @@ export const updateMentions = (serverName, mentions, unreads) => {
emitMentions(serverName);
};
export const updateUnreads = (serverName, unreads) => {
export const updateUnreads = (serverName: string, unreads: boolean) => {
status.unreads.set(serverName, Boolean(unreads));
emitMentions(serverName);
};
export const getUnreads = (serverName) => {
export const getUnreads = (serverName: string) => {
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.
};
export const getIsExpired = (serverName) => {
export const getIsExpired = (serverName: string) => {
return status.expired.get(serverName) || false;
};
@ -101,11 +101,11 @@ export const anyExpired = () => {
};
// 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);
};
export const setSessionExpired = (serverName, expired) => {
export const setSessionExpired = (serverName: string, expired: boolean) => {
const isExpired = Boolean(expired);
const old = status.expired.get(serverName);
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.
// 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 {app, BrowserWindow, dialog, ipcMain, shell} from 'electron';
import {app, BrowserWindow, BrowserWindowConstructorOptions, dialog, ipcMain, IpcMainEvent, shell} from 'electron';
import log from 'electron-log';
import {autoUpdater, CancellationToken} from 'electron-updater';
@ -18,18 +22,18 @@ autoUpdater.log.transports.file.level = 'info';
let updaterModal = null;
function createEventListener(win, eventName) {
return (event) => {
function createEventListener(win: BrowserWindow, eventName: string) {
return (event: IpcMainEvent) => {
if (event.sender === win.webContents) {
win.emit(eventName);
}
};
}
function createUpdaterModal(parentWindow, options) {
function createUpdaterModal(parentWindow: BrowserWindow, options: {linuxAppIcon: string; notifyOnly: boolean}) {
const windowWidth = 480;
const windowHeight = 280;
const windowOptions = {
const windowOptions: BrowserWindowConstructorOptions = {
title: `${app.name} Updater`,
parent: parentWindow,
modal: true,
@ -67,7 +71,7 @@ function createUpdaterModal(parentWindow, options) {
return modal;
}
function isUpdateApplicable(now, skippedVersion, updateInfo) {
function isUpdateApplicable(now: Date, skippedVersion, updateInfo) {
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
@ -83,7 +87,7 @@ function isUpdateApplicable(now, skippedVersion, updateInfo) {
return true;
}
function downloadAndInstall(cancellationToken) {
function downloadAndInstall(cancellationToken?: CancellationToken) {
autoUpdater.on('update-downloaded', () => {
global.willAppQuit = true;
autoUpdater.quitAndInstall();
@ -150,7 +154,7 @@ function initialize(appState, mainWindow, notifyOnly = false) {
});
}
function shouldCheckForUpdatesOnStart(updateCheckedDate) {
function shouldCheckForUpdatesOnStart(updateCheckedDate: Date) {
if (updateCheckedDate) {
if (Date.now() - updateCheckedDate.getTime() < UPDATER_INTERVAL_IN_MS) {
return false;
@ -167,6 +171,8 @@ function checkForUpdates(isManual = false) {
}
class AutoUpdaterConfig {
data: {notifyOnly?: boolean};
constructor() {
this.data = {};
}

View file

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

View file

@ -1,6 +1,9 @@
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import log from 'electron-log';
import {Certificate, WebContents} from 'electron';
import {CertificateModalData} from 'types/certificate';
import * as WindowManager from './windows/windowManager';
@ -10,12 +13,18 @@ import {getLocalURLString, getLocalPreload} from './utils';
const modalPreload = getLocalPreload('modalPreload.js');
const html = getLocalURLString('certificateModal.html');
type CertificateModalResult = {
cert: Certificate;
}
export class CertificateManager {
certificateRequestCallbackMap: Map<string, (certificate?: Certificate | undefined) => void>;
constructor() {
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) {
event.preventDefault(); // prevent the app from getting the first certificate available
@ -27,8 +36,13 @@ export class CertificateManager {
}
}
popCertificateModal = (url, list) => {
const modalPromise = addModal(`certificate-${url}`, html, modalPreload, {url, list}, WindowManager.getMainWindow());
popCertificateModal = (url: string, list: Certificate[]) => {
const mainWindow = WindowManager.getMainWindow();
if (!mainWindow) {
return;
}
const modalPromise = addModal<CertificateModalData, CertificateModalResult>(`certificate-${url}`, html, modalPreload, {url, list}, mainWindow);
if (modalPromise) {
modalPromise.then((data) => {
const {cert} = data;
this.handleSelectedCertificate(url, cert);
@ -39,8 +53,9 @@ export class CertificateManager {
this.handleSelectedCertificate(url);
});
}
}
handleSelectedCertificate = (server, cert) => {
handleSelectedCertificate = (server: string, cert?: Certificate) => {
const callback = this.certificateRequestCallbackMap.get(server);
if (!callback) {
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.
// 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';
const defaultMenuOptions = {
shouldShowMenu: (e, p) => {
shouldShowMenu: (e: Event, p: ContextMenuParams) => {
const isInternalLink = p.linkURL.endsWith('#') && p.linkURL.slice(0, -1) === p.pageURL;
let isInternalSrc;
try {
const srcurl = urlUtils.parseURL(p.srcURL);
isInternalSrc = srcurl.protocol === 'file:';
isInternalSrc = srcurl?.protocol === 'file:';
} catch (err) {
isInternalSrc = false;
}
@ -27,8 +28,12 @@ const defaultMenuOptions = {
};
export default class ContextMenu {
constructor(options, view) {
const providedOptions = options || {};
view: BrowserWindow | BrowserView;
menuOptions: Options;
menuDispose?: () => void;
constructor(options: Options, view: BrowserWindow | BrowserView) {
const providedOptions: Options = options || {};
this.menuOptions = Object.assign({}, defaultMenuOptions, providedOptions);
this.view = view;
@ -39,7 +44,7 @@ export default class ContextMenu {
dispose = () => {
if (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
* @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);
}
}

View file

@ -1,16 +1,16 @@
// Copyright (c) 2015-2016 Yuya Ochiai
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {app} from 'electron';
import {app, Session} from 'electron';
import log from 'electron-log';
function flushCookiesStore(session) {
function flushCookiesStore(session: Session) {
session.cookies.flushStore().catch((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.
// So manually flush cookie store to disk on closing the app.
// 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.
// See LICENSE.txt for license information.
// Copyright (c) 2015-2016 Yuya Ochiai
/* eslint-disable max-lines */
import fs from 'fs';
import path from 'path';
import electron from 'electron';
import electron, {BrowserWindow, IpcMainEvent, IpcMainInvokeEvent, Rectangle} from 'electron';
import isDev from 'electron-is-dev';
import installExtension, {REACT_DEVELOPER_TOOLS} from 'electron-devtools-installer';
import log from 'electron-log';
import 'airbnb-js-shims/target/es2015';
import Utils from 'common/utils/util';
import urlUtils from 'common/utils/url';
import {Team} from 'types/config';
import {MentionData} from 'types/notification';
import {Boundaries} from 'types/utils';
import {
SWITCH_SERVER,
@ -33,6 +38,10 @@ import {
} from 'common/communication';
import Config from 'common/config';
import Utils from 'common/utils/util';
import urlUtils from 'common/utils/url';
import {protocols} from '../../electron-builder.json';
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
// be closed automatically when the JavaScript object is garbage collected.
let certificateStore = null;
let trustedOriginsStore = null;
let scheme = null;
let certificateStore: CertificateStore;
let trustedOriginsStore;
let scheme: string;
let appVersion = null;
let config = null;
let authManager = null;
let certificateManager = null;
let config: Config;
let authManager: AuthManager;
let certificateManager: CertificateManager;
/**
* Main entry point for the application, ensures that everything initializes in the proper order
@ -140,7 +149,7 @@ function initializeArgs() {
}
async function initializeConfig() {
const loadConfig = new Promise((resolve) => {
const loadConfig = new Promise<void>((resolve) => {
config = new Config(app.getPath('userData') + '/config.json');
config.once('update', (configData) => {
config.on('update', handleConfigUpdate);
@ -169,7 +178,11 @@ function initializeAppEventListeners() {
}
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.load();
@ -196,7 +209,7 @@ function initializeBeforeAppReady() {
allowProtocolDialog.init();
authManager = new AuthManager(config, trustedOriginsStore);
authManager = new AuthManager(config.data, trustedOriginsStore);
certificateManager = new CertificateManager();
if (isDev) {
@ -237,7 +250,10 @@ function initializeInterCommunicationEventListeners() {
// config event handlers
//
function handleConfigUpdate(newConfig) {
function handleConfigUpdate(newConfig: Config) {
if (!newConfig.data) {
return;
}
if (process.platform === 'win32' || process.platform === 'linux') {
const appLauncher = new AutoLauncher();
const autoStartTask = config.autostart ? appLauncher.enable() : appLauncher.disable();
@ -247,6 +263,7 @@ function handleConfigUpdate(newConfig) {
log.error('error:', err);
});
WindowManager.setConfig(newConfig.data);
authManager.handleConfigUpdate(newConfig.data);
setUnreadBadgeSetting(newConfig.data && newConfig.data.showUnreadBadge);
}
@ -254,6 +271,10 @@ function handleConfigUpdate(newConfig) {
}
function handleConfigSynchronize() {
if (!config.data) {
return;
}
// TODO: send this to server manager
WindowManager.setConfig(config.data);
setUnreadBadgeSetting(config.data.showUnreadBadge);
@ -267,7 +288,7 @@ function handleConfigSynchronize() {
function handleReloadConfig() {
config.reload();
WindowManager.setConfig(config.data);
WindowManager.setConfig(config.data!);
}
function handleAppVersion() {
@ -277,7 +298,7 @@ function handleAppVersion() {
};
}
function handleDarkModeChange(darkMode) {
function handleDarkModeChange(darkMode: boolean) {
refreshTrayImages(config.trayIconTheme);
WindowManager.sendToRenderer(DARK_MODE_CHANGE, darkMode);
WindowManager.updateLoadingScreenDarkMode(darkMode);
@ -288,11 +309,13 @@ function handleDarkModeChange(darkMode) {
//
// activate first app instance, subsequent instances will quit themselves
function handleAppSecondInstance(event, argv) {
function handleAppSecondInstance(event: Event, argv: string[]) {
// Protocol handler for win32
// argv: An array of the second instances (command line / deep linked) arguments
const deeplinkingUrl = getDeeplinkingURL(argv);
if (deeplinkingUrl) {
openDeepLink(deeplinkingUrl);
}
}
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
resizeScreen(electron.screen, newWindow);
resizeScreen(newWindow);
}
function handleAppActivate() {
@ -318,18 +341,18 @@ function handleAppBeforeQuit() {
global.willAppQuit = true;
}
function handleQuit(e, reason, stack) {
function handleQuit(e: IpcMainEvent, reason: string, stack: string) {
log.error(`Exiting App. Reason: ${reason}`);
log.info(`Stacktrace:\n${stack}`);
handleAppBeforeQuit();
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);
}
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);
if (!parsedURL) {
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?
const mainWindow = WindowManager.getMainWindow();
if (!mainWindow) {
return;
}
dialog.showMessageBox(mainWindow, {
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.',
@ -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);
}
function handleAppGPUProcessCrashed(event, killed) {
function handleAppGPUProcessCrashed(event: electron.Event, killed: boolean) {
log.error(`The GPU process has crashed (killed = ${killed})`);
}
function openDeepLink(deeplinkingUrl) {
function openDeepLink(deeplinkingUrl: string) {
try {
WindowManager.showMainWindow(deeplinkingUrl);
} catch (err) {
@ -427,7 +453,7 @@ function handleAppWillFinishLaunching() {
});
}
function handleSwitchServer(event, serverName) {
function handleSwitchServer(event: IpcMainEvent, serverName: string) {
WindowManager.switchServer(serverName);
}
@ -436,7 +462,11 @@ function handleNewServerModal() {
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) {
modalPromise.then((data) => {
const teams = config.teams;
@ -506,7 +536,7 @@ function initializeAfterAppReady() {
WindowManager.showSettingsWindow();
}
criticalErrorHandler.setMainWindow(WindowManager.getMainWindow());
criticalErrorHandler.setMainWindow(WindowManager.getMainWindow()!);
// listen for status updates and pass on to renderer
userActivityMonitor.on('status', (status) => {
@ -519,7 +549,7 @@ function initializeAfterAppReady() {
if (shouldShowTrayIcon()) {
setupTray(config.trayIconTheme);
}
setupBadge(config.showUnreadBadge);
setupBadge();
session.defaultSession.on('will-download', (event, item, webContents) => {
const filename = item.getFilename();
@ -533,13 +563,13 @@ function initializeAfterAppReady() {
}
item.setSaveDialogOptions({
title: filename,
defaultPath: path.resolve(config.combinedData.downloadLocation, filename),
defaultPath: path.resolve(config.downloadLocation, filename),
filters,
});
item.on('done', (doneEvent, state) => {
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
//
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);
}
@ -605,23 +635,21 @@ function handleCloseAppMenu() {
WindowManager.focusBrowserView();
}
function handleUpdateMenuEvent(event, menuConfig) {
// TODO: this might make sense to move to window manager? so it updates the window referenced if needed.
const mainWindow = WindowManager.getMainWindow();
function handleUpdateMenuEvent(event: IpcMainEvent, menuConfig: Config) {
const aMenu = appMenu.createMenu(menuConfig);
Menu.setApplicationMenu(aMenu);
aMenu.addListener('menu-will-close', handleCloseAppMenu);
// set up context menu for tray icon
if (shouldShowTrayIcon()) {
const tMenu = trayMenu.createMenu(menuConfig.data);
setTrayMenu(tMenu, mainWindow);
const tMenu = trayMenu.createMenu(menuConfig.data!);
setTrayMenu(tMenu);
}
}
async function handleSelectDownload(event, startFrom) {
async function handleSelectDownload(event: IpcMainInvokeEvent, startFrom: string) {
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,
properties:
['openDirectory', 'createDirectory', 'dontAddToRecent', 'promptToCreate']});
@ -632,7 +660,7 @@ async function handleSelectDownload(event, startFrom) {
// helper functions
//
function getDeeplinkingURL(args) {
function getDeeplinkingURL(args: string[]) {
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)
const url = args[args.length - 1];
@ -640,14 +668,14 @@ function getDeeplinkingURL(args) {
return url;
}
}
return null;
return undefined;
}
function shouldShowTrayIcon() {
return config.showTrayIcon || process.platform === 'win32';
}
function wasUpdated(lastAppVersion) {
function wasUpdated(lastAppVersion?: string) {
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);
if (!startsWithinDisplay) {
return false;
@ -674,7 +702,7 @@ function isWithinDisplay(state, display) {
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
// (e.g. because the screen has been plugged off)
const boundaries = Utils.getDisplayBoundaries();
@ -688,7 +716,7 @@ function getValidWindowPosition(state) {
return {x: state.x, y: state.y};
}
function resizeScreen(screen, browserWindow) {
function resizeScreen(browserWindow: BrowserWindow) {
function handle() {
const position = browserWindow.getPosition();
const size = browserWindow.getSize();

View file

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

View file

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

View file

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

View file

@ -4,6 +4,8 @@
import path from 'path';
import {app, Notification} from 'electron';
import {MentionOptions} from 'types/notification';
import osVersion from 'common/osVersion';
const assetsDir = path.resolve(app.getAppPath(), 'assets');
@ -13,23 +15,28 @@ const defaultOptions = {
title: 'Someone mentioned you',
silent: false,
icon: appIconURL,
urgency: 'normal',
urgency: 'normal' as Notification['urgency'],
};
export const DEFAULT_WIN7 = 'Ding';
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};
if (process.platform === 'darwin') {
// Notification Center shows app's icon, so there were two icons on the notification.
Reflect.deleteProperty(options, 'icon');
}
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) {
options.silent = true;
}
super(options);
this.customSound = customSound;
this.channel = channel;
this.teamId = teamId;

View file

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

View file

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

View file

@ -11,12 +11,12 @@ import * as AppState from '../appState';
const assetsDir = path.resolve(app.getAppPath(), 'assets');
let trayImages;
let trayIcon;
let trayImages: Record<string, Electron.NativeImage>;
let trayIcon: Tray;
let lastStatus = 'normal';
let lastMessage = app.name;
export function refreshTrayImages(trayIconTheme) {
export function refreshTrayImages(trayIconTheme: string) {
const winTheme = nativeTheme.shouldUseDarkColors ? 'dark' : 'light';
switch (process.platform) {
@ -69,7 +69,7 @@ export function refreshTrayImages(trayIconTheme) {
return trayImages;
}
export function setupTray(icontheme) {
export function setupTray(icontheme: string) {
refreshTrayImages(icontheme);
trayIcon = new Tray(trayImages.normal);
if (process.platform === 'darwin') {
@ -103,7 +103,7 @@ export function setupTray(icontheme) {
});
}
function setTray(status, message) {
function setTray(status: string, message: string) {
lastStatus = status;
lastMessage = message;
trayIcon.setImage(trayImages[status]);
@ -116,17 +116,8 @@ export function destroyTray() {
}
}
export function setTrayMenu(tMenu, mainWindow) {
if (process.platform === 'darwin' || process.platform === 'linux') {
// 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"
export function setTrayMenu(tMenu: Electron.Menu) {
if (trayIcon) {
trayIcon.setContextMenu(tMenu);
mainWindow.trayWasVisible = true;
} else {
mainWindow.trayWasVisible = false;
}
} else if (trayIcon) {
trayIcon.setContextMenu(tMenu);
}
}

View file

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

View file

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

View file

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

View file

@ -1,23 +1,24 @@
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// 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 {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
// 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);
if (!foundModal) {
const modalPromise = new Promise((resolve, reject) => {
const mv = new ModalView(key, html, preload, data, resolve, reject, win);
const modalPromise = new Promise((resolve: (value: T2) => void, reject) => {
const mv = new ModalView<T, T2>(key, html, preload, data, resolve, reject, win);
modalQueue.push(mv);
});
@ -34,7 +35,7 @@ ipcMain.handle(RETRIEVE_MODAL_INFO, handleInfoRequest);
ipcMain.on(MODAL_RESULT, handleModalResult);
ipcMain.on(MODAL_CANCEL, handleModalCancel);
function findModalByCaller(event) {
function findModalByCaller(event: IpcMainInvokeEvent) {
if (modalQueue.length) {
const requestModal = modalQueue.find((modal) => {
return (modal.view && modal.view.webContents && modal.view.webContents.id === event.sender.id);
@ -44,7 +45,7 @@ function findModalByCaller(event) {
return null;
}
function handleInfoRequest(event) {
function handleInfoRequest(event: IpcMainInvokeEvent) {
const requestModal = findModalByCaller(event);
if (requestModal) {
return requestModal.handleInfoRequest();
@ -53,12 +54,11 @@ function handleInfoRequest(event) {
}
export function showModal() {
let noWindow;
const withDevTools = process.env.MM_DEBUG_MODALS || false;
modalQueue.forEach((modal, index) => {
if (index === 0) {
WindowManager.sendToRenderer(MODAL_OPEN);
modal.show(noWindow, withDevTools);
modal.show(undefined, Boolean(withDevTools));
} else {
WindowManager.sendToRenderer(MODAL_CLOSE);
modal.hide();
@ -66,7 +66,7 @@ export function showModal() {
});
}
function handleModalResult(event, data) {
function handleModalResult(event: IpcMainEvent, data: unknown) {
const requestModal = findModalByCaller(event);
if (requestModal) {
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);
if (requestModal) {
requestModal.reject(data);

View file

@ -1,18 +1,31 @@
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {BrowserView} from 'electron';
import {BrowserView, BrowserWindow} from 'electron';
import log from 'electron-log';
import ContextMenu from '../contextMenu';
import {getWindowBoundaries} from '../utils';
const ACTIVE = 'active';
const SHOWING = 'showing';
const DONE = 'done';
enum Status {
ACTIVE,
SHOWING,
DONE
}
export class ModalView {
constructor(key, html, preload, data, onResolve, onReject, currentWindow) {
export class ModalView<T, T2> {
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.html = html;
this.data = data;
@ -26,8 +39,8 @@ export class ModalView {
this.onReject = onReject;
this.onResolve = onResolve;
this.window = currentWindow;
this.windowAttached = null;
this.status = ACTIVE;
this.status = Status.ACTIVE;
try {
this.view.webContents.loadURL(this.html);
} catch (e) {
@ -38,7 +51,7 @@ export class ModalView {
this.contextMenu = new ContextMenu({}, this.view);
}
show = (win, withDevTools) => {
show = (win?: BrowserWindow, withDevTools?: boolean) => {
if (this.windowAttached) {
// we'll reatach
this.windowAttached.removeBrowserView(this.view);
@ -53,7 +66,7 @@ export class ModalView {
horizontal: true,
vertical: true,
});
this.status = SHOWING;
this.status = Status.SHOWING;
if (this.view.webContents.isLoading()) {
this.view.webContents.once('did-finish-load', () => {
this.view.webContents.focus();
@ -77,10 +90,12 @@ export class ModalView {
// workaround to eliminate zombie processes
// https://github.com/mattermost/desktop/pull/1519
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
this.view.webContents.destroy();
this.windowAttached = null;
this.status = ACTIVE;
delete this.windowAttached;
this.status = Status.ACTIVE;
}
}
@ -88,21 +103,21 @@ export class ModalView {
return this.data;
}
reject = (data) => {
reject = (data: T2) => {
if (this.onReject) {
this.onReject(data);
}
this.hide();
this.status = DONE;
this.status = Status.DONE;
}
resolve = (data) => {
resolve = (data: T2) => {
if (this.onResolve) {
this.onResolve(data);
}
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.
// See LICENSE.txt for license information.
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 {
@ -26,16 +29,23 @@ const URL_VIEW_DURATION = 10 * SECOND;
const URL_VIEW_HEIGHT = 36;
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.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.currentView = null;
this.urlView = null;
this.mainWindow = mainWindow;
}
updateMainWindow = (mainWindow) => {
updateMainWindow = (mainWindow: BrowserWindow) => {
this.mainWindow = mainWindow;
}
@ -43,7 +53,7 @@ export class ViewManager {
return this.configServers;
}
loadServer = (server) => {
loadServer = (server: Team) => {
const srv = new MattermostServer(server.name, server.url);
const view = new MattermostView(srv, this.mainWindow, this.viewOptions);
this.views.set(server.name, view);
@ -61,7 +71,7 @@ export class ViewManager {
this.configServers.forEach((server) => this.loadServer(server));
}
reloadConfiguration = (configServers) => {
reloadConfiguration = (configServers: Team[]) => {
this.configServers = configServers.concat();
const oldviews = this.views;
this.views = new Map();
@ -72,11 +82,11 @@ export class ViewManager {
if (recycle && recycle.isVisible) {
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);
this.views.set(recycle.name, recycle);
} else {
this.loadServer(server, this.mainWindow);
this.loadServer(server);
}
});
oldviews.forEach((unused) => {
@ -98,12 +108,12 @@ export class ViewManager {
}
}
showByName = (name) => {
showByName = (name: string) => {
const newView = this.views.get(name);
if (newView) {
if (newView.isVisible) {
return;
}
if (newView) {
if (this.currentView && this.currentView !== name) {
const previous = this.getCurrentView();
if (previous) {
@ -116,6 +126,10 @@ export class ViewManager {
this.showLoadingScreen();
}
const serverInfo = this.configServers.find((candidate) => candidate.name === newView.server.name);
if (!serverInfo) {
log.error(`Couldn't find a server in the config with the name ${newView.server.name}`);
return;
}
newView.window.webContents.send(SET_SERVER_KEY, serverInfo.order);
if (newView.isReady()) {
// if view is not ready, the renderer will have something to display instead.
@ -148,18 +162,22 @@ export class ViewManager {
view.focus();
}
}
activateView = (viewName) => {
activateView = (viewName: string) => {
if (this.currentView === viewName) {
this.showByName(this.currentView);
}
const view = this.views.get(viewName);
if (!view) {
log.error(`Couldn't find a view with the name ${viewName}`);
return;
}
addWebContentsEventListeners(view, this.getServers);
}
finishLoading = (server) => {
finishLoading = (server: string) => {
const view = this.views.get(server);
if (view && this.getCurrentView() === view) {
this.showByName(this.currentView);
this.showByName(this.currentView!);
this.fadeLoadingScreen();
}
}
@ -169,19 +187,23 @@ export class ViewManager {
}
getCurrentView() {
if (this.currentView) {
return this.views.get(this.currentView);
}
return undefined;
}
openViewDevTools = () => {
const view = this.getCurrentView();
if (view) {
view.openDevTools({mode: 'detach'});
view.openDevTools();
} else {
log.error(`couldn't find ${this.currentView}`);
}
}
findByWebContent(webContentId) {
findByWebContent(webContentId: number) {
let found = null;
let serverName;
let view;
@ -198,7 +220,7 @@ export class ViewManager {
return found;
}
showURLView = (url) => {
showURLView = (url: URL | string) => {
if (this.urlViewCancel) {
this.urlViewCancel();
}
@ -213,9 +235,8 @@ export class ViewManager {
const query = new Map([['url', urlString]]);
const localURL = getLocalURLString('urlView.html', query);
urlView.webContents.loadURL(localURL);
const currentWindow = this.getCurrentView().window;
currentWindow.addBrowserView(urlView);
const boundaries = currentWindow.getBounds();
this.mainWindow.addBrowserView(urlView);
const boundaries = this.mainWindow.getBounds();
urlView.setBounds({
x: 0,
y: boundaries.height - URL_VIEW_HEIGHT,
@ -224,11 +245,13 @@ export class ViewManager {
});
const hideView = () => {
this.urlViewCancel = null;
currentWindow.removeBrowserView(urlView);
delete this.urlViewCancel;
this.mainWindow.removeBrowserView(urlView);
// workaround to eliminate zombie processes
// https://github.com/mattermost/desktop/pull/1519
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
urlView.webContents.destroy();
};
@ -263,12 +286,12 @@ export class ViewManager {
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)) {
this.mainWindow.setTopBrowserView(this.loadingScreen);
if (this.mainWindow.getBrowserViews().includes(this.loadingScreen!)) {
this.mainWindow.setTopBrowserView(this.loadingScreen!);
} else {
this.mainWindow.addBrowserView(this.loadingScreen);
this.mainWindow.addBrowserView(this.loadingScreen!);
}
this.setLoadingScreenBounds();
@ -286,7 +309,7 @@ export class ViewManager {
}
}
setServerInitialized = (server) => {
setServerInitialized = (server: string) => {
const view = this.views.get(server);
if (view) {
view.setInitialized();
@ -296,30 +319,40 @@ export class ViewManager {
}
}
updateLoadingScreenDarkMode = (darkMode) => {
updateLoadingScreenDarkMode = (darkMode: boolean) => {
if (this.loadingScreen) {
this.loadingScreen.webContents.send(GET_LOADING_SCREEN_DATA, {darkMode});
}
}
deeplinkSuccess = (serverName) => {
deeplinkSuccess = (serverName: string) => {
const view = this.views.get(serverName);
if (!view) {
return;
}
this.showByName(serverName);
view.removeListener(LOAD_FAILED, this.deeplinkFailed);
};
deeplinkFailed = (serverName, err, url) => {
const view = this.views.get(serverName);
deeplinkFailed = (serverName: string, err: string, url: string) => {
log.error(`[${serverName}] failed to load deeplink ${url}: ${err}`);
const view = this.views.get(serverName);
if (!view) {
return;
}
view.removeListener(LOAD_SUCCESS, this.deeplinkSuccess);
}
handleDeepLink = (url) => {
handleDeepLink = (url: string | URL) => {
if (url) {
const parsedURL = urlUtils.parseURL(url);
const parsedURL = urlUtils.parseURL(url)!;
const server = urlUtils.getServer(parsedURL, this.configServers, true);
if (server) {
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.
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));
}
}

View file

@ -1,12 +1,12 @@
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {BrowserWindow, shell} from 'electron';
import {BrowserWindow, shell, WebContents} from 'electron';
import log from 'electron-log';
import {DEVELOPMENT, PRODUCTION} from 'common/utils/constants';
import {Team} from 'types/config';
import urlUtils from 'common/utils/url';
import Utils from 'common/utils/util';
import * as WindowManager from '../windows/windowManager';
@ -15,26 +15,32 @@ import {protocols} from '../../../electron-builder.json';
import allowProtocolDialog from '../allowProtocolDialog';
import {composeUserAgent} from '../utils';
const customLogins = {};
const listeners = {};
let popupWindow = null;
import {MattermostView} from './MattermostView';
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) {
return false;
}
if (!popupWindow) {
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 generateWillNavigate = (getServersFunction) => {
return (event, url) => {
const generateWillNavigate = (getServersFunction: () => Team[]) => {
return (event: Event & {sender: WebContents}, url: string) => {
const contentID = event.sender.id;
const parsedURL = urlUtils.parseURL(url);
const parsedURL = urlUtils.parseURL(url)!;
const configServers = getServersFunction();
const server = urlUtils.getServer(parsedURL, configServers);
@ -42,7 +48,7 @@ const generateWillNavigate = (getServersFunction) => {
return;
}
if (urlUtils.isCustomLoginURL(parsedURL, server, configServers)) {
if (server && urlUtils.isCustomLoginURL(parsedURL, server, configServers)) {
return;
}
if (parsedURL.protocol === 'mailto:') {
@ -51,30 +57,24 @@ const generateWillNavigate = (getServersFunction) => {
if (customLogins[contentID].inProgress) {
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}`);
event.preventDefault();
};
};
const generateDidStartNavigation = (getServersFunction) => {
return (event, url) => {
const generateDidStartNavigation = (getServersFunction: () => Team[]) => {
return (event: Event & {sender: WebContents}, url: string) => {
const serverList = getServersFunction();
const contentID = event.sender.id;
const parsedURL = urlUtils.parseURL(url);
const parsedURL = urlUtils.parseURL(url)!;
const server = urlUtils.getServer(parsedURL, serverList);
if (!urlUtils.isTrustedURL(parsedURL, serverList)) {
return;
}
if (urlUtils.isCustomLoginURL(parsedURL, server, serverList)) {
if (server && urlUtils.isCustomLoginURL(parsedURL, server, serverList)) {
customLogins[contentID].inProgress = true;
} else if (customLogins[contentID].inProgress) {
customLogins[contentID].inProgress = false;
@ -82,8 +82,8 @@ const generateDidStartNavigation = (getServersFunction) => {
};
};
const generateNewWindowListener = (getServersFunction, spellcheck) => {
return (event, url) => {
const generateNewWindowListener = (getServersFunction: () => Team[], spellcheck?: boolean) => {
return (event: Event, url: string) => {
const parsedURL = urlUtils.parseURL(url);
if (!parsedURL) {
event.preventDefault();
@ -146,14 +146,14 @@ const generateNewWindowListener = (getServersFunction, spellcheck) => {
log.info(`${url} is an admin console page, preventing to open a new window`);
return;
}
if (popupWindow && !popupWindow.closed && popupWindow.getURL() === url) {
if (popupWindow && popupWindow.webContents.getURL() === url) {
log.info(`Popup window already open at provided url: ${url}`);
return;
}
// TODO: move popups to its own and have more than one.
if (urlUtils.isPluginUrl(server.url, parsedURL) || urlUtils.isManagedResource(server.url, parsedURL)) {
if (!popupWindow || popupWindow.closed) {
if (!popupWindow) {
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
//parent: WindowManager.getMainWindow(),
@ -167,10 +167,10 @@ const generateNewWindowListener = (getServersFunction, spellcheck) => {
},
});
popupWindow.once('ready-to-show', () => {
popupWindow.show();
popupWindow!.show();
});
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]) {
listeners[id]();
}
};
export const addWebContentsEventListeners = (mmview, getServersFunction) => {
export const addWebContentsEventListeners = (mmview: MattermostView, getServersFunction: () => Team[]) => {
const contents = mmview.view.webContents;
// initialize custom login tracking
@ -206,7 +206,7 @@ export const addWebContentsEventListeners = (mmview, 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):
// 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?
// - indicate custom login is NOT in progress
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);
contents.on('new-window', newWindow);
@ -227,8 +227,8 @@ export const addWebContentsEventListeners = (mmview, getServersFunction) => {
const removeListeners = () => {
try {
contents.removeListener('will-navigate', willNavigate);
contents.removeListener('did-start-navigation', didStartNavigation);
contents.removeListener('will-navigate', willNavigate as (e: Event, u: string) => void);
contents.removeListener('did-start-navigation', didStartNavigation as (e: Event, u: string) => void);
contents.removeListener('new-window', newWindow);
contents.removeListener('page-title-updated', mmview.handleTitleUpdate);
contents.removeListener('page-favicon-updated', mmview.handleFaviconUpdate);

View file

@ -6,19 +6,24 @@ import fs from 'fs';
import path from 'path';
import os from 'os';
import {app, BrowserWindow, ipcMain} from 'electron';
import {app, BrowserWindow, BrowserWindowConstructorOptions, ipcMain} from 'electron';
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 * as Validator from '../Validator';
import ContextMenu from '../contextMenu';
import {getLocalPreload, getLocalURLString} from '../utils';
function saveWindowState(file, window) {
const windowState = window.getBounds();
windowState.maximized = window.isMaximized();
windowState.fullscreen = window.isFullScreen();
function saveWindowState(file: string, window: BrowserWindow) {
const windowState: SavedWindowState = {
...window.getBounds(),
maximized: window.isMaximized(),
fullscreen: window.isFullScreen(),
};
try {
fs.writeFileSync(file, JSON.stringify(windowState));
} catch (e) {
@ -31,7 +36,7 @@ function isFramelessWindow() {
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 defaultWindowHeight = 700;
const minimumWindowWidth = 400;
@ -40,26 +45,23 @@ function createMainWindow(config, options) {
// Create the browser window.
const preload = getLocalPreload('mainWindow.js');
const boundsInfoPath = path.join(app.getPath('userData'), 'bounds-info.json');
let windowOptions;
let savedWindowState;
try {
windowOptions = JSON.parse(fs.readFileSync(boundsInfoPath, 'utf-8'));
windowOptions = Validator.validateBoundsInfo(windowOptions);
if (!windowOptions) {
savedWindowState = JSON.parse(fs.readFileSync(boundsInfoPath, 'utf-8'));
savedWindowState = Validator.validateBoundsInfo(savedWindowState);
if (!savedWindowState) {
throw new Error('Provided bounds info file does not validate, using defaults instead.');
}
} catch (e) {
// 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);
if (process.platform === 'linux') {
windowOptions.icon = options.linuxAppIcon;
}
Object.assign(windowOptions, {
const windowOptions: BrowserWindowConstructorOptions = Object.assign({}, savedWindowState, {
title: app.name,
fullscreenable: true,
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,
minHeight: minimumWindowHeight,
frame: !isFramelessWindow(),
fullscreen: windowOptions.fullscreen,
titleBarStyle: 'hidden',
fullscreen: savedWindowState.fullscreen,
titleBarStyle: 'hidden' as const,
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
webPreferences: {
@ -81,10 +83,18 @@ function createMainWindow(config, options) {
},
});
if (process.platform === 'linux') {
windowOptions.icon = options.linuxAppIcon;
}
const mainWindow = new BrowserWindow(windowOptions);
mainWindow.setMenuBarVisibility(false);
try {
ipcMain.handle(GET_FULL_SCREEN_STATUS, () => mainWindow.isFullScreen());
} catch (e) {
log.error('Tried to register second handler, skipping');
}
const localURL = getLocalURLString('index.html');
mainWindow.loadURL(localURL).catch(
@ -122,7 +132,7 @@ function createMainWindow(config, options) {
saveWindowState(boundsInfoPath, mainWindow);
} else { // Minimize or hide the window for close button.
event.preventDefault();
function hideWindow(window) {
function hideWindow(window: BrowserWindow) {
window.blur(); // To move focus to the next top-level window in Windows
window.hide();
}

View file

@ -3,15 +3,15 @@
import {BrowserWindow} from 'electron';
import log from 'electron-log';
import {CombinedConfig} from 'types/config';
import ContextMenu from '../contextMenu';
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 spellcheck = (typeof config.useSpellChecker === 'undefined' ? true : config.useSpellChecker);
const settingsWindow = new BrowserWindow({
...config.data,
parent: mainWindow,
title: 'Desktop App Settings',
fullscreen: false,

View file

@ -2,28 +2,32 @@
// See LICENSE.txt for license information.
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 {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 urlUtils from 'common/utils/url';
import {getAdjustedWindowBoundaries} from '../utils';
import {ViewManager} from '../views/viewManager';
import {CriticalErrorHandler} from '../CriticalErrorHandler';
import CriticalErrorHandler from '../CriticalErrorHandler';
import {createSettingsWindow} from './settingsWindow';
import createMainWindow from './mainWindow';
// singleton module to manage application's windows
const status = {
mainWindow: null,
settingsWindow: null,
config: null,
viewManager: null,
type WindowManagerStatus = {
mainWindow?: BrowserWindow;
settingsWindow?: BrowserWindow;
config?: CombinedConfig;
viewManager?: ViewManager;
};
const status: WindowManagerStatus = {};
const assetsDir = path.resolve(app.getAppPath(), 'assets');
ipcMain.on(HISTORY, handleHistory);
@ -31,12 +35,12 @@ ipcMain.handle(GET_LOADING_SCREEN_DATA, handleLoadingScreenDataRequest);
ipcMain.on(REACT_APP_INITIALIZED, handleReactAppInitialized);
ipcMain.on(LOADING_SCREEN_ANIMATION_FINISHED, handleLoadingScreenAnimationFinished);
export function setConfig(data) {
export function setConfig(data: CombinedConfig) {
if (data) {
status.config = data;
}
if (status.viewManager) {
status.viewManager.reloadConfiguration(status.config.teams);
if (status.viewManager && status.config) {
status.viewManager.reloadConfiguration(status.config.teams || []);
}
}
@ -47,17 +51,20 @@ export function showSettingsWindow() {
if (!status.mainWindow) {
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 = null;
delete status.settingsWindow;
focusBrowserView();
});
}
}
export function showMainWindow(deeplinkingURL) {
export function showMainWindow(deeplinkingURL?: string | URL) {
if (status.mainWindow) {
if (status.mainWindow.isVisible()) {
status.mainWindow.focus();
@ -65,6 +72,9 @@ export function showMainWindow(deeplinkingURL) {
status.mainWindow.show();
}
} else {
if (!status.config) {
return;
}
status.mainWindow = createMainWindow(status.config, {
linuxAppIcon: path.join(assetsDir, 'linux', 'app_icon.png'),
});
@ -77,14 +87,13 @@ export function showMainWindow(deeplinkingURL) {
// window handlers
status.mainWindow.on('closed', () => {
log.warn('main window closed');
status.mainWindow = null;
delete status.mainWindow;
});
status.mainWindow.on('unresponsive', () => {
const criticalErrorHandler = new CriticalErrorHandler();
criticalErrorHandler.setMainWindow(status.mainWindow);
criticalErrorHandler.setMainWindow(status.mainWindow!);
criticalErrorHandler.windowUnresponsiveHandler();
});
status.mainWindow.on('crashed', handleMainWindowWebContentsCrashed);
status.mainWindow.on('maximize', handleMaximizeMainWindow);
status.mainWindow.on('unmaximize', handleUnmaximizeMainWindow);
status.mainWindow.on('resize', handleResizeMainWindow);
@ -103,24 +112,18 @@ export function showMainWindow(deeplinkingURL) {
initializeViewManager();
if (deeplinkingURL) {
status.viewManager.handleDeepLink(deeplinkingURL);
status.viewManager!.handleDeepLink(deeplinkingURL);
}
}
export function getMainWindow(ensureCreated) {
if (ensureCreated && status.mainWindow === null) {
export function getMainWindow(ensureCreated?: boolean) {
if (ensureCreated && !status.mainWindow) {
showMainWindow();
}
return status.mainWindow;
}
export function on(event, listener) {
return status.mainWindow.on(event, listener);
}
function handleMainWindowWebContentsCrashed() {
throw new Error('webContents \'crashed\' event has been emitted');
}
export const on = status.mainWindow?.on;
function handleMaximizeMainWindow() {
sendToRenderer(MAXIMIZE_CHANGE, true);
@ -131,8 +134,11 @@ function handleUnmaximizeMainWindow() {
}
function handleResizeMainWindow() {
if (!(status.viewManager && status.mainWindow)) {
return;
}
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:
// https://github.com/electron/electron/issues/28699
@ -146,7 +152,7 @@ function handleResizeMainWindow() {
const setBoundsFunction = () => {
if (currentView) {
currentView.setBounds(getAdjustedWindowBoundaries(bounds.width, bounds.height, !urlUtils.isTeamUrl(currentView.server.url, currentView.view.webContents.getURL())));
currentView.setBounds(getAdjustedWindowBoundaries(bounds.width!, bounds.height!, !urlUtils.isTeamUrl(currentView.server.url, currentView.view.webContents.getURL())));
}
};
@ -160,17 +166,17 @@ function handleResizeMainWindow() {
status.viewManager.setLoadingScreenBounds();
}
export function sendToRenderer(channel, ...args) {
export function sendToRenderer(channel: string, ...args: any[]) {
if (!status.mainWindow) {
showMainWindow();
}
status.mainWindow.webContents.send(channel, ...args);
status.mainWindow!.webContents.send(channel, ...args);
if (status.settingsWindow && status.settingsWindow.isVisible()) {
status.settingsWindow.webContents.send(channel, ...args);
}
}
export function sendToAll(channel, ...args) {
export function sendToAll(channel: string, ...args: any[]) {
sendToRenderer(channel, ...args);
if (status.settingsWindow) {
status.settingsWindow.webContents.send(channel, ...args);
@ -179,7 +185,7 @@ export function sendToAll(channel, ...args) {
// TODO: should we include popups?
}
export function sendToMattermostViews(channel, ...args) {
export function sendToMattermostViews(channel: string, ...args: any[]) {
if (status.viewManager) {
status.viewManager.sendToAllViews(channel, ...args);
}
@ -190,16 +196,16 @@ export function restoreMain() {
if (!status.mainWindow) {
showMainWindow();
}
if (!status.mainWindow.isVisible() || status.mainWindow.isMinimized()) {
if (status.mainWindow.isMinimized()) {
status.mainWindow.restore();
if (!status.mainWindow!.isVisible() || status.mainWindow!.isMinimized()) {
if (status.mainWindow!.isMinimized()) {
status.mainWindow!.restore();
} else {
status.mainWindow.show();
status.mainWindow!.show();
}
if (status.settingsWindow) {
status.settingsWindow.focus();
} else {
status.mainWindow.focus();
status.mainWindow!.focus();
}
if (process.platform === 'darwin') {
app.dock.show();
@ -207,31 +213,36 @@ export function restoreMain() {
} else if (status.settingsWindow) {
status.settingsWindow.focus();
} else {
status.mainWindow.focus();
status.mainWindow!.focus();
}
}
export function flashFrame(flash) {
export function flashFrame(flash: boolean) {
if (process.platform === 'linux' || process.platform === 'win32') {
status.mainWindow.flashFrame(flash);
status.mainWindow?.flashFrame(flash);
if (status.settingsWindow) {
// main might be hidden behind the settings
status.settingsWindow.flashFrame(flash);
}
}
if (process.platform === 'darwin' && status.config.notifications.bounceIcon) {
app.dock.bounce(status.config.notifications.bounceIconType);
if (process.platform === 'darwin' && status.config?.notifications.bounceIcon) {
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 size = (small ? 20 : 16) * scale;
const canvas = document.createElement('canvas');
canvas.setAttribute('width', size);
canvas.setAttribute('height', size);
canvas.setAttribute('width', `${size}`);
canvas.setAttribute('height', `${size}`);
const ctx = canvas.getContext('2d');
if (!ctx) {
log.error('Could not create canvas context');
return null;
}
// circle
ctx.fillStyle = '#FF1744'; // Material Red A400
ctx.beginPath();
@ -248,7 +259,7 @@ function drawBadge(text, small) {
return canvas.toDataURL();
}
function createDataURL(text, small) {
function createDataURL(text: string, small: boolean) {
const win = status.mainWindow;
if (!win) {
return null;
@ -263,10 +274,11 @@ function createDataURL(text, small) {
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') {
let overlay = null;
if (status.mainWindow && badgeText) {
if (status.mainWindow) {
if (badgeText) {
try {
const dataUrl = await createDataURL(badgeText, small);
overlay = nativeImage.createFromDataURL(dataUrl);
@ -276,13 +288,14 @@ export async function setOverlayIcon(badgeText, description, small) {
}
status.mainWindow.setOverlayIcon(overlay, description);
}
}
}
export function isMainWindow(window) {
export function isMainWindow(window: BrowserWindow) {
return status.mainWindow && status.mainWindow === window;
}
export function handleDoubleClick(e, windowType) {
export function handleDoubleClick(e: IpcMainEvent, windowType?: string) {
let action = 'Maximize';
if (process.platform === 'darwin') {
action = systemPreferences.getUserDefault('AppleActionOnDoubleClick', 'string');
@ -311,16 +324,16 @@ export function handleDoubleClick(e, windowType) {
}
function initializeViewManager() {
if (!status.viewManager) {
if (!status.viewManager && status.config && status.mainWindow) {
status.viewManager = new ViewManager(status.config, status.mainWindow);
status.viewManager.load();
status.viewManager.showInitial();
}
}
export function switchServer(serverName) {
export function switchServer(serverName: string) {
showMainWindow();
status.viewManager.showByName(serverName);
status.viewManager?.showByName(serverName);
}
export function focusBrowserView() {
@ -346,11 +359,11 @@ export function focusThreeDotMenu() {
function handleLoadingScreenDataRequest() {
return {
darkMode: status.config.darkMode,
darkMode: status.config?.darkMode || false,
};
}
function handleReactAppInitialized(_, server) {
function handleReactAppInitialized(e: IpcMainEvent, server: string) {
if (status.viewManager) {
status.viewManager.setServerInitialized(server);
}
@ -362,27 +375,19 @@ function handleLoadingScreenAnimationFinished() {
}
}
export function updateLoadingScreenDarkMode(darkMode) {
export function updateLoadingScreenDarkMode(darkMode: boolean) {
if (status.viewManager) {
status.viewManager.updateLoadingScreenDarkMode(darkMode);
}
}
export function getServerNameByWebContentsId(webContentsId) {
if (status.viewManager) {
return status.viewManager.findByWebContent(webContentsId);
}
return null;
export function getServerNameByWebContentsId(webContentsId: number) {
return status.viewManager?.findByWebContent(webContentsId);
}
export function close() {
const focused = BrowserWindow.getFocusedWindow();
if (focused.id === status.mainWindow.id) {
// TODO: figure out logic for closing
focused.close();
} else {
focused.close();
}
focused?.close();
}
export function maximize() {
const focused = BrowserWindow.getFocusedWindow();
@ -404,21 +409,21 @@ export function restore() {
}
export function reload() {
const currentView = status.viewManager.getCurrentView();
const currentView = status.viewManager?.getCurrentView();
if (currentView) {
status.viewManager.showLoadingScreen();
status.viewManager?.showLoadingScreen();
currentView.reload();
}
}
export function sendToFind() {
const currentView = status.viewManager.getCurrentView();
const currentView = status.viewManager?.getCurrentView();
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) {
const activeView = status.viewManager.getCurrentView();
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.
// See LICENSE.txt for license information.
// Copyright (c) 2015-2016 Yuya Ochiai
import React from 'react';
import PropTypes from 'prop-types';
import {Alert} from 'react-bootstrap';
const baseClassName = 'AutoSaveIndicator';
const leaveClassName = `${baseClassName}-Leave`;
const SAVING_STATE_SAVING = 'saving';
const SAVING_STATE_SAVED = 'saved';
const SAVING_STATE_ERROR = 'error';
const SAVING_STATE_DONE = 'done';
export enum SavingState {
SAVING_STATE_SAVING = 'saving',
SAVING_STATE_SAVED = 'saved',
SAVING_STATE_ERROR = 'error',
SAVING_STATE_DONE = 'done',
}
function getClassNameAndMessage(savingState, errorMessage) {
function getClassNameAndMessage(savingState: SavingState, errorMessage?: string) {
switch (savingState) {
case SAVING_STATE_SAVING:
case SavingState.SAVING_STATE_SAVING:
return {className: baseClassName, message: 'Saving...'};
case SAVING_STATE_SAVED:
case SavingState.SAVING_STATE_SAVED:
return {className: baseClassName, message: 'Saved'};
case SAVING_STATE_ERROR:
case SavingState.SAVING_STATE_ERROR:
return {className: `${baseClassName}`, message: errorMessage};
case SAVING_STATE_DONE:
case SavingState.SAVING_STATE_DONE:
return {className: `${baseClassName} ${leaveClassName}`, message: 'Saved'};
default:
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 {className, message} = getClassNameAndMessage(savingState, errorMessage);
return (
@ -42,15 +49,3 @@ export default function AutoSaveIndicator(props) {
</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.
// See LICENSE.txt for license information.
// Copyright (c) 2015-2016 Yuya Ochiai
import React from '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.
// See LICENSE.txt for license information.
// Copyright (c) 2015-2016 Yuya Ochiai
import React from 'react';
import PropTypes from 'prop-types';
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 {
title,
body,
@ -14,9 +23,13 @@ export default function DestructiveConfirmationModal(props) {
cancelLabel,
onAccept,
onCancel,
onHide,
...rest} = props;
return (
<Modal {...rest}>
<Modal
onHide={onHide}
{...rest}
>
<Modal.Header closeButton={true}>
<Modal.Title>{title}</Modal.Title>
</Modal.Header>
@ -34,12 +47,3 @@ export default function DestructiveConfirmationModal(props) {
</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.
// 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
import React from 'react';
import PropTypes from 'prop-types';
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'];
if (!props.active) {
classNames.push('ErrorView-hidden');
@ -58,7 +65,7 @@ export default function ErrorView(props) {
target='_blank'
rel='noreferrer'
>
{props.errorInfo.validatedURL}
{props.url}
</a>{' from a browser window.'}</li>
</ul>
<br/>
@ -78,11 +85,3 @@ export default function ErrorView(props) {
</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.
import React from 'react';
import PropTypes from 'prop-types';
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 = () => {
if (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 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 = {
INITIALIZING: 'initializing', // animation graphics are hidden
@ -18,6 +17,12 @@ const LOADING_STATE = {
const ANIMATION_COMPLETION_DELAY = 500;
type Props = {
loading: boolean;
darkMode: boolean;
onLoadAnimationComplete?: () => void;
}
/**
* A function component for rendering the animated MM logo loading sequence
* @param {boolean} loading - Prop that indicates whether currently loading or not
@ -27,7 +32,7 @@ const ANIMATION_COMPLETION_DELAY = 500;
function LoadingAnimation({
loading = false,
darkMode = false,
onLoadAnimationComplete = null},
onLoadAnimationComplete = undefined}: Props,
) {
const loadingIconContainerRef = React.useRef(null);
const [animationState, setAnimationState] = React.useState(LOADING_STATE.INITIALIZING);
@ -55,14 +60,14 @@ function LoadingAnimation({
}, [loadingAnimationComplete]);
// listen for end of the css logo animation sequence
useAnimationEnd(loadingIconContainerRef, () => {
useAnimationEnd<HTMLDivElement>(loadingIconContainerRef, () => {
setTimeout(() => {
setLoadingAnimationComplete(true);
}, ANIMATION_COMPLETION_DELAY);
}, 'LoadingAnimation__compass-shrink');
// listen for end of final css logo fade/shrink animation sequence
useAnimationEnd(loadingIconContainerRef, () => {
useAnimationEnd<HTMLDivElement>(loadingIconContainerRef, () => {
if (onLoadAnimationComplete) {
onLoadAnimationComplete();
}
@ -84,10 +89,4 @@ function LoadingAnimation({
);
}
LoadingAnimation.propTypes = {
loading: PropTypes.bool,
darkMode: PropTypes.bool,
onLoadAnimationComplete: PropTypes.func,
};
export default LoadingAnimation;

View file

@ -1,4 +1,4 @@
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// 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 classNames from 'classnames';
import PropTypes from 'prop-types';
import useTransitionEnd from '../hooks/useTransitionEnd.js';
import useTransitionEnd from '../hooks/useTransitionEnd';
import LoadingAnimation from './LoadingAnimation';
type Props = {
loading?: boolean;
darkMode?: boolean;
onFadeOutComplete?: () => void;
};
/**
* A function component for rendering the desktop app loading screen
* @param {boolean} loading - Prop that indicates whether currently loading or not
* @param {boolean} darkMode - Prop that indicates if dark mode is enabled
* @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 [loadingIsComplete, setLoadingIsComplete] = React.useState(true);
@ -35,10 +40,10 @@ function LoadingScreen({loading = false, darkMode = false, onFadeOutComplete = (
setLoadAnimationIsComplete(true);
}
useTransitionEnd(loadingScreenRef, React.useCallback(() => {
useTransitionEnd<HTMLDivElement>(loadingScreenRef, React.useCallback(() => {
setFadeOutIsComplete(true);
onFadeOutComplete();
}), ['opacity']);
}, []), ['opacity']);
function loadingInProgress() {
return !(loadingIsComplete && loadAnimationIsComplete && fadeOutIsComplete);
@ -69,10 +74,4 @@ function LoadingScreen({loading = false, darkMode = false, onFadeOutComplete = (
return loadingInProgress() ? loadingScreen : null;
}
LoadingScreen.propTypes = {
loading: PropTypes.bool,
darkMode: PropTypes.bool,
onFadeOutComplete: PropTypes.func,
};
export default LoadingScreen;

View file

@ -1,11 +1,14 @@
// Copyright (c) 2015-2016 Yuya Ochiai
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
// Copyright (c) 2015-2016 Yuya Ochiai
import React, {Fragment} from 'react';
import PropTypes from 'prop-types';
import {Grid, Row} from 'react-bootstrap';
import DotsVerticalIcon from 'mdi-react/DotsVerticalIcon';
import {IpcRendererEvent} from 'electron/renderer';
import {DropResult} from 'react-smooth-dnd';
import {Team} from 'types/config';
import {
FOCUS_BROWSERVIEW,
@ -42,18 +45,54 @@ import closeButton from '../../assets/titlebar/chrome-close.svg';
import {playSound} from '../notificationSounds';
import TabBar from './TabBar.jsx';
import ExtraBar from './ExtraBar.jsx';
import ErrorView from './ErrorView.jsx';
import TabBar from './TabBar';
import ExtraBar from './ExtraBar';
import ErrorView from './ErrorView';
const LOADING = 1;
const DONE = 2;
const RETRY = -1;
const FAILED = 0;
const NOSERVERS = -2;
enum Status {
LOADING = 1,
DONE = 2,
RETRY = -1,
FAILED = 0,
NOSERVERS = -2,
}
export default class MainPage extends React.PureComponent {
constructor(props) {
type 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);
this.topBar = React.createRef();
@ -66,7 +105,7 @@ export default class MainPage extends React.PureComponent {
mentionCounts: {},
targetURL: '',
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,
};
}
@ -79,10 +118,10 @@ export default class MainPage extends React.PureComponent {
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);
status.set(server, newStatusValue);
this.setState({tabStatus: status});
@ -93,7 +132,7 @@ export default class MainPage extends React.PureComponent {
window.ipcRenderer.on(LOAD_RETRY, (_, server, retry, err, loadUrl) => {
console.log(`${server}: failed to load ${err}, but retrying`);
const statusValue = {
status: RETRY,
status: Status.RETRY,
extra: {
retry,
error: err,
@ -104,13 +143,13 @@ export default class MainPage extends React.PureComponent {
});
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) => {
console.log(`${server}: failed to load ${err}`);
const statusValue = {
status: FAILED,
status: Status.FAILED,
extra: {
error: err,
url: loadUrl,
@ -198,44 +237,50 @@ export default class MainPage extends React.PureComponent {
}
}
handleMaximizeState = (_, maximized) => {
handleMaximizeState = (_: IpcRendererEvent, maximized: boolean) => {
this.setState({maximized});
}
handleFullScreenState = (isFullScreen) => {
handleFullScreenState = (isFullScreen: boolean) => {
this.setState({fullScreen: isFullScreen});
}
handleSetServerKey = (key) => {
handleSetServerKey = (key: number) => {
const newKey = (this.props.teams.length + key) % this.props.teams.length;
this.setState({key: newKey});
}
handleSelect = (name, key) => {
handleSelect = (name: string, key: number) => {
window.ipcRenderer.send(SWITCH_SERVER, name);
this.handleSetServerKey(key);
}
handleDragAndDrop = async (dropResult) => {
handleDragAndDrop = async (dropResult: DropResult) => {
const {removedIndex, addedIndex} = dropResult;
if (removedIndex === null || addedIndex === null) {
return;
}
if (removedIndex !== addedIndex) {
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;
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.
window.ipcRenderer.send(WINDOW_CLOSE);
}
handleMinimize = (e) => {
handleMinimize = (e: React.MouseEvent<HTMLDivElement>) => {
e.stopPropagation();
window.ipcRenderer.send(WINDOW_MINIMIZE);
}
handleMaximize = (e) => {
handleMaximize = (e: React.MouseEvent<HTMLDivElement>) => {
e.stopPropagation();
window.ipcRenderer.send(WINDOW_MAXIMIZE);
}
@ -246,7 +291,7 @@ export default class MainPage extends React.PureComponent {
openMenu = () => {
if (window.process.platform !== 'darwin') {
this.threeDotMenu.current.blur();
this.threeDotMenu.current?.blur();
}
this.props.openMenu();
}
@ -263,10 +308,6 @@ export default class MainPage extends React.PureComponent {
window.ipcRenderer.send(FOCUS_BROWSERVIEW);
}
setInputRef = (ref) => {
this.inputRef = ref;
}
render() {
const tabsRow = (
<TabBar
@ -352,7 +393,7 @@ export default class MainPage extends React.PureComponent {
>
<div
ref={this.topBar}
className={`topBar-bg${this.state.unfocused ? ' unfocused' : ''}`}
className={'topBar-bg'}
>
<button
className='three-dot-menu'
@ -383,32 +424,29 @@ export default class MainPage extends React.PureComponent {
return null;
}
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 = (
<ErrorView
id={'NoServers'}
className='errorView'
errorInfo={'No Servers configured'}
url={tabStatus.extra ? tabStatus.extra.url : ''}
active={true}
retry={null}
appName={this.props.appName}
/>);
break;
case FAILED:
case Status.FAILED:
component = (
<ErrorView
id={this.state.key + '-fail'}
className='errorView'
errorInfo={tabStatus.extra ? tabStatus.extra.error : null}
errorInfo={tabStatus.extra?.error}
url={tabStatus.extra ? tabStatus.extra.url : ''}
active={true}
appName={this.props.appName}
/>);
break;
case LOADING:
case RETRY:
case DONE:
case Status.LOADING:
case Status.RETRY:
case Status.DONE:
component = null;
}
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.
// See LICENSE.txt for license information.
// Copyright (c) 2015-2016 Yuya Ochiai
import React from 'react';
import PropTypes from 'prop-types';
import {Modal, Button, FormGroup, FormControl, ControlLabel, HelpBlock} from 'react-bootstrap';
import {TeamWithIndex} from 'types/config';
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 = {
restoreFocus: true,
};
constructor(props) {
constructor(props: Props) {
super(props);
this.wasShown = false;
@ -29,7 +52,7 @@ export default class NewTeamModal extends React.PureComponent {
this.setState({
teamName: this.props.team ? this.props.team.name : '',
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),
saveStarted: false,
});
@ -46,7 +69,7 @@ export default class NewTeamModal extends React.PureComponent {
return this.getTeamNameValidationError() === null ? null : 'error';
}
handleTeamNameChange = (e) => {
handleTeamNameChange = (e: React.ChangeEvent<FormControl & HTMLInputElement>) => {
this.setState({
teamName: e.target.value,
});
@ -72,7 +95,7 @@ export default class NewTeamModal extends React.PureComponent {
return this.getTeamUrlValidationError() === null ? null : 'error';
}
handleTeamUrlChange = (e) => {
handleTeamUrlChange = (e: React.ChangeEvent<FormControl & HTMLInputElement>) => {
this.setState({
teamUrl: e.target.value,
});
@ -102,10 +125,10 @@ export default class NewTeamModal extends React.PureComponent {
saveStarted: true,
}, () => {
if (this.validateForm()) {
this.props.onSave({
this.props.onSave?.({
url: this.state.teamUrl,
name: this.state.teamName,
index: this.state.teamIndex,
index: this.state.teamIndex!,
order: this.state.teamOrder,
});
}
@ -139,7 +162,7 @@ export default class NewTeamModal extends React.PureComponent {
show={this.props.show}
id='newServerModal'
enforceFocus={true}
onEntered={() => this.teamNameInputRef.focus()}
onEntered={() => this.teamNameInputRef?.focus()}
onHide={this.props.onClose}
restoreFocus={this.props.restoreFocus}
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.
// See LICENSE.txt for license information.
// Copyright (c) 2015-2016 Yuya Ochiai
import React from 'react';
import PropTypes from 'prop-types';
import {Modal} from 'react-bootstrap';
import {Button, 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;
return (
<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.
// See LICENSE.txt for license information.
// Copyright (c) 2015-2016 Yuya Ochiai
/* eslint-disable max-lines */
import 'renderer/css/settings.css';
import React from 'react';
import PropTypes from 'prop-types';
import {Checkbox, Col, FormGroup, Grid, HelpBlock, Navbar, Radio, Row, Button} from 'react-bootstrap';
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 TeamList from './TeamList.jsx';
import AutoSaveIndicator from './AutoSaveIndicator.jsx';
import TeamList from './TeamList';
import AutoSaveIndicator, {SavingState} from './AutoSaveIndicator';
const CONFIG_TYPE_SERVERS = 'servers';
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.close();
}
export default class SettingsPage extends React.PureComponent {
constructor(props) {
export default class SettingsPage extends React.PureComponent<Record<string, never>, State> {
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);
this.state = {
ready: false,
teams: [],
showAddTeamForm: false,
savingState: {
appOptions: AutoSaveIndicator.SAVING_STATE_DONE,
servers: AutoSaveIndicator.SAVING_STATE_DONE,
appOptions: SavingState.SAVING_STATE_DONE,
servers: SavingState.SAVING_STATE_DONE,
},
userOpenedDownloadDialog: false,
};
@ -67,26 +108,26 @@ export default class SettingsPage extends React.PureComponent {
getConfig = () => {
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 = {}) => {
const newState = Object.assign({}, configData);
convertConfigDataToState = (configData: Partial<LocalConfiguration>, currentState: Partial<State> = {}) => {
const newState = Object.assign({} as State, configData);
newState.showAddTeamForm = currentState.showAddTeamForm || 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.showAddTeamForm = true;
}
newState.savingState = currentState.savingState || {
appOptions: AutoSaveIndicator.SAVING_STATE_DONE,
servers: AutoSaveIndicator.SAVING_STATE_DONE,
appOptions: SavingState.SAVING_STATE_DONE,
servers: SavingState.SAVING_STATE_DONE,
};
return newState;
}
saveSetting = (configType, {key, data}) => {
saveSetting = (configType: ConfigType, {key, data}: {key: keyof CombinedConfig; data: CombinedConfig[keyof CombinedConfig]}) => {
this.saveQueue.push({
configType,
key,
@ -115,25 +156,25 @@ export default class SettingsPage extends React.PureComponent {
Object.entries(queuedUpdateCounts).forEach(([configType, count]) => {
if (count > 0) {
savingState[configType] = AutoSaveIndicator.SAVING_STATE_SAVING;
} else if (count === 0 && savingState[configType] === AutoSaveIndicator.SAVING_STATE_SAVING) {
savingState[configType] = AutoSaveIndicator.SAVING_STATE_SAVED;
this.resetSaveState(configType);
savingState[configType as keyof SavingStateItems] = SavingState.SAVING_STATE_SAVING;
} else if (count === 0 && savingState[configType as keyof SavingStateItems] === SavingState.SAVING_STATE_SAVING) {
savingState[configType as keyof SavingStateItems] = SavingState.SAVING_STATE_SAVED;
this.resetSaveState(configType as keyof SavingStateItems);
}
});
this.setState({savingState});
}
resetSaveState = debounce((configType) => {
if (this.state.savingState[configType] !== AutoSaveIndicator.SAVING_STATE_SAVING) {
resetSaveState = debounce((configType: keyof SavingStateItems) => {
if (this.state.savingState[configType] !== SavingState.SAVING_STATE_SAVING) {
const savingState = Object.assign({}, this.state.savingState);
savingState[configType] = AutoSaveIndicator.SAVING_STATE_DONE;
savingState[configType] = SavingState.SAVING_STATE_DONE;
this.setState({savingState});
}
}, 2000);
handleTeamsChange = (teams) => {
handleTeamsChange = (teams: Team[]) => {
window.timers.setImmediate(this.saveSetting, CONFIG_TYPE_SERVERS, {key: 'teams', data: teams});
this.setState({
showAddTeamForm: false,
@ -145,7 +186,7 @@ export default class SettingsPage extends React.PureComponent {
}
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});
this.setState({
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});
this.setState({
trayIconTheme: theme,
@ -166,14 +207,14 @@ export default class SettingsPage extends React.PureComponent {
}
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({
autostart: !this.autostartRef.current.props.checked,
autostart: !this.autostartRef.current?.props.checked,
});
}
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});
this.setState({
@ -185,10 +226,10 @@ export default class SettingsPage extends React.PureComponent {
this.setState({
showAddTeamForm: !this.state.showAddTeamForm,
});
document.activeElement.blur();
(document.activeElement as HTMLElement).blur();
}
setShowTeamFormVisibility = (val) => {
setShowTeamFormVisibility = (val: boolean) => {
this.setState({
showAddTeamForm: val,
});
@ -199,13 +240,13 @@ export default class SettingsPage extends React.PureComponent {
key: 'notifications',
data: {
...this.state.notifications,
flashWindow: this.flashWindowRef.current.props.checked ? 0 : 2,
flashWindow: this.flashWindowRef.current?.props.checked ? 0 : 2,
},
});
this.setState({
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',
data: {
...this.state.notifications,
bounceIcon: !this.bounceIconRef.current.props.checked,
bounceIcon: !this.bounceIconRef.current?.props.checked,
},
});
this.setState({
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, {
key: 'notifications',
data: {
@ -237,33 +278,33 @@ export default class SettingsPage extends React.PureComponent {
this.setState({
notifications: {
...this.state.notifications,
bounceIconType: event.target.value,
bounceIconType: event.target.value as 'critical' | 'informational',
},
});
}
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({
showUnreadBadge: !this.showUnreadBadgeRef.current.props.checked,
showUnreadBadge: !this.showUnreadBadgeRef.current?.props.checked,
});
}
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({
useSpellChecker: !this.useSpellCheckerRef.current.props.checked,
useSpellChecker: !this.useSpellCheckerRef.current?.props.checked,
});
}
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({
enableHardwareAcceleration: !this.enableHardwareAccelerationRef.current.props.checked,
enableHardwareAcceleration: !this.enableHardwareAccelerationRef.current?.props.checked,
});
}
saveDownloadLocation = (location) => {
saveDownloadLocation = (location: string) => {
if (!location) {
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});
}
handleChangeDownloadLocation = (e) => {
handleChangeDownloadLocation = (e: React.ChangeEvent<HTMLInputElement>) => {
this.saveDownloadLocation(e.target.value);
}
@ -285,8 +326,8 @@ export default class SettingsPage extends React.PureComponent {
this.setState({userOpenedDownloadDialog: false});
}
updateTeam = (index, newData) => {
const teams = this.state.teams;
updateTeam = (index: number, newData: Team) => {
const teams = this.state.teams || [];
teams[index] = newData;
window.timers.setImmediate(this.saveSetting, CONFIG_TYPE_SERVERS, {key: 'teams', data: teams});
this.setState({
@ -294,8 +335,8 @@ export default class SettingsPage extends React.PureComponent {
});
}
addServer = (team) => {
const teams = this.state.teams;
addServer = (team: Team) => {
const teams = this.state.teams || [];
teams.push(team);
window.timers.setImmediate(this.saveSetting, CONFIG_TYPE_SERVERS, {key: 'teams', data: teams});
this.setState({
@ -303,12 +344,6 @@ export default class SettingsPage extends React.PureComponent {
});
}
openMenu = () => {
// @eslint-ignore
this.threeDotMenu.current.blur();
this.props.openMenu();
}
handleDoubleClick = () => {
window.ipcRenderer.send(DOUBLE_CLICK_ON_WINDOW, 'settings');
}
@ -317,7 +352,7 @@ export default class SettingsPage extends React.PureComponent {
const settingsPage = {
navbar: {
backgroundColor: '#fff',
position: 'relative',
position: 'relative' as const,
},
close: {
textDecoration: 'none',
@ -329,7 +364,7 @@ export default class SettingsPage extends React.PureComponent {
color: '#bbb',
},
heading: {
textAlign: 'center',
textAlign: 'center' as const,
fontSize: '24px',
margin: '0',
padding: '1em 0',
@ -356,7 +391,7 @@ export default class SettingsPage extends React.PureComponent {
padding: '0 12px',
borderRadius: '4px',
border: '1px solid #ccc',
fontWeight: '500',
fontWeight: 500,
},
downloadLocationButton: {
@ -372,14 +407,12 @@ export default class SettingsPage extends React.PureComponent {
<Row>
<Col md={12}>
<TeamList
teams={this.state.teams}
teams={this.state.teams!}
showAddTeamForm={this.state.showAddTeamForm}
toggleAddTeamForm={this.toggleShowTeamForm}
setAddTeamFormVisibility={this.setShowTeamFormVisibility}
onTeamsChange={this.handleTeamsChange}
updateTeam={this.updateTeam}
addServer={this.addServer}
allowTeamEdit={this.state.enableServerManagement}
onTeamClick={(name) => {
backToIndex(name);
}}
@ -576,7 +609,7 @@ export default class SettingsPage extends React.PureComponent {
name='trayIconTheme'
value='light'
defaultChecked={this.state.trayIconTheme === 'light' || !this.state.trayIconTheme}
onChange={(event) => this.handleChangeTrayIconTheme('light', event)}
onChange={() => this.handleChangeTrayIconTheme('light')}
>
{'Light'}
</Radio>
@ -586,7 +619,7 @@ export default class SettingsPage extends React.PureComponent {
name='trayIconTheme'
value='dark'
defaultChecked={this.state.trayIconTheme === 'dark'}
onChange={(event) => this.handleChangeTrayIconTheme('dark', event)}
onChange={() => this.handleChangeTrayIconTheme('dark')}
>{'Dark'}</Radio>
</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.
// See LICENSE.txt for license information.
// Copyright (c) 2015-2016 Yuya Ochiai
import React from 'react';
import PropTypes from 'prop-types';
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 {Team} from 'types/config';
import {GET_CONFIGURATION} from 'common/communication';
export default class TabBar extends React.PureComponent { // need "this"
constructor(props) {
type 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);
this.state = {
hasGPOTeams: false,
@ -37,7 +59,7 @@ export default class TabBar extends React.PureComponent { // need "this"
mentionCount = this.props.mentionCounts[index];
}
let badgeDiv;
let badgeDiv: React.ReactNode;
if (sessionExpired) {
badgeDiv = (
<div className='TabBar-expired'/>
@ -63,7 +85,6 @@ export default class TabBar extends React.PureComponent { // need "this"
draggable={false}
ref={id}
active={this.props.activeKey === index}
activeKey={this.props.activeKey}
onMouseDown={() => {
this.props.onSelect(team.name, index);
}}
@ -98,7 +119,6 @@ export default class TabBar extends React.PureComponent { // need "this"
eventKey='addServerButton'
draggable={false}
title='Add new server'
activeKey={this.props.activeKey}
onSelect={() => {
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
ref={ref}
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.
// See LICENSE.txt for license information.
// Copyright (c) 2015-2016 Yuya Ochiai
import React from 'react';
import PropTypes from 'prop-types';
import {ListGroup} from 'react-bootstrap';
import TeamListItem from './TeamListItem.jsx';
import NewTeamModal from './NewTeamModal.jsx';
import RemoveServerModal from './RemoveServerModal.jsx';
import {Team, TeamWithIndex} from 'types/config';
export default class TeamList extends React.PureComponent {
constructor(props) {
import TeamListItem from './TeamListItem';
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);
this.state = {
@ -20,13 +37,13 @@ export default class TeamList extends React.PureComponent {
team: {
url: '',
name: '',
index: false,
index: 0,
order: props.teams.length,
},
};
}
handleTeamRemove = (index) => {
handleTeamRemove = (index: number) => {
console.log(index);
const teams = this.props.teams;
const removedOrder = this.props.teams[index].order;
@ -39,7 +56,7 @@ export default class TeamList extends React.PureComponent {
this.props.onTeamsChange(teams);
}
handleTeamAdd = (team) => {
handleTeamAdd = (team: TeamWithIndex) => {
const teams = this.props.teams;
// 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: {
url: '',
name: '',
index: false,
index: 0,
order: teams.length,
},
});
@ -64,7 +81,7 @@ export default class TeamList extends React.PureComponent {
this.props.onTeamsChange(teams);
}
openServerRemoveModal = (indexForServer) => {
openServerRemoveModal = (indexForServer: number) => {
this.setState({indexToRemoveServer: indexForServer});
}
@ -72,16 +89,16 @@ export default class TeamList extends React.PureComponent {
this.setState({indexToRemoveServer: -1});
}
handleTeamRemovePrompt = (index) => {
handleTeamRemovePrompt = (index: number) => {
return () => {
document.activeElement.blur();
(document.activeElement as HTMLElement).blur();
this.openServerRemoveModal(index);
};
}
handleTeamEditing = (team, index) => {
handleTeamEditing = (team: Team, index: number) => {
return () => {
document.activeElement.blur();
(document.activeElement as HTMLElement).blur();
this.setState({
showEditTeamForm: true,
team: {
@ -119,7 +136,7 @@ export default class TeamList extends React.PureComponent {
team: {
name: '',
url: '',
index: false,
index: 0,
order: this.props.teams.length,
},
});
@ -137,12 +154,11 @@ export default class TeamList extends React.PureComponent {
this.props.updateTeam(newTeam.index, teamData);
}
this.setState({
showNewTeamModal: false,
showEditTeamForm: false,
team: {
name: '',
url: '',
index: false,
index: 0,
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.
// See LICENSE.txt for license information.
// Copyright (c) 2015-2016 Yuya Ochiai
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 = () => {
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.
// See LICENSE.txt for license information.
// Copyright (c) 2015-2016 Yuya Ochiai
import React from 'react';
import propTypes from 'prop-types';
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) {
return (
<Button
@ -23,13 +28,20 @@ function InstallButton(props) {
);
}
InstallButton.propTypes = {
notifyOnly: propTypes.bool.isRequired,
onClickInstall: propTypes.func.isRequired,
onClickDownload: propTypes.func.isRequired,
type UpdaterPageProps = {
appName: string;
notifyOnly?: boolean;
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;
if (props.isDownloading) {
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;

View file

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

View file

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

View file

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

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
* ignores events bubbling up from descendent elements
*/
function useAnimationEnd(
ref,
callback,
animationName,
function useAnimationEnd<T extends Element>(
ref: React.RefObject<T>,
callback: (event: Event) => void,
animationName: string,
listenForEventBubbling = true,
) {
): void {
React.useEffect(() => {
if (!ref.current) {
return undefined;
}
function handleAnimationend(event) {
function handleAnimationend(event: Event & {animationName?: string}) {
if (!listenForEventBubbling && event.target !== ref.current) {
return;
}
@ -36,7 +36,7 @@ function useAnimationEnd(
ref.current.addEventListener('animationend', handleAnimationend);
return () => {
ref.current.removeEventListener('animationend', handleAnimationend);
ref.current?.removeEventListener('animationend', handleAnimationend);
};
}, [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
* ignores events bubbling up from descendent elements
*/
function useTransitionend(
ref,
callback,
properties,
function useTransitionend<T extends Element>(
ref: React.RefObject<T>,
callback: (event: Event) => void,
properties: string[],
listenForEventBubbling = true,
) {
React.useEffect(() => {
@ -23,7 +23,7 @@ function useTransitionend(
return undefined;
}
function handleTransitionEnd(event) {
function handleTransitionEnd(event: Event & {propertyName?: string}) {
if (!listenForEventBubbling && event.target !== ref.current) {
return;
}

View file

@ -1,6 +1,6 @@
// Copyright (c) 2015-2016 Yuya Ochiai
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
// Copyright (c) 2015-2016 Yuya Ochiai
import 'bootstrap/dist/css/bootstrap.min.css';
import 'renderer/css/index.css';
@ -8,11 +8,18 @@ import 'renderer/css/index.css';
import React from 'react';
import ReactDOM from 'react-dom';
import {CombinedConfig, Team} from 'types/config';
import {GET_CONFIGURATION, UPDATE_TEAMS, QUIT, RELOAD_CONFIGURATION} from 'common/communication';
import MainPage from './components/MainPage.jsx';
class Root extends React.PureComponent {
constructor(props) {
import MainPage from './components/MainPage';
type State = {
config?: CombinedConfig;
}
class Root extends React.PureComponent<Record<string, never>, State> {
constructor(props: Record<string, never>) {
super(props);
this.state = {};
}
@ -39,7 +46,10 @@ class Root extends React.PureComponent {
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 tabOrder = teams.map((team, index) => {
return {
@ -62,12 +72,9 @@ class Root extends React.PureComponent {
return teamIndex;
};
teamConfigChange = async (updatedTeams, callback) => {
const updatedConfig = await window.ipcRenderer.invoke(UPDATE_TEAMS, updatedTeams);
teamConfigChange = async (updatedTeams: Team[]) => {
await window.ipcRenderer.invoke(UPDATE_TEAMS, updatedTeams);
await this.reloadConfig();
if (callback) {
callback(updatedConfig);
}
};
reloadConfig = async () => {
@ -75,7 +82,7 @@ class Root extends React.PureComponent {
this.setState({config});
};
requestConfig = async (exitOnError) => {
requestConfig = async (exitOnError?: boolean) => {
// todo: should we block?
try {
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}) => {
// 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__}`);
});

View file

@ -3,10 +3,11 @@
import React from 'react';
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 'renderer/css/modals.css';
@ -16,7 +17,7 @@ const handleCancel = () => {
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);
};

View file

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

View file

@ -4,18 +4,27 @@
import React from 'react';
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 'renderer/css/modals.css';
import 'renderer/css/components/LoadingAnimation.css';
import 'renderer/css/components/LoadingScreen.css';
class LoadingScreenRoot extends React.PureComponent {
constructor() {
super();
type Props = Record<string, never>;
type State = {
showLoadingScreen: boolean;
darkMode: boolean;
}
class LoadingScreenRoot extends React.PureComponent<Props, State> {
constructor(props: Props) {
super(props);
this.state = {
showLoadingScreen: true,
darkMode: false,
@ -32,7 +41,7 @@ class LoadingScreenRoot extends React.PureComponent {
window.removeEventListener('message', this.handleMessageEvent);
}
handleMessageEvent = (event) => {
handleMessageEvent = (event: {data: ModalMessage<any>}) => {
if (event.data.type === RECEIVED_LOADING_SCREEN_DATA) {
this.setState({
darkMode: event.data.data.darkMode,

View file

@ -3,19 +3,20 @@
import React from 'react';
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 'renderer/css/modals.css';
const handleLoginCancel = (request) => {
const handleLoginCancel = (request: AuthenticationResponseDetails) => {
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);
};

View file

@ -3,20 +3,34 @@
// Copyright (c) 2015-2016 Yuya Ochiai
import React from 'react';
import PropTypes from 'prop-types';
import {Button, Col, ControlLabel, Form, FormGroup, FormControl, Modal} from 'react-bootstrap';
import {MODAL_INFO} from 'common/communication';
import urlUtils from 'common/utils/url';
import {LoginModalData} from 'types/auth';
import {ModalMessage} from 'types/modals';
import {AuthenticationResponseDetails, AuthInfo} from 'electron/renderer';
export default class LoginModal extends React.PureComponent {
constructor(props) {
import urlUtils from 'common/utils/url';
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);
this.state = {
username: '',
password: '',
request: null,
authInfo: null,
};
}
@ -30,7 +44,7 @@ export default class LoginModal extends React.PureComponent {
window.removeEventListener('message', this.handleAuthInfoMessage);
}
handleAuthInfoMessage = (event) => {
handleAuthInfoMessage = (event: {data: ModalMessage<LoginModalData>}) => {
switch (event.data.type) {
case MODAL_INFO: {
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();
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({
username: '',
password: '',
request: null,
authInfo: null,
request: undefined,
authInfo: undefined,
});
}
handleCancel = (event) => {
handleCancel = (event: React.MouseEvent<Button>) => {
event.preventDefault();
this.props.onCancel(this.state.request);
this.props.onCancel(this.state.request!);
this.setState({
username: '',
password: '',
request: null,
authInfo: null,
request: undefined,
authInfo: undefined,
});
}
setUsername = (e) => {
setUsername = (e: React.ChangeEvent<FormControl & HTMLInputElement>) => {
this.setState({username: e.target.value});
}
setPassword = (e) => {
setPassword = (e: React.ChangeEvent<FormControl & HTMLInputElement>) => {
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}`;
} else {
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.`;
return (
<Modal show={Boolean(this.state.request && this.state.authInfo)}>
<Modal
show={Boolean(this.state.request && this.state.authInfo)}
onHide={() => {}}
>
<Modal.Header>
<Modal.Title>{'Authentication Required'}</Modal.Title>
</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 'renderer/css/modals.css';
const queryString = window.location.search;
const urlParams = new URLSearchParams(queryString);
import React from 'react';
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 = () => {
window.postMessage({type: MODAL_CANCEL}, window.location.href);
};
const onSave = (data) => {
const onSave = (data: TeamWithIndex) => {
window.postMessage({type: MODAL_RESULT, data}, window.location.href);
};
@ -29,7 +28,6 @@ const start = async () => {
onSave={onSave}
editMode={false}
show={true}
url={decodeURIComponent(urlParams.get('url'))}
/>,
document.getElementById('app'),
);

View file

@ -4,9 +4,9 @@
import React from 'react';
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 'renderer/css/modals.css';
@ -23,7 +23,7 @@ const getPermissionInfo = () => {
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);
};

View file

@ -3,14 +3,29 @@
import React from 'react';
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 {MODAL_INFO} from 'common/communication';
import {PERMISSION_DESCRIPTION} from 'common/permissions';
export default class PermissionModal extends React.PureComponent {
constructor(props) {
type 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);
this.state = {};
}
@ -25,7 +40,7 @@ export default class PermissionModal extends React.PureComponent {
window.removeEventListener('message', this.handlePermissionInfoMessage);
}
handlePermissionInfoMessage = (event) => {
handlePermissionInfoMessage = (event: {data: ModalMessage<{url: string; permission: PermissionType}>}) => {
switch (event.data.type) {
case MODAL_INFO: {
const {url, permission} = event.data.data;
@ -38,7 +53,7 @@ export default class PermissionModal extends React.PureComponent {
}
getModalTitle() {
return `${PERMISSION_DESCRIPTION[this.state.permission]} Required`;
return `${PERMISSION_DESCRIPTION[this.state.permission!]} Required`;
}
getModalBody() {
@ -46,12 +61,12 @@ export default class PermissionModal extends React.PureComponent {
const originDisplay = url ? urlUtil.getHost(url) : 'unknown origin';
const originLink = url ? originDisplay : '';
const click = (e) => {
const click = (e: React.MouseEvent<HTMLAnchorElement>) => {
e.preventDefault();
let parseUrl;
try {
parseUrl = urlUtil.parseURL(originLink);
this.props.openExternalLink(parseUrl.protocol, originLink);
this.props.openExternalLink(parseUrl!.protocol, originLink);
} catch (err) {
console.error(`invalid url ${originLink} supplied to externallink: ${err}`);
}
@ -60,7 +75,7 @@ export default class PermissionModal extends React.PureComponent {
return (
<div>
<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>
<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)}
id='requestPermissionModal'
enforceFocus={true}
onHide={() => {}}
>
<Modal.Header>
<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 ReactDOM from 'react-dom';
import UrlDescription from '../../components/urlDescription.jsx';
import UrlDescription from '../../components/urlDescription';
const start = async () => {
ReactDOM.render(
<UrlDescription
url={decodeURIComponent(urlParams.get('url'))}
url={decodeURIComponent(urlParams.get('url')!)}
/>,
document.getElementById('app'),
);

View file

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

View file

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

View file

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