From 131b5fa2ac42eaf4ad33fb55f5fd175f6fe32df0 Mon Sep 17 00:00:00 2001 From: Tasos Boulis Date: Fri, 7 Oct 2022 11:40:27 +0300 Subject: [PATCH] [MM-22239] Downloads dropdown (#2227) * WIP: show/hide temp downloads dropdown * WIP: Position downloads dropdown correctly under the button * WIP: Use correct width for dropdown so that right radius and shadows are displayed * WIP: Add items to download list after finished downloading * WIP: Add download item base components * Add "clear all" functionality * Use type Record<> for downloads saved in config * Add styling to files in the downloads dropdown * Open file in folder when clicking it from downloads dropdown. Center svg in parent element * Update scrollbar styling * Update scrollbar styling * Update state of downloaded items if deleted from folder * Add progress bar in downloads * Use "x-uncompressed-content-length" in file downloads. * Keep downloads open when clicking outside their browserview * Use correct color for downloads dropdown button * Add better styling to downloads dropdown button * Allow only 50 download files maximum. Oldest file is being removed if reached * Autoclose downloads dropdown after 4s of download finish * Add file thumbnails * Dont show second dialog if first dismissed * Add red badge when downloads running and dropdown closed * Add menu item for Downloads * Add support for more code file extensions * Open downloads dropdown instead of folder from the menu * Run lint:js and fix problems * Add tests for utils * Fix issue with dropdown not displaying * Remove unecessary comment * Move downloads to separate json file, outside Config * Add downloads dropdown menu for the 3-dot button * Dont show dev tools for downloads * Add cancel download functionality * Add dark mode styling * Use View state for downloadsMenu open state * Fix some style issues * Add image preview for downloaded images * Remove extra devTool in weback config * Fix issue with paths on windows * Align items left in downloads menu * Use pretty-bytes for file sizes * Show download remaining time * Close downloads dropdown when clicking outside * Show different units in received bytes when they are different from the total units (kb/mb) * Dont hide downloads when mattermost view is clicked * Keep downloads open if download button is clicked * Use closest() to check for download clicks * Fix unit tests. Add tests for new Views and downloadManager Add @types/jest as devDependency for intellisense * Remove unecessary tsconfig for jest * Fix types error * Add all critical tests for downloadsManager * WIP: add e2e tests for downloads * WIP: add e2e tests for downloads * Rename downloads spec file * WIP: make vscode debugger work for e2e tests * Remove unused mock * Remove defaults for v4 config * Use electron-mocha for e2e debugger * Fix e2e tests spawning JsonFileManager twice * Add async fs functions and add tests for download item UI * Add async fs functions and add tests for download item UI * Improve tests with "waitForSelector" to wait for visible elements * Wait for page load before assertions * Add tests for file uploads/downloads * Dont show native notification for completed downloads if dropdown is open * Increment filenames if file already exists * Fix antializing in downloads dropdown * Fix styling of downloads header * Increase dimensions of green/red icons in downloads * Fix styling of 3-dot button * Fix unit tests * Show 3-dot button only on hover or click * PR review fixes * Revert vscode debug fixes * Mock fs.constants * Mock fs instead of JsonFileManager in downlaods tests * Mock fs instead of JsonFileManager in downlaods tests * Add necessary mocks for downloads manager * Mark file as deleted if user deleted it * Fix min-height of downloads dropdown and 3-dot icon position * Add more tests * Make size of downloads dropdown dynamic based on content * Combine log statements * Close 3-dot menu if user clicks elsewhere * Move application updates inside downloads dropdown * Fix update issues * Fix ipc event payload * Add missing prop * Remove unused translations * Fix failing test * Fix version unknown * Remove commented out component --- .eslintrc.json | 4 +- .vscode/settings.json | 25 +- e2e/modules/environment.js | 55 +- e2e/modules/utils.js | 87 +++ .../downloads_dropdown_items.test.js | 193 ++++++ e2e/specs/downloads/downloads_manager.test.js | 84 +++ e2e/specs/downloads/downloads_menubar.test.js | 126 ++++ i18n/en.json | 32 +- package-lock.json | 556 +++++++++++++++++ package.json | 1 + scripts/watch_main_and_preload.js | 3 + src/common/communication.ts | 40 +- src/common/constants.ts | 8 + src/common/utils/constants.ts | 15 + src/main/Validator.ts | 22 +- src/main/app/initialize.test.js | 53 +- src/main/app/initialize.ts | 34 +- src/main/autoUpdater.test.js | 3 + src/main/autoUpdater.ts | 81 ++- src/main/constants.ts | 2 + src/main/downloadsManager.test.js | 194 ++++++ src/main/downloadsManager.ts | 575 ++++++++++++++++++ src/main/menus/app.test.js | 47 +- src/main/menus/app.ts | 15 +- src/main/preload/downloadsDropdown.js | 81 +++ src/main/preload/downloadsDropdownMenu.js | 58 ++ src/main/preload/loadingScreenPreload.js | 2 + src/main/preload/mattermost.js | 18 +- src/main/utils.test.js | 57 +- src/main/utils.ts | 38 ++ .../views/downloadsDropdownMenuView.test.js | 85 +++ src/main/views/downloadsDropdownMenuView.ts | 223 +++++++ src/main/views/downloadsDropdownView.test.js | 102 ++++ src/main/views/downloadsDropdownView.ts | 201 ++++++ src/main/windows/windowManager.test.js | 5 + src/main/windows/windowManager.ts | 27 +- .../DownloadsDropdownButton.tsx | 58 ++ .../DownloadsDropdownItem.tsx | 28 + .../DownloadsDropdownItemFile.tsx | 70 +++ .../DownloadsDropdown/FileSizeAndStatus.tsx | 37 ++ .../DownloadsDropdown/ProgressBar.tsx | 26 + .../DownloadsDropdown/ThreeDotButton.tsx | 48 ++ .../DownloadsDropdown/Thumbnail.tsx | 63 ++ .../Update/UpdateAvailable.tsx | 57 ++ .../Update/UpdateDownloaded.tsx | 61 ++ .../Update/UpdateWrapper.tsx | 26 + src/renderer/components/MainPage.tsx | 166 +++-- src/renderer/constants.ts | 100 +++ src/renderer/css/components/Button.scss | 1 + .../DownloadsDropdownButton.scss | 50 ++ src/renderer/css/downloadsDropdown.scss | 518 ++++++++++++++++ src/renderer/css/downloadsDropdownMenu.scss | 85 +++ src/renderer/css/thumbnails/audio.svg | 6 + src/renderer/css/thumbnails/code.svg | 5 + src/renderer/css/thumbnails/excel.svg | 5 + src/renderer/css/thumbnails/generic.svg | 5 + src/renderer/css/thumbnails/image.svg | 6 + src/renderer/css/thumbnails/mattermost.svg | 9 + src/renderer/css/thumbnails/patch.svg | 5 + src/renderer/css/thumbnails/pdf.svg | 5 + src/renderer/css/thumbnails/ppt.svg | 5 + src/renderer/css/thumbnails/text.svg | 8 + src/renderer/css/thumbnails/video.svg | 5 + src/renderer/css/thumbnails/word.svg | 5 + src/renderer/css/thumbnails/zip.svg | 16 + src/renderer/downloadsDropdown.tsx | 126 ++++ src/renderer/downloadsDropdownMenu.tsx | 163 +++++ src/renderer/dropdown.tsx | 6 +- src/renderer/index.tsx | 1 - src/renderer/utils.ts | 94 +++ src/types/downloads.ts | 29 + tsconfig.json | 3 +- webpack.config.main.js | 2 + webpack.config.renderer.js | 14 + 74 files changed, 4805 insertions(+), 264 deletions(-) create mode 100644 e2e/specs/downloads/downloads_dropdown_items.test.js create mode 100644 e2e/specs/downloads/downloads_manager.test.js create mode 100644 e2e/specs/downloads/downloads_menubar.test.js create mode 100644 src/common/constants.ts create mode 100644 src/main/downloadsManager.test.js create mode 100644 src/main/downloadsManager.ts create mode 100644 src/main/preload/downloadsDropdown.js create mode 100644 src/main/preload/downloadsDropdownMenu.js create mode 100644 src/main/views/downloadsDropdownMenuView.test.js create mode 100644 src/main/views/downloadsDropdownMenuView.ts create mode 100644 src/main/views/downloadsDropdownView.test.js create mode 100644 src/main/views/downloadsDropdownView.ts create mode 100644 src/renderer/components/DownloadsDropdown/DownloadsDropdownButton.tsx create mode 100644 src/renderer/components/DownloadsDropdown/DownloadsDropdownItem.tsx create mode 100644 src/renderer/components/DownloadsDropdown/DownloadsDropdownItemFile.tsx create mode 100644 src/renderer/components/DownloadsDropdown/FileSizeAndStatus.tsx create mode 100644 src/renderer/components/DownloadsDropdown/ProgressBar.tsx create mode 100644 src/renderer/components/DownloadsDropdown/ThreeDotButton.tsx create mode 100644 src/renderer/components/DownloadsDropdown/Thumbnail.tsx create mode 100644 src/renderer/components/DownloadsDropdown/Update/UpdateAvailable.tsx create mode 100644 src/renderer/components/DownloadsDropdown/Update/UpdateDownloaded.tsx create mode 100644 src/renderer/components/DownloadsDropdown/Update/UpdateWrapper.tsx create mode 100644 src/renderer/constants.ts create mode 100644 src/renderer/css/components/DownloadsDropdown/DownloadsDropdownButton.scss create mode 100644 src/renderer/css/downloadsDropdown.scss create mode 100644 src/renderer/css/downloadsDropdownMenu.scss create mode 100644 src/renderer/css/thumbnails/audio.svg create mode 100644 src/renderer/css/thumbnails/code.svg create mode 100644 src/renderer/css/thumbnails/excel.svg create mode 100644 src/renderer/css/thumbnails/generic.svg create mode 100644 src/renderer/css/thumbnails/image.svg create mode 100644 src/renderer/css/thumbnails/mattermost.svg create mode 100644 src/renderer/css/thumbnails/patch.svg create mode 100644 src/renderer/css/thumbnails/pdf.svg create mode 100644 src/renderer/css/thumbnails/ppt.svg create mode 100644 src/renderer/css/thumbnails/text.svg create mode 100644 src/renderer/css/thumbnails/video.svg create mode 100644 src/renderer/css/thumbnails/word.svg create mode 100644 src/renderer/css/thumbnails/zip.svg create mode 100644 src/renderer/downloadsDropdown.tsx create mode 100644 src/renderer/downloadsDropdownMenu.tsx create mode 100644 src/renderer/utils.ts create mode 100644 src/types/downloads.ts diff --git a/.eslintrc.json b/.eslintrc.json index 5de9aa41..7d875005 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -24,7 +24,9 @@ "import/no-commonjs": 2, "no-process-env": 0, "no-var": 2, - "react/no-find-dom-node": 2 + "react/no-find-dom-node": 2, + "multiline-ternary": 0, + "max-lines": ["warn", 650] }, "overrides": [ { diff --git a/.vscode/settings.json b/.vscode/settings.json index e3a092f4..4f752c72 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -6,12 +6,35 @@ "source.fixAll.eslint": true }, "cSpell.words": [ + "automations", + "Autoupgrade", "browserview", + "btns", + "chromedriver", + "chromedriverlog", "deauthorize", + "deeplinking", + "diskimage", + "dont", + "favicons", + "filechooser", + "flac", + "FOCALBOARD", + "ICONNAME", "loadscreen", + "mmjstool", + "NOSERVERS", "Ochiai", + "officedocument", + "openxmlformats", + "presentationml", + "spreadsheetml", + "textbox", "UNCLOSEABLE", + "Unreads", "webcontents", + "wordprocessingml", "Yuya" - ] + ], + "i18n-ally.keystyle": "nested" } diff --git a/e2e/modules/environment.js b/e2e/modules/environment.js index e6b0818b..546395d7 100644 --- a/e2e/modules/environment.js +++ b/e2e/modules/environment.js @@ -15,7 +15,7 @@ const {ipcRenderer} = require('electron'); const {SHOW_SETTINGS_WINDOW} = require('../../src/common/communication'); -const {asyncSleep} = require('./utils'); +const {asyncSleep, mkDirAsync, rmDirAsync, unlinkAsync} = require('./utils'); chai.should(); const sourceRootDir = path.join(__dirname, '../..'); @@ -28,6 +28,8 @@ const electronBinaryPath = (() => { })(); const userDataDir = path.join(sourceRootDir, 'e2e/testUserData/'); const configFilePath = path.join(userDataDir, 'config.json'); +const downloadsFilePath = path.join(userDataDir, 'downloads.json'); +const downloadsLocation = path.join(userDataDir, 'Downloads'); const boundsInfoPath = path.join(userDataDir, 'bounds-info.json'); const appUpdatePath = path.join(userDataDir, 'app-update.yml'); const exampleURL = 'http://example.com/'; @@ -51,12 +53,10 @@ const exampleTeam = { { name: 'TAB_FOCALBOARD', order: 1, - isOpen: true, }, { name: 'TAB_PLAYBOOKS', order: 2, - isOpen: true, }, ], lastActiveTab: 0, @@ -100,10 +100,14 @@ const demoConfig = { useSpellChecker: true, enableHardwareAcceleration: true, autostart: true, + hideOnStart: false, + spellCheckerLocales: [], darkMode: false, lastActiveTeam: 0, - spellCheckerLocales: [], + startInFullscreen: false, + autoCheckForUpdates: false, appLanguage: 'en', + logLevel: 'silly', }; const demoMattermostConfig = { @@ -119,6 +123,8 @@ const cmdOrCtrl = process.platform === 'darwin' ? 'command' : 'control'; module.exports = { sourceRootDir, configFilePath, + downloadsFilePath, + downloadsLocation, userDataDir, boundsInfoPath, appUpdatePath, @@ -147,7 +153,7 @@ module.exports = { }, cleanTestConfig() { - [configFilePath, boundsInfoPath].forEach((file) => { + [configFilePath, downloadsFilePath, boundsInfoPath].forEach((file) => { try { fs.unlinkSync(file); } catch (err) { @@ -158,6 +164,13 @@ module.exports = { } }); }, + async cleanTestConfigAsync() { + await Promise.all( + [configFilePath, downloadsFilePath, boundsInfoPath].map((file) => { + return unlinkAsync(file); + }), + ); + }, cleanDataDir() { try { @@ -169,28 +182,35 @@ module.exports = { } } }, + cleanDataDirAsync() { + return rmDirAsync(userDataDir); + }, createTestUserDataDir() { if (!fs.existsSync(userDataDir)) { fs.mkdirSync(userDataDir); } }, + async createTestUserDataDirAsync() { + await mkDirAsync(userDataDir); + }, async getApp(args = []) { const options = { + downloadsPath: downloadsLocation, env: { ...process.env, RESOURCES_PATH: userDataDir, }, executablePath: electronBinaryPath, - args: [`${path.join(sourceRootDir, 'dist')}`, `--data-dir=${userDataDir}`, '--disable-dev-mode', ...args], + args: [`${path.join(sourceRootDir, 'dist')}`, `--user-data-dir=${userDataDir}`, '--disable-dev-mode', ...args], }; // if (process.env.MM_DEBUG_SETTINGS) { // options.chromeDriverLogPath = './chromedriverlog.txt'; // } // if (process.platform === 'darwin' || process.platform === 'linux') { - // // on a mac, debbuging port might conflict with other apps + // // on a mac, debugging port might conflict with other apps // // this changes the default debugging port so chromedriver can run without issues. // options.chromeDriverArgs.push('remote-debugging-port=9222'); //} @@ -242,6 +262,27 @@ module.exports = { await window.click('#saveSetting'); }, + async openDownloadsDropdown(app) { + const mainWindow = app.windows().find((window) => window.url().includes('index')); + await mainWindow.waitForLoadState(); + await mainWindow.bringToFront(); + + const dlButtonLocator = await mainWindow.waitForSelector('.DownloadsDropdownButton'); + await dlButtonLocator.click(); + await asyncSleep(500); + + const downloadsWindow = app.windows().find((window) => window.url().includes('downloadsDropdown')); + await downloadsWindow.waitForLoadState(); + await downloadsWindow.bringToFront(); + return downloadsWindow; + }, + + async downloadsDropdownIsOpen(app) { + const downloadsWindow = app.windows().find((window) => window.url().includes('downloadsDropdown')); + const result = await downloadsWindow.isVisible('.DownloadsDropdown'); + return result; + }, + addClientCommands(client) { client.addCommand('loadSettingsPage', function async() { ipcRenderer.send(SHOW_SETTINGS_WINDOW); diff --git a/e2e/modules/utils.js b/e2e/modules/utils.js index 1f417289..cd1706e4 100644 --- a/e2e/modules/utils.js +++ b/e2e/modules/utils.js @@ -1,6 +1,7 @@ // Copyright (c) 2015-2016 Yuya Ochiai // Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. +const fs = require('fs'); function asyncSleep(timeout) { return new Promise((resolve) => { @@ -10,6 +11,92 @@ function asyncSleep(timeout) { }); } +function dirExistsAsync(path) { + return new Promise((resolve, reject) => { + fs.stat(path, (error, stats) => { + if (error) { + if (error.code === 'ENOENT') { + resolve(false); + } else { + reject(error); + } + return; + } + resolve(stats.isDirectory()); + }); + }); +} + +function mkDirAsync(path) { + return new Promise((resolve, reject) => { + dirExistsAsync(path).then((exists) => { + if (!exists) { + fs.mkdir(path, {recursive: true}, (error) => { + if (error) { + reject(error); + return; + } + resolve(); + }); + } + }).catch((err) => { + reject(err); + }); + }); +} + +function rmDirAsync(path) { + return new Promise((resolve, reject) => { + dirExistsAsync(path).then((exists) => { + if (exists) { + fs.rm(path, {recursive: true, force: true}, (error) => { + if (error) { + if (error.code === 'ENOENT') { + resolve(); + } + reject(error); + } + resolve(); + }); + } + resolve(); + }).catch((err) => { + reject(err); + }); + }); +} + +function unlinkAsync(path) { + return new Promise((resolve, reject) => { + fs.unlink(path, (error) => { + if (error) { + if (error.code === 'ENOENT') { + resolve(); + } + reject(error); + } + resolve(); + }); + }); +} + +function writeFileAsync(path, data) { + return new Promise((resolve, reject) => { + fs.writeFile(path, data, (err) => { + if (err) { + reject(err); + return; + } + resolve(); + }); + }); +} + module.exports = { asyncSleep, + dirExistsAsync, + mkDirAsync, + rmDirAsync, + unlinkAsync, + writeFileAsync, }; diff --git a/e2e/specs/downloads/downloads_dropdown_items.test.js b/e2e/specs/downloads/downloads_dropdown_items.test.js new file mode 100644 index 00000000..4d445d29 --- /dev/null +++ b/e2e/specs/downloads/downloads_dropdown_items.test.js @@ -0,0 +1,193 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. +'use strict'; + +const path = require('path'); + +const env = require('../../modules/environment'); +const {asyncSleep, mkDirAsync, rmDirAsync, writeFileAsync} = require('../../modules/utils'); + +const config = env.demoConfig; + +const file1 = { + addedAt: Date.UTC(2022, 8, 8, 10), // Aug 08, 2022 10:00AM UTC + filename: 'file1.txt', + mimeType: 'plain/text', + location: path.join(env.downloadsLocation, 'file1.txt'), + progress: 100, + receivedBytes: 3917388, + state: 'completed', + totalBytes: 3917388, + type: 'file', +}; +const file2 = { + addedAt: Date.UTC(2022, 8, 8, 11), // Aug 08, 2022 11:00AM UTC + filename: 'file2.txt', + mimeType: 'plain/text', + location: path.join(env.downloadsLocation, 'file2.txt'), + progress: 100, + receivedBytes: 7917388, + state: 'completed', + totalBytes: 7917388, + type: 'file', +}; + +describe('downloads/downloads_dropdown_items', function desc() { + this.timeout(30000); + + describe('The list has one downloaded file', () => { + const downloads = { + [file1.filename]: file1, + }; + + beforeEach(async () => { + await env.createTestUserDataDirAsync(); + await env.cleanTestConfigAsync(); + await writeFileAsync(env.configFilePath, JSON.stringify(config)); + await writeFileAsync(env.downloadsFilePath, JSON.stringify(downloads)); + await mkDirAsync(env.downloadsLocation); + await writeFileAsync(path.join(env.downloadsLocation, 'file1.txt'), 'file1 content'); + await asyncSleep(1000); + this.app = await env.getApp(); + this.downloadsWindow = await env.openDownloadsDropdown(this.app); + }); + + afterEach(async () => { + await rmDirAsync(env.downloadsLocation); + await this.app?.close?.(); + await env.clearElectronInstances(); + }); + + it('MM-22239 should display the file correctly (downloaded)', async () => { + const filenameTextLocator = await this.downloadsWindow.waitForSelector('.DownloadsDropdown__File__Body__Details__Filename'); + const filenameInnerText = await filenameTextLocator.innerText(); + filenameInnerText.should.equal(downloads['file1.txt'].filename); + + const fileStateLocator = await this.downloadsWindow.waitForSelector('.DownloadsDropdown__File__Body__Details__FileSizeAndStatus'); + const fileStateInnerText = await fileStateLocator.innerText(); + fileStateInnerText.should.equal('3.92 MB • Downloaded'); + + const fileThumbnailLocator = await this.downloadsWindow.waitForSelector('.DownloadsDropdown__File__Body__Thumbnail'); + const thumbnailBackgroundImage = await fileThumbnailLocator.evaluate((node) => window.getComputedStyle(node).getPropertyValue('background-image')); + thumbnailBackgroundImage.should.include('text..svg'); + }); + }); + + describe('The list has one downloaded file but it is deleted from the folder', () => { + const downloads = { + [file1.filename]: file1, + }; + + beforeEach(async () => { + await env.createTestUserDataDirAsync(); + await env.cleanTestConfigAsync(); + await writeFileAsync(env.configFilePath, JSON.stringify(config)); + await writeFileAsync(env.downloadsFilePath, JSON.stringify(downloads)); + await mkDirAsync(env.downloadsLocation); + await asyncSleep(1000); + this.app = await env.getApp(); + this.downloadsWindow = await env.openDownloadsDropdown(this.app); + }); + + afterEach(async () => { + await rmDirAsync(env.downloadsLocation); + await this.app?.close?.(); + await env.clearElectronInstances(); + }); + + it('MM-22239 should display the file correctly (deleted)', async () => { + const filenameTextLocator = await this.downloadsWindow.waitForSelector('.DownloadsDropdown__File__Body__Details__Filename'); + const filenameInnerText = await filenameTextLocator.innerText(); + filenameInnerText.should.equal(downloads['file1.txt'].filename); + + const fileStateLocator = await this.downloadsWindow.waitForSelector('.DownloadsDropdown__File__Body__Details__FileSizeAndStatus'); + const fileStateInnerText = await fileStateLocator.innerText(); + fileStateInnerText.should.equal('3.92 MB • Deleted'); + + const fileThumbnailLocator = await this.downloadsWindow.waitForSelector('.DownloadsDropdown__File__Body__Thumbnail'); + const thumbnailBackgroundImage = await fileThumbnailLocator.evaluate((node) => window.getComputedStyle(node).getPropertyValue('background-image')); + thumbnailBackgroundImage.should.include('text..svg'); + }); + }); + + describe('The list has one cancelled file', () => { + const downloads = { + [file1.filename]: { + ...file1, + state: 'progressing', + progress: 50, + receivedBytes: 1958694, + totalBytes: 3917388, + }, + }; + + beforeEach(async () => { + await env.createTestUserDataDirAsync(); + await env.cleanTestConfigAsync(); + await writeFileAsync(env.configFilePath, JSON.stringify(config)); + await writeFileAsync(env.downloadsFilePath, JSON.stringify(downloads)); + await mkDirAsync(env.downloadsLocation); + await writeFileAsync(path.join(env.downloadsLocation, 'file1.txt'), 'file1 content'); + await asyncSleep(1000); + this.app = await env.getApp(); + this.downloadsWindow = await env.openDownloadsDropdown(this.app); + }); + + afterEach(async () => { + await rmDirAsync(env.downloadsLocation); + await this.app?.close?.(); + await env.clearElectronInstances(); + }); + + it('MM-22239 should display the file correctly (cancelled)', async () => { + const filenameTextLocator = await this.downloadsWindow.waitForSelector('.DownloadsDropdown__File__Body__Details__Filename'); + const filenameInnerText = await filenameTextLocator.innerText(); + filenameInnerText.should.equal(downloads['file1.txt'].filename); + + const fileStateLocator = await this.downloadsWindow.waitForSelector('.DownloadsDropdown__File__Body__Details__FileSizeAndStatus'); + const fileStateInnerText = await fileStateLocator.innerText(); + fileStateInnerText.should.equal('3.92 MB • Cancelled'); + + const fileThumbnailLocator = await this.downloadsWindow.waitForSelector('.DownloadsDropdown__File__Body__Thumbnail'); + const thumbnailBackgroundImage = await fileThumbnailLocator.evaluate((node) => window.getComputedStyle(node).getPropertyValue('background-image')); + thumbnailBackgroundImage.should.include('text..svg'); + }); + }); + + describe('The list has two downloaded files', () => { + const downloads = { + 'file1.txt': file1, + 'file2.txt': file2, + }; + + beforeEach(async () => { + await env.createTestUserDataDirAsync(); + await env.cleanTestConfigAsync(); + await writeFileAsync(env.configFilePath, JSON.stringify(config)); + await writeFileAsync(env.downloadsFilePath, JSON.stringify(downloads)); + await mkDirAsync(env.downloadsLocation); + await writeFileAsync(path.join(env.downloadsLocation, 'file1.txt'), 'file1 content'); + await writeFileAsync(path.join(env.downloadsLocation, 'file2.txt'), 'file2 content'); + await asyncSleep(1000); + this.app = await env.getApp(); + this.downloadsWindow = await env.openDownloadsDropdown(this.app); + }); + + afterEach(async () => { + await rmDirAsync(env.downloadsLocation); + await this.app?.close?.(); + await env.clearElectronInstances(); + }); + + it('MM-22239 should display the files in correct order', async () => { + const filenameTextLocators = this.downloadsWindow.locator('.DownloadsDropdown__File__Body__Details__Filename'); + (await filenameTextLocators.count()).should.equal(2); + const firstItemLocator = filenameTextLocators.first(); + const file1InnerText = await firstItemLocator.innerText(); + file1InnerText.should.equal(downloads['file2.txt'].filename); // newest first + const secondItemLocator = filenameTextLocators.nth(1); + const file2InnerText = await secondItemLocator.innerText(); + file2InnerText.should.equal(downloads['file1.txt'].filename); + }); + }); +}); diff --git a/e2e/specs/downloads/downloads_manager.test.js b/e2e/specs/downloads/downloads_manager.test.js new file mode 100644 index 00000000..26cf4892 --- /dev/null +++ b/e2e/specs/downloads/downloads_manager.test.js @@ -0,0 +1,84 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. +'use strict'; + +const robot = require('robotjs'); + +const env = require('../../modules/environment'); +const {asyncSleep, rmDirAsync, writeFileAsync} = require('../../modules/utils'); + +const config = { + ...env.demoMattermostConfig, + teams: [ + ...env.demoMattermostConfig.teams, + { + url: 'https://community.mattermost.com', + name: 'community', + order: 0, + tabs: [ + { + name: 'TAB_MESSAGING', + order: 0, + isOpen: true, + }, + { + name: 'TAB_FOCALBOARD', + order: 1, + }, + { + name: 'TAB_PLAYBOOKS', + order: 2, + }, + ], + lastActiveTab: 0, + }, + ], +}; + +describe('downloads/downloads_manager', function desc() { + this.timeout(30000); + let firstServer; + const filename = `${Date.now().toString()}.txt`; + + beforeEach(async () => { + await env.cleanDataDirAsync(); + await env.cleanTestConfigAsync(); + await env.createTestUserDataDirAsync(); + await writeFileAsync(env.configFilePath, JSON.stringify(config)); + await asyncSleep(1000); + + this.app = await env.getApp(); + this.serverMap = await env.getServerMap(this.app); + const loadingScreen = this.app.windows().find((window) => window.url().includes('loadingScreen')); + await loadingScreen.waitForSelector('.LoadingScreen', {state: 'hidden'}); + firstServer = this.serverMap[`${config.teams[0].name}___TAB_MESSAGING`].win; + await env.loginToMattermost(firstServer); + + const textbox = await firstServer.waitForSelector('#post_textbox'); + const fileInput = await firstServer.waitForSelector('input[type="file"]'); + await fileInput.setInputFiles({ + name: filename, + mimeType: 'text/plain', + buffer: Buffer.from('this is test file'), + }); + await asyncSleep(1000); + await textbox.focus(); + robot.keyTap('enter'); + }); + + afterEach(async () => { + await rmDirAsync(env.downloadsLocation); + await this.app?.close?.(); + await env.clearElectronInstances(); + }); + + it('MM-22239 should open downloads dropdown when a download starts', async () => { + await firstServer.locator('#file-attachment-link', {hasText: filename}).click(); + await asyncSleep(1000); + await Promise.all([ + firstServer.waitForEvent('download'), // It is important to call waitForEvent before click to set up waiting. + firstServer.locator(`div[role="dialog"] a[download="${filename}"]`).click(), // Triggers the download. + ]); + (await env.downloadsDropdownIsOpen(this.app)).should.equal(true); + }); +}); diff --git a/e2e/specs/downloads/downloads_menubar.test.js b/e2e/specs/downloads/downloads_menubar.test.js new file mode 100644 index 00000000..90766a3d --- /dev/null +++ b/e2e/specs/downloads/downloads_menubar.test.js @@ -0,0 +1,126 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. +'use strict'; + +const path = require('path'); + +const env = require('../../modules/environment'); +const {asyncSleep, writeFileAsync} = require('../../modules/utils'); + +const config = env.demoConfig; + +const downloads = { + 'file1.txt': { + addedAt: Date.UTC(2022, 8, 8, 10), // Aug 08, 2022 10:00AM UTC + filename: 'file1.txt', + mimeType: 'plain/text', + location: path.join(env.downloadsLocation, 'file1.txt'), + progress: 100, + receivedBytes: 3917388, + state: 'completed', + totalBytes: 3917388, + type: 'file', + }, +}; + +describe('downloads/downloads_menubar', function desc() { + this.timeout(30000); + + describe('The download list is empty', () => { + beforeEach(async () => { + await env.createTestUserDataDirAsync(); + await env.cleanTestConfigAsync(); + await writeFileAsync(env.configFilePath, JSON.stringify(config)); + await writeFileAsync(env.downloadsFilePath, JSON.stringify({})); + await asyncSleep(1000); + this.app = await env.getApp(); + }); + + afterEach(async () => { + await this.app?.close?.(); + await env.clearElectronInstances(); + }); + + it('MM-22239 should not show the downloads dropdown and the menu item should be disabled', async () => { + const mainWindow = this.app.windows().find((window) => window.url().includes('index')); + await mainWindow.waitForLoadState(); + await mainWindow.bringToFront(); + + const dlButton = mainWindow.locator('.DownloadsDropdownButton'); + + (await dlButton.isVisible()).should.equal(false); + + const saveMenuItem = await this.app.evaluate(async ({app}) => { + const viewMenu = app.applicationMenu.getMenuItemById('view'); + const saveItem = viewMenu.submenu.getMenuItemById('app-menu-downloads'); + + return saveItem; + }); + + saveMenuItem.should.haveOwnProperty('enabled', false); + }); + }); + + describe('The download list has one file', () => { + beforeEach(async () => { + await env.createTestUserDataDirAsync(); + await env.cleanTestConfigAsync(); + await writeFileAsync(env.configFilePath, JSON.stringify(config)); + await writeFileAsync(env.downloadsFilePath, JSON.stringify(downloads)); + await asyncSleep(1000); + this.app = await env.getApp(); + }); + + afterEach(async () => { + await this.app?.close?.(); + await env.clearElectronInstances(); + }); + + it('MM-22239 should show the downloads dropdown button and the menu item should be enabled', async () => { + const mainWindow = this.app.windows().find((window) => window.url().includes('index')); + await mainWindow.waitForLoadState(); + await mainWindow.bringToFront(); + + const dlButton = await mainWindow.waitForSelector('.DownloadsDropdownButton', {state: 'attached'}); + (await dlButton.isVisible()).should.equal(true); + + const saveMenuItem = await this.app.evaluate(async ({app}) => { + const viewMenu = app.applicationMenu.getMenuItemById('view'); + const saveItem = viewMenu.submenu.getMenuItemById('app-menu-downloads'); + + return saveItem; + }); + + saveMenuItem.should.haveOwnProperty('enabled', true); + }); + + it('MM-22239 should open the downloads dropdown when clicking the download button in the menubar', async () => { + const mainWindow = this.app.windows().find((window) => window.url().includes('index')); + await mainWindow.waitForLoadState(); + await mainWindow.bringToFront(); + + const dlButton = await mainWindow.waitForSelector('.DownloadsDropdownButton', {state: 'attached'}); + (await dlButton.isVisible()).should.equal(true); + await dlButton.click(); + + await asyncSleep(500); + (await env.downloadsDropdownIsOpen(this.app)).should.equal(true); + }); + + it('MM-22239 should open the downloads dropdown from the app menu', async () => { + const mainWindow = this.app.windows().find((window) => window.url().includes('index')); + await mainWindow.waitForLoadState(); + await mainWindow.bringToFront(); + + await this.app.evaluate(async ({app}) => { + const viewMenu = app.applicationMenu.getMenuItemById('view'); + const downloadsItem = viewMenu.submenu.getMenuItemById('app-menu-downloads'); + + downloadsItem.click(); + }); + + await asyncSleep(500); + (await env.downloadsDropdownIsOpen(this.app)).should.equal(true); + }); + }); +}); diff --git a/i18n/en.json b/i18n/en.json index eb8efeab..0769976d 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -33,16 +33,8 @@ "main.app.utils.migrateMacAppStore.button.selectAndImport": "Select Directory and Import", "main.app.utils.migrateMacAppStore.dialog.detail": "It appears that an existing {appName} configuration exists, would you like to import it? You will be asked to pick the correct configuration directory.", "main.app.utils.migrateMacAppStore.dialog.message": "Import Existing Configuration", - "main.autoUpdater.download.dialog.button.download": "Download", - "main.autoUpdater.download.dialog.button.remindMeLater": "Remind me Later", - "main.autoUpdater.download.dialog.detail": "A new version of the {appName} Desktop App is available for you to download and install now.", - "main.autoUpdater.download.dialog.message": "New desktop version available", "main.autoUpdater.noUpdate.detail": "You are using the latest version of the {appName} Desktop App (version {version}). You'll be notified when a new version is available to install.", "main.autoUpdater.noUpdate.message": "You're up to date", - "main.autoUpdater.update.dialog.button.remindMeLater": "Remind me Later", - "main.autoUpdater.update.dialog.button.restartAndUpdate": "Restart and Update", - "main.autoUpdater.update.dialog.detail": "A new version of the {appName} Desktop App is ready to install.", - "main.autoUpdater.update.dialog.message": "A new version is ready to install", "main.badge.noUnreads": "You have no unread messages", "main.badge.sessionExpired": "Session Expired: Please sign in to continue receiving notifications.", "main.badge.unreadChannels": "You have unread channels", @@ -84,6 +76,7 @@ "main.menus.app.view.clearCacheAndReload": "Clear Cache and Reload", "main.menus.app.view.devToolsAppWrapper": "Developer Tools for Application Wrapper", "main.menus.app.view.devToolsCurrentServer": "Developer Tools for Current Server", + "main.menus.app.view.downloads": "Downloads", "main.menus.app.view.find": "Find..", "main.menus.app.view.fullscreen": "Toggle Full Screen", "main.menus.app.view.reload": "Reload", @@ -140,10 +133,7 @@ "renderer.components.extraBar.back": "Back", "renderer.components.input.required": "This field is required", "renderer.components.mainPage.contextMenu.ariaLabel": "Context menu", - "renderer.components.mainPage.downloadingUpdate": "Downloading update. {percentDone}% of {total} @ {speed}/s", "renderer.components.mainPage.titleBar": "Mattermost", - "renderer.components.mainPage.updateAvailable": "Update available", - "renderer.components.mainPage.updateReady": "Update ready to install", "renderer.components.newTeamModal.error.nameRequired": "Name is required.", "renderer.components.newTeamModal.error.serverNameExists": "A server with the same name already exists.", "renderer.components.newTeamModal.error.serverUrlExists": "A server with the same URL already exists.", @@ -235,6 +225,21 @@ "renderer.components.welcomeScreen.slides.playbooks.title": "Playbooks", "renderer.components.welcomeScreen.slides.welcome.subtitle": "Mattermost is an open source platform for developer collaboration. Secure, flexible, and integrated with the tools you love.", "renderer.components.welcomeScreen.slides.welcome.title": "Welcome", + "renderer.downloadsDropdown.ClearAll": "Clear All", + "renderer.downloadsDropdown.Downloads": "Downloads", + "renderer.downloadsDropdown.remaining": "remaining", + "renderer.downloadsDropdown.Update.ANewVersionIsAvailableToInstall": "A new version of the Mattermost Desktop App (version {version}) is available to install.", + "renderer.downloadsDropdown.Update.DownloadUpdate": "Download Update", + "renderer.downloadsDropdown.Update.MattermostVersionX": "Mattermost version {version}", + "renderer.downloadsDropdown.Update.NewDesktopVersionAvailable": "New Desktop version available", + "renderer.downloadsDropdown.Update.RestartAndUpdate": "Restart & update", + "renderer.downloadsDropdownMenu.CancelDownload": "Cancel Download", + "renderer.downloadsDropdownMenu.Clear": "Clear", + "renderer.downloadsDropdownMenu.Open": "Open", + "renderer.downloadsDropdownMenu.ShowInFileExplorer": "Show in File Explorer", + "renderer.downloadsDropdownMenu.ShowInFileManager": "Show in File Manager", + "renderer.downloadsDropdownMenu.ShowInFinder": "Show in Finder", + "renderer.downloadsDropdownMenu.ShowInFolder": "Show in Folder", "renderer.dropdown.addAServer": "Add a server", "renderer.dropdown.servers": "Servers", "renderer.modals.certificate.certificateModal.certInfoButton": "Certificate Information", @@ -252,5 +257,8 @@ "renderer.modals.permission.permissionModal.body": "A site that's not included in your Mattermost server configuration requires access for {permission}.", "renderer.modals.permission.permissionModal.requestOriginatedFromOrigin": "This request originated from {origin}", "renderer.modals.permission.permissionModal.title": "{permission} Required", - "renderer.modals.permission.permissionModal.unknownOrigin": "unknown origin" + "renderer.modals.permission.permissionModal.unknownOrigin": "unknown origin", + "renderer.time.hours": "hours", + "renderer.time.mins": "mins", + "renderer.time.sec": "sec" } diff --git a/package-lock.json b/package-lock.json index 5f220a2b..21151f2f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -52,6 +52,7 @@ "@storybook/react": "6.4.20", "@types/auto-launch": "5.0.2", "@types/electron-devtools-installer": "2.2.1", + "@types/jest": "^29.0.0", "@types/react": "17.0.43", "@types/react-beautiful-dnd": "13.0.0", "@types/react-dom": "17.0.14", @@ -2872,6 +2873,27 @@ "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" } }, + "node_modules/@jest/expect-utils": { + "version": "29.0.2", + "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-29.0.2.tgz", + "integrity": "sha512-+wcQF9khXKvAEi8VwROnCWWmHfsJYCZAs5dmuMlJBKk57S6ZN2/FQMIlo01F29fJyT8kV/xblE7g3vkIdTLOjw==", + "dev": true, + "dependencies": { + "jest-get-type": "^29.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/expect-utils/node_modules/jest-get-type": { + "version": "29.0.0", + "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-29.0.0.tgz", + "integrity": "sha512-83X19z/HuLKYXYHskZlBAShO7UfLFXu/vWajw9ZNJASN32li8yHMaVGAQqxFW1RCFOkB7cubaL6FaJVQqqJLSw==", + "dev": true, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, "node_modules/@jest/fake-timers": { "version": "27.5.1", "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-27.5.1.tgz", @@ -3013,6 +3035,18 @@ "node": ">=8" } }, + "node_modules/@jest/schemas": { + "version": "29.0.0", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.0.0.tgz", + "integrity": "sha512-3Ab5HgYIIAnS0HjqJHQYZS+zXc4tUmTmBH3z83ajI6afXp8X3ZtdLX+nXx+I7LNkJD7uN9LAVhgnjDgZa2z0kA==", + "dev": true, + "dependencies": { + "@sinclair/typebox": "^0.24.1" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, "node_modules/@jest/source-map": { "version": "27.5.1", "resolved": "https://registry.npmjs.org/@jest/source-map/-/source-map-27.5.1.tgz", @@ -3685,6 +3719,12 @@ "resolved": "https://registry.npmjs.org/@sideway/pinpoint/-/pinpoint-2.0.0.tgz", "integrity": "sha512-RNiOoTPkptFtSVzQevY/yWtZwf/RxyVnPy/OcA9HBM3MlGDnBEYL5B41H0MTn0Uec8Hi+2qUtTfG2WWZBmMejQ==" }, + "node_modules/@sinclair/typebox": { + "version": "0.24.38", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.24.38.tgz", + "integrity": "sha512-IbYB6vdhLFmzGEyXXEdFAJKyq7S4/RsivkgxNzs/LzwYuUJHmeNQ0cHkjG/Yqm6VgUzzZDLMZAf0XgeeaZAocA==", + "dev": true + }, "node_modules/@sindresorhus/is": { "version": "0.14.0", "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-0.14.0.tgz", @@ -8989,6 +9029,276 @@ "@types/istanbul-lib-report": "*" } }, + "node_modules/@types/jest": { + "version": "29.0.0", + "resolved": "https://registry.npmjs.org/@types/jest/-/jest-29.0.0.tgz", + "integrity": "sha512-X6Zjz3WO4cT39Gkl0lZ2baFRaEMqJl5NC1OjElkwtNzAlbkr2K/WJXkBkH5VP0zx4Hgsd2TZYdOEfvp2Dxia+Q==", + "dev": true, + "dependencies": { + "expect": "^29.0.0", + "pretty-format": "^29.0.0" + } + }, + "node_modules/@types/jest/node_modules/@jest/types": { + "version": "29.0.2", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-29.0.2.tgz", + "integrity": "sha512-5WNMesBLmlkt1+fVkoCjHa0X3i3q8zc4QLTDkdHgCa2gyPZc7rdlZBWgVLqwS1860ZW5xJuCDwAzqbGaXIr/ew==", + "dev": true, + "dependencies": { + "@jest/schemas": "^29.0.0", + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^17.0.8", + "chalk": "^4.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@types/jest/node_modules/@types/yargs": { + "version": "17.0.12", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.12.tgz", + "integrity": "sha512-Nz4MPhecOFArtm81gFQvQqdV7XYCrWKx5uUt6GNHredFHn1i2mtWqXTON7EPXMtNi1qjtjEM/VCHDhcHsAMLXQ==", + "dev": true, + "dependencies": { + "@types/yargs-parser": "*" + } + }, + "node_modules/@types/jest/node_modules/braces": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", + "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", + "dev": true, + "dependencies": { + "fill-range": "^7.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@types/jest/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/@types/jest/node_modules/ci-info": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.3.2.tgz", + "integrity": "sha512-xmDt/QIAdeZ9+nfdPsaBCpMvHNLFiLdjj59qjqn+6iPe6YmHGQ35sBnQ8uslRBXFmXkiZQOJRjvQeoGppoTjjg==", + "dev": true + }, + "node_modules/@types/jest/node_modules/diff-sequences": { + "version": "29.0.0", + "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.0.0.tgz", + "integrity": "sha512-7Qe/zd1wxSDL4D/X/FPjOMB+ZMDt71W94KYaq05I2l0oQqgXgs7s4ftYYmV38gBSrPz2vcygxfs1xn0FT+rKNA==", + "dev": true, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@types/jest/node_modules/expect": { + "version": "29.0.2", + "resolved": "https://registry.npmjs.org/expect/-/expect-29.0.2.tgz", + "integrity": "sha512-JeJlAiLKn4aApT4pzUXBVxl3NaZidWIOdg//smaIlP9ZMBDkHZGFd9ubphUZP9pUyDEo7bC6M0IIZR51o75qQw==", + "dev": true, + "dependencies": { + "@jest/expect-utils": "^29.0.2", + "jest-get-type": "^29.0.0", + "jest-matcher-utils": "^29.0.2", + "jest-message-util": "^29.0.2", + "jest-util": "^29.0.2" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@types/jest/node_modules/fill-range": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", + "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", + "dev": true, + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@types/jest/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/@types/jest/node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/@types/jest/node_modules/jest-diff": { + "version": "29.0.2", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-29.0.2.tgz", + "integrity": "sha512-b9l9970sa1rMXH1owp2Woprmy42qIwwll/htsw4Gf7+WuSp5bZxNhkKHDuCGKL+HoHn1KhcC+tNEeAPYBkD2Jg==", + "dev": true, + "dependencies": { + "chalk": "^4.0.0", + "diff-sequences": "^29.0.0", + "jest-get-type": "^29.0.0", + "pretty-format": "^29.0.2" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@types/jest/node_modules/jest-get-type": { + "version": "29.0.0", + "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-29.0.0.tgz", + "integrity": "sha512-83X19z/HuLKYXYHskZlBAShO7UfLFXu/vWajw9ZNJASN32li8yHMaVGAQqxFW1RCFOkB7cubaL6FaJVQqqJLSw==", + "dev": true, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@types/jest/node_modules/jest-matcher-utils": { + "version": "29.0.2", + "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-29.0.2.tgz", + "integrity": "sha512-s62YkHFBfAx0JLA2QX1BlnCRFwHRobwAv2KP1+YhjzF6ZCbCVrf1sG8UJyn62ZUsDaQKpoo86XMTjkUyO5aWmQ==", + "dev": true, + "dependencies": { + "chalk": "^4.0.0", + "jest-diff": "^29.0.2", + "jest-get-type": "^29.0.0", + "pretty-format": "^29.0.2" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@types/jest/node_modules/jest-message-util": { + "version": "29.0.2", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-29.0.2.tgz", + "integrity": "sha512-kcJAgms3ckJV0wUoLsAM40xAhY+pb9FVSZwicjFU9PFkaTNmqh9xd99/CzKse48wPM1ANUQKmp03/DpkY+lGrA==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.12.13", + "@jest/types": "^29.0.2", + "@types/stack-utils": "^2.0.0", + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "micromatch": "^4.0.4", + "pretty-format": "^29.0.2", + "slash": "^3.0.0", + "stack-utils": "^2.0.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@types/jest/node_modules/jest-util": { + "version": "29.0.2", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-29.0.2.tgz", + "integrity": "sha512-ozk8ruEEEACxqpz0hN9UOgtPZS0aN+NffwQduR5dVlhN+eN47vxurtvgZkYZYMpYrsmlAEx1XabkB3BnN0GfKQ==", + "dev": true, + "dependencies": { + "@jest/types": "^29.0.2", + "@types/node": "*", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "graceful-fs": "^4.2.9", + "picomatch": "^2.2.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@types/jest/node_modules/micromatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.5.tgz", + "integrity": "sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==", + "dev": true, + "dependencies": { + "braces": "^3.0.2", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/@types/jest/node_modules/pretty-format": { + "version": "29.0.2", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.0.2.tgz", + "integrity": "sha512-wp3CdtUa3cSJVFn3Miu5a1+pxc1iPIQTenOAn+x5erXeN1+ryTcLesV5pbK/rlW5EKwp27x38MoYfNGaNXDDhg==", + "dev": true, + "dependencies": { + "@jest/schemas": "^29.0.0", + "ansi-styles": "^5.0.0", + "react-is": "^18.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@types/jest/node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@types/jest/node_modules/react-is": { + "version": "18.2.0", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz", + "integrity": "sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==", + "dev": true + }, + "node_modules/@types/jest/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@types/jest/node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, "node_modules/@types/json-buffer": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/@types/json-buffer/-/json-buffer-3.0.0.tgz", @@ -34827,6 +35137,23 @@ "jest-mock": "^27.5.1" } }, + "@jest/expect-utils": { + "version": "29.0.2", + "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-29.0.2.tgz", + "integrity": "sha512-+wcQF9khXKvAEi8VwROnCWWmHfsJYCZAs5dmuMlJBKk57S6ZN2/FQMIlo01F29fJyT8kV/xblE7g3vkIdTLOjw==", + "dev": true, + "requires": { + "jest-get-type": "^29.0.0" + }, + "dependencies": { + "jest-get-type": { + "version": "29.0.0", + "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-29.0.0.tgz", + "integrity": "sha512-83X19z/HuLKYXYHskZlBAShO7UfLFXu/vWajw9ZNJASN32li8yHMaVGAQqxFW1RCFOkB7cubaL6FaJVQqqJLSw==", + "dev": true + } + } + }, "@jest/fake-timers": { "version": "27.5.1", "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-27.5.1.tgz", @@ -34934,6 +35261,15 @@ } } }, + "@jest/schemas": { + "version": "29.0.0", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.0.0.tgz", + "integrity": "sha512-3Ab5HgYIIAnS0HjqJHQYZS+zXc4tUmTmBH3z83ajI6afXp8X3ZtdLX+nXx+I7LNkJD7uN9LAVhgnjDgZa2z0kA==", + "dev": true, + "requires": { + "@sinclair/typebox": "^0.24.1" + } + }, "@jest/source-map": { "version": "27.5.1", "resolved": "https://registry.npmjs.org/@jest/source-map/-/source-map-27.5.1.tgz", @@ -35454,6 +35790,12 @@ "resolved": "https://registry.npmjs.org/@sideway/pinpoint/-/pinpoint-2.0.0.tgz", "integrity": "sha512-RNiOoTPkptFtSVzQevY/yWtZwf/RxyVnPy/OcA9HBM3MlGDnBEYL5B41H0MTn0Uec8Hi+2qUtTfG2WWZBmMejQ==" }, + "@sinclair/typebox": { + "version": "0.24.38", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.24.38.tgz", + "integrity": "sha512-IbYB6vdhLFmzGEyXXEdFAJKyq7S4/RsivkgxNzs/LzwYuUJHmeNQ0cHkjG/Yqm6VgUzzZDLMZAf0XgeeaZAocA==", + "dev": true + }, "@sindresorhus/is": { "version": "0.14.0", "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-0.14.0.tgz", @@ -39623,6 +39965,220 @@ "@types/istanbul-lib-report": "*" } }, + "@types/jest": { + "version": "29.0.0", + "resolved": "https://registry.npmjs.org/@types/jest/-/jest-29.0.0.tgz", + "integrity": "sha512-X6Zjz3WO4cT39Gkl0lZ2baFRaEMqJl5NC1OjElkwtNzAlbkr2K/WJXkBkH5VP0zx4Hgsd2TZYdOEfvp2Dxia+Q==", + "dev": true, + "requires": { + "expect": "^29.0.0", + "pretty-format": "^29.0.0" + }, + "dependencies": { + "@jest/types": { + "version": "29.0.2", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-29.0.2.tgz", + "integrity": "sha512-5WNMesBLmlkt1+fVkoCjHa0X3i3q8zc4QLTDkdHgCa2gyPZc7rdlZBWgVLqwS1860ZW5xJuCDwAzqbGaXIr/ew==", + "dev": true, + "requires": { + "@jest/schemas": "^29.0.0", + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^17.0.8", + "chalk": "^4.0.0" + } + }, + "@types/yargs": { + "version": "17.0.12", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.12.tgz", + "integrity": "sha512-Nz4MPhecOFArtm81gFQvQqdV7XYCrWKx5uUt6GNHredFHn1i2mtWqXTON7EPXMtNi1qjtjEM/VCHDhcHsAMLXQ==", + "dev": true, + "requires": { + "@types/yargs-parser": "*" + } + }, + "braces": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", + "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", + "dev": true, + "requires": { + "fill-range": "^7.0.1" + } + }, + "chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "requires": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + } + }, + "ci-info": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.3.2.tgz", + "integrity": "sha512-xmDt/QIAdeZ9+nfdPsaBCpMvHNLFiLdjj59qjqn+6iPe6YmHGQ35sBnQ8uslRBXFmXkiZQOJRjvQeoGppoTjjg==", + "dev": true + }, + "diff-sequences": { + "version": "29.0.0", + "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.0.0.tgz", + "integrity": "sha512-7Qe/zd1wxSDL4D/X/FPjOMB+ZMDt71W94KYaq05I2l0oQqgXgs7s4ftYYmV38gBSrPz2vcygxfs1xn0FT+rKNA==", + "dev": true + }, + "expect": { + "version": "29.0.2", + "resolved": "https://registry.npmjs.org/expect/-/expect-29.0.2.tgz", + "integrity": "sha512-JeJlAiLKn4aApT4pzUXBVxl3NaZidWIOdg//smaIlP9ZMBDkHZGFd9ubphUZP9pUyDEo7bC6M0IIZR51o75qQw==", + "dev": true, + "requires": { + "@jest/expect-utils": "^29.0.2", + "jest-get-type": "^29.0.0", + "jest-matcher-utils": "^29.0.2", + "jest-message-util": "^29.0.2", + "jest-util": "^29.0.2" + } + }, + "fill-range": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", + "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", + "dev": true, + "requires": { + "to-regex-range": "^5.0.1" + } + }, + "has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true + }, + "is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true + }, + "jest-diff": { + "version": "29.0.2", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-29.0.2.tgz", + "integrity": "sha512-b9l9970sa1rMXH1owp2Woprmy42qIwwll/htsw4Gf7+WuSp5bZxNhkKHDuCGKL+HoHn1KhcC+tNEeAPYBkD2Jg==", + "dev": true, + "requires": { + "chalk": "^4.0.0", + "diff-sequences": "^29.0.0", + "jest-get-type": "^29.0.0", + "pretty-format": "^29.0.2" + } + }, + "jest-get-type": { + "version": "29.0.0", + "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-29.0.0.tgz", + "integrity": "sha512-83X19z/HuLKYXYHskZlBAShO7UfLFXu/vWajw9ZNJASN32li8yHMaVGAQqxFW1RCFOkB7cubaL6FaJVQqqJLSw==", + "dev": true + }, + "jest-matcher-utils": { + "version": "29.0.2", + "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-29.0.2.tgz", + "integrity": "sha512-s62YkHFBfAx0JLA2QX1BlnCRFwHRobwAv2KP1+YhjzF6ZCbCVrf1sG8UJyn62ZUsDaQKpoo86XMTjkUyO5aWmQ==", + "dev": true, + "requires": { + "chalk": "^4.0.0", + "jest-diff": "^29.0.2", + "jest-get-type": "^29.0.0", + "pretty-format": "^29.0.2" + } + }, + "jest-message-util": { + "version": "29.0.2", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-29.0.2.tgz", + "integrity": "sha512-kcJAgms3ckJV0wUoLsAM40xAhY+pb9FVSZwicjFU9PFkaTNmqh9xd99/CzKse48wPM1ANUQKmp03/DpkY+lGrA==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.12.13", + "@jest/types": "^29.0.2", + "@types/stack-utils": "^2.0.0", + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "micromatch": "^4.0.4", + "pretty-format": "^29.0.2", + "slash": "^3.0.0", + "stack-utils": "^2.0.3" + } + }, + "jest-util": { + "version": "29.0.2", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-29.0.2.tgz", + "integrity": "sha512-ozk8ruEEEACxqpz0hN9UOgtPZS0aN+NffwQduR5dVlhN+eN47vxurtvgZkYZYMpYrsmlAEx1XabkB3BnN0GfKQ==", + "dev": true, + "requires": { + "@jest/types": "^29.0.2", + "@types/node": "*", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "graceful-fs": "^4.2.9", + "picomatch": "^2.2.3" + } + }, + "micromatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.5.tgz", + "integrity": "sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==", + "dev": true, + "requires": { + "braces": "^3.0.2", + "picomatch": "^2.3.1" + } + }, + "pretty-format": { + "version": "29.0.2", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.0.2.tgz", + "integrity": "sha512-wp3CdtUa3cSJVFn3Miu5a1+pxc1iPIQTenOAn+x5erXeN1+ryTcLesV5pbK/rlW5EKwp27x38MoYfNGaNXDDhg==", + "dev": true, + "requires": { + "@jest/schemas": "^29.0.0", + "ansi-styles": "^5.0.0", + "react-is": "^18.0.0" + }, + "dependencies": { + "ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true + } + } + }, + "react-is": { + "version": "18.2.0", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz", + "integrity": "sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==", + "dev": true + }, + "supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "requires": { + "has-flag": "^4.0.0" + } + }, + "to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "requires": { + "is-number": "^7.0.0" + } + } + } + }, "@types/json-buffer": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/@types/json-buffer/-/json-buffer-3.0.0.tgz", diff --git a/package.json b/package.json index f733516e..c21cc169 100644 --- a/package.json +++ b/package.json @@ -135,6 +135,7 @@ "@storybook/react": "6.4.20", "@types/auto-launch": "5.0.2", "@types/electron-devtools-installer": "2.2.1", + "@types/jest": "^29.0.0", "@types/react": "17.0.43", "@types/react-beautiful-dnd": "13.0.0", "@types/react-dom": "17.0.14", diff --git a/scripts/watch_main_and_preload.js b/scripts/watch_main_and_preload.js index 9bd7acbf..0ea31f15 100644 --- a/scripts/watch_main_and_preload.js +++ b/scripts/watch_main_and_preload.js @@ -11,6 +11,9 @@ let started = false; const mainCompiler = webpack(mainConfig); mainCompiler.watch({}, (err, stats) => { + if (err) { + console.error(err); + } process.stdout.write(stats.toString({colors: true})); process.stdout.write('\n'); if (!stats.hasErrors()) { diff --git a/src/common/communication.ts b/src/common/communication.ts index c3b7dd6b..d5f08733 100644 --- a/src/common/communication.ts +++ b/src/common/communication.ts @@ -90,13 +90,16 @@ export const REQUEST_TEAMS_DROPDOWN_INFO = 'request-teams-dropdown-info'; export const RECEIVE_DROPDOWN_MENU_SIZE = 'receive-dropdown-menu-size'; export const SEND_DROPDOWN_MENU_SIZE = 'send-dropdown-menu-size'; -export const UPDATE_AVAILABLE = 'update_available'; -export const UPDATE_DOWNLOADED = 'update_downloaded'; -export const CANCEL_UPGRADE = 'cancel_upgrade'; -export const START_UPGRADE = 'start_upgrade'; -export const START_DOWNLOAD = 'start_download'; -export const CHECK_FOR_UPDATES = 'check_for_updates'; -export const UPDATE_PROGRESS = 'update_progress'; +export const UPDATE_AVAILABLE = 'update-available'; +export const UPDATE_DOWNLOADED = 'update-downloaded'; +export const UPDATE_PROGRESS = 'update-progress'; +export const UPDATE_REMIND_LATER = 'update-remind-later'; +export const CANCEL_UPDATE_DOWNLOAD = 'cancel-update-download'; +export const CANCEL_UPGRADE = 'cancel-upgrade'; +export const START_UPDATE_DOWNLOAD = 'start-update-download'; +export const START_UPGRADE = 'start-upgrade'; +export const CHECK_FOR_UPDATES = 'check-for-updates'; +export const NO_UPDATE_AVAILABLE = 'no-update-available'; export const BROWSER_HISTORY_BUTTON = 'browser-history-button'; export const BROWSER_HISTORY_PUSH = 'browser-history-push'; @@ -129,3 +132,26 @@ export const RETRIEVED_LANGUAGE_INFORMATION = 'retrieved-language-information'; export const GET_AVAILABLE_LANGUAGES = 'get-available-languages'; export const VIEW_FINISHED_RESIZING = 'view-finished-resizing'; + +export const REQUEST_CLEAR_DOWNLOADS_DROPDOWN = 'request-clear-downloads-dropdown'; +export const CLOSE_DOWNLOADS_DROPDOWN = 'close-downloads-dropdown'; +export const OPEN_DOWNLOADS_DROPDOWN = 'open-downloads-dropdown'; +export const SHOW_DOWNLOADS_DROPDOWN_BUTTON_BADGE = 'show-downloads-dropdown-button-badge'; +export const HIDE_DOWNLOADS_DROPDOWN_BUTTON_BADGE = 'hide-downloads-dropdown-button-badge'; +export const REQUEST_DOWNLOADS_DROPDOWN_INFO = 'request-downloads-dropdown-info'; +export const UPDATE_DOWNLOADS_DROPDOWN = 'update-downloads-dropdown'; +export const DOWNLOADS_DROPDOWN_SHOW_FILE_IN_FOLDER = 'downloads-dropdown-show-file-in-folder'; +export const REQUEST_HAS_DOWNLOADS = 'request-has-downloads'; +export const DOWNLOADS_DROPDOWN_FOCUSED = 'downloads-dropdown-focused'; +export const RECEIVE_DOWNLOADS_DROPDOWN_SIZE = 'receive-downloads-dropdown-size'; +export const SEND_DOWNLOADS_DROPDOWN_SIZE = 'send-downloads-dropdown-size'; + +export const OPEN_DOWNLOADS_DROPDOWN_MENU = 'open-downloads-dropdown-menu'; +export const CLOSE_DOWNLOADS_DROPDOWN_MENU = 'close-downloads-dropdown-menu'; +export const TOGGLE_DOWNLOADS_DROPDOWN_MENU = 'toggle-downloads-dropdown-menu'; +export const UPDATE_DOWNLOADS_DROPDOWN_MENU = 'update-downloads-dropdown-menu'; +export const UPDATE_DOWNLOADS_DROPDOWN_MENU_ITEM = 'update-downloads-dropdown-menu-item'; +export const REQUEST_DOWNLOADS_DROPDOWN_MENU_INFO = 'request-downloads-dropdown-menu-info'; +export const DOWNLOADS_DROPDOWN_MENU_CANCEL_DOWNLOAD = 'downloads-dropdown-menu-cancel-download'; +export const DOWNLOADS_DROPDOWN_MENU_CLEAR_FILE = 'downloads-dropdown-menu-clear-file'; +export const DOWNLOADS_DROPDOWN_MENU_OPEN_FILE = 'downloads-dropdown-menu-open-file'; diff --git a/src/common/constants.ts b/src/common/constants.ts new file mode 100644 index 00000000..48563f54 --- /dev/null +++ b/src/common/constants.ts @@ -0,0 +1,8 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +/** + * This string includes special characters so that it's not confused with + * a file that may have the same filename (eg APP_UPDATE) + */ +export const APP_UPDATE_KEY = '#:(APP_UPDATE):#'; diff --git a/src/common/utils/constants.ts b/src/common/utils/constants.ts index 8c0092bd..ddc98c64 100644 --- a/src/common/utils/constants.ts +++ b/src/common/utils/constants.ts @@ -14,6 +14,7 @@ export const MAX_SERVER_RETRIES = 3; export const MAX_LOADING_SCREEN_SECONDS = 4 * SECOND; export const TAB_BAR_HEIGHT = 40; +export const TAB_BAR_PADDING = 4; export const BACK_BAR_HEIGHT = 36; export const THREE_DOT_MENU_WIDTH = 40; export const THREE_DOT_MENU_WIDTH_MAC = 80; @@ -24,6 +25,20 @@ export const DEFAULT_WINDOW_HEIGHT = 800; export const MINIMUM_WINDOW_WIDTH = 700; export const MINIMUM_WINDOW_HEIGHT = 240; +export const DOWNLOADS_DROPDOWN_HEIGHT = 360; +export const DOWNLOADS_DROPDOWN_WIDTH = 280; +export const DOWNLOADS_DROPDOWN_PADDING = 24; +export const DOWNLOADS_DROPDOWN_MENU_HEIGHT = 160; +export const DOWNLOADS_DROPDOWN_MENU_WIDTH = 154; +export const DOWNLOADS_DROPDOWN_MENU_PADDING = 12; + +// In order to display the box-shadow & radius on the left + right, use this WIDTH in the browserView for downloadsDropdown +export const DOWNLOADS_DROPDOWN_FULL_WIDTH = DOWNLOADS_DROPDOWN_PADDING + DOWNLOADS_DROPDOWN_WIDTH + TAB_BAR_PADDING; +export const DOWNLOADS_DROPDOWN_MENU_FULL_WIDTH = (DOWNLOADS_DROPDOWN_MENU_PADDING * 2) + DOWNLOADS_DROPDOWN_MENU_WIDTH; +export const DOWNLOADS_DROPDOWN_MENU_FULL_HEIGHT = DOWNLOADS_DROPDOWN_MENU_HEIGHT + TAB_BAR_PADDING; // only bottom padding included for better positioning +export const DOWNLOADS_DROPDOWN_MAX_ITEMS = 50; +export const DOWNLOADS_DROPDOWN_AUTOCLOSE_TIMEOUT = 4000; // 4 sec + // supported custom login paths (oath, saml) export const customLoginRegexPaths = [ /^\/oauth\/authorize$/i, diff --git a/src/main/Validator.ts b/src/main/Validator.ts index be411fb7..ead99aa5 100644 --- a/src/main/Validator.ts +++ b/src/main/Validator.ts @@ -6,6 +6,7 @@ import Joi from 'joi'; import {Args} from 'types/args'; import {ConfigV0, ConfigV1, ConfigV2, ConfigV3, TeamWithTabs} from 'types/config'; +import {DownloadedItems} from 'types/downloads'; import {SavedWindowState} from 'types/mainWindow'; import {AppState} from 'types/appState'; import {ComparableCertificate} from 'types/certificate'; @@ -45,6 +46,20 @@ const appStateSchema = Joi.object({ updateCheckedDate: Joi.string(), }); +const downloadsSchema = Joi.object().pattern( + Joi.string(), + { + type: Joi.string().valid('file', 'update'), + filename: Joi.string().allow(null), + state: Joi.string().valid('interrupted', 'progressing', 'completed', 'cancelled', 'deleted', 'available'), + progress: Joi.number().min(0).max(100), + location: Joi.string().allow(''), + mimeType: Joi.string().allow(null), + addedAt: Joi.number().min(0), + receivedBytes: Joi.number().min(0), + totalBytes: Joi.number().min(0), + }); + const configDataSchemaV0 = Joi.object({ url: Joi.string().required(), }); @@ -96,7 +111,7 @@ const configDataSchemaV2 = Joi.object({ }); const configDataSchemaV3 = Joi.object({ - version: Joi.number().min(2).default(2), + version: Joi.number().min(3).default(3), teams: Joi.array().items(Joi.object({ name: Joi.string().required(), url: Joi.string().required(), @@ -171,6 +186,11 @@ export function validateAppState(data: AppState) { return validateAgainstSchema(data, appStateSchema); } +// validate downloads.json +export function validateDownloads(data: DownloadedItems) { + return validateAgainstSchema(data, downloadsSchema); +} + // validate v.0 config.json export function validateV0ConfigData(data: ConfigV0) { return validateAgainstSchema(data, configDataSchemaV0); diff --git a/src/main/app/initialize.test.js b/src/main/app/initialize.test.js index 4009e2a5..8ee70f5d 100644 --- a/src/main/app/initialize.test.js +++ b/src/main/app/initialize.test.js @@ -8,7 +8,6 @@ import {app, session} from 'electron'; import Config from 'common/config'; import urlUtils from 'common/utils/url'; -import {displayDownloadCompleted} from 'main/notifications'; import parseArgs from 'main/ParseArgs'; import WindowManager from 'main/windows/windowManager'; @@ -17,6 +16,10 @@ import {clearAppCache, getDeeplinkingURL, wasUpdated} from './utils'; jest.mock('fs', () => ({ unlinkSync: jest.fn(), + existsSync: jest.fn().mockReturnValue(false), + readFileSync: jest.fn().mockImplementation((text) => text), + writeFile: jest.fn(), + })); jest.mock('path', () => { @@ -143,9 +146,9 @@ jest.mock('main/windows/windowManager', () => ({ getMainWindow: jest.fn(), showMainWindow: jest.fn(), sendToMattermostViews: jest.fn(), + sendToRenderer: jest.fn(), getServerNameByWebContentsId: jest.fn(), })); - describe('main/app/initialize', () => { beforeEach(() => { parseArgs.mockReturnValue({}); @@ -228,52 +231,6 @@ describe('main/app/initialize', () => { expect(WindowManager.showMainWindow).toHaveBeenCalledWith('mattermost://server-1.com'); }); - it('should setup save dialog correctly', async () => { - const item = { - getFilename: () => 'filename.txt', - on: jest.fn(), - setSaveDialogOptions: jest.fn(), - }; - Config.downloadLocation = '/some/dir'; - path.resolve.mockImplementation((base, p) => `${base}/${p}`); - session.defaultSession.on.mockImplementation((event, cb) => { - if (event === 'will-download') { - cb(null, item, {id: 0, getURL: jest.fn()}); - } - }); - - await initialize(); - expect(item.setSaveDialogOptions).toHaveBeenCalledWith(expect.objectContaining({ - title: 'filename.txt', - defaultPath: '/some/dir/filename.txt', - })); - }); - - it('should use name of saved file instead of original file name', async () => { - const item = { - getFilename: () => 'filename.txt', - on: jest.fn(), - setSaveDialogOptions: jest.fn(), - savePath: '/some/dir/new_filename.txt', - }; - Config.downloadLocation = '/some/dir'; - path.resolve.mockImplementation((base, p) => `${base}/${p}`); - session.defaultSession.on.mockImplementation((event, cb) => { - if (event === 'will-download') { - cb(null, item, {id: 0, getURL: jest.fn()}); - } - }); - - item.on.mockImplementation((event, cb) => { - if (event === 'done') { - cb(null, 'completed'); - } - }); - - await initialize(); - expect(displayDownloadCompleted).toHaveBeenCalledWith('new_filename.txt', '/some/dir/new_filename.txt', expect.anything()); - }); - it('should allow permission requests for supported types from trusted URLs', async () => { let callback = jest.fn(); session.defaultSession.setPermissionRequestHandler.mockImplementation((cb) => { diff --git a/src/main/app/initialize.ts b/src/main/app/initialize.ts index a8fcf4ec..f05c2c68 100644 --- a/src/main/app/initialize.ts +++ b/src/main/app/initialize.ts @@ -32,7 +32,7 @@ import { GET_AVAILABLE_SPELL_CHECKER_LANGUAGES, USER_ACTIVITY_UPDATE, START_UPGRADE, - START_DOWNLOAD, + START_UPDATE_DOWNLOAD, PING_DOMAIN, MAIN_WINDOW_SHOWN, } from 'common/communication'; @@ -48,8 +48,8 @@ import {setupBadge} from 'main/badge'; import CertificateManager from 'main/certificateManager'; import {updatePaths} from 'main/constants'; import CriticalErrorHandler from 'main/CriticalErrorHandler'; -import i18nManager, {localizeMessage} from 'main/i18nManager'; -import {displayDownloadCompleted} from 'main/notifications'; +import downloadsManager from 'main/downloadsManager'; +import i18nManager from 'main/i18nManager'; import parseArgs from 'main/ParseArgs'; import TrustedOriginsStore from 'main/trustedOrigins'; import {refreshTrayImages, setupTray} from 'main/tray/tray'; @@ -259,7 +259,7 @@ function initializeInterCommunicationEventListeners() { ipcMain.on(SHOW_SETTINGS_WINDOW, WindowManager.showSettingsWindow); ipcMain.handle(GET_AVAILABLE_SPELL_CHECKER_LANGUAGES, () => session.defaultSession.availableSpellCheckerLanguages); ipcMain.handle(GET_DOWNLOAD_LOCATION, handleSelectDownload); - ipcMain.on(START_DOWNLOAD, handleStartDownload); + ipcMain.on(START_UPDATE_DOWNLOAD, handleStartDownload); ipcMain.on(START_UPGRADE, handleStartUpgrade); ipcMain.handle(PING_DOMAIN, handlePingDomain); } @@ -272,7 +272,7 @@ function initializeAfterAppReady() { if (process.platform !== 'darwin') { defaultSession.on('spellcheck-dictionary-download-failure', (event, lang) => { if (Config.spellCheckerURL) { - log.error(`There was an error while trying to load the dictionary definitions for ${lang} fromfully the specified url. Please review you have access to the needed files. Url used was ${Config.spellCheckerURL}`); + log.error(`There was an error while trying to load the dictionary definitions for ${lang} from fully the specified url. Please review you have access to the needed files. Url used was ${Config.spellCheckerURL}`); } else { log.warn(`There was an error while trying to download the dictionary definitions for ${lang}, spellchecking might not work properly.`); } @@ -358,29 +358,7 @@ function initializeAfterAppReady() { } setupBadge(); - defaultSession.on('will-download', (event, item, webContents) => { - log.debug('Initialize.will-download', {item, sourceURL: webContents.getURL()}); - const filename = item.getFilename(); - const fileElements = filename.split('.'); - const filters = []; - if (fileElements.length > 1) { - filters.push({ - name: localizeMessage('main.app.initialize.downloadBox.allFiles', 'All files'), - extensions: ['*'], - }); - } - item.setSaveDialogOptions({ - title: filename, - defaultPath: Config.downloadLocation ? path.resolve(Config.downloadLocation, filename) : undefined, - filters, - }); - - item.on('done', (doneEvent, state) => { - if (state === 'completed') { - displayDownloadCompleted(path.basename(item.savePath), item.savePath, WindowManager.getServerNameByWebContentsId(webContents.id) || ''); - } - }); - }); + defaultSession.on('will-download', downloadsManager.handleNewDownload); // needs to be done after app ready // must be done before update menu diff --git a/src/main/autoUpdater.test.js b/src/main/autoUpdater.test.js index c6898d9b..50edaad0 100644 --- a/src/main/autoUpdater.test.js +++ b/src/main/autoUpdater.test.js @@ -31,6 +31,9 @@ jest.mock('electron-updater', () => ({ downloadUpdate: jest.fn(), checkForUpdates: jest.fn(), }, + CancellationToken: jest.fn().mockImplementation(() => { + return {}; + }), })); jest.mock('common/config', () => ({ diff --git a/src/main/autoUpdater.ts b/src/main/autoUpdater.ts index 519d9cb6..f32cf363 100644 --- a/src/main/autoUpdater.ts +++ b/src/main/autoUpdater.ts @@ -6,16 +6,24 @@ import path from 'path'; import {dialog, ipcMain, app, nativeImage} from 'electron'; import log from 'electron-log'; -import {autoUpdater, ProgressInfo, UpdateInfo} from 'electron-updater'; +import {autoUpdater, CancellationToken, ProgressInfo, UpdateInfo} from 'electron-updater'; import {localizeMessage} from 'main/i18nManager'; import {displayUpgrade, displayRestartToUpgrade} from 'main/notifications'; -import {CANCEL_UPGRADE, UPDATE_AVAILABLE, UPDATE_DOWNLOADED, CHECK_FOR_UPDATES, UPDATE_SHORTCUT_MENU, UPDATE_PROGRESS} from 'common/communication'; +import { + CANCEL_UPGRADE, + UPDATE_AVAILABLE, + UPDATE_DOWNLOADED, + CHECK_FOR_UPDATES, + UPDATE_SHORTCUT_MENU, + UPDATE_PROGRESS, + NO_UPDATE_AVAILABLE, + CANCEL_UPDATE_DOWNLOAD, + UPDATE_REMIND_LATER, +} from 'common/communication'; import Config from 'common/config'; -import WindowManager from './windows/windowManager'; - const NEXT_NOTIFY = 86400000; // 24 hours const NEXT_CHECK = 3600000; // 1 hour @@ -44,12 +52,15 @@ const appIcon = nativeImage.createFromPath(appIconURL); **/ export class UpdateManager { + cancellationToken?: CancellationToken; lastNotification?: NodeJS.Timeout; lastCheck?: NodeJS.Timeout; versionAvailable?: string; versionDownloaded?: string; constructor() { + this.cancellationToken = new CancellationToken(); + autoUpdater.on('error', (err: Error) => { log.error(`[Mattermost] There was an error while trying to update: ${err}`); }); @@ -70,7 +81,7 @@ export class UpdateManager { }); autoUpdater.on('download-progress', (progress: ProgressInfo) => { - WindowManager.sendToRenderer(UPDATE_PROGRESS, progress.total, progress.delta, progress.transferred, progress.percent, progress.bytesPerSecond); + ipcMain.emit(UPDATE_PROGRESS, null, progress); }); ipcMain.on(CANCEL_UPGRADE, () => { @@ -80,6 +91,9 @@ export class UpdateManager { ipcMain.on(CHECK_FOR_UPDATES, () => { this.checkForUpdates(true); }); + + ipcMain.on(CANCEL_UPDATE_DOWNLOAD, this.handleCancelDownload); + ipcMain.on(UPDATE_REMIND_LATER, this.handleRemindLater); } notify = (): void => { @@ -95,12 +109,12 @@ export class UpdateManager { } notifyUpgrade = (): void => { - WindowManager.sendToRenderer(UPDATE_AVAILABLE, this.versionAvailable); + ipcMain.emit(UPDATE_AVAILABLE, null, this.versionAvailable); displayUpgrade(this.versionAvailable || 'unknown', this.handleDownload); } notifyDownloaded = (): void => { - WindowManager.sendToRenderer(UPDATE_DOWNLOADED, this.versionDownloaded); + ipcMain.emit(UPDATE_DOWNLOADED, null, this.versionDownloaded); displayRestartToUpgrade(this.versionDownloaded || 'unknown', this.handleUpdate); } @@ -108,23 +122,16 @@ export class UpdateManager { if (this.lastCheck) { clearTimeout(this.lastCheck); } - dialog.showMessageBox({ - title: app.name, - message: localizeMessage('main.autoUpdater.download.dialog.message', 'New desktop version available'), - detail: localizeMessage('main.autoUpdater.download.dialog.detail', 'A new version of the {appName} Desktop App is available for you to download and install now.', {appName: app.name}), - icon: appIcon, - buttons: [ - localizeMessage('main.autoUpdater.download.dialog.button.download', 'Download'), - localizeMessage('main.autoUpdater.download.dialog.button.remindMeLater', 'Remind me Later'), - ], - type: 'info', - defaultId: 0, - cancelId: 1, - }).then(({response}) => { - if (response === 0) { - autoUpdater.downloadUpdate(); - } - }); + autoUpdater.downloadUpdate(this.cancellationToken); + } + + handleCancelDownload = (): void => { + this.cancellationToken?.cancel(); + this.cancellationToken = new CancellationToken(); + } + + handleRemindLater = (): void => { + // TODO } handleOnQuit = (): void => { @@ -134,27 +141,12 @@ export class UpdateManager { } handleUpdate = (): void => { - dialog.showMessageBox({ - title: app.name, - message: localizeMessage('main.autoUpdater.update.dialog.message', 'A new version is ready to install'), - detail: localizeMessage('main.autoUpdater.update.dialog.detail', 'A new version of the {appName} Desktop App is ready to install.', {appName: app.name}), - icon: appIcon, - buttons: [ - localizeMessage('main.autoUpdater.update.dialog.button.restartAndUpdate', 'Restart and Update'), - localizeMessage('main.autoUpdater.update.dialog.button.remindMeLater', 'Remind me Later'), - ], - type: 'info', - defaultId: 0, - cancelId: 1, - }).then(({response}) => { - if (response === 0) { - autoUpdater.quitAndInstall(); - } - }); + autoUpdater.quitAndInstall(); } displayNoUpgrade = (): void => { const version = app.getVersion(); + ipcMain.emit(NO_UPDATE_AVAILABLE); dialog.showMessageBox({ title: app.name, icon: appIcon, @@ -177,7 +169,12 @@ export class UpdateManager { if (manually) { autoUpdater.once('update-not-available', this.displayNoUpgrade); } - autoUpdater.checkForUpdates().catch((reason) => { + autoUpdater.checkForUpdates().then((result) => { + if (!result?.updateInfo) { + ipcMain.emit(NO_UPDATE_AVAILABLE); + } + }).catch((reason) => { + ipcMain.emit(NO_UPDATE_AVAILABLE); log.error(`[Mattermost] Failed to check for updates: ${reason}`); }); this.lastCheck = setTimeout(() => this.checkForUpdates(false), NEXT_CHECK); diff --git a/src/main/constants.ts b/src/main/constants.ts index bd6b325d..5ac4c99e 100644 --- a/src/main/constants.ts +++ b/src/main/constants.ts @@ -18,6 +18,7 @@ export let certificateStorePath = ''; export let trustedOriginsStoreFile = ''; export let boundsInfoPath = ''; export let migrationInfoPath = ''; +export let downloadsJson = ''; export function updatePaths(emit = false) { userDataPath = app.getPath('userData'); @@ -29,6 +30,7 @@ export function updatePaths(emit = false) { trustedOriginsStoreFile = path.resolve(userDataPath, 'trustedOrigins.json'); boundsInfoPath = path.join(userDataPath, 'bounds-info.json'); migrationInfoPath = path.resolve(userDataPath, 'migration-info.json'); + downloadsJson = path.resolve(userDataPath, 'downloads.json'); if (emit) { ipcMain.emit(UPDATE_PATHS); diff --git a/src/main/downloadsManager.test.js b/src/main/downloadsManager.test.js new file mode 100644 index 00000000..dd1a7242 --- /dev/null +++ b/src/main/downloadsManager.test.js @@ -0,0 +1,194 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. +import path from 'path'; +import fs from 'fs'; + +import {shell} from 'electron'; + +import {getDoNotDisturb as getDarwinDoNotDisturb} from 'macos-notification-state'; + +import {DownloadsManager} from 'main/downloadsManager'; + +const downloadLocationMock = '/path/to/downloads'; +const locationMock = '/some/dir/file.txt'; +const locationMock1 = '/downloads/file1.txt'; +jest.mock('electron', () => { + class NotificationMock { + static isSupported = jest.fn(); + static didConstruct = jest.fn(); + constructor() { + NotificationMock.didConstruct(); + } + on = jest.fn(); + show = jest.fn(); + click = jest.fn(); + close = jest.fn(); + } + return { + app: { + getAppPath: jest.fn(), + }, + BrowserView: jest.fn().mockImplementation(() => ({ + webContents: { + loadURL: jest.fn(), + focus: jest.fn(), + send: jest.fn(), + }, + setBounds: jest.fn(), + })), + ipcMain: { + emit: jest.fn(), + handle: jest.fn(), + on: jest.fn(), + }, + Menu: { + getApplicationMenu: () => ({ + getMenuItemById: jest.fn(), + }), + }, + Notification: NotificationMock, + session: { + defaultSession: { + on: jest.fn(), + }, + }, + shell: { + showItemInFolder: jest.fn(), + openPath: jest.fn(), + }, + }; +}); +jest.mock('path', () => { + const original = jest.requireActual('path'); + return { + ...original, + resolve: jest.fn(), + parse: jest.fn(), + }; +}); +jest.mock('fs', () => ({ + existsSync: jest.fn().mockReturnValue(false), + readFileSync: jest.fn().mockImplementation((text) => text), + writeFile: jest.fn(), +})); +jest.mock('macos-notification-state', () => ({ + getDoNotDisturb: jest.fn(), +})); +jest.mock('main/windows/windowManager', () => ({ + sendToRenderer: jest.fn(), +})); +jest.mock('common/config', () => { + const original = jest.requireActual('common/config'); + return { + ...original, + downloadLocation: downloadLocationMock, + }; +}); + +const downloadsJson = { + 'file1.txt': { + addedAt: 1662545584346, + filename: 'file1.txt', + mimeType: 'text/plain', + location: '/downloads/file1.txt', + progress: 100, + receivedBytes: 5425, + state: 'completed', + totalBytes: 5425, + type: 'file', + }, + 'file2.txt': { + addedAt: 1662545588346, + filename: 'file2.txt', + mimeType: 'text/plain', + location: '/downloads/file2.txt', + progress: 100, + receivedBytes: 5425, + state: 'cancelled', + totalBytes: 5425, + type: 'file', + }, +}; +const nowSeconds = Date.now() / 1000; +const item = { + getFilename: () => 'file.txt', + getMimeType: () => 'text/plain', + getReceivedBytes: () => 2121, + getStartTime: () => nowSeconds, + getTotalBytes: () => 4242, + getSavePath: () => locationMock, + setSavePath: jest.fn(), + on: jest.fn(), + setSaveDialogOptions: jest.fn(), + once: jest.fn(), + location: locationMock, +}; +const item1 = { + ...item, + getFilename: () => 'file1.txt', + getSavePath: () => locationMock1, + location: locationMock1, +}; +describe('main/downloadsManager', () => { + beforeEach(() => { + getDarwinDoNotDisturb.mockReturnValue(false); + }); + + it('should be initialized', () => { + expect(new DownloadsManager({})).toHaveProperty('downloads', {}); + }); + it('should mark "completed" files that were deleted as "deleted"', () => { + expect(new DownloadsManager(JSON.stringify(downloadsJson))).toHaveProperty('downloads', {...downloadsJson, 'file1.txt': {...downloadsJson['file1.txt'], state: 'deleted'}}); + }); + it('should handle a new download', () => { + const dl = new DownloadsManager({}); + path.parse.mockImplementation(() => ({base: 'file.txt'})); + dl.handleNewDownload({}, item, {id: 0, getURL: jest.fn()}); + expect(dl).toHaveProperty('downloads', {'file.txt': { + addedAt: nowSeconds * 1000, + filename: 'file.txt', + mimeType: 'text/plain', + location: '/some/dir/file.txt', + progress: 50, + receivedBytes: 2121, + state: 'progressing', + totalBytes: 4242, + type: 'file', + }}); + }); + + it('should monitor network to retrieve the file size of downloading items', () => { + const dl = new DownloadsManager({}); + const details = { + responseHeaders: { + 'content-encoding': ['gzip'], + 'x-uncompressed-content-length': ['4242'], + 'content-disposition': ['attachment; filename="file.txt"; foobar'], + }, + }; + dl.webRequestOnHeadersReceivedHandler(details, jest.fn()); + expect(dl.fileSizes.get('file.txt')).toBe('4242'); + }); + + it('should clear the downloads list', () => { + const dl = new DownloadsManager(JSON.stringify(downloadsJson)); + dl.clearDownloadsDropDown(); + expect(dl).toHaveProperty('downloads', {}); + }); + + it('should open downloads folder if file deleted', () => { + const dl = new DownloadsManager(JSON.stringify(downloadsJson)); + path.parse.mockImplementation(() => ({base: 'file1.txt'})); + dl.showFileInFolder(item1); + expect(shell.openPath).toHaveBeenCalledWith(downloadLocationMock); + }); + + it('should show the file in the downloads folder', () => { + const dl = new DownloadsManager(JSON.stringify(downloadsJson)); + fs.existsSync.mockReturnValueOnce(true); + path.parse.mockImplementation(() => ({base: 'file1.txt'})); + dl.showFileInFolder(item1); + expect(shell.showItemInFolder).toHaveBeenCalledWith(locationMock1); + }); +}); + diff --git a/src/main/downloadsManager.ts b/src/main/downloadsManager.ts new file mode 100644 index 00000000..a9a28c00 --- /dev/null +++ b/src/main/downloadsManager.ts @@ -0,0 +1,575 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. +import path from 'path'; +import fs from 'fs'; + +import {DownloadItem, Event, WebContents, FileFilter, ipcMain, dialog, shell, Menu} from 'electron'; +import log from 'electron-log'; +import {ProgressInfo} from 'electron-updater'; + +import {DownloadedItem, DownloadItemDoneEventState, DownloadedItems, DownloadItemState, DownloadItemUpdatedEventState} from 'types/downloads'; + +import { + CANCEL_UPDATE_DOWNLOAD, + CLOSE_DOWNLOADS_DROPDOWN, + CLOSE_DOWNLOADS_DROPDOWN_MENU, + DOWNLOADS_DROPDOWN_FOCUSED, + HIDE_DOWNLOADS_DROPDOWN_BUTTON_BADGE, + NO_UPDATE_AVAILABLE, + OPEN_DOWNLOADS_DROPDOWN, + REQUEST_HAS_DOWNLOADS, + SHOW_DOWNLOADS_DROPDOWN_BUTTON_BADGE, + UPDATE_AVAILABLE, + UPDATE_DOWNLOADED, + UPDATE_DOWNLOADS_DROPDOWN, + UPDATE_PATHS, + UPDATE_PROGRESS, +} from 'common/communication'; +import Config from 'common/config'; +import {localizeMessage} from 'main/i18nManager'; +import {displayDownloadCompleted} from 'main/notifications'; +import WindowManager from 'main/windows/windowManager'; +import {doubleSecToMs, getPercentage, isStringWithLength, readFilenameFromContentDispositionHeader, shouldIncrementFilename} from 'main/utils'; +import {DOWNLOADS_DROPDOWN_AUTOCLOSE_TIMEOUT, DOWNLOADS_DROPDOWN_MAX_ITEMS} from 'common/utils/constants'; +import JsonFileManager from 'common/JsonFileManager'; + +import {APP_UPDATE_KEY} from 'common/constants'; + +import {downloadsJson} from './constants'; +import * as Validator from './Validator'; + +export enum DownloadItemTypeEnum { + FILE = 'file', + UPDATE = 'update', +} + +export class DownloadsManager extends JsonFileManager { + autoCloseTimeout: NodeJS.Timeout | null; + open: boolean; + + fileSizes: Map; + progressingItems: Map; + downloads: DownloadedItems; + + constructor(file: string) { + super(file); + + this.open = false; + this.fileSizes = new Map(); + this.progressingItems = new Map(); + this.autoCloseTimeout = null; + this.downloads = {}; + + this.init(); + } + + private init = () => { + // ensure data loaded from file is valid + const validatedJSON = Validator.validateDownloads(this.json); + log.debug('DownloadsManager.init', {'this.json': this.json, validatedJSON}); + if (validatedJSON) { + this.saveAll(validatedJSON); + } else { + this.saveAll({}); + } + this.checkForDeletedFiles(); + + ipcMain.handle(REQUEST_HAS_DOWNLOADS, () => { + return this.hasDownloads(); + }); + ipcMain.on(DOWNLOADS_DROPDOWN_FOCUSED, this.clearAutoCloseTimeout); + ipcMain.on(UPDATE_AVAILABLE, this.onUpdateAvailable); + ipcMain.on(UPDATE_DOWNLOADED, this.onUpdateDownloaded); + ipcMain.on(UPDATE_PROGRESS, this.onUpdateProgress); + ipcMain.on(NO_UPDATE_AVAILABLE, this.noUpdateAvailable); + } + + handleNewDownload = (event: Event, item: DownloadItem, webContents: WebContents) => { + log.debug('DownloadsManager.handleNewDownload', {item, sourceURL: webContents.getURL()}); + + const shouldShowSaveDialog = this.shouldShowSaveDialog(Config.downloadLocation); + if (shouldShowSaveDialog) { + const saveDialogSuccess = this.showSaveDialog(item); + if (!saveDialogSuccess) { + item.cancel(); + return; + } + } else { + const filename = this.createFilename(item); + const savePath = this.getSavePath(`${Config.downloadLocation}`, filename); + item.setSavePath(savePath); + } + this.upsertFileToDownloads(item, 'progressing'); + this.progressingItems.set(this.getFileId(item), item); + this.handleDownloadItemEvents(item, webContents); + this.openDownloadsDropdown(); + this.toggleAppMenuDownloadsEnabled(true); + }; + + /** + * This function monitors webRequests and retrieves the total file size (of files being downloaded) + * from the custom HTTP header "x-uncompressed-content-length". + */ + webRequestOnHeadersReceivedHandler = (details: Electron.OnHeadersReceivedListenerDetails, cb: (headersReceivedResponse: Electron.HeadersReceivedResponse) => void) => { + const headers = details.responseHeaders ?? {}; + + if (headers?.['content-encoding']?.includes('gzip') && headers?.['x-uncompressed-content-length'] && headers?.['content-disposition'].join(';')?.includes('filename=')) { + const filename = readFilenameFromContentDispositionHeader(headers['content-disposition']); + const fileSize = headers['x-uncompressed-content-length']?.[0] || '0'; + if (filename && (!this.fileSizes.has(filename) || this.fileSizes.get(filename)?.toString() !== fileSize)) { + this.fileSizes.set(filename, fileSize); + } + } + + // With no arguments it uses the same headers + cb({}); + }; + + checkForDeletedFiles = () => { + log.debug('DownloadsManager.checkForDeletedFiles'); + + const downloads = this.downloads; + let modified = false; + + for (const fileId in downloads) { + if (fileId === APP_UPDATE_KEY) { + continue; + } + if (Object.prototype.hasOwnProperty.call(downloads, fileId)) { + const file = downloads[fileId]; + if ((file.state === 'completed')) { + if (!file.location || !fs.existsSync(file.location)) { + downloads[fileId].state = 'deleted'; + modified = true; + } + } else if (file.state === 'progressing') { + downloads[fileId].state = 'interrupted'; + modified = true; + } + } + } + + if (modified) { + this.saveAll(downloads); + } + } + + clearDownloadsDropDown = () => { + log.debug('DownloadsManager.clearDownloadsDropDown'); + + this.saveAll({}); + this.fileSizes = new Map(); + this.closeDownloadsDropdown(); + this.toggleAppMenuDownloadsEnabled(false); + } + + showFileInFolder = (item?: DownloadedItem) => { + log.debug('DownloadsDropdownView.showFileInFolder', {item}); + + if (!item) { + log.debug('DownloadsDropdownView.showFileInFolder', 'ITEM_UNDEFINED'); + return; + } + + if (item.type === DownloadItemTypeEnum.UPDATE) { + return; + } + + if (fs.existsSync(item.location)) { + shell.showItemInFolder(item.location); + return; + } + this.markFileAsDeleted(item); + + if (Config.downloadLocation) { + shell.openPath(Config.downloadLocation); + return; + } + + log.debug('DownloadsDropdownView.showFileInFolder', 'NO_DOWNLOAD_LOCATION'); + } + + openFile = (item?: DownloadedItem) => { + log.debug('DownloadsDropdownView.openFile', {item}); + + if (!item) { + log.debug('DownloadsDropdownView.openFile', 'FILE_UNDEFINED'); + return; + } + + if (item.type === DownloadItemTypeEnum.UPDATE) { + return; + } + + if (fs.existsSync(item.location)) { + shell.openPath(item.location).catch((err) => { + log.debug('DownloadsDropdownView.openFileError', {err}); + this.showFileInFolder(item); + }); + } else { + log.debug('DownloadsDropdownView.openFile', 'COULD_NOT_OPEN_FILE'); + this.markFileAsDeleted(item); + this.showFileInFolder(item); + } + } + + clearFile = (item?: DownloadedItem) => { + log.debug('DownloadsDropdownView.clearFile', {item}); + + if (!item || item.type === DownloadItemTypeEnum.UPDATE) { + return; + } + + const fileId = this.getDownloadedFileId(item); + const downloads = this.downloads; + delete downloads[fileId]; + this.saveAll(downloads); + + if (!this.hasDownloads()) { + this.closeDownloadsDropdown(); + } + } + + cancelDownload = (item?: DownloadedItem) => { + log.debug('DownloadsDropdownView.cancelDownload', {item}); + + if (!item) { + return; + } + + const fileId = this.getDownloadedFileId(item); + + if (this.isAppUpdate(item)) { + ipcMain.emit(CANCEL_UPDATE_DOWNLOAD); + const update = this.downloads[APP_UPDATE_KEY]; + update.state = 'cancelled'; + this.save(APP_UPDATE_KEY, update); + } else if (this.progressingItems.has(fileId)) { + this.progressingItems.get(fileId)?.cancel?.(); + this.progressingItems.delete(fileId); + } + } + + onOpen = () => { + this.open = true; + WindowManager.sendToRenderer(HIDE_DOWNLOADS_DROPDOWN_BUTTON_BADGE); + } + + onClose = () => { + this.open = false; + ipcMain.emit(CLOSE_DOWNLOADS_DROPDOWN_MENU); + this.clearAutoCloseTimeout(); + } + + getIsOpen = () => { + return this.open; + } + + hasDownloads = () => { + log.debug('DownloadsManager.hasDownloads'); + return (Object.keys(this.downloads)?.length || 0) > 0; + } + + getDownloads = () => { + return this.downloads; + } + + openDownloadsDropdown = () => { + log.debug('DownloadsManager.openDownloadsDropdown'); + + this.open = true; + ipcMain.emit(OPEN_DOWNLOADS_DROPDOWN); + WindowManager.sendToRenderer(HIDE_DOWNLOADS_DROPDOWN_BUTTON_BADGE); + } + + private markFileAsDeleted = (item: DownloadedItem) => { + const fileId = this.getDownloadedFileId(item); + const file = this.downloads[fileId]; + file.state = 'deleted'; + this.save(fileId, file); + } + + private toggleAppMenuDownloadsEnabled = (value: boolean) => { + const appMenuDownloads = Menu.getApplicationMenu()?.getMenuItemById('app-menu-downloads'); + if (appMenuDownloads) { + appMenuDownloads.enabled = value; + } + } + + private saveAll = (downloads: DownloadedItems) => { + log.debug('DownloadsManager.saveAll'); + + this.downloads = downloads; + this.setJson(downloads); + ipcMain.emit(UPDATE_DOWNLOADS_DROPDOWN, true, this.downloads); + WindowManager?.sendToRenderer(UPDATE_DOWNLOADS_DROPDOWN, this.downloads); + } + + private save = (key: string, item: DownloadedItem) => { + log.debug('DownloadsManager.save'); + + this.downloads[key] = item; + this.setValue(key, item); + ipcMain.emit(UPDATE_DOWNLOADS_DROPDOWN, true, this.downloads); + WindowManager?.sendToRenderer(UPDATE_DOWNLOADS_DROPDOWN, this.downloads); + } + + private handleDownloadItemEvents = (item: DownloadItem, webContents: WebContents) => { + item.on('updated', (updateEvent, state) => { + this.updatedEventController(updateEvent, state, item); + }); + item.once('done', (doneEvent, state) => { + this.doneEventController(doneEvent, state, item, webContents); + }); + } + + /** + * This function return true if "downloadLocation" is undefined + */ + private shouldShowSaveDialog = (downloadLocation?: string) => { + log.debug('DownloadsManager.shouldShowSaveDialog', {downloadLocation}); + + return !downloadLocation; + }; + + private showSaveDialog = (item: DownloadItem) => { + const filename = item.getFilename(); + const fileElements = filename.split('.'); + const filters = this.getFileFilters(fileElements); + + const newPath = dialog.showSaveDialogSync({ + title: filename, + defaultPath: filename, + filters, + }); + + if (newPath) { + item.setSavePath(newPath); + return true; + } + return false; + } + + private closeDownloadsDropdown = () => { + log.debug('DownloadsManager.closeDownloadsDropdown'); + + this.open = false; + ipcMain.emit(CLOSE_DOWNLOADS_DROPDOWN); + ipcMain.emit(CLOSE_DOWNLOADS_DROPDOWN_MENU); + + this.clearAutoCloseTimeout(); + } + + private clearAutoCloseTimeout = () => { + if (this.autoCloseTimeout) { + clearTimeout(this.autoCloseTimeout); + this.autoCloseTimeout = null; + } + } + + private upsertFileToDownloads = (item: DownloadItem, state: DownloadItemState) => { + const fileId = this.getFileId(item); + log.debug('DownloadsManager.upsertFileToDownloads', {fileId}); + + const formattedItem = this.formatDownloadItem(item, state); + this.save(fileId, formattedItem); + this.checkIfMaxFilesReached(); + }; + + private checkIfMaxFilesReached = () => { + const downloads = this.downloads; + if (Object.keys(downloads).length > DOWNLOADS_DROPDOWN_MAX_ITEMS) { + const oldestFileId = Object.keys(downloads).reduce((prev, curr) => { + return downloads[prev].addedAt > downloads[curr].addedAt ? curr : prev; + }); + delete downloads[oldestFileId]; + this.saveAll(downloads); + } + } + + private shouldAutoClose = () => { + // if some other file is being downloaded + if (Object.values(this.downloads).some((item) => item.state === 'progressing')) { + return; + } + if (this.autoCloseTimeout) { + this.autoCloseTimeout.refresh(); + } else { + this.autoCloseTimeout = setTimeout(() => this.closeDownloadsDropdown(), DOWNLOADS_DROPDOWN_AUTOCLOSE_TIMEOUT); + } + } + + private shouldShowBadge = () => { + log.debug('DownloadsManager.shouldShowBadge'); + + if (this.open === true) { + WindowManager.sendToRenderer(HIDE_DOWNLOADS_DROPDOWN_BUTTON_BADGE); + } else { + WindowManager.sendToRenderer(SHOW_DOWNLOADS_DROPDOWN_BUTTON_BADGE); + } + } + + /** + * DownloadItem event handlers + */ + private updatedEventController = (updatedEvent: Event, state: DownloadItemUpdatedEventState, item: DownloadItem) => { + log.debug('DownloadsManager.updatedEventController', {state}); + + this.upsertFileToDownloads(item, state); + + if (state === 'interrupted') { + this.fileSizes.delete(item.getFilename()); + this.progressingItems.delete(this.getFileId(item)); + } + this.shouldShowBadge(); + } + + private doneEventController = (doneEvent: Event, state: DownloadItemDoneEventState, item: DownloadItem, webContents: WebContents) => { + log.debug('DownloadsManager.doneEventController', {state}); + + if (state === 'completed' && !this.open) { + displayDownloadCompleted(path.basename(item.savePath), item.savePath, WindowManager.getServerNameByWebContentsId(webContents.id) || ''); + } + + this.upsertFileToDownloads(item, state); + + this.fileSizes.delete(item.getFilename()); + this.progressingItems.delete(this.getFileId(item)); + this.shouldAutoClose(); + this.shouldShowBadge(); + } + + /** + * Related to application updates + */ + private onUpdateAvailable = (event: Event, version = 'unknown') => { + this.save(APP_UPDATE_KEY, { + type: DownloadItemTypeEnum.UPDATE, + filename: version, + state: 'available', + progress: 0, + location: '', + mimeType: null, + addedAt: 0, + receivedBytes: 0, + totalBytes: 0, + }); + this.openDownloadsDropdown(); + } + private onUpdateDownloaded = (event: Event, version = 'unknown') => { + const update = this.downloads[APP_UPDATE_KEY]; + update.state = 'completed'; + update.progress = 100; + update.filename = version; + this.save(APP_UPDATE_KEY, update); + this.openDownloadsDropdown(); + } + private onUpdateProgress = (event: Event, progress: ProgressInfo) => { + log.debug('DownloadsManager.onUpdateProgress', {progress}); + const {total, transferred, percent} = progress; + + const update = this.downloads[APP_UPDATE_KEY]; + if (typeof update.addedAt !== 'number' || update.addedAt === 0) { + update.addedAt = Date.now(); + } + update.state = 'progressing'; + update.totalBytes = total; + update.receivedBytes = transferred; + update.progress = Math.round(percent); + this.shouldShowBadge(); + } + private noUpdateAvailable = () => { + const downloads = this.downloads; + delete downloads[APP_UPDATE_KEY]; + this.saveAll(downloads); + + if (!this.hasDownloads()) { + this.closeDownloadsDropdown(); + } + } + + /** + * Internal utils + */ + private formatDownloadItem = (item: DownloadItem, state: DownloadItemState): DownloadedItem => { + const totalBytes = this.getFileSize(item); + const receivedBytes = item.getReceivedBytes(); + const progress = getPercentage(receivedBytes, totalBytes); + + return { + addedAt: doubleSecToMs(item.getStartTime()), + filename: this.getFileId(item), + mimeType: item.getMimeType(), + location: item.getSavePath(), + progress, + receivedBytes, + state, + totalBytes, + type: DownloadItemTypeEnum.FILE, + }; + } + + private getFileSize = (item: DownloadItem) => { + const itemTotalBytes = item.getTotalBytes(); + if (!itemTotalBytes) { + return parseInt(this.fileSizes.get(item.getFilename()) || '0', 10); + } + return itemTotalBytes; + } + + private getSavePath = (downloadLocation: string, filename?: string) => { + const name = isStringWithLength(filename) ? `${filename}` : 'file'; + return path.join(downloadLocation, name); + }; + + private getFileFilters = (fileElements: string[]): FileFilter[] => { + const filters = []; + + if (fileElements.length > 1) { + filters.push({ + name: localizeMessage('main.app.initialize.downloadBox.allFiles', 'All files'), + extensions: ['*'], + }); + } + + return filters; + } + + private createFilename = (item: DownloadItem): string => { + const defaultFilename = item.getFilename(); + const incrementedFilenameIfExists = shouldIncrementFilename(path.join(`${Config.downloadLocation}`, defaultFilename)); + return incrementedFilenameIfExists; + } + + private readFilenameFromPath = (savePath: string) => { + const pathObj = path.parse(savePath); + return pathObj.base; + } + + private getFileId = (item: DownloadItem) => { + const fileNameFromPath = this.readFilenameFromPath(item.savePath); + const itemFilename = item.getFilename(); + return fileNameFromPath && fileNameFromPath !== itemFilename ? fileNameFromPath : itemFilename; + } + + private getDownloadedFileId = (item: DownloadedItem) => { + if (item.type === DownloadItemTypeEnum.UPDATE) { + return APP_UPDATE_KEY; + } + const fileNameFromPath = this.readFilenameFromPath(item.location); + const itemFilename = item.filename; + return fileNameFromPath && fileNameFromPath !== itemFilename ? fileNameFromPath : itemFilename; + } + + private isAppUpdate = (item: DownloadedItem): boolean => { + return item.type === DownloadItemTypeEnum.UPDATE; + } +} + +let downloadsManager = new DownloadsManager(downloadsJson); + +ipcMain.on(UPDATE_PATHS, () => { + downloadsManager = new DownloadsManager(downloadsJson); +}); + +export default downloadsManager; diff --git a/src/main/menus/app.test.js b/src/main/menus/app.test.js index fb72e944..ede26aea 100644 --- a/src/main/menus/app.test.js +++ b/src/main/menus/app.test.js @@ -3,26 +3,54 @@ 'use strict'; +import {getDoNotDisturb as getDarwinDoNotDisturb} from 'macos-notification-state'; + import {localizeMessage} from 'main/i18nManager'; import WindowManager from 'main/windows/windowManager'; import {createTemplate} from './app'; -jest.mock('electron', () => ({ - app: { - name: 'AppName', - getVersion: () => '5.0.0', - }, +jest.mock('electron', () => { + class NotificationMock { + static isSupported = jest.fn(); + static didConstruct = jest.fn(); + constructor() { + NotificationMock.didConstruct(); + } + on = jest.fn(); + show = jest.fn(); + click = jest.fn(); + close = jest.fn(); + } + return { + app: { + name: 'AppName', + getVersion: () => '5.0.0', + getAppPath: () => '', + }, + ipcMain: { + emit: jest.fn(), + handle: jest.fn(), + on: jest.fn(), + }, + Notification: NotificationMock, + }; +}); +jest.mock('fs', () => ({ + existsSync: jest.fn().mockReturnValue(false), + readFileSync: jest.fn().mockImplementation((text) => text), + writeFile: jest.fn(), +})); +jest.mock('macos-notification-state', () => ({ + getDoNotDisturb: jest.fn(), })); - jest.mock('main/i18nManager', () => ({ localizeMessage: jest.fn(), })); - jest.mock('main/windows/windowManager', () => ({ getCurrentTeamName: jest.fn(), + sendToRenderer: jest.fn(), })); - jest.mock('common/tabs/TabView', () => ({ getTabDisplayName: (name) => name, })); @@ -79,6 +107,9 @@ describe('main/menus/app', () => { helpLink: 'http://link-to-help.site.com', }, }; + beforeEach(() => { + getDarwinDoNotDisturb.mockReturnValue(false); + }); describe('mac only', () => { let originalPlatform; diff --git a/src/main/menus/app.ts b/src/main/menus/app.ts index 02d377ce..68aa4537 100644 --- a/src/main/menus/app.ts +++ b/src/main/menus/app.ts @@ -13,6 +13,7 @@ import {Config} from 'common/config'; import {localizeMessage} from 'main/i18nManager'; import WindowManager from 'main/windows/windowManager'; import {UpdateManager} from 'main/autoUpdater'; +import downloadsManager from 'main/downloadsManager'; export function createTemplate(config: Config, updateManager: UpdateManager) { const separatorItem: MenuItemConstructorOptions = { @@ -78,12 +79,14 @@ export function createTemplate(config: Config, updateManager: UpdateManager) { } template.push({ + id: 'file', label: firstMenuName, submenu: [ ...platformAppMenu, ], }); template.push({ + id: 'edit', label: localizeMessage('main.menus.app.edit', '&Edit'), submenu: [{ role: 'undo', @@ -159,6 +162,13 @@ export function createTemplate(config: Config, updateManager: UpdateManager) { role: 'zoomOut', visible: false, accelerator: 'CmdOrCtrl+Shift+-', + }, separatorItem, { + id: 'app-menu-downloads', + label: localizeMessage('main.menus.app.view.downloads', 'Downloads'), + enabled: downloadsManager.hasDownloads(), + click() { + return downloadsManager.openDownloadsDropdown(); + }, }, separatorItem, { label: localizeMessage('main.menus.app.view.devToolsAppWrapper', 'Developer Tools for Application Wrapper'), accelerator: (() => { @@ -195,10 +205,12 @@ export function createTemplate(config: Config, updateManager: UpdateManager) { } template.push({ + id: 'view', label: localizeMessage('main.menus.app.view', '&View'), submenu: viewSubMenu, }); template.push({ + id: 'history', label: localizeMessage('main.menus.app.history', '&History'), submenu: [{ label: localizeMessage('main.menus.app.history.back', 'Back'), @@ -225,6 +237,7 @@ export function createTemplate(config: Config, updateManager: UpdateManager) { const teams = config.data?.teams || []; const windowMenu = { + id: 'window', label: localizeMessage('main.menus.app.window', '&Window'), role: isMac ? 'windowMenu' : null, submenu: [{ @@ -339,7 +352,7 @@ export function createTemplate(config: Config, updateManager: UpdateManager) { }, }); - template.push({label: localizeMessage('main.menus.app.help', 'Hel&p'), submenu}); + template.push({id: 'help', label: localizeMessage('main.menus.app.help', 'Hel&p'), submenu}); return template; } diff --git a/src/main/preload/downloadsDropdown.js b/src/main/preload/downloadsDropdown.js new file mode 100644 index 00000000..82cf6c66 --- /dev/null +++ b/src/main/preload/downloadsDropdown.js @@ -0,0 +1,81 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +'use strict'; + +import {ipcRenderer, contextBridge} from 'electron'; + +import { + CLOSE_DOWNLOADS_DROPDOWN, + CLOSE_DOWNLOADS_DROPDOWN_MENU, + DOWNLOADS_DROPDOWN_FOCUSED, + DOWNLOADS_DROPDOWN_SHOW_FILE_IN_FOLDER, + GET_LANGUAGE_INFORMATION, + RECEIVE_DOWNLOADS_DROPDOWN_SIZE, + REQUEST_CLEAR_DOWNLOADS_DROPDOWN, + REQUEST_DOWNLOADS_DROPDOWN_INFO, + RETRIEVED_LANGUAGE_INFORMATION, + SEND_DOWNLOADS_DROPDOWN_SIZE, + START_UPDATE_DOWNLOAD, + START_UPGRADE, + TOGGLE_DOWNLOADS_DROPDOWN_MENU, + UPDATE_DOWNLOADS_DROPDOWN, +} from 'common/communication'; + +console.log('preloaded for the downloadsDropdown!'); + +contextBridge.exposeInMainWorld('process', { + platform: process.platform, +}); + +window.addEventListener('click', () => { + ipcRenderer.send(CLOSE_DOWNLOADS_DROPDOWN_MENU); +}); + +window.addEventListener('mousemove', () => { + ipcRenderer.send(DOWNLOADS_DROPDOWN_FOCUSED); +}); + +/** + * renderer => main + */ +window.addEventListener('message', async (event) => { + switch (event.data.type) { + case CLOSE_DOWNLOADS_DROPDOWN: + ipcRenderer.send(CLOSE_DOWNLOADS_DROPDOWN); + break; + case TOGGLE_DOWNLOADS_DROPDOWN_MENU: + ipcRenderer.send(TOGGLE_DOWNLOADS_DROPDOWN_MENU, event.data.payload); + break; + case REQUEST_DOWNLOADS_DROPDOWN_INFO: + ipcRenderer.send(REQUEST_DOWNLOADS_DROPDOWN_INFO); + break; + case SEND_DOWNLOADS_DROPDOWN_SIZE: + ipcRenderer.send(RECEIVE_DOWNLOADS_DROPDOWN_SIZE, event.data.data.width, event.data.data.height); + break; + case REQUEST_CLEAR_DOWNLOADS_DROPDOWN: + ipcRenderer.send(REQUEST_CLEAR_DOWNLOADS_DROPDOWN); + break; + case DOWNLOADS_DROPDOWN_SHOW_FILE_IN_FOLDER: + ipcRenderer.send(DOWNLOADS_DROPDOWN_SHOW_FILE_IN_FOLDER, event.data.payload.item); + break; + case START_UPDATE_DOWNLOAD: + ipcRenderer.send(START_UPDATE_DOWNLOAD); + break; + case START_UPGRADE: + ipcRenderer.send(START_UPGRADE); + break; + case GET_LANGUAGE_INFORMATION: + window.postMessage({type: RETRIEVED_LANGUAGE_INFORMATION, data: await ipcRenderer.invoke(GET_LANGUAGE_INFORMATION)}); + break; + default: + console.log('Got an unknown message. Unknown messages are ignored'); + } +}); + +/** + * main => renderer + */ +ipcRenderer.on(UPDATE_DOWNLOADS_DROPDOWN, (event, downloads, darkMode, windowBounds, item) => { + window.postMessage({type: UPDATE_DOWNLOADS_DROPDOWN, data: {downloads, darkMode, windowBounds, item}}, window.location.href); +}); diff --git a/src/main/preload/downloadsDropdownMenu.js b/src/main/preload/downloadsDropdownMenu.js new file mode 100644 index 00000000..58d0e61e --- /dev/null +++ b/src/main/preload/downloadsDropdownMenu.js @@ -0,0 +1,58 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +'use strict'; + +import {ipcRenderer, contextBridge} from 'electron'; + +import { + GET_LANGUAGE_INFORMATION, + DOWNLOADS_DROPDOWN_MENU_CLEAR_FILE, + RETRIEVED_LANGUAGE_INFORMATION, + DOWNLOADS_DROPDOWN_SHOW_FILE_IN_FOLDER, + DOWNLOADS_DROPDOWN_MENU_CANCEL_DOWNLOAD, + DOWNLOADS_DROPDOWN_MENU_OPEN_FILE, + UPDATE_DOWNLOADS_DROPDOWN_MENU, + REQUEST_DOWNLOADS_DROPDOWN_MENU_INFO, +} from 'common/communication'; + +console.log('preloaded for the downloadsDropdownMenu!'); + +contextBridge.exposeInMainWorld('process', { + platform: process.platform, +}); + +/** + * renderer => main + */ +window.addEventListener('message', async (event) => { + switch (event.data.type) { + case REQUEST_DOWNLOADS_DROPDOWN_MENU_INFO: + ipcRenderer.send(REQUEST_DOWNLOADS_DROPDOWN_MENU_INFO); + break; + case DOWNLOADS_DROPDOWN_SHOW_FILE_IN_FOLDER: + ipcRenderer.send(DOWNLOADS_DROPDOWN_SHOW_FILE_IN_FOLDER, event.data.payload.item); + break; + case DOWNLOADS_DROPDOWN_MENU_CANCEL_DOWNLOAD: + ipcRenderer.send(DOWNLOADS_DROPDOWN_MENU_CANCEL_DOWNLOAD, event.data.payload.item); + break; + case DOWNLOADS_DROPDOWN_MENU_CLEAR_FILE: + ipcRenderer.send(DOWNLOADS_DROPDOWN_MENU_CLEAR_FILE, event.data.payload.item); + break; + case DOWNLOADS_DROPDOWN_MENU_OPEN_FILE: + ipcRenderer.send(DOWNLOADS_DROPDOWN_MENU_OPEN_FILE, event.data.payload.item); + break; + case GET_LANGUAGE_INFORMATION: + window.postMessage({type: RETRIEVED_LANGUAGE_INFORMATION, data: await ipcRenderer.invoke(GET_LANGUAGE_INFORMATION)}); + break; + default: + console.log('Got an unknown message. Unknown messages are ignored'); + } +}); + +/** + * main => renderer + */ +ipcRenderer.on(UPDATE_DOWNLOADS_DROPDOWN_MENU, (event, item, darkMode) => { + window.postMessage({type: UPDATE_DOWNLOADS_DROPDOWN_MENU, data: {item, darkMode}}, window.location.href); +}); diff --git a/src/main/preload/loadingScreenPreload.js b/src/main/preload/loadingScreenPreload.js index 6b808b05..b368cf05 100644 --- a/src/main/preload/loadingScreenPreload.js +++ b/src/main/preload/loadingScreenPreload.js @@ -12,6 +12,7 @@ import { LOADING_SCREEN_ANIMATION_FINISHED, TOGGLE_LOADING_SCREEN_VISIBILITY, CLOSE_TEAMS_DROPDOWN, + CLOSE_DOWNLOADS_DROPDOWN, } from 'common/communication'; console.log('preloaded for the loading screen!'); @@ -40,4 +41,5 @@ ipcRenderer.on(TOGGLE_LOADING_SCREEN_VISIBILITY, (_, toggle) => { window.addEventListener('click', () => { ipcRenderer.send(CLOSE_TEAMS_DROPDOWN); + ipcRenderer.send(CLOSE_DOWNLOADS_DROPDOWN); }); diff --git a/src/main/preload/mattermost.js b/src/main/preload/mattermost.js index 644fc71c..a72e3536 100644 --- a/src/main/preload/mattermost.js +++ b/src/main/preload/mattermost.js @@ -30,6 +30,7 @@ import { DISPATCH_GET_DESKTOP_SOURCES, DESKTOP_SOURCES_RESULT, VIEW_FINISHED_RESIZING, + CLOSE_DOWNLOADS_DROPDOWN, } from 'common/communication'; const UNREAD_COUNT_INTERVAL = 1000; @@ -243,8 +244,23 @@ setInterval(() => { webFrame.clearCache(); }, CLEAR_CACHE_INTERVAL); -window.addEventListener('click', () => { +function isDownloadLink(el) { + if (typeof el !== 'object') { + return false; + } + const parentEl = el.parentElement; + if (typeof parentEl !== 'object') { + return el.className?.includes?.('download') || el.tagName?.toLowerCase?.() === 'svg'; + } + return el.closest('a[download]') !== null; +} + +window.addEventListener('click', (e) => { ipcRenderer.send(CLOSE_TEAMS_DROPDOWN); + const el = e.target; + if (!isDownloadLink(el)) { + ipcRenderer.send(CLOSE_DOWNLOADS_DROPDOWN); + } }); ipcRenderer.on(BROWSER_HISTORY_PUSH, (event, pathName) => { diff --git a/src/main/utils.test.js b/src/main/utils.test.js index 614fb2fc..90de8137 100644 --- a/src/main/utils.test.js +++ b/src/main/utils.test.js @@ -2,7 +2,6 @@ // See LICENSE.txt for license information. 'use strict'; - import {BACK_BAR_HEIGHT, TAB_BAR_HEIGHT} from 'common/utils/constants'; import {runMode} from 'common/utils/util'; @@ -28,6 +27,14 @@ jest.mock('path', () => { resolve: (basePath, ...restOfPath) => original.join('/path/to/app/src/main', ...restOfPath), }; }); +jest.mock('fs', () => ({ + accessSync: jest.fn().mockImplementation(() => { + throw new Error('file missing'); + }).mockImplementationOnce(() => {}), + constants: { + F_OK: 0, + }, +})); describe('main/utils', () => { describe('shouldBeHiddenOnStartup', () => { @@ -107,4 +114,52 @@ describe('main/utils', () => { expect(Utils.shouldHaveBackBar('https://server-1.com', 'https://server-1.com/login')).toBe(false); }); }); + + describe('isStringWithLength', () => { + it('should return true for valid string', () => { + expect(Utils.isStringWithLength('string')).toBe(true); + }); + it('should return false for empty string', () => { + expect(Utils.isStringWithLength('')).toBe(false); + }); + it('should return false for invalid inputs', () => { + expect( + Utils.isStringWithLength(null) || + Utils.isStringWithLength(undefined) || + Utils.isStringWithLength(1) || + Utils.isStringWithLength({}) || + Utils.isStringWithLength(() => {}), + ).toBe(false); + }); + }); + + describe('getPercentage', () => { + it('should return 0 if denominator is 0', () => { + expect(Utils.getPercentage(1, 0)).toBe(0); + }); + it('should return the correct percentage', () => { + expect(Utils.getPercentage(2, 4)).toBe(50); + }); + it('should return the correct percentage as integer', () => { + expect(Utils.getPercentage(1, 3)).toBe(33); + }); + }); + + describe('readFilenameFromContentDispositionHeader', () => { + it('should read the filename from the HTTP Content-Disposition header\'s value', () => { + expect(Utils.readFilenameFromContentDispositionHeader(['attachment; filename="filename.jpg"; foobar'])).toBe('filename.jpg'); + }); + }); + + describe('doubleSecToMs', () => { + it('should convert a double number of seconds to integer milliseconds', () => { + expect(Utils.doubleSecToMs(1662561807.067542)).toBe(1662561807068); + }); + }); + + describe('shouldIncrementFilename', () => { + it('should increment filename if file already exists', () => { + expect(Utils.shouldIncrementFilename('filename.txt')).toBe('filename (1).txt'); + }); + }); }); diff --git a/src/main/utils.ts b/src/main/utils.ts index a96a518b..979c646b 100644 --- a/src/main/utils.ts +++ b/src/main/utils.ts @@ -3,6 +3,7 @@ // See LICENSE.txt for license information. import path from 'path'; +import fs from 'fs'; import {app, BrowserWindow} from 'electron'; @@ -98,3 +99,40 @@ export function composeUserAgent() { return `${filteredUserAgent.join(' ')} Mattermost/${app.getVersion()}`; } + +export function isStringWithLength(string: unknown): boolean { + return typeof string === 'string' && string.length > 0; +} + +export function getPercentage(received: number, total: number) { + if (total === 0) { + return 0; + } + return Math.round((received / total) * 100); +} + +export function readFilenameFromContentDispositionHeader(header: string[]) { + return header?.join(';')?.match(/(?<=filename=")(.*)(?=")/g)?.[0]; +} + +export function doubleSecToMs(d: number): number { + return Math.round(d * 1000); +} + +export function shouldIncrementFilename(filepath: string, increment = 0): string { + const {dir, name, ext} = path.parse(filepath); + const incrementString = increment ? ` (${increment})` : ''; + const filename = `${name}${incrementString}${ext}`; + + let fileExists = true; + try { + fs.accessSync(path.join(dir, filename), fs.constants.F_OK); + } catch (error) { + fileExists = false; + } + + if (fileExists) { + return shouldIncrementFilename(filepath, increment + 1); + } + return filename; +} diff --git a/src/main/views/downloadsDropdownMenuView.test.js b/src/main/views/downloadsDropdownMenuView.test.js new file mode 100644 index 00000000..3a7bacd2 --- /dev/null +++ b/src/main/views/downloadsDropdownMenuView.test.js @@ -0,0 +1,85 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +'use strict'; + +import {getDoNotDisturb as getDarwinDoNotDisturb} from 'macos-notification-state'; + +import {DOWNLOADS_DROPDOWN_FULL_WIDTH, DOWNLOADS_DROPDOWN_MENU_FULL_HEIGHT, DOWNLOADS_DROPDOWN_MENU_FULL_WIDTH, TAB_BAR_HEIGHT} from 'common/utils/constants'; + +import DownloadsDropdownMenuView from './downloadsDropdownMenuView'; + +jest.mock('main/utils', () => ({ + getLocalPreload: (file) => file, + getLocalURLString: (file) => file, +})); +jest.mock('electron', () => { + class NotificationMock { + static isSupported = jest.fn(); + static didConstruct = jest.fn(); + constructor() { + NotificationMock.didConstruct(); + } + on = jest.fn(); + show = jest.fn(); + click = jest.fn(); + close = jest.fn(); + } + return { + app: { + getAppPath: () => '', + }, + BrowserView: jest.fn().mockImplementation(() => ({ + webContents: { + loadURL: jest.fn(), + focus: jest.fn(), + send: jest.fn(), + }, + setBounds: jest.fn(), + })), + ipcMain: { + emit: jest.fn(), + handle: jest.fn(), + on: jest.fn(), + }, + Notification: NotificationMock, + }; +}); +jest.mock('macos-notification-state', () => ({ + getDoNotDisturb: jest.fn(), +})); +jest.mock('main/windows/windowManager', () => ({ + sendToRenderer: jest.fn(), +})); +jest.mock('fs', () => ({ + existsSync: jest.fn().mockReturnValue(false), + readFileSync: jest.fn().mockImplementation((text) => text), + writeFile: jest.fn(), +})); + +describe('main/views/DownloadsDropdownMenuView', () => { + const window = { + getContentBounds: () => ({width: 800, height: 600, x: 0, y: 0}), + addBrowserView: jest.fn(), + setTopBrowserView: jest.fn(), + }; + const downloadsDropdownMenuView = new DownloadsDropdownMenuView(window, {}, false); + + beforeEach(() => { + getDarwinDoNotDisturb.mockReturnValue(false); + }); + + describe('getBounds', () => { + it('should be placed top-left inside the downloads dropdown if coordinates not used', () => { + expect(downloadsDropdownMenuView.getBounds(DOWNLOADS_DROPDOWN_MENU_FULL_WIDTH, DOWNLOADS_DROPDOWN_MENU_FULL_HEIGHT)).toStrictEqual({x: 800 - DOWNLOADS_DROPDOWN_FULL_WIDTH - DOWNLOADS_DROPDOWN_MENU_FULL_WIDTH, y: TAB_BAR_HEIGHT, width: DOWNLOADS_DROPDOWN_MENU_FULL_WIDTH, height: DOWNLOADS_DROPDOWN_MENU_FULL_HEIGHT}); + }); + }); + + it('should change the view bounds based on open/closed state', () => { + downloadsDropdownMenuView.bounds = {width: 400, height: 300}; + downloadsDropdownMenuView.handleOpen(); + expect(downloadsDropdownMenuView.view.setBounds).toBeCalledWith(downloadsDropdownMenuView.bounds); + downloadsDropdownMenuView.handleClose(); + expect(downloadsDropdownMenuView.view.setBounds).toBeCalledWith({width: 0, height: 0, x: expect.any(Number), y: expect.any(Number)}); + }); +}); diff --git a/src/main/views/downloadsDropdownMenuView.ts b/src/main/views/downloadsDropdownMenuView.ts new file mode 100644 index 00000000..0dfae5f5 --- /dev/null +++ b/src/main/views/downloadsDropdownMenuView.ts @@ -0,0 +1,223 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. +import {BrowserView, BrowserWindow, ipcMain, IpcMainEvent} from 'electron'; + +import log from 'electron-log'; + +import {CombinedConfig} from 'types/config'; +import {CoordinatesToJsonType, DownloadedItem, DownloadsMenuOpenEventPayload} from 'types/downloads'; + +import { + CLOSE_DOWNLOADS_DROPDOWN_MENU, + DOWNLOADS_DROPDOWN_MENU_CANCEL_DOWNLOAD, + DOWNLOADS_DROPDOWN_MENU_CLEAR_FILE, + DOWNLOADS_DROPDOWN_MENU_OPEN_FILE, + DOWNLOADS_DROPDOWN_SHOW_FILE_IN_FOLDER, + EMIT_CONFIGURATION, + OPEN_DOWNLOADS_DROPDOWN_MENU, + REQUEST_DOWNLOADS_DROPDOWN_MENU_INFO, + TOGGLE_DOWNLOADS_DROPDOWN_MENU, + UPDATE_DOWNLOADS_DROPDOWN_MENU, + UPDATE_DOWNLOADS_DROPDOWN_MENU_ITEM, +} from 'common/communication'; +import { + DOWNLOADS_DROPDOWN_FULL_WIDTH, + DOWNLOADS_DROPDOWN_MENU_FULL_HEIGHT, + DOWNLOADS_DROPDOWN_MENU_FULL_WIDTH, + TAB_BAR_HEIGHT, +} from 'common/utils/constants'; +import {getLocalPreload, getLocalURLString} from 'main/utils'; + +import WindowManager from '../windows/windowManager'; +import downloadsManager from 'main/downloadsManager'; + +export default class DownloadsDropdownMenuView { + open: boolean; + view: BrowserView; + bounds?: Electron.Rectangle; + item?: DownloadedItem; + coordinates?: CoordinatesToJsonType; + darkMode: boolean; + window: BrowserWindow; + windowBounds: Electron.Rectangle; + + constructor(window: BrowserWindow, darkMode: boolean) { + this.open = false; + this.item = undefined; + this.coordinates = undefined; + this.window = window; + this.darkMode = darkMode; + + this.windowBounds = this.window.getContentBounds(); + this.bounds = this.getBounds(DOWNLOADS_DROPDOWN_MENU_FULL_WIDTH, DOWNLOADS_DROPDOWN_MENU_FULL_HEIGHT); + + const preload = getLocalPreload('downloadsDropdownMenu.js'); + this.view = new BrowserView({webPreferences: { + preload, + + // Workaround for this issue: https://github.com/electron/electron/issues/30993 + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + transparent: true, + }}); + + this.view.webContents.loadURL(getLocalURLString('downloadsDropdownMenu.html')); + this.window.addBrowserView(this.view); + + ipcMain.on(OPEN_DOWNLOADS_DROPDOWN_MENU, this.handleOpen); + ipcMain.on(CLOSE_DOWNLOADS_DROPDOWN_MENU, this.handleClose); + ipcMain.on(TOGGLE_DOWNLOADS_DROPDOWN_MENU, this.handleToggle); + ipcMain.on(EMIT_CONFIGURATION, this.updateConfig); + ipcMain.on(REQUEST_DOWNLOADS_DROPDOWN_MENU_INFO, this.updateDownloadsDropdownMenu); + ipcMain.on(DOWNLOADS_DROPDOWN_MENU_OPEN_FILE, this.openFile); + ipcMain.on(DOWNLOADS_DROPDOWN_SHOW_FILE_IN_FOLDER, this.showFileInFolder); + ipcMain.on(DOWNLOADS_DROPDOWN_MENU_CANCEL_DOWNLOAD, this.cancelDownload); + ipcMain.on(DOWNLOADS_DROPDOWN_MENU_CLEAR_FILE, this.clearFile); + ipcMain.on(UPDATE_DOWNLOADS_DROPDOWN_MENU, this.updateItem); + } + + updateItem = (event: IpcMainEvent, item: DownloadedItem) => { + log.debug('DownloadsDropdownMenuView.updateItem', {item}); + + this.item = item; + + this.updateDownloadsDropdownMenu(); + } + + updateConfig = (event: IpcMainEvent, config: CombinedConfig) => { + log.debug('DownloadsDropdownMenuView.updateConfig'); + + this.darkMode = config.darkMode; + this.updateDownloadsDropdownMenu(); + } + + /** + * This is called every time the "window" is resized so that we can position + * the downloads dropdown at the correct position + */ + updateWindowBounds = () => { + log.debug('DownloadsDropdownMenuView.updateWindowBounds'); + + this.windowBounds = this.window.getContentBounds(); + this.updateDownloadsDropdownMenu(); + this.repositionDownloadsDropdownMenu(); + } + + updateDownloadsDropdownMenu = () => { + log.debug('DownloadsDropdownMenuView.updateDownloadsDropdownMenu'); + + this.view.webContents.send( + UPDATE_DOWNLOADS_DROPDOWN_MENU, + this.item, + this.darkMode, + ); + ipcMain.emit(UPDATE_DOWNLOADS_DROPDOWN_MENU_ITEM, true, this.item); + this.repositionDownloadsDropdownMenu(); + } + + handleOpen = (event: IpcMainEvent, payload: DownloadsMenuOpenEventPayload = {} as DownloadsMenuOpenEventPayload) => { + log.debug('DownloadsDropdownMenuView.handleOpen', {bounds: this.bounds, payload}); + + if (!this.bounds) { + return; + } + + const {item, coordinates} = payload; + + log.debug('DownloadsDropdownMenuView.handleOpen', {item, coordinates}); + + this.open = true; + this.coordinates = coordinates; + this.item = item; + this.bounds = this.getBounds(DOWNLOADS_DROPDOWN_MENU_FULL_WIDTH, DOWNLOADS_DROPDOWN_MENU_FULL_HEIGHT); + this.view.setBounds(this.bounds); + this.window.setTopBrowserView(this.view); + this.view.webContents.focus(); + this.updateDownloadsDropdownMenu(); + } + + handleClose = () => { + log.debug('DownloadsDropdownMenuView.handleClose'); + + this.open = false; + this.item = undefined; + ipcMain.emit(UPDATE_DOWNLOADS_DROPDOWN_MENU_ITEM); + this.view.setBounds(this.getBounds(0, 0)); + WindowManager.sendToRenderer(CLOSE_DOWNLOADS_DROPDOWN_MENU); + } + + handleToggle = (event: IpcMainEvent, payload: DownloadsMenuOpenEventPayload) => { + if (this.open) { + if (this.item?.location === payload.item.location) { + // clicking 3-dot in the same item + this.handleClose(); + } else { + // clicking 3-dot in a different item + this.handleClose(); + this.handleOpen(event, payload); + } + } else { + this.handleOpen(event, payload); + } + } + + openFile = () => { + downloadsManager.openFile(this.item); + this.handleClose(); + } + + clearFile = () => { + downloadsManager.clearFile(this.item); + this.handleClose(); + } + + cancelDownload = () => { + downloadsManager.cancelDownload(this.item); + this.handleClose(); + } + + showFileInFolder = (e: IpcMainEvent, item: DownloadedItem) => { + log.debug('DownloadsDropdownMenuView.showFileInFolder', {item}); + + downloadsManager.showFileInFolder(item); + this.handleClose(); + } + + getBounds = (width: number, height: number) => { + // MUST return integers + return { + x: this.getX(), + y: this.getY(), + width: Math.round(width), + height: Math.round(height), + }; + } + + getX = () => { + const result = (this.windowBounds.width - DOWNLOADS_DROPDOWN_FULL_WIDTH - DOWNLOADS_DROPDOWN_MENU_FULL_WIDTH) + (this.coordinates?.x || 0) + (this.coordinates?.width || 0); + if (result <= DOWNLOADS_DROPDOWN_MENU_FULL_WIDTH) { + return 0; + } + return Math.round(result); + } + + getY = () => { + const result = TAB_BAR_HEIGHT + (this.coordinates?.y || 0) + (this.coordinates?.height || 0); + return Math.round(result); + } + + repositionDownloadsDropdownMenu = () => { + this.bounds = this.getBounds(DOWNLOADS_DROPDOWN_MENU_FULL_WIDTH, DOWNLOADS_DROPDOWN_MENU_FULL_HEIGHT); + if (downloadsManager.getIsOpen()) { + this.view.setBounds(this.bounds); + } + } + + destroy = () => { + // 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(); + } +} diff --git a/src/main/views/downloadsDropdownView.test.js b/src/main/views/downloadsDropdownView.test.js new file mode 100644 index 00000000..56320c90 --- /dev/null +++ b/src/main/views/downloadsDropdownView.test.js @@ -0,0 +1,102 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +'use strict'; + +import {getDoNotDisturb as getDarwinDoNotDisturb} from 'macos-notification-state'; + +import {DOWNLOADS_DROPDOWN_FULL_WIDTH, DOWNLOADS_DROPDOWN_HEIGHT, TAB_BAR_HEIGHT} from 'common/utils/constants'; + +import DownloadsDropdownView from './downloadsDropdownView'; + +jest.mock('main/utils', () => ({ + getLocalPreload: (file) => file, + getLocalURLString: (file) => file, +})); +jest.mock('fs', () => ({ + existsSync: jest.fn().mockReturnValue(false), + readFileSync: jest.fn().mockImplementation((text) => text), + writeFile: jest.fn(), +})); +jest.mock('macos-notification-state', () => ({ + getDoNotDisturb: jest.fn(), +})); +jest.mock('electron', () => { + class NotificationMock { + static isSupported = jest.fn(); + static didConstruct = jest.fn(); + constructor() { + NotificationMock.didConstruct(); + } + on = jest.fn(); + show = jest.fn(); + click = jest.fn(); + close = jest.fn(); + } + return { + app: { + getAppPath: () => '', + }, + BrowserView: jest.fn().mockImplementation(() => ({ + webContents: { + loadURL: jest.fn(), + focus: jest.fn(), + session: { + webRequest: { + onHeadersReceived: jest.fn(), + }, + }, + }, + setBounds: jest.fn(), + })), + ipcMain: { + emit: jest.fn(), + handle: jest.fn(), + on: jest.fn(), + }, + Notification: NotificationMock, + }; +}); +jest.mock('main/windows/windowManager', () => ({ + sendToRenderer: jest.fn(), +})); + +describe('main/views/DownloadsDropdownView', () => { + beforeEach(() => { + getDarwinDoNotDisturb.mockReturnValue(false); + }); + describe('getBounds', () => { + it('should be placed far right when window is large enough', () => { + const window = { + getContentBounds: () => ({width: 800, height: 600, x: 0, y: 0}), + addBrowserView: jest.fn(), + setTopBrowserView: jest.fn(), + }; + const downloadsDropdownView = new DownloadsDropdownView(window, {}, false); + expect(downloadsDropdownView.getBounds(DOWNLOADS_DROPDOWN_FULL_WIDTH, DOWNLOADS_DROPDOWN_HEIGHT)).toStrictEqual({x: 800 - DOWNLOADS_DROPDOWN_FULL_WIDTH, y: TAB_BAR_HEIGHT, width: DOWNLOADS_DROPDOWN_FULL_WIDTH, height: DOWNLOADS_DROPDOWN_HEIGHT}); + }); + it('should be placed left if window is very small', () => { + const window = { + getContentBounds: () => ({width: 500, height: 400, x: 0, y: 0}), + addBrowserView: jest.fn(), + setTopBrowserView: jest.fn(), + }; + const downloadsDropdownView = new DownloadsDropdownView(window, {}, false); + expect(downloadsDropdownView.getBounds(DOWNLOADS_DROPDOWN_FULL_WIDTH, DOWNLOADS_DROPDOWN_HEIGHT)).toStrictEqual({x: 0, y: TAB_BAR_HEIGHT, width: DOWNLOADS_DROPDOWN_FULL_WIDTH, height: DOWNLOADS_DROPDOWN_HEIGHT}); + }); + }); + + it('should change the view bounds based on open/closed state', () => { + const window = { + getContentBounds: () => ({width: 800, height: 600, x: 0, y: 0}), + addBrowserView: jest.fn(), + setTopBrowserView: jest.fn(), + }; + const downloadsDropdownView = new DownloadsDropdownView(window, {}, false); + downloadsDropdownView.bounds = {width: 400, height: 300}; + downloadsDropdownView.handleOpen(); + expect(downloadsDropdownView.view.setBounds).toBeCalledWith(downloadsDropdownView.bounds); + downloadsDropdownView.handleClose(); + expect(downloadsDropdownView.view.setBounds).toBeCalledWith({width: 0, height: 0, x: expect.any(Number), y: expect.any(Number)}); + }); +}); diff --git a/src/main/views/downloadsDropdownView.ts b/src/main/views/downloadsDropdownView.ts new file mode 100644 index 00000000..c611eb60 --- /dev/null +++ b/src/main/views/downloadsDropdownView.ts @@ -0,0 +1,201 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. +import {BrowserView, BrowserWindow, ipcMain, IpcMainEvent} from 'electron'; + +import log from 'electron-log'; + +import {CombinedConfig} from 'types/config'; +import {DownloadedItem, DownloadedItems} from 'types/downloads'; + +import { + CLOSE_DOWNLOADS_DROPDOWN, + DOWNLOADS_DROPDOWN_SHOW_FILE_IN_FOLDER, + EMIT_CONFIGURATION, + OPEN_DOWNLOADS_DROPDOWN, + RECEIVE_DOWNLOADS_DROPDOWN_SIZE, + REQUEST_CLEAR_DOWNLOADS_DROPDOWN, + REQUEST_DOWNLOADS_DROPDOWN_INFO, + UPDATE_DOWNLOADS_DROPDOWN, + UPDATE_DOWNLOADS_DROPDOWN_MENU_ITEM, +} from 'common/communication'; +import {TAB_BAR_HEIGHT, DOWNLOADS_DROPDOWN_WIDTH, DOWNLOADS_DROPDOWN_HEIGHT, DOWNLOADS_DROPDOWN_FULL_WIDTH} from 'common/utils/constants'; +import {getLocalPreload, getLocalURLString} from 'main/utils'; + +import WindowManager from '../windows/windowManager'; +import downloadsManager from 'main/downloadsManager'; + +export default class DownloadsDropdownView { + bounds?: Electron.Rectangle; + darkMode: boolean; + downloads: DownloadedItems; + item: DownloadedItem | undefined; + view: BrowserView; + window: BrowserWindow; + windowBounds: Electron.Rectangle; + + constructor(window: BrowserWindow, downloads: DownloadedItems, darkMode: boolean) { + this.downloads = downloads; + this.window = window; + this.darkMode = darkMode; + this.item = undefined; + + this.windowBounds = this.window.getContentBounds(); + this.bounds = this.getBounds(DOWNLOADS_DROPDOWN_FULL_WIDTH, DOWNLOADS_DROPDOWN_HEIGHT); + + const preload = getLocalPreload('downloadsDropdown.js'); + this.view = new BrowserView({webPreferences: { + preload, + + // Workaround for this issue: https://github.com/electron/electron/issues/30993 + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + transparent: true, + }}); + + this.view.webContents.loadURL(getLocalURLString('downloadsDropdown.html')); + this.window.addBrowserView(this.view); + + this.view.webContents.session.webRequest.onHeadersReceived(downloadsManager.webRequestOnHeadersReceivedHandler); + + ipcMain.on(OPEN_DOWNLOADS_DROPDOWN, this.handleOpen); + ipcMain.on(CLOSE_DOWNLOADS_DROPDOWN, this.handleClose); + ipcMain.on(EMIT_CONFIGURATION, this.updateConfig); + ipcMain.on(REQUEST_DOWNLOADS_DROPDOWN_INFO, this.updateDownloadsDropdown); + ipcMain.on(REQUEST_CLEAR_DOWNLOADS_DROPDOWN, this.clearDownloads); + ipcMain.on(RECEIVE_DOWNLOADS_DROPDOWN_SIZE, this.handleReceivedDownloadsDropdownSize); + ipcMain.on(DOWNLOADS_DROPDOWN_SHOW_FILE_IN_FOLDER, this.showFileInFolder); + ipcMain.on(UPDATE_DOWNLOADS_DROPDOWN, this.updateDownloads); + ipcMain.on(UPDATE_DOWNLOADS_DROPDOWN_MENU_ITEM, this.updateDownloadsDropdownMenuItem); + } + + updateDownloads = (event: IpcMainEvent, downloads: DownloadedItems) => { + log.debug('DownloadsDropdownView.updateDownloads', {downloads}); + + this.downloads = downloads; + + this.updateDownloadsDropdown(); + } + + updateDownloadsDropdownMenuItem = (event: IpcMainEvent, item?: DownloadedItem) => { + log.debug('DownloadsDropdownView.updateDownloadsDropdownMenuItem', {item}); + this.item = item; + this.updateDownloadsDropdown(); + } + + updateConfig = (event: IpcMainEvent, config: CombinedConfig) => { + log.debug('DownloadsDropdownView.updateConfig'); + + this.darkMode = config.darkMode; + this.updateDownloadsDropdown(); + } + + /** + * This is called every time the "window" is resized so that we can position + * the downloads dropdown at the correct position + */ + updateWindowBounds = () => { + log.debug('DownloadsDropdownView.updateWindowBounds'); + + this.windowBounds = this.window.getContentBounds(); + this.updateDownloadsDropdown(); + this.repositionDownloadsDropdown(); + } + + updateDownloadsDropdown = () => { + log.debug('DownloadsDropdownView.updateDownloadsDropdown'); + + this.view.webContents.send( + UPDATE_DOWNLOADS_DROPDOWN, + this.downloads, + this.darkMode, + this.windowBounds, + this.item, + ); + } + + handleOpen = () => { + log.debug('DownloadsDropdownView.handleOpen', {bounds: this.bounds}); + + if (!this.bounds) { + return; + } + + this.view.setBounds(this.bounds); + this.window.setTopBrowserView(this.view); + this.view.webContents.focus(); + downloadsManager.onOpen(); + WindowManager.sendToRenderer(OPEN_DOWNLOADS_DROPDOWN); + } + + handleClose = () => { + log.debug('DownloadsDropdownView.handleClose'); + + this.view.setBounds(this.getBounds(0, 0)); + downloadsManager.onClose(); + WindowManager.sendToRenderer(CLOSE_DOWNLOADS_DROPDOWN); + } + + clearDownloads = () => { + downloadsManager.clearDownloadsDropDown(); + this.handleClose(); + } + + showFileInFolder = (e: IpcMainEvent, item: DownloadedItem) => { + log.debug('DownloadsDropdownView.showFileInFolder', {item}); + + downloadsManager.showFileInFolder(item); + } + + getBounds = (width: number, height: number) => { + // Must always use integers + return { + x: this.getX(this.windowBounds.width), + y: this.getY(), + width: Math.round(width), + height: Math.round(height), + }; + } + + getX = (windowWidth: number) => { + const result = windowWidth - DOWNLOADS_DROPDOWN_FULL_WIDTH; + if (result <= DOWNLOADS_DROPDOWN_WIDTH) { + return 0; + } + return Math.round(result); + } + + getY = () => { + return Math.round(TAB_BAR_HEIGHT); + } + + repositionDownloadsDropdown = () => { + if (!this.bounds) { + return; + } + this.bounds = { + ...this.bounds, + x: this.getX(this.windowBounds.width), + y: this.getY(), + }; + if (downloadsManager.getIsOpen()) { + this.view.setBounds(this.bounds); + } + } + + handleReceivedDownloadsDropdownSize = (event: IpcMainEvent, width: number, height: number) => { + log.silly('DownloadsDropdownView.handleReceivedDownloadsDropdownSize', {width, height}); + + this.bounds = this.getBounds(width, height); + if (downloadsManager.getIsOpen()) { + this.view.setBounds(this.bounds); + } + } + + destroy = () => { + // 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(); + } +} diff --git a/src/main/windows/windowManager.test.js b/src/main/windows/windowManager.test.js index 368ee6ef..b16cd1d4 100644 --- a/src/main/windows/windowManager.test.js +++ b/src/main/windows/windowManager.test.js @@ -64,10 +64,15 @@ jest.mock('../views/viewManager', () => ({ })); jest.mock('../CriticalErrorHandler', () => jest.fn()); jest.mock('../views/teamDropdownView', () => jest.fn()); +jest.mock('../views/downloadsDropdownView', () => jest.fn()); +jest.mock('../views/downloadsDropdownMenuView', () => jest.fn()); jest.mock('./settingsWindow', () => ({ createSettingsWindow: jest.fn(), })); jest.mock('./mainWindow', () => jest.fn()); +jest.mock('../downloadsManager', () => ({ + getDownloads: () => {}, +})); describe('main/windows/windowManager', () => { describe('handleUpdateConfig', () => { diff --git a/src/main/windows/windowManager.ts b/src/main/windows/windowManager.ts index a94e9222..8b7f7bdf 100644 --- a/src/main/windows/windowManager.ts +++ b/src/main/windows/windowManager.ts @@ -41,6 +41,10 @@ import {ViewManager, LoadingScreenState} from '../views/viewManager'; import CriticalErrorHandler from '../CriticalErrorHandler'; import TeamDropdownView from '../views/teamDropdownView'; +import DownloadsDropdownView from '../views/downloadsDropdownView'; +import DownloadsDropdownMenuView from '../views/downloadsDropdownMenuView'; + +import downloadsManager from 'main/downloadsManager'; import {createSettingsWindow} from './settingsWindow'; import createMainWindow from './mainWindow'; @@ -55,6 +59,8 @@ export class WindowManager { settingsWindow?: BrowserWindow; viewManager?: ViewManager; teamDropdown?: TeamDropdownView; + downloadsDropdown?: DownloadsDropdownView; + downloadsDropdownMenu?: DownloadsDropdownMenuView; currentServerName?: string; constructor() { @@ -156,6 +162,8 @@ export class WindowManager { } this.teamDropdown = new TeamDropdownView(this.mainWindow, Config.teams, Config.darkMode, Config.enableServerManagement); + this.downloadsDropdown = new DownloadsDropdownView(this.mainWindow, downloadsManager.getDownloads(), Config.darkMode); + this.downloadsDropdownMenu = new DownloadsDropdownMenuView(this.mainWindow, Config.darkMode); } this.initializeViewManager(); @@ -199,6 +207,8 @@ export class WindowManager { this.throttledWillResize(newBounds); this.viewManager?.setLoadingScreenBounds(); this.teamDropdown?.updateWindowBounds(); + this.downloadsDropdown?.updateWindowBounds(); + this.downloadsDropdownMenu?.updateWindowBounds(); ipcMain.emit(RESIZE_MODAL, null, newBounds); } @@ -234,11 +244,12 @@ export class WindowManager { const bounds = this.getBounds(); - // Another workaround since the window doesn't update p roperly under Linux for some reason + // Another workaround since the window doesn't update properly under Linux for some reason // See above comment setTimeout(this.setCurrentViewBounds, 10, bounds); this.viewManager.setLoadingScreenBounds(); this.teamDropdown?.updateWindowBounds(); + this.downloadsDropdown?.updateWindowBounds(); ipcMain.emit(RESIZE_MODAL, null, bounds); }; @@ -274,7 +285,7 @@ export class WindowManager { } // max retries allows the message to get to the renderer even if it is sent while the app is starting up. - sendToRendererWithRetry = (maxRetries: number, channel: string, ...args: any[]) => { + sendToRendererWithRetry = (maxRetries: number, channel: string, ...args: unknown[]) => { if (!this.mainWindow || !this.mainWindowReady) { if (maxRetries > 0) { log.info(`Can't send ${channel}, will retry`); @@ -296,11 +307,11 @@ export class WindowManager { } } - sendToRenderer = (channel: string, ...args: any[]) => { + sendToRenderer = (channel: string, ...args: unknown[]) => { this.sendToRendererWithRetry(3, channel, ...args); } - sendToAll = (channel: string, ...args: any[]) => { + sendToAll = (channel: string, ...args: unknown[]) => { this.sendToRenderer(channel, ...args); if (this.settingsWindow) { this.settingsWindow.webContents.send(channel, ...args); @@ -309,7 +320,7 @@ export class WindowManager { // TODO: should we include popups? } - sendToMattermostViews = (channel: string, ...args: any[]) => { + sendToMattermostViews = (channel: string, ...args: unknown[]) => { if (this.viewManager) { this.viewManager.sendToAllViews(channel, ...args); } @@ -498,7 +509,7 @@ export class WindowManager { if (this.viewManager) { this.viewManager.focus(); } else { - log.error('Trying to call focus when the viewmanager has not yet been initialized'); + log.error('Trying to call focus when the viewManager has not yet been initialized'); } } @@ -648,7 +659,7 @@ export class WindowManager { } handleBrowserHistoryPush = (e: IpcMainEvent, viewName: string, pathName: string) => { - log.debug('WwindowManager.handleBrowserHistoryPush', {viewName, pathName}); + log.debug('WindowManager.handleBrowserHistoryPush', {viewName, pathName}); const currentView = this.viewManager?.views.get(viewName); const cleanedPathName = urlUtils.cleanPathName(currentView?.tab.server.url.pathname || '', pathName); @@ -676,7 +687,7 @@ export class WindowManager { } handleBrowserHistoryButton = (e: IpcMainEvent, viewName: string) => { - log.debug('EindowManager.handleBrowserHistoryButton', viewName); + log.debug('WindowManager.handleBrowserHistoryButton', viewName); const currentView = this.viewManager?.views.get(viewName); if (currentView) { diff --git a/src/renderer/components/DownloadsDropdown/DownloadsDropdownButton.tsx b/src/renderer/components/DownloadsDropdown/DownloadsDropdownButton.tsx new file mode 100644 index 00000000..d43fa004 --- /dev/null +++ b/src/renderer/components/DownloadsDropdown/DownloadsDropdownButton.tsx @@ -0,0 +1,58 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import classNames from 'classnames'; +import React, {useEffect} from 'react'; + +import '../../css/components/DownloadsDropdown/DownloadsDropdownButton.scss'; + +type Props = { + closeDownloadsDropdown: () => void; + darkMode: boolean; + isDownloadsDropdownOpen: boolean; + openDownloadsDropdown: () => void; + showDownloadsBadge: boolean; +} + +const DownloadsDropDownButtonBadge = ({show}: { show: boolean }) => ( + show ? : null +); + +const DownloadsDropdownButton: React.FC = ({darkMode, isDownloadsDropdownOpen, showDownloadsBadge, closeDownloadsDropdown, openDownloadsDropdown}: Props) => { + const buttonRef: React.RefObject = React.createRef(); + + useEffect(() => { + if (!isDownloadsDropdownOpen) { + buttonRef.current?.blur(); + } + }, [isDownloadsDropdownOpen, buttonRef]); + + const handleToggleButton = (event: React.MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + if (isDownloadsDropdownOpen) { + closeDownloadsDropdown(); + } else { + openDownloadsDropdown(); + } + }; + + return ( + + ); +}; + +export default DownloadsDropdownButton; diff --git a/src/renderer/components/DownloadsDropdown/DownloadsDropdownItem.tsx b/src/renderer/components/DownloadsDropdown/DownloadsDropdownItem.tsx new file mode 100644 index 00000000..01865a98 --- /dev/null +++ b/src/renderer/components/DownloadsDropdown/DownloadsDropdownItem.tsx @@ -0,0 +1,28 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {DownloadedItem} from 'types/downloads'; + +import DownloadsDropdownItemFile from './DownloadsDropdownItemFile'; +import UpdateWrapper from './Update/UpdateWrapper'; + +type OwnProps = { + activeItem?: DownloadedItem; + item: DownloadedItem; +} + +const DownloadsDropdownItem = ({item, activeItem}: OwnProps) => { + if (item.type === 'update' && item.state !== 'progressing') { + return ; + } + + return ( + + ); +}; + +export default DownloadsDropdownItem; diff --git a/src/renderer/components/DownloadsDropdown/DownloadsDropdownItemFile.tsx b/src/renderer/components/DownloadsDropdown/DownloadsDropdownItemFile.tsx new file mode 100644 index 00000000..c1bdde83 --- /dev/null +++ b/src/renderer/components/DownloadsDropdown/DownloadsDropdownItemFile.tsx @@ -0,0 +1,70 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useState} from 'react'; +import {DownloadedItem} from 'types/downloads'; +import classNames from 'classnames'; + +import {useIntl} from 'react-intl'; + +import {DOWNLOADS_DROPDOWN_SHOW_FILE_IN_FOLDER} from 'common/communication'; + +import FileSizeAndStatus from './FileSizeAndStatus'; +import ProgressBar from './ProgressBar'; +import ThreeDotButton from './ThreeDotButton'; +import Thumbnail from './Thumbnail'; + +type OwnProps = { + activeItem?: DownloadedItem; + item: DownloadedItem; +} + +const DownloadsDropdownItemFile = ({item, activeItem}: OwnProps) => { + const [threeDotButtonVisible, setThreeDotButtonVisible] = useState(false); + const translate = useIntl(); + + const onFileClick = (e: React.MouseEvent) => { + e.preventDefault(); + + window.postMessage({type: DOWNLOADS_DROPDOWN_SHOW_FILE_IN_FOLDER, payload: {item}}, window.location.href); + }; + + const itemFilename = item.type === 'update' ? + translate.formatMessage({id: 'renderer.downloadsDropdown.Update.MattermostVersionX', defaultMessage: `Mattermost version ${item.filename}`}, {version: item.filename}) : + item.filename; + + return ( +
setThreeDotButtonVisible(true)} + onMouseLeave={() => setThreeDotButtonVisible(false)} + > +
+ +
+
+ {itemFilename} +
+
+ +
+
+ +
+ +
+ ); +}; + +export default DownloadsDropdownItemFile; diff --git a/src/renderer/components/DownloadsDropdown/FileSizeAndStatus.tsx b/src/renderer/components/DownloadsDropdown/FileSizeAndStatus.tsx new file mode 100644 index 00000000..75649fc9 --- /dev/null +++ b/src/renderer/components/DownloadsDropdown/FileSizeAndStatus.tsx @@ -0,0 +1,37 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useCallback} from 'react'; +import {useIntl} from 'react-intl'; + +import {DownloadedItem} from 'types/downloads'; + +import {getDownloadingFileStatus, getFileSizeOrBytesProgress, prettyETA} from 'renderer/utils'; + +type OwnProps = { + item: DownloadedItem; +} + +const FileSizeAndStatus = ({item}: OwnProps) => { + const translate = useIntl(); + + const {totalBytes, receivedBytes, addedAt} = item; + + const getRemainingTime = useCallback(() => { + const elapsedMs = Date.now() - addedAt; + const bandwidth = receivedBytes / elapsedMs; + const etaMS = Math.round((totalBytes - receivedBytes) / bandwidth); + return prettyETA(etaMS, translate); + }, [receivedBytes, addedAt, totalBytes, translate]); + + const fileSizeOrByteProgress = getFileSizeOrBytesProgress(item); + const statusOrETA = item.state === 'progressing' ? getRemainingTime() : getDownloadingFileStatus(item); + + return ( + <> + {fileSizeOrByteProgress}{' • '}{statusOrETA} + + ); +}; + +export default FileSizeAndStatus; diff --git a/src/renderer/components/DownloadsDropdown/ProgressBar.tsx b/src/renderer/components/DownloadsDropdown/ProgressBar.tsx new file mode 100644 index 00000000..757152db --- /dev/null +++ b/src/renderer/components/DownloadsDropdown/ProgressBar.tsx @@ -0,0 +1,26 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {DownloadedItem} from 'types/downloads'; + +type OwnProps = { + item: DownloadedItem; +} + +const ProgressBar = ({item}: OwnProps) => { + if (item.state !== 'progressing') { + return null; + } + + return ( +
+
+
+ ); +}; + +export default ProgressBar; diff --git a/src/renderer/components/DownloadsDropdown/ThreeDotButton.tsx b/src/renderer/components/DownloadsDropdown/ThreeDotButton.tsx new file mode 100644 index 00000000..7108cf79 --- /dev/null +++ b/src/renderer/components/DownloadsDropdown/ThreeDotButton.tsx @@ -0,0 +1,48 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useRef} from 'react'; +import {DownloadedItem} from 'types/downloads'; + +import classNames from 'classnames'; + +import {TOGGLE_DOWNLOADS_DROPDOWN_MENU} from 'common/communication'; + +type OwnProps = { + activeItem?: DownloadedItem; + item: DownloadedItem; + visible: boolean; +} + +const ThreeDotButton = ({item, activeItem, visible}: OwnProps) => { + const buttonElement = useRef(null); + + const onClick = (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + + const coords = buttonElement.current?.getBoundingClientRect(); + window.postMessage({ + type: TOGGLE_DOWNLOADS_DROPDOWN_MENU, + payload: { + coordinates: coords?.toJSON(), + item, + }, + }, window.location.href); + }; + + return ( + + ); +}; + +export default ThreeDotButton; diff --git a/src/renderer/components/DownloadsDropdown/Thumbnail.tsx b/src/renderer/components/DownloadsDropdown/Thumbnail.tsx new file mode 100644 index 00000000..87a411d9 --- /dev/null +++ b/src/renderer/components/DownloadsDropdown/Thumbnail.tsx @@ -0,0 +1,63 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {DownloadedItem} from 'types/downloads'; + +import {CheckCircleIcon, CloseCircleIcon} from '@mattermost/compass-icons/components'; + +import {getIconClassName, isImageFile} from 'renderer/utils'; + +type OwnProps = { + item: DownloadedItem; +} + +const iconSize = 12; +const colorGreen = '#3DB887'; +const colorRed = '#D24B4E'; + +const isWin = window.process.platform === 'win32'; + +const Thumbnail = ({item}: OwnProps) => { + const showBadge = (state: DownloadedItem['state']) => { + switch (state) { + case 'completed': + return ( + + ); + case 'progressing': + return null; + case 'available': + return null; + default: + return ( + + ); + } + }; + + const showImagePreview = isImageFile(item) && item.state === 'completed'; + + return ( +
+ {showImagePreview ? +
: +
} + {showBadge(item.state)} +
+ ); +}; + +export default Thumbnail; diff --git a/src/renderer/components/DownloadsDropdown/Update/UpdateAvailable.tsx b/src/renderer/components/DownloadsDropdown/Update/UpdateAvailable.tsx new file mode 100644 index 00000000..14b966cd --- /dev/null +++ b/src/renderer/components/DownloadsDropdown/Update/UpdateAvailable.tsx @@ -0,0 +1,57 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {DownloadedItem} from 'types/downloads'; + +import {FormattedMessage} from 'react-intl'; + +import {Button} from 'react-bootstrap'; + +import {START_UPDATE_DOWNLOAD} from 'common/communication'; + +import Thumbnail from '../Thumbnail'; + +type OwnProps = { + item: DownloadedItem; +} + +const UpdateAvailable = ({item}: OwnProps) => { + const onButtonClick = (e: React.MouseEvent) => { + e?.preventDefault?.(); + window.postMessage({type: START_UPDATE_DOWNLOAD}, window.location.href); + }; + + return ( +
+ +
+
+ +
+
+ +
+ +
+
+ ); +}; + +export default UpdateAvailable; diff --git a/src/renderer/components/DownloadsDropdown/Update/UpdateDownloaded.tsx b/src/renderer/components/DownloadsDropdown/Update/UpdateDownloaded.tsx new file mode 100644 index 00000000..92aed909 --- /dev/null +++ b/src/renderer/components/DownloadsDropdown/Update/UpdateDownloaded.tsx @@ -0,0 +1,61 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {DownloadedItem} from 'types/downloads'; + +import {FormattedMessage, useIntl} from 'react-intl'; + +import {Button} from 'react-bootstrap'; + +import classNames from 'classnames'; + +import {START_UPGRADE} from 'common/communication'; + +import Thumbnail from '../Thumbnail'; +import FileSizeAndStatus from '../FileSizeAndStatus'; + +type OwnProps = { + item: DownloadedItem; +} + +const UpdateAvailable = ({item}: OwnProps) => { + const translate = useIntl(); + + const onButtonClick = (e: React.MouseEvent) => { + e?.preventDefault?.(); + window.postMessage({type: START_UPGRADE}, window.location.href); + }; + + return ( +
+
+ +
+
+ {translate.formatMessage({id: 'renderer.downloadsDropdown.Update.MattermostVersionX', defaultMessage: `Mattermost version ${item.filename}`}, {version: item.filename})} +
+
+ +
+ +
+
+
+ ); +}; + +export default UpdateAvailable; diff --git a/src/renderer/components/DownloadsDropdown/Update/UpdateWrapper.tsx b/src/renderer/components/DownloadsDropdown/Update/UpdateWrapper.tsx new file mode 100644 index 00000000..fa6405ca --- /dev/null +++ b/src/renderer/components/DownloadsDropdown/Update/UpdateWrapper.tsx @@ -0,0 +1,26 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {DownloadedItem} from 'types/downloads'; + +import UpdateAvailable from './UpdateAvailable'; +import UpdateDownloaded from './UpdateDownloaded'; + +import 'renderer/css/components/Button.scss'; + +type OwnProps = { + item: DownloadedItem; +} + +const UpdateWrapper = ({item}: OwnProps) => { + if (item.state === 'available') { + return ; + } + if (item.state === 'completed') { + return ; + } + return null; +}; + +export default UpdateWrapper; diff --git a/src/renderer/components/MainPage.tsx b/src/renderer/components/MainPage.tsx index a1b16ad6..732e66a7 100644 --- a/src/renderer/components/MainPage.tsx +++ b/src/renderer/components/MainPage.tsx @@ -10,9 +10,9 @@ import {Container, Row} from 'react-bootstrap'; import {DropResult} from 'react-beautiful-dnd'; import {injectIntl, IntlShape} from 'react-intl'; import {IpcRendererEvent} from 'electron/renderer'; -import prettyBytes from 'pretty-bytes'; import {TeamWithTabs} from 'types/config'; +import {DownloadedItems} from 'types/downloads'; import {getTabViewName} from 'common/tabs/TabView'; @@ -40,13 +40,15 @@ import { CLOSE_TEAMS_DROPDOWN, OPEN_TEAMS_DROPDOWN, SWITCH_TAB, - UPDATE_AVAILABLE, - UPDATE_DOWNLOADED, - UPDATE_PROGRESS, - START_UPGRADE, - START_DOWNLOAD, CLOSE_TAB, RELOAD_CURRENT_VIEW, + CLOSE_DOWNLOADS_DROPDOWN, + OPEN_DOWNLOADS_DROPDOWN, + SHOW_DOWNLOADS_DROPDOWN_BUTTON_BADGE, + HIDE_DOWNLOADS_DROPDOWN_BUTTON_BADGE, + UPDATE_DOWNLOADS_DROPDOWN, + REQUEST_HAS_DOWNLOADS, + CLOSE_DOWNLOADS_DROPDOWN_MENU, } from 'common/communication'; import restoreButton from '../../assets/titlebar/chrome-restore.svg'; @@ -60,6 +62,8 @@ import TabBar from './TabBar'; import ExtraBar from './ExtraBar'; import ErrorView from './ErrorView'; import TeamDropdownButton from './TeamDropdownButton'; +import DownloadsDropdownButton from './DownloadsDropdown/DownloadsDropdownButton'; + import '../css/components/UpgradeButton.scss'; enum Status { @@ -70,13 +74,6 @@ enum Status { NOSERVERS = -2, } -enum UpgradeStatus { - NONE = 0, - AVAILABLE = 1, - DOWNLOADING = 2, - DOWNLOADED = 3, -} - type Props = { teams: TeamWithTabs[]; lastActiveTeam?: number; @@ -101,14 +98,9 @@ type State = { fullScreen?: boolean; showExtraBar?: boolean; isMenuOpen: boolean; - upgradeStatus: UpgradeStatus; - upgradeProgress?: { - total: number; - delta: number; - transferred: number; - percent: number; - bytesPerSecond: number; - }; + isDownloadsDropdownOpen: boolean; + showDownloadsBadge: boolean; + hasDownloads: boolean; }; type TabViewStatus = { @@ -146,7 +138,9 @@ class MainPage extends React.PureComponent { tabViewStatus: new Map(this.props.teams.map((team) => team.tabs.map((tab) => getTabViewName(team.name, tab.name))).flat().map((tabViewName) => [tabViewName, {status: Status.LOADING}])), darkMode: this.props.darkMode, isMenuOpen: false, - upgradeStatus: UpgradeStatus.NONE, + isDownloadsDropdownOpen: false, + showDownloadsBadge: false, + hasDownloads: false, }; } @@ -163,7 +157,21 @@ class MainPage extends React.PureComponent { this.setState({tabViewStatus: status}); } + async requestDownloadsLength() { + try { + const hasDownloads = await window.ipcRenderer.invoke(REQUEST_HAS_DOWNLOADS); + this.setState({ + hasDownloads, + }); + } catch (error) { + console.error(error); + } + } + componentDidMount() { + // request downloads + this.requestDownloadsLength(); + // set page on retry window.ipcRenderer.on(LOAD_RETRY, (_, viewName, retry, err, loadUrl) => { console.log(`${viewName}: failed to load ${err}, but retrying`); @@ -249,24 +257,25 @@ class MainPage extends React.PureComponent { this.setState({isMenuOpen: true}); }); - window.ipcRenderer.on(UPDATE_AVAILABLE, () => { - this.setState({upgradeStatus: UpgradeStatus.AVAILABLE}); + window.ipcRenderer.on(CLOSE_DOWNLOADS_DROPDOWN, () => { + this.setState({isDownloadsDropdownOpen: false}); }); - window.ipcRenderer.on(UPDATE_DOWNLOADED, () => { - this.setState({upgradeStatus: UpgradeStatus.DOWNLOADED}); + window.ipcRenderer.on(OPEN_DOWNLOADS_DROPDOWN, () => { + this.setState({isDownloadsDropdownOpen: true}); }); - window.ipcRenderer.on(UPDATE_PROGRESS, (event, total, delta, transferred, percent, bytesPerSecond) => { + window.ipcRenderer.on(SHOW_DOWNLOADS_DROPDOWN_BUTTON_BADGE, () => { + this.setState({showDownloadsBadge: true}); + }); + + window.ipcRenderer.on(HIDE_DOWNLOADS_DROPDOWN_BUTTON_BADGE, () => { + this.setState({showDownloadsBadge: false}); + }); + + window.ipcRenderer.on(UPDATE_DOWNLOADS_DROPDOWN, (event, downloads: DownloadedItems) => { this.setState({ - upgradeStatus: UpgradeStatus.DOWNLOADING, - upgradeProgress: { - total, - delta, - transferred, - percent, - bytesPerSecond, - }, + hasDownloads: (Object.values(downloads)?.length || 0) > 0, }); }); @@ -278,15 +287,17 @@ class MainPage extends React.PureComponent { }); } - window.addEventListener('click', this.handleCloseTeamsDropdown); + window.addEventListener('click', this.handleCloseDropdowns); } componentWillUnmount() { - window.removeEventListener('click', this.handleCloseTeamsDropdown); + window.removeEventListener('click', this.handleCloseDropdowns); } - handleCloseTeamsDropdown = () => { + handleCloseDropdowns = () => { window.ipcRenderer.send(CLOSE_TEAMS_DROPDOWN); + window.ipcRenderer.send(CLOSE_DOWNLOADS_DROPDOWN); + window.ipcRenderer.send(CLOSE_DOWNLOADS_DROPDOWN_MENU); } handleMaximizeState = (_: IpcRendererEvent, maximized: boolean) => { @@ -359,13 +370,26 @@ class MainPage extends React.PureComponent { focusOnWebView = () => { window.ipcRenderer.send(FOCUS_BROWSERVIEW); - this.handleCloseTeamsDropdown(); + this.handleCloseDropdowns(); } reloadCurrentView = () => { window.ipcRenderer.send(RELOAD_CURRENT_VIEW); } + showHideDownloadsBadge(value = false) { + this.setState({showDownloadsBadge: value}); + } + + closeDownloadsDropdown() { + window.ipcRenderer.send(CLOSE_DOWNLOADS_DROPDOWN); + window.ipcRenderer.send(CLOSE_DOWNLOADS_DROPDOWN_MENU); + } + + openDownloadsDropdown() { + window.ipcRenderer.send(OPEN_DOWNLOADS_DROPDOWN); + } + render() { const {intl} = this.props; const currentTabs = this.props.teams.find((team) => team.name === this.state.activeServerName)?.tabs || []; @@ -394,6 +418,16 @@ class MainPage extends React.PureComponent { fullScreen: this.state.fullScreen, }); + const downloadsDropdownButton = this.state.hasDownloads ? ( + + ) : null; + let maxButton; if (this.state.maximized || this.state.fullScreen) { maxButton = ( @@ -421,58 +455,6 @@ class MainPage extends React.PureComponent { ); } - let upgradeTooltip; - switch (this.state.upgradeStatus) { - case UpgradeStatus.AVAILABLE: - upgradeTooltip = intl.formatMessage({id: 'renderer.components.mainPage.updateAvailable', defaultMessage: 'Update available'}); - break; - case UpgradeStatus.DOWNLOADED: - upgradeTooltip = intl.formatMessage({id: 'renderer.components.mainPage.updateReady', defaultMessage: 'Update ready to install'}); - break; - case UpgradeStatus.DOWNLOADING: - upgradeTooltip = intl.formatMessage({ - id: 'renderer.components.mainPage.downloadingUpdate', - defaultMessage: 'Downloading update. {percentDone}% of {total} @ {speed}/s', - }, { - percentDone: String(this.state.upgradeProgress?.percent).split('.')[0], - total: prettyBytes(this.state.upgradeProgress?.total || 0), - speed: prettyBytes(this.state.upgradeProgress?.bytesPerSecond || 0), - }); - break; - } - - let upgradeIcon; - if (this.state.upgradeStatus !== UpgradeStatus.NONE) { - upgradeIcon = ( - - ); - } - let titleBarButtons; if (window.process.platform === 'win32' && !this.props.useNativeWindow) { titleBarButtons = ( @@ -548,7 +530,7 @@ class MainPage extends React.PureComponent { /> )} {tabsRow} - {upgradeIcon} + {downloadsDropdownButton} {titleBarButtons}
diff --git a/src/renderer/constants.ts b/src/renderer/constants.ts new file mode 100644 index 00000000..378bda3a --- /dev/null +++ b/src/renderer/constants.ts @@ -0,0 +1,100 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +export const Constants = { + SECOND_MS: 1000, + MINUTE_MS: 60 * 1000, + HOUR_MS: 60 * 60 * 1000, + ICON_NAME_FROM_MIME_TYPE: { + 'application/pdf': 'pdf', + 'application/vnd.openxmlformats-officedocument.presentationml.presentation': 'ppt', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': 'excel', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': 'word', + 'application/x-apple-diskimage': 'generic', + 'application/zip': 'zip', + 'audio/mpeg': 'audio', + 'image/jpeg': 'image', + 'text/html': 'code', + 'text/plain': 'text', + 'video/mp4': 'video', + }, + ICON_NAME_FROM_EXTENSION: { + pdf: 'pdf', + doc: 'word', + docx: 'word', + ppt: 'ppt', + pptx: 'ppt', + xls: 'excel', + xlsx: 'excel', + patch: 'patch', + txt: 'text', + + // ZIP + zip: 'zip', + rar: 'zip', + '7z': 'zip', + tar: 'zip', + gz: 'zip', + + // Audio + mp3: 'audio', + aac: 'audio', + wav: 'audio', + flac: 'audio', + ogg: 'audio', + + // Image + jpg: 'image', + jpeg: 'image', + svg: 'image', + gif: 'image', + png: 'image', + bmp: 'image', + tif: 'image', + tiff: 'image', + + // Code + html: 'code', + xhtml: 'code', + htm: 'code', + css: 'code', + sass: 'code', + scss: 'code', + js: 'code', + jsx: 'code', + tsx: 'code', + ts: 'code', + go: 'code', + json: 'code', + sh: 'code', + py: 'code', + rpy: 'code', + c: 'code', + cgi: 'code', + pl: 'code', + class: 'code', + cpp: 'code', + cc: 'code', + cs: 'code', + h: 'code', + java: 'code', + php: 'code', + swift: 'code', + vb: 'code', + jsp: 'code', + r: 'code', + lib: 'code', + dll: 'code', + perl: 'code', + run: 'code', + + // Video + mp4: 'video', + mov: 'video', + wmv: 'video', + avi: 'video', + mkv: 'video', + flv: 'video', + webm: 'video', + }, +}; diff --git a/src/renderer/css/components/Button.scss b/src/renderer/css/components/Button.scss index a11bfc8a..0c435cf8 100644 --- a/src/renderer/css/components/Button.scss +++ b/src/renderer/css/components/Button.scss @@ -1,6 +1,7 @@ @import url("../_css_variables.scss"); .primary-button { + cursor: pointer; position: relative; display: inline-flex; align-items: center; diff --git a/src/renderer/css/components/DownloadsDropdown/DownloadsDropdownButton.scss b/src/renderer/css/components/DownloadsDropdown/DownloadsDropdownButton.scss new file mode 100644 index 00000000..086c6ba0 --- /dev/null +++ b/src/renderer/css/components/DownloadsDropdown/DownloadsDropdownButton.scss @@ -0,0 +1,50 @@ +.DownloadsDropdownButton { + align-items: center; + background: transparent; + border-radius: 4px; + border: none; + cursor: pointer; + display: flex; + flex-direction: column; + justify-content: center; + margin: 4px; + position: relative; + width: 32px; + + i { + color: rgba(63, 67, 80, 0.56); + cursor: pointer; + font-size: 21px; + line-height: 21px; + } + + &:hover, &:focus, &.isDownloadsDropdownOpen { + background-color: rgba(28, 88, 217, 0.08); + i { + color: rgba(56, 111, 229, 1); + } + } + + .DownloadsDropdownButton__badge { + background: rgba(210, 75, 78, 1); + border-radius: 10px; + width: 10px; + height: 10px; + position: absolute; + top: 5px; + right: 5px; + } + + &.darkMode { + i { + color: rgba(221, 223, 228, 0.56); + } + + &:hover, &:focus, &.isDownloadsDropdownOpen { + background-color: rgba(56, 111, 229, 0.08); + i { + color: rgba(56, 111, 229, 1); + } + } + } +} diff --git a/src/renderer/css/downloadsDropdown.scss b/src/renderer/css/downloadsDropdown.scss new file mode 100644 index 00000000..13897ad6 --- /dev/null +++ b/src/renderer/css/downloadsDropdown.scss @@ -0,0 +1,518 @@ +@import url("fonts.css"); +@import '~@mattermost/compass-icons/css/compass-icons.css'; + +@mixin file-icon($path) { + background-image: url($path); + background-position: center; + background-repeat: no-repeat; + background-size: 24px 24px; +} + +/* with this, padding doesn't change an element's width & height */ +* { + box-sizing: border-box; + -moz-box-sizing: border-box; + -webkit-box-sizing: border-box; + + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +/* Custom scrollbar */ +::-webkit-scrollbar { + width: 8px; +} +::-webkit-scrollbar-track { + display: none; +} +::-webkit-scrollbar-thumb { + background: rgba(63, 67, 80, 0.6); + border-radius: 4px; + width: 8px; +} + +body { + margin: 0; + background: transparent; + font-family: Open Sans; + overflow: hidden; + + #app { + width: 328px; // 280px + 24px*2 + padding: 0 24px 24px; + + .DownloadsDropdown { + background: #ffffff; + border-radius: 4px; + border: 1px solid rgba(61, 60, 64, 0.16); + box-shadow: 0px 8px 24px rgba(0, 0, 0, 0.12); + display: flex; + flex-direction: column; + max-height: 360px; + max-width: 280px; + min-height: 120px; + padding: 0 0 8px; + width: 280px; + + .DownloadsDropdown__Thumbnail__Container { + align-items: center; + background: rgba(63, 67, 80, 0.08); + border-radius: 4px; + display: flex; + height: 32px; + justify-content: center; + margin-right: 8px; + min-height: 32px; + min-width: 32px; + position: relative; + width: 32px; + + > svg { + background: #ffffff; + border-radius: 50%; + border: 2px solid #ffffff; + position: absolute; + right: 0; + top: 0; + transform: translate(50%, -50%); + } + + .DownloadsDropdown__Thumbnail { + border-radius: 4px; + width: 100%; + height: 100%; + + &.mattermost { + @include file-icon('./thumbnails/mattermost.svg'); + background-size: 32px 32px; + } + &.text { + @include file-icon('./thumbnails/text.svg'); + } + &.audio { + @include file-icon('./thumbnails/audio.svg'); + } + &.video { + @include file-icon('./thumbnails/video.svg'); + } + &.ppt { + @include file-icon('./thumbnails/ppt.svg'); + } + &.generic, + &.other { + @include file-icon('./thumbnails/generic.svg'); + } + &.code { + @include file-icon('./thumbnails/code.svg'); + } + &.excel { + @include file-icon('./thumbnails/excel.svg'); + } + &.word { + @include file-icon('./thumbnails/word.svg'); + } + &.pdf { + @include file-icon('./thumbnails/pdf.svg'); + } + &.patch { + @include file-icon('./thumbnails/patch.svg'); + } + &.image { + @include file-icon('./thumbnails/image.svg'); + } + &.zip { + @include file-icon('./thumbnails/zip.svg'); + } + } + } + + .DownloadsDropdown__header { + align-items: center; + display: flex; + flex-direction: row; + min-height: 44px; + justify-content: space-between; + padding: 6px 10px 6px 20px; + + .DownloadsDropdown__Downloads { + font-weight: 600; + font-size: 14px; + line-height: 20px; + color: #3D3C40; + } + + .DownloadsDropdown__clearAllButton { + background: transparent; + border-radius: 4px; + color: #1C58D9; + cursor: pointer; + font-size: 11px; + font-style: normal; + font-weight: 600; + height: 24px; + letter-spacing: 0.02em; + padding: 4px 10px; + + &:hover { + background: rgba(28, 88, 217, 0.08); + } + + &.disabled { + cursor: default; + color: rgba(63, 67, 80, 0.32); + } + } + } + + .DownloadsDropdown__divider { + border-top: 1px solid rgba(61, 60, 64, 0.08); + border-bottom: 0; + width: 100%; + margin: 0 0 8px; + } + + .DownloadsDropdown__list { + align-items: flex-start; + display: flex; + flex-direction: column; + font-size: 12px; + justify-content: flex-start; + overflow-x: hidden; + overflow-y: auto; + width: 100%; + + .DownloadsDropdown__File { + align-items: center; + cursor: pointer; + display: flex; + flex-direction: column; + height: 100%; + justify-content: flex-start; + height: 56px; + padding: 12px 12px 12px 20px; + width: 100%; + + &.progressing { + height: 68px; + } + + &.update { + height: 92px; + .DownloadsDropdown__File__Body { + height: 100%; + .DownloadsDropdown__File__Body__Details { + width: calc(100% - 32px - 16px); + + button#restartAndUpdateButton { + font-style: normal; + font-weight: 600; + font-size: 12px; + height: 32px; + margin-top: 6px; + padding: 10px 16px; + width: auto; + } + } + } + } + + &:hover { + background: rgba(63, 67, 80, 0.08); + } + + .DownloadsDropdown__File__Body { + align-items: center; + display: flex; + flex-direction: row; + justify-content: space-between; + width: 100%; + + .DownloadsDropdown__File__Body__Thumbnail__Container { + align-items: center; + background: rgba(63, 67, 80, 0.08); + border-radius: 4px; + display: flex; + height: 32px; + justify-content: center; + margin-right: 8px; + position: relative; + width: 32px; + + > svg { + background: #ffffff; + border-radius: 50%; + border: 1px solid #ffffff; + height: 16px; + position: absolute; + right: 0; + top: 0; + transform: translate(50%, -50%); + width: 16px; + } + + .DownloadsDropdown__File__Body__Thumbnail { + border-radius: 4px; + width: 100%; + height: 100%; + + &.text { + @include file-icon('./thumbnails/text.svg'); + } + &.audio { + @include file-icon('./thumbnails/audio.svg'); + } + &.video { + @include file-icon('./thumbnails/video.svg'); + } + &.ppt { + @include file-icon('./thumbnails/ppt.svg'); + } + &.generic, + &.other { + @include file-icon('./thumbnails/generic.svg'); + } + &.code { + @include file-icon('./thumbnails/code.svg'); + } + &.excel { + @include file-icon('./thumbnails/excel.svg'); + } + &.word { + @include file-icon('./thumbnails/word.svg'); + } + &.pdf { + @include file-icon('./thumbnails/pdf.svg'); + } + &.patch { + @include file-icon('./thumbnails/patch.svg'); + } + &.image { + @include file-icon('./thumbnails/image.svg'); + } + &.zip { + @include file-icon('./thumbnails/zip.svg'); + } + } + } + + .DownloadsDropdown__File__Body__Details { + margin-right: 8px; + /* 100% - thumbnail width - three dot button width - margins of first 2 elements */ + width: calc(100% - 32px - 28px - 16px); + + > * { + overflow: hidden; + text-align: left; + text-overflow: ellipsis; + white-space: nowrap; + width: 100%; + + &.DownloadsDropdown__File__Body__Details__Filename { + color: #3F4350; + font-size: 12px; + font-weight: 600; + } + &.DownloadsDropdown__File__Body__Details__FileSizeAndStatus { + margin-top: 2px; + color: rgba(63, 67, 80, 0.64); + font-size: 10px; + font-weight: 400; + + &.cancelled { + color: #D24B4E; + } + } + } + } + + .DownloadsDropdown__File__Body__ThreeDotButton { + background: transparent; + border: none; + border-radius: 4px; + color: rgba(63, 67, 80, 0.56); + cursor: pointer; + visibility: hidden; + font-size: 18px; + height: 28px; + outline: none; + position: relative; + width: 28px; + + &.visible { + visibility: visible; + } + &.active { + visibility: visible; + background: rgba(28, 88, 217, 0.08); + color: rgba(28, 88, 217, 1); + } + &:hover { + background-color: rgba(63, 67, 80, 0.08); + color: rgba(63, 67, 80, 0.72) + } + &:active { + background: rgba(28, 88, 217, 0.08); + color: rgba(28, 88, 217, 1); + } + > .icon-dots-vertical { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + } + } + } + + .DownloadsDropdown__File__ProgressBarContainer { + width: 100%; + height: 4px; + background: rgba(63, 67, 80, 0.16); + border-radius: 4px; + margin-top: 4px; + overflow: hidden; + position: relative; + + .DownloadsDropdown__File__ProgressBar { + background: #1c58d9; + height: 100%; + left: 0; + position: absolute; + top: 0; + transition: all 0.2s; + width: 0%; + } + } + } + + .DownloadsDropdown__Update { + align-items: flex-start; + display: flex; + flex-direction: row; + justify-content: flex-start; + padding: 12px 12px 12px 20px; + width: 100%; + + .DownloadsDropdown__Update__Details { + align-items: flex-start; + display: flex; + flex-direction: column; + justify-content: space-between; + + .DownloadsDropdown__Update__Details__Title { + color: #3F4350; + font-family: 'Open Sans'; + font-size: 12px; + font-style: normal; + font-weight: 600; + line-height: 16px; + max-width: 200px; + overflow: hidden; + text-align: left; + text-overflow: ellipsis; + white-space: nowrap; + } + + .DownloadsDropdown__Update__Details__Description { + color: rgba(63, 67, 80, 0.64); + font-size: 10px; + font-style: normal; + font-weight: 400; + letter-spacing: 0.02em; + line-height: 16px; + max-width: 200px; + } + + button#downloadUpdateButton { + font-style: normal; + font-weight: 600; + font-size: 12px; + height: 32px; + margin-top: 6px; + padding: 10px 16px; + } + } + } + } + + &.darkMode { + background: #1f1f1f; + border: 1px solid rgba(221, 223, 228, 0.16); + + .DownloadsDropdown__header .DownloadsDropdown__Downloads { + color: #DDD; + } + + .DownloadsDropdown__divider { + border-color: rgba(221, 221, 221, 0.08); + } + + + .DownloadsDropdown__Thumbnail__Container { + background: rgba(221, 223, 228, 0.08); + + > svg { + background: #1f1f1f; + border: 2px solid #1f1f1f; + } + } + + .DownloadsDropdown__list { + color: #DDD; + + .DownloadsDropdown__File .DownloadsDropdown__File__Body { + align-items: flex-start; + + .DownloadsDropdown__File__Body__Thumbnail__Container { + background: rgba(221, 223, 228, 0.08); + + > svg { + background: #1f1f1f; + border-color: #1f1f1f; + } + } + + .DownloadsDropdown__File__Body__Details { + .DownloadsDropdown__File__Body__Details__Filename { + color: rgba(221, 223, 228, 1); + } + + .DownloadsDropdown__File__Body__Details__FileSizeAndStatus { + color: rgba(221, 223, 228, 0.64); + + &.cancelled { + color: #D24B4E; + } + } + } + + .DownloadsDropdown__File__Body__ThreeDotButton { + color: rgba(221, 223, 228, 0.56); + + &:hover { + background: rgba(56, 111, 229, 0.08); + color: rgba(28, 88, 217, 0.72); + } + &:active { + background: rgba(56, 111, 229, 0.08); + color: rgba(28, 88, 217, 1); + } + &.active { + color: rgba(28, 88, 217, 1); + } + } + } + + .DownloadsDropdown__Update .DownloadsDropdown__Update__Details { + .DownloadsDropdown__Update__Details__Title { + color: rgba(221, 223, 228, 1); + } + + .DownloadsDropdown__Update__Details__Description { + color: rgba(221, 223, 228, 0.64); + } + } + } + } + } + } +} diff --git a/src/renderer/css/downloadsDropdownMenu.scss b/src/renderer/css/downloadsDropdownMenu.scss new file mode 100644 index 00000000..edcb91a3 --- /dev/null +++ b/src/renderer/css/downloadsDropdownMenu.scss @@ -0,0 +1,85 @@ +@import url("fonts.css"); +@import '~@mattermost/compass-icons/css/compass-icons.css'; + +@mixin file-icon($path) { + background-image: url($path); + background-position: center; + background-repeat: no-repeat; + background-size: 24px 24px; +} + +/* with this, padding doesn't change an element's width & height */ +* { + box-sizing: border-box; + -moz-box-sizing: border-box; + -webkit-box-sizing: border-box; + + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +body { + margin: 0; + background-color: transparent; + font-family: Open Sans; + overflow: hidden; + height: 172px; // 160 + 12 + width: 176px; // 154 + 12 + + #app { + background-color: transparent; + padding: 0 0 12px 12px; + height: 100%; + width: 100%; + + .DownloadsDropdownMenu { + background: #ffffff; + border-radius: 4px; + border: 1px solid rgba(61, 60, 64, 0.16); + box-shadow: 0px 4px 12px rgba(0, 0, 0, 0.12); + display: flex; + flex-direction: column; + height: 100%; + overflow: hidden; + padding: 8px 0px; + width: 100%; + + .DownloadsDropdownMenu__MenuItem { + align-items: center; + background-color: transparent; + color: rgba(63, 67, 80, 1); + cursor: pointer; + display: flex; + font-size: 14px; + font-weight: 400; + height: 25%; + justify-content: flex-start; + padding-left: 20px; + + &:hover { + background-color: rgba(63, 67, 80, 0.08); + } + + &.disabled { + background-color: transparent; + cursor: unset; + color: rgba(63, 67, 80, 0.32); + } + } + + &.darkMode { + background: #1f1f1f; + border: 1px solid rgba(221, 223, 228, 0.16); + color: rgba(221, 223, 228, 1); + + .DownloadsDropdownMenu__MenuItem { + color: rgba(221, 223, 228, 1); + + &.disabled { + color: rgba(221, 223, 228, 0.32); + } + } + } + } + } +} diff --git a/src/renderer/css/thumbnails/audio.svg b/src/renderer/css/thumbnails/audio.svg new file mode 100644 index 00000000..bc0f6e13 --- /dev/null +++ b/src/renderer/css/thumbnails/audio.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/renderer/css/thumbnails/code.svg b/src/renderer/css/thumbnails/code.svg new file mode 100644 index 00000000..7f62822f --- /dev/null +++ b/src/renderer/css/thumbnails/code.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/renderer/css/thumbnails/excel.svg b/src/renderer/css/thumbnails/excel.svg new file mode 100644 index 00000000..b677d0a7 --- /dev/null +++ b/src/renderer/css/thumbnails/excel.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/renderer/css/thumbnails/generic.svg b/src/renderer/css/thumbnails/generic.svg new file mode 100644 index 00000000..9a02d224 --- /dev/null +++ b/src/renderer/css/thumbnails/generic.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/renderer/css/thumbnails/image.svg b/src/renderer/css/thumbnails/image.svg new file mode 100644 index 00000000..e18d36a2 --- /dev/null +++ b/src/renderer/css/thumbnails/image.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/renderer/css/thumbnails/mattermost.svg b/src/renderer/css/thumbnails/mattermost.svg new file mode 100644 index 00000000..7803a316 --- /dev/null +++ b/src/renderer/css/thumbnails/mattermost.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/renderer/css/thumbnails/patch.svg b/src/renderer/css/thumbnails/patch.svg new file mode 100644 index 00000000..409d9d29 --- /dev/null +++ b/src/renderer/css/thumbnails/patch.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/renderer/css/thumbnails/pdf.svg b/src/renderer/css/thumbnails/pdf.svg new file mode 100644 index 00000000..ae9016a8 --- /dev/null +++ b/src/renderer/css/thumbnails/pdf.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/renderer/css/thumbnails/ppt.svg b/src/renderer/css/thumbnails/ppt.svg new file mode 100644 index 00000000..9e8c075e --- /dev/null +++ b/src/renderer/css/thumbnails/ppt.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/renderer/css/thumbnails/text.svg b/src/renderer/css/thumbnails/text.svg new file mode 100644 index 00000000..e2ce1ca6 --- /dev/null +++ b/src/renderer/css/thumbnails/text.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/src/renderer/css/thumbnails/video.svg b/src/renderer/css/thumbnails/video.svg new file mode 100644 index 00000000..3b2140bf --- /dev/null +++ b/src/renderer/css/thumbnails/video.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/renderer/css/thumbnails/word.svg b/src/renderer/css/thumbnails/word.svg new file mode 100644 index 00000000..5a5d080a --- /dev/null +++ b/src/renderer/css/thumbnails/word.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/renderer/css/thumbnails/zip.svg b/src/renderer/css/thumbnails/zip.svg new file mode 100644 index 00000000..652f92a3 --- /dev/null +++ b/src/renderer/css/thumbnails/zip.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/src/renderer/downloadsDropdown.tsx b/src/renderer/downloadsDropdown.tsx new file mode 100644 index 00000000..901f4171 --- /dev/null +++ b/src/renderer/downloadsDropdown.tsx @@ -0,0 +1,126 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import ReactDOM from 'react-dom'; +import classNames from 'classnames'; +import {FormattedMessage} from 'react-intl'; + +import {DownloadedItem} from 'types/downloads'; + +import { + CLOSE_DOWNLOADS_DROPDOWN, + REQUEST_CLEAR_DOWNLOADS_DROPDOWN, + REQUEST_DOWNLOADS_DROPDOWN_INFO, + SEND_DOWNLOADS_DROPDOWN_SIZE, + UPDATE_DOWNLOADS_DROPDOWN, +} from 'common/communication'; + +import IntlProvider from './intl_provider'; +import DownloadsDropdownItem from './components/DownloadsDropdown/DownloadsDropdownItem'; + +import './css/downloadsDropdown.scss'; + +type State = { + downloads: DownloadedItem[]; + darkMode?: boolean; + windowBounds?: Electron.Rectangle; + item?: DownloadedItem; +} + +class DownloadsDropdown extends React.PureComponent, State> { + constructor(props: Record) { + super(props); + + this.state = { + downloads: [], + }; + + window.addEventListener('message', this.handleMessageEvent); + } + + componentDidMount() { + window.postMessage({type: REQUEST_DOWNLOADS_DROPDOWN_INFO}, window.location.href); + } + + componentDidUpdate() { + window.postMessage({type: SEND_DOWNLOADS_DROPDOWN_SIZE, data: {width: document.body.scrollWidth, height: document.body.scrollHeight}}, window.location.href); + } + + handleMessageEvent = (event: MessageEvent) => { + if (event.data.type === UPDATE_DOWNLOADS_DROPDOWN) { + const {downloads, darkMode, windowBounds, item} = event.data.data; + const newDownloads = Object.values(downloads); + newDownloads.sort((a, b) => { + // Show App update first + if (a.type === 'update') { + return -1; + } else if (b.type === 'update') { + return 1; + } + return b.addedAt - a.addedAt; + }); + this.setState({ + downloads: newDownloads, + darkMode, + windowBounds, + item, + }); + } + } + + closeMenu = () => { + window.postMessage({type: CLOSE_DOWNLOADS_DROPDOWN}, window.location.href); + } + + clearAll = () => { + window.postMessage({type: REQUEST_CLEAR_DOWNLOADS_DROPDOWN}, window.location.href); + } + + render() { + return ( + +
+
+
+ +
+
+ +
+
+
+
+ {(this.state.downloads || []).map((downloadItem: DownloadedItem) => { + return ( + + ); + })} +
+
+
+ ); + } +} + +ReactDOM.render( + , + document.getElementById('app'), +); diff --git a/src/renderer/downloadsDropdownMenu.tsx b/src/renderer/downloadsDropdownMenu.tsx new file mode 100644 index 00000000..5dc3fc2b --- /dev/null +++ b/src/renderer/downloadsDropdownMenu.tsx @@ -0,0 +1,163 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useCallback, useEffect, useState} from 'react'; +import ReactDOM from 'react-dom'; +import classNames from 'classnames'; +import {FormattedMessage} from 'react-intl'; + +import {DownloadedItem} from 'types/downloads'; + +import { + DOWNLOADS_DROPDOWN_MENU_CANCEL_DOWNLOAD, + DOWNLOADS_DROPDOWN_MENU_CLEAR_FILE, + DOWNLOADS_DROPDOWN_MENU_OPEN_FILE, + DOWNLOADS_DROPDOWN_SHOW_FILE_IN_FOLDER, + REQUEST_DOWNLOADS_DROPDOWN_MENU_INFO, + UPDATE_DOWNLOADS_DROPDOWN_MENU, +} from 'common/communication'; + +import IntlProvider from './intl_provider'; + +import './css/downloadsDropdownMenu.scss'; + +const DownloadsDropdownMenu = () => { + const [item, setItem] = useState(null); + const [darkMode, setDarkMode] = useState(false); + + useEffect(() => { + const handleMessageEvent = (event: MessageEvent) => { + if (event.data.type === UPDATE_DOWNLOADS_DROPDOWN_MENU) { + const {item, darkMode} = event.data.data; + setItem(item); + setDarkMode(darkMode); + } + }; + + window.addEventListener('message', handleMessageEvent); + window.postMessage({type: REQUEST_DOWNLOADS_DROPDOWN_MENU_INFO}, window.location.href); + return () => { + window.removeEventListener('message', handleMessageEvent); + }; + }, []); + + const preventPropagation = (event: React.MouseEvent) => { + event.stopPropagation(); + }; + + const getOSFileManager = () => { + switch (window.process.platform) { + case 'darwin': + return ( + ); + case 'linux': + return ( + ); + case 'win32': + return ( + ); + + default: + return ( + ); + } + }; + + const openFile = useCallback(() => { + if (item?.type === 'update') { + return; + } + window.postMessage({type: DOWNLOADS_DROPDOWN_MENU_OPEN_FILE, payload: {item}}, window.location.href); + }, [item]); + + const showInFolder = useCallback(() => { + if (item?.type === 'update') { + return; + } + window.postMessage({type: DOWNLOADS_DROPDOWN_SHOW_FILE_IN_FOLDER, payload: {item}}, window.location.href); + }, [item]); + + const clearFile = useCallback(() => { + if (item?.type === 'update') { + return; + } + window.postMessage({type: DOWNLOADS_DROPDOWN_MENU_CLEAR_FILE, payload: {item}}, window.location.href); + }, [item]); + + const cancelDownload = useCallback(() => { + if (item?.state !== 'progressing') { + return; + } + window.postMessage({type: DOWNLOADS_DROPDOWN_MENU_CANCEL_DOWNLOAD, payload: {item}}, window.location.href); + }, [item]); + + return ( + +
+
+ +
+
+ {getOSFileManager()} +
+
+ +
+
+ +
+
+
+ ); +}; + +ReactDOM.render( + , + document.getElementById('app'), +); diff --git a/src/renderer/dropdown.tsx b/src/renderer/dropdown.tsx index 7581dfd1..cab23f4d 100644 --- a/src/renderer/dropdown.tsx +++ b/src/renderer/dropdown.tsx @@ -99,7 +99,7 @@ class TeamDropdown extends React.PureComponent, State> { } } - preventPropogation = (event: React.MouseEvent) => { + preventPropagation = (event: React.MouseEvent) => { event.stopPropagation(); } @@ -160,7 +160,7 @@ class TeamDropdown extends React.PureComponent, State> { window.removeEventListener('keydown', this.handleKeyboardShortcuts); } - setButtonRef = (teamIndex: number, refMethod?: (element: HTMLButtonElement) => any) => { + setButtonRef = (teamIndex: number, refMethod?: (element: HTMLButtonElement) => unknown) => { return (ref: HTMLButtonElement) => { this.addButtonRef(teamIndex, ref); refMethod?.(ref); @@ -232,7 +232,7 @@ class TeamDropdown extends React.PureComponent, State> { return (
, State> { if (!config) { return null; } - return ( { + let returnValue = 'N/A'; + if (typeof value === 'number') { + returnValue = prettyBytes(value); + } else if (typeof value === 'string') { + const parsed = parseInt(value, 10); + + if (typeof parsed === 'number') { + returnValue = prettyBytes(parsed); + } + } + if (excludeUnits && totalUnits === returnValue.split(' ')[1]) { + return returnValue.split(' ')[0]; + } + return returnValue; +}; + +const getFileSizeOrBytesProgress = (item: DownloadedItem) => { + const totalMegabytes = prettyBytesConverter(item.totalBytes); + if (item.state === 'progressing') { + return `${prettyBytesConverter(item.receivedBytes, true, totalMegabytes.split(' ')[1])}/${totalMegabytes}`; + } + return `${totalMegabytes}`; +}; + +const getDownloadingFileStatus = (item: DownloadedItem) => { + switch (item.state) { + case 'completed': + return 'Downloaded'; + case 'deleted': + return 'Deleted'; + default: + return 'Cancelled'; + } +}; + +const getIconClassName = (file: DownloadedItem) => { + if (file.type === 'update') { + return 'mattermost'; + } + + if (!file.mimeType) { + return 'generic'; + } + + // Find thumbnail icon form MIME type + const fileType = file.mimeType.toLowerCase() as keyof typeof Constants.ICON_NAME_FROM_MIME_TYPE; + if (fileType in Constants.ICON_NAME_FROM_MIME_TYPE) { + return Constants.ICON_NAME_FROM_MIME_TYPE[fileType]; + } + + // Fallback to file extension + const extension = file.location.toLowerCase().split('.').pop() as keyof typeof Constants.ICON_NAME_FROM_EXTENSION; + if (extension && (extension in Constants.ICON_NAME_FROM_EXTENSION)) { + return Constants.ICON_NAME_FROM_EXTENSION[extension]; + } + + // use generic icon + return 'generic'; +}; + +const isImageFile = (file: DownloadedItem): boolean => { + return file.mimeType?.toLowerCase().startsWith('image/') ?? false; +}; + +const prettyETA = (ms = 0, intl: IntlShape) => { + let eta; + + if (ms < Constants.MINUTE_MS) { + eta = `${Math.round(ms / Constants.SECOND_MS)} ${intl.formatMessage({id: 'renderer.time.sec', defaultMessage: 'sec'})}`; + } else if (ms < Constants.HOUR_MS) { + eta = `${Math.round(ms / Constants.MINUTE_MS)} ${intl.formatMessage({id: 'renderer.time.mins', defaultMessage: 'mins'})}`; + } else { + eta = `${Math.round(ms / Constants.HOUR_MS)} ${intl.formatMessage({id: 'renderer.time.hours', defaultMessage: 'hours'})}`; + } + return `${eta} ${intl.formatMessage({id: 'renderer.downloadsDropdown.remaining', defaultMessage: 'remaining'})}`; +}; + +export { + getDownloadingFileStatus, + getFileSizeOrBytesProgress, + getIconClassName, + isImageFile, + prettyETA, +}; diff --git a/src/types/downloads.ts b/src/types/downloads.ts new file mode 100644 index 00000000..9598f189 --- /dev/null +++ b/src/types/downloads.ts @@ -0,0 +1,29 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {DownloadItemTypeEnum} from 'main/downloadsManager'; + +export type DownloadItemUpdatedEventState = 'interrupted' | 'progressing'; +export type DownloadItemDoneEventState = 'completed' | 'cancelled' | 'interrupted'; +export type DownloadItemState = DownloadItemUpdatedEventState | DownloadItemDoneEventState | 'deleted' | 'available'; + +export type DownloadedItem = { + type: DownloadItemTypeEnum; + filename: string; + state: DownloadItemState; + progress: number; + location: string; + mimeType: string | null; + addedAt: number; + receivedBytes: number; + totalBytes: number; +} + +export type DownloadedItems = Record; + +export type CoordinatesToJsonType = Omit + +export type DownloadsMenuOpenEventPayload = { + item: DownloadedItem; + coordinates: CoordinatesToJsonType; +} diff --git a/tsconfig.json b/tsconfig.json index 782bc48d..a42ed2ce 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -31,5 +31,4 @@ "storybook-static", "coverage" ] - } - \ No newline at end of file +} diff --git a/webpack.config.main.js b/webpack.config.main.js index 3451c7d2..96627a3f 100644 --- a/webpack.config.main.js +++ b/webpack.config.main.js @@ -17,6 +17,8 @@ module.exports = merge(base, { index: './src/main/app/index.ts', mainWindow: './src/main/preload/mainWindow.js', dropdown: './src/main/preload/dropdown.js', + downloadsDropdown: './src/main/preload/downloadsDropdown.js', + downloadsDropdownMenu: './src/main/preload/downloadsDropdownMenu.js', preload: './src/main/preload/mattermost.js', modalPreload: './src/main/preload/modalPreload.js', loadingScreenPreload: './src/main/preload/loadingScreenPreload.js', diff --git a/webpack.config.renderer.js b/webpack.config.renderer.js index a20a02fa..3279a9c6 100644 --- a/webpack.config.renderer.js +++ b/webpack.config.renderer.js @@ -21,6 +21,8 @@ module.exports = merge(base, { index: './src/renderer/index.tsx', settings: './src/renderer/settings.tsx', dropdown: './src/renderer/dropdown.tsx', + downloadsDropdownMenu: './src/renderer/downloadsDropdownMenu.tsx', + downloadsDropdown: './src/renderer/downloadsDropdown.tsx', urlView: './src/renderer/modals/urlView/urlView.tsx', newServer: './src/renderer/modals/newServer/newServer.tsx', editServer: './src/renderer/modals/editServer/editServer.tsx', @@ -55,6 +57,18 @@ module.exports = merge(base, { chunks: ['dropdown'], filename: 'dropdown.html', }), + new HtmlWebpackPlugin({ + title: 'Mattermost Desktop Downloads', + template: 'src/renderer/index.html', + chunks: ['downloadsDropdown'], + filename: 'downloadsDropdown.html', + }), + new HtmlWebpackPlugin({ + title: 'Mattermost Desktop Downloads', + template: 'src/renderer/index.html', + chunks: ['downloadsDropdownMenu'], + filename: 'downloadsDropdownMenu.html', + }), new HtmlWebpackPlugin({ title: 'Mattermost Desktop Settings', template: 'src/renderer/index.html',