[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
This commit is contained in:
Tasos Boulis
2022-10-07 11:40:27 +03:00
committed by GitHub
parent cf6ca93627
commit 131b5fa2ac
74 changed files with 4805 additions and 264 deletions

View File

@@ -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<DownloadedItems> {
autoCloseTimeout: NodeJS.Timeout | null;
open: boolean;
fileSizes: Map<string, string>;
progressingItems: Map<string, DownloadItem>;
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;