[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,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 ? <span className='DownloadsDropdownButton__badge'/> : null
);
const DownloadsDropdownButton: React.FC<Props> = ({darkMode, isDownloadsDropdownOpen, showDownloadsBadge, closeDownloadsDropdown, openDownloadsDropdown}: Props) => {
const buttonRef: React.RefObject<HTMLButtonElement> = React.createRef();
useEffect(() => {
if (!isDownloadsDropdownOpen) {
buttonRef.current?.blur();
}
}, [isDownloadsDropdownOpen, buttonRef]);
const handleToggleButton = (event: React.MouseEvent<HTMLButtonElement>) => {
event.preventDefault();
event.stopPropagation();
if (isDownloadsDropdownOpen) {
closeDownloadsDropdown();
} else {
openDownloadsDropdown();
}
};
return (
<button
ref={buttonRef}
className={classNames('DownloadsDropdownButton', {
isDownloadsDropdownOpen,
darkMode,
})}
onClick={handleToggleButton}
onDoubleClick={(event) => {
event.stopPropagation();
}}
>
<i className='icon-arrow-down-bold-circle-outline'/>
<DownloadsDropDownButtonBadge show={showDownloadsBadge}/>
</button>
);
};
export default DownloadsDropdownButton;

View File

@@ -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 <UpdateWrapper item={item}/>;
}
return (
<DownloadsDropdownItemFile
item={item}
activeItem={activeItem}
/>
);
};
export default DownloadsDropdownItem;

View File

@@ -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<HTMLDivElement>) => {
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 (
<div
className={classNames('DownloadsDropdown__File', {
progressing: item.state === 'progressing',
})}
onClick={onFileClick}
onMouseEnter={() => setThreeDotButtonVisible(true)}
onMouseLeave={() => setThreeDotButtonVisible(false)}
>
<div className='DownloadsDropdown__File__Body'>
<Thumbnail item={item}/>
<div className='DownloadsDropdown__File__Body__Details'>
<div className='DownloadsDropdown__File__Body__Details__Filename'>
{itemFilename}
</div>
<div
className={classNames('DownloadsDropdown__File__Body__Details__FileSizeAndStatus', {
cancelled: (/(cancelled|deleted|interrupted)/).test(item.state),
})}
>
<FileSizeAndStatus item={item}/>
</div>
</div>
<ThreeDotButton
item={item}
activeItem={activeItem}
visible={threeDotButtonVisible}
/>
</div>
<ProgressBar item={item}/>
</div>
);
};
export default DownloadsDropdownItemFile;

View File

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

View File

@@ -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 (
<div className='DownloadsDropdown__File__ProgressBarContainer'>
<div
className='DownloadsDropdown__File__ProgressBar'
style={{width: `${Math.max(1, item.progress)}%`}}
/>
</div>
);
};
export default ProgressBar;

View File

@@ -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<HTMLButtonElement>(null);
const onClick = (e: React.MouseEvent<HTMLButtonElement>) => {
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 (
<button
className={classNames('DownloadsDropdown__File__Body__ThreeDotButton', {
active: item.location && (item.location === activeItem?.location),
visible,
})}
onClick={onClick}
ref={buttonElement}
>
<i className='icon-dots-vertical'/>
</button>
);
};
export default ThreeDotButton;

View File

@@ -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 (
<CheckCircleIcon
size={iconSize}
color={colorGreen}
/>
);
case 'progressing':
return null;
case 'available':
return null;
default:
return (
<CloseCircleIcon
size={iconSize}
color={colorRed}
/>
);
}
};
const showImagePreview = isImageFile(item) && item.state === 'completed';
return (
<div className='DownloadsDropdown__Thumbnail__Container'>
{showImagePreview ?
<div
className='DownloadsDropdown__Thumbnail preview'
style={{
backgroundImage: `url("${isWin ? `file:///${item.location.replaceAll('\\', '/')}` : item.location}")`,
backgroundSize: 'cover',
}}
/> :
<div className={`DownloadsDropdown__Thumbnail ${getIconClassName(item)}`}/>}
{showBadge(item.state)}
</div>
);
};
export default Thumbnail;

View File

@@ -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<HTMLButtonElement>) => {
e?.preventDefault?.();
window.postMessage({type: START_UPDATE_DOWNLOAD}, window.location.href);
};
return (
<div className='DownloadsDropdown__Update'>
<Thumbnail item={item}/>
<div className='DownloadsDropdown__Update__Details'>
<div className='DownloadsDropdown__Update__Details__Title'>
<FormattedMessage
id='renderer.downloadsDropdown.Update.NewDesktopVersionAvailable'
defaultMessage='New Desktop version available'
/>
</div>
<div className='DownloadsDropdown__Update__Details__Description'>
<FormattedMessage
id='renderer.downloadsDropdown.Update.ANewVersionIsAvailableToInstall'
defaultMessage={`A new version of the Mattermost Desktop App (version ${item.filename}) is available to install.`}
values={{version: item.filename}}
/>
</div>
<Button
id='downloadUpdateButton'
className='primary-button'
onClick={onButtonClick}
>
<FormattedMessage
id='renderer.downloadsDropdown.Update.DownloadUpdate'
defaultMessage='Download Update'
/>
</Button>
</div>
</div>
);
};
export default UpdateAvailable;

View File

@@ -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<HTMLButtonElement>) => {
e?.preventDefault?.();
window.postMessage({type: START_UPGRADE}, window.location.href);
};
return (
<div className='DownloadsDropdown__File update'>
<div className='DownloadsDropdown__File__Body'>
<Thumbnail item={item}/>
<div className='DownloadsDropdown__File__Body__Details'>
<div className='DownloadsDropdown__File__Body__Details__Filename'>
{translate.formatMessage({id: 'renderer.downloadsDropdown.Update.MattermostVersionX', defaultMessage: `Mattermost version ${item.filename}`}, {version: item.filename})}
</div>
<div
className={classNames('DownloadsDropdown__File__Body__Details__FileSizeAndStatus', {
cancelled: (/(cancelled|deleted|interrupted)/).test(item.state),
})}
>
<FileSizeAndStatus item={item}/>
</div>
<Button
id='restartAndUpdateButton'
className='primary-button'
onClick={onButtonClick}
>
<FormattedMessage
id='renderer.downloadsDropdown.Update.RestartAndUpdate'
defaultMessage={'Restart & update'}
/>
</Button>
</div>
</div>
</div>
);
};
export default UpdateAvailable;

View File

@@ -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 <UpdateAvailable item={item}/>;
}
if (item.state === 'completed') {
return <UpdateDownloaded item={item}/>;
}
return null;
};
export default UpdateWrapper;