aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authorLibravatar André Oliveira <37463445+SpecialAro@users.noreply.github.com>2023-09-02 16:28:04 +0100
committerLibravatar GitHub <noreply@github.com>2023-09-02 15:28:04 +0000
commitd1c623f4c3d72c859f9ad9cb985be127d6a3eb62 (patch)
treee102da856ae328c70e822d60ac53909acd4627b9 /src
parentDowngrade 'electron' to 25.x (diff)
downloadferdium-app-d1c623f4c3d72c859f9ad9cb985be127d6a3eb62.tar.gz
ferdium-app-d1c623f4c3d72c859f9ad9cb985be127d6a3eb62.tar.zst
ferdium-app-d1c623f4c3d72c859f9ad9cb985be127d6a3eb62.zip
feat: Add Download Manager (pause, stop, delete) (#1339)
Diffstat (limited to 'src')
-rw-r--r--src/@types/stores.types.ts1
-rw-r--r--src/actions/app.ts6
-rw-r--r--src/actions/ui.ts3
-rw-r--r--src/components/downloadManager/DownloadManagerDashboard.tsx288
-rw-r--r--src/components/downloadManager/DownloadManagerLayout.tsx81
-rw-r--r--src/components/layout/Sidebar.tsx27
-rw-r--r--src/components/settings/settings/EditSettingsForm.tsx2
-rw-r--r--src/config.ts1
-rw-r--r--src/containers/download-manager/DownloadManagerScreen.tsx15
-rw-r--r--src/containers/download-manager/DownloadManagerWindow.tsx46
-rw-r--r--src/containers/layout/AppLayoutContainer.tsx4
-rw-r--r--src/containers/settings/EditSettingsScreen.tsx14
-rw-r--r--src/environment.ts2
-rw-r--r--src/i18n/globalMessages.ts4
-rw-r--r--src/i18n/locales/en-US.json4
-rw-r--r--src/index.ts10
-rw-r--r--src/lib/Menu.ts19
-rw-r--r--src/models/Service.ts81
-rw-r--r--src/routes.tsx11
-rw-r--r--src/stores/AppStore.ts104
-rw-r--r--src/stores/UIStore.ts7
-rw-r--r--src/styles/layout.scss21
22 files changed, 748 insertions, 3 deletions
diff --git a/src/@types/stores.types.ts b/src/@types/stores.types.ts
index dc5da563f..973889802 100644
--- a/src/@types/stores.types.ts
+++ b/src/@types/stores.types.ts
@@ -125,6 +125,7 @@ export interface AppStore extends TypedStore {
125 FAILED: 'FAILED'; 125 FAILED: 'FAILED';
126 }; 126 };
127 universalDarkMode: boolean; 127 universalDarkMode: boolean;
128 isDownloading: () => boolean;
128 cacheSize: () => void; 129 cacheSize: () => void;
129 debugInfo: () => void; 130 debugInfo: () => void;
130 enableLongPressServiceHint: boolean; 131 enableLongPressServiceHint: boolean;
diff --git a/src/actions/app.ts b/src/actions/app.ts
index 2e60c9327..07cc47078 100644
--- a/src/actions/app.ts
+++ b/src/actions/app.ts
@@ -28,4 +28,10 @@ export default <ActionDefinitions>{
28 toggleMuteApp: {}, 28 toggleMuteApp: {},
29 toggleCollapseMenu: {}, 29 toggleCollapseMenu: {},
30 clearAllCache: {}, 30 clearAllCache: {},
31 addDownload: {},
32 removeDownload: {},
33 updateDownload: {},
34 endedDownload: {},
35 stopDownload: {},
36 togglePauseDownload: {},
31}; 37};
diff --git a/src/actions/ui.ts b/src/actions/ui.ts
index 7d2dbccfa..f496c5c07 100644
--- a/src/actions/ui.ts
+++ b/src/actions/ui.ts
@@ -5,6 +5,9 @@ export default <ActionDefinitions>{
5 openSettings: { 5 openSettings: {
6 path: PropTypes.string, 6 path: PropTypes.string,
7 }, 7 },
8 openDownloads: {
9 path: PropTypes.string,
10 },
8 closeSettings: {}, 11 closeSettings: {},
9 toggleServiceUpdatedInfoBar: { 12 toggleServiceUpdatedInfoBar: {
10 visible: PropTypes.bool, 13 visible: PropTypes.bool,
diff --git a/src/components/downloadManager/DownloadManagerDashboard.tsx b/src/components/downloadManager/DownloadManagerDashboard.tsx
new file mode 100644
index 000000000..86facc476
--- /dev/null
+++ b/src/components/downloadManager/DownloadManagerDashboard.tsx
@@ -0,0 +1,288 @@
1import { Component } from 'react';
2import { observer } from 'mobx-react';
3import { IntlShape, defineMessages, injectIntl } from 'react-intl';
4import { shell } from 'electron';
5import prettyBytes from 'pretty-bytes';
6import {
7 Typography,
8 Card,
9 CardContent,
10 LinearProgress,
11 Box,
12 IconButton,
13 ListItemButton,
14 ListItemIcon,
15 ListItemText,
16} from '@mui/material';
17import { mdiDownload } from '@mdi/js';
18import PlayArrowIcon from '@mui/icons-material/PlayArrow';
19import PauseIcon from '@mui/icons-material/Pause';
20import CancelIcon from '@mui/icons-material/Cancel';
21import FolderIcon from '@mui/icons-material/Folder';
22import DeleteIcon from '@mui/icons-material/Delete';
23import ClearAllIcon from '@mui/icons-material/ClearAll';
24import { round } from 'lodash';
25import { RealStores } from '../../stores';
26import { Actions } from '../../actions/lib/actions';
27import Icon from '../ui/icon';
28
29const messages = defineMessages({
30 headline: {
31 id: 'downloadManager.headline',
32 defaultMessage: 'Download Manager',
33 },
34 empty: {
35 id: 'downloadManager.empty',
36 defaultMessage: 'Your download list is empty.',
37 },
38});
39
40interface IProps {
41 intl: IntlShape;
42 stores?: RealStores;
43 actions?: Actions;
44}
45
46interface IState {
47 data: string;
48}
49
50// eslint-disable-next-line react/prefer-stateless-function
51class DownloadManagerDashboard extends Component<IProps, IState> {
52 render() {
53 const { intl, stores, actions } = this.props;
54
55 const downloads = stores?.app.downloads ?? [];
56
57 return (
58 <div className="settings__main">
59 <div className="settings__header">
60 <span className="settings__header-item">
61 <Box
62 sx={{
63 display: 'flex',
64 justifyContent: 'center',
65 alignItems: 'center',
66 }}
67 gap={1.5}
68 >
69 <Icon icon={mdiDownload} size={1.5} />
70 {intl.formatMessage(messages.headline)}
71 <span className="badge badge--success">beta</span>
72 </Box>
73 </span>
74 </div>
75 <div className="settings__body">
76 {downloads.length === 0 ? (
77 <Box
78 sx={{
79 display: 'flex',
80 flexDirection: 'column',
81 justifyContent: 'center',
82 alignItems: 'center',
83 }}
84 gap={4}
85 >
86 <Icon icon={mdiDownload} size={1.8} />
87 <Typography variant="h4">
88 {intl.formatMessage(messages.empty)}
89 </Typography>
90 </Box>
91 ) : (
92 <Box
93 sx={{
94 display: 'flex',
95 flexDirection: 'row',
96 justifyContent: 'flex-end',
97 height: 'fit-content',
98 }}
99 >
100 <Box
101 sx={{
102 maxWidth: '176px',
103 }}
104 >
105 <ListItemButton
106 onClick={() => {
107 actions?.app.removeDownload(null);
108 }}
109 >
110 <ListItemIcon>
111 <ClearAllIcon />
112 </ListItemIcon>
113 <ListItemText primary="Clear all completed" />
114 </ListItemButton>
115 </Box>
116 </Box>
117 )}
118 {downloads.map(download => {
119 const {
120 totalBytes,
121 receivedBytes,
122 filename,
123 url,
124 savePath,
125 state,
126 id,
127 paused,
128 } = download;
129
130 const downloadPercentage =
131 receivedBytes !== undefined && totalBytes !== undefined
132 ? round((receivedBytes / totalBytes) * 100, 2)
133 : null;
134
135 const stateParse =
136 state === 'progressing'
137 ? paused === false || paused === undefined
138 ? null
139 : 'Paused'
140 : state === 'cancelled'
141 ? 'Cancelled'
142 : state === 'completed'
143 ? null
144 : 'Error';
145
146 return (
147 <Card
148 key={id}
149 style={{
150 marginBottom: '16px',
151 height: 'fit-content',
152 display: 'flex',
153 }}
154 >
155 <Box
156 sx={{ display: 'flex', flexDirection: 'column', flexGrow: 1 }}
157 >
158 <CardContent>
159 <Box
160 sx={{
161 display: 'flex',
162 }}
163 gap={2}
164 >
165 <button
166 type="button"
167 disabled={state !== 'completed'}
168 style={{
169 pointerEvents:
170 state === 'completed' ? undefined : 'none',
171 }}
172 onClick={() => {
173 if (savePath) shell.openPath(savePath);
174 }}
175 >
176 <Typography
177 variant="h6"
178 color={state === 'completed' ? 'primary' : undefined}
179 sx={{
180 textDecoration:
181 stateParse !== null && stateParse !== 'Paused'
182 ? 'line-through'
183 : state === 'completed'
184 ? 'underline'
185 : null,
186 }}
187 >
188 {filename}
189 </Typography>
190 </button>
191 <Typography
192 variant="h6"
193 color={stateParse === 'Paused' ? '#ed6c02' : undefined}
194 >
195 {stateParse !== null && stateParse !== 'Paused'
196 ? stateParse
197 : stateParse === 'Paused'
198 ? stateParse
199 : null}
200 </Typography>
201 </Box>
202 <Typography variant="body2">{url}</Typography>
203 <LinearProgress
204 variant="determinate"
205 value={downloadPercentage || 0}
206 style={{ marginTop: '8px', marginBottom: '8px' }}
207 />
208 <Typography variant="body2">
209 {`${
210 downloadPercentage ? `${downloadPercentage}% - ` : ''
211 }${
212 receivedBytes ? `${prettyBytes(receivedBytes)} of ` : ''
213 }${totalBytes ? prettyBytes(totalBytes) : ''}`}
214 </Typography>
215 </CardContent>
216 </Box>
217
218 <Box
219 sx={{
220 display: 'flex',
221 flexDirection: 'column',
222 justifyContent: 'center',
223 alignItems: 'center',
224 padding: '8px',
225 }}
226 >
227 {state !== 'completed' && state !== 'cancelled' && (
228 <IconButton
229 color="error"
230 size="small"
231 onClick={() => {
232 actions?.app.stopDownload(id);
233 }}
234 >
235 <CancelIcon />
236 </IconButton>
237 )}
238 {state === 'progressing' && (
239 <IconButton
240 color={
241 paused === false || paused === undefined
242 ? 'warning'
243 : 'success'
244 }
245 size="small"
246 onClick={() => {
247 actions?.app.togglePauseDownload(id);
248 }}
249 >
250 {(paused === false || paused === undefined) && (
251 <PauseIcon />
252 )}
253 {paused && <PlayArrowIcon />}
254 </IconButton>
255 )}
256 {(state === 'cancelled' || state === 'completed') && (
257 <IconButton
258 color="error"
259 onClick={() => {
260 actions?.app.removeDownload(id);
261 }}
262 size="small"
263 >
264 <DeleteIcon />
265 </IconButton>
266 )}
267 {state !== 'cancelled' && (
268 <IconButton
269 color="primary"
270 onClick={() => {
271 if (savePath) shell.showItemInFolder(savePath);
272 }}
273 size="small"
274 >
275 <FolderIcon />
276 </IconButton>
277 )}
278 </Box>
279 </Card>
280 );
281 })}
282 </div>
283 </div>
284 );
285 }
286}
287
288export default injectIntl(observer(DownloadManagerDashboard));
diff --git a/src/components/downloadManager/DownloadManagerLayout.tsx b/src/components/downloadManager/DownloadManagerLayout.tsx
new file mode 100644
index 000000000..1e018cfb8
--- /dev/null
+++ b/src/components/downloadManager/DownloadManagerLayout.tsx
@@ -0,0 +1,81 @@
1import React, { Component } from 'react';
2import { inject, observer } from 'mobx-react';
3import { defineMessages, injectIntl, WrappedComponentProps } from 'react-intl';
4
5import { mdiClose } from '@mdi/js';
6import { Outlet } from 'react-router-dom';
7import { Actions } from '../../actions/lib/actions';
8import { isEscKeyPress } from '../../jsUtils';
9import Appear from '../ui/effects/Appear';
10import ErrorBoundary from '../util/ErrorBoundary';
11import Icon from '../ui/icon';
12
13const messages = defineMessages({
14 closeSettings: {
15 id: 'settings.app.closeSettings',
16 defaultMessage: 'Close settings',
17 },
18});
19
20interface IProps extends WrappedComponentProps {
21 actions?: Actions;
22 // eslint-disable-next-line react/no-unused-prop-types
23 children?: React.ReactNode;
24}
25
26@inject('stores', 'actions')
27@observer
28class DownloadManagerLayout extends Component<IProps> {
29 componentDidMount() {
30 document.addEventListener('keydown', this.handleKeyDown.bind(this), false);
31 }
32
33 componentWillUnmount() {
34 document.removeEventListener(
35 'keydown',
36 // eslint-disable-next-line unicorn/no-invalid-remove-event-listener
37 this.handleKeyDown.bind(this),
38 false,
39 );
40 }
41
42 handleKeyDown(e) {
43 if (isEscKeyPress(e.keyCode)) {
44 this.props.actions!.ui.closeSettings();
45 }
46 }
47
48 render() {
49 const { closeSettings } = this.props.actions!.ui;
50
51 const { intl } = this.props;
52
53 return (
54 <Appear transitionName="fadeIn-fast">
55 <div className="settings-wrapper">
56 <ErrorBoundary>
57 <button
58 type="button"
59 className="settings-wrapper__action"
60 onClick={closeSettings}
61 aria-label={intl.formatMessage(messages.closeSettings)}
62 />
63 <div className="settings franz-form">
64 <Outlet />
65 <button
66 type="button"
67 className="settings__close"
68 onClick={closeSettings}
69 aria-label={intl.formatMessage(messages.closeSettings)}
70 >
71 <Icon icon={mdiClose} size={1.35} />
72 </button>
73 </div>
74 </ErrorBoundary>
75 </div>
76 </Appear>
77 );
78 }
79}
80
81export default injectIntl<'intl', IProps>(DownloadManagerLayout);
diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx
index 6fd911a24..7904d7653 100644
--- a/src/components/layout/Sidebar.tsx
+++ b/src/components/layout/Sidebar.tsx
@@ -14,11 +14,12 @@ import {
14 mdiPlusBox, 14 mdiPlusBox,
15 mdiViewGrid, 15 mdiViewGrid,
16 mdiViewSplitVertical, 16 mdiViewSplitVertical,
17 mdiDownload,
17} from '@mdi/js'; 18} from '@mdi/js';
18
19import Tabbar from '../services/tabs/Tabbar'; 19import Tabbar from '../services/tabs/Tabbar';
20import { 20import {
21 addNewServiceShortcutKey, 21 addNewServiceShortcutKey,
22 downloadsShortcutKey,
22 lockFerdiumShortcutKey, 23 lockFerdiumShortcutKey,
23 muteFerdiumShortcutKey, 24 muteFerdiumShortcutKey,
24 settingsShortcutKey, 25 settingsShortcutKey,
@@ -91,6 +92,7 @@ interface IProps extends WrappedComponentProps {
91 toggleCollapseMenu: () => void; 92 toggleCollapseMenu: () => void;
92 toggleWorkspaceDrawer: () => void; 93 toggleWorkspaceDrawer: () => void;
93 openSettings: (args: { path: string }) => void; 94 openSettings: (args: { path: string }) => void;
95 openDownloads: (args: { path: string }) => void;
94 // eslint-disable-next-line react/no-unused-prop-types 96 // eslint-disable-next-line react/no-unused-prop-types
95 closeSettings: () => void; 97 closeSettings: () => void;
96 setActive: (args: { serviceId: string }) => void; 98 setActive: (args: { serviceId: string }) => void;
@@ -141,6 +143,7 @@ class Sidebar extends Component<IProps, IState> {
141 render() { 143 render() {
142 const { 144 const {
143 openSettings, 145 openSettings,
146 openDownloads,
144 toggleMuteApp, 147 toggleMuteApp,
145 toggleCollapseMenu, 148 toggleCollapseMenu,
146 isAppMuted, 149 isAppMuted,
@@ -156,6 +159,7 @@ class Sidebar extends Component<IProps, IState> {
156 hideWorkspacesButton, 159 hideWorkspacesButton,
157 hideNotificationsButton, 160 hideNotificationsButton,
158 hideSettingsButton, 161 hideSettingsButton,
162 hideDownloadButton,
159 hideSplitModeButton, 163 hideSplitModeButton,
160 useHorizontalStyle, 164 useHorizontalStyle,
161 splitMode, 165 splitMode,
@@ -180,6 +184,8 @@ class Sidebar extends Component<IProps, IState> {
180 184
181 const { isMenuCollapsed } = stores!.settings.all.app; 185 const { isMenuCollapsed } = stores!.settings.all.app;
182 186
187 const { isDownloading, justFinishedDownloading } = stores!.app;
188
183 return ( 189 return (
184 <div className="sidebar"> 190 <div className="sidebar">
185 <Tabbar 191 <Tabbar
@@ -340,6 +346,25 @@ class Sidebar extends Component<IProps, IState> {
340 style={{ height: 'auto', overflowY: 'unset' }} 346 style={{ height: 'auto', overflowY: 'unset' }}
341 /> 347 />
342 )} 348 )}
349
350 {!hideDownloadButton && !isMenuCollapsed ? (
351 <button
352 type="button"
353 onClick={() => openDownloads({ path: '/downloadmanager' })}
354 className={
355 'sidebar__button' +
356 `${isDownloading ? ' sidebar__button--downloading' : ''}` +
357 `${justFinishedDownloading ? ' sidebar__button--done' : ''}`
358 }
359 data-tooltip-id="tooltip-sidebar-button"
360 data-tooltip-content={`${intl.formatMessage(
361 globalMessages.downloads,
362 )} (${downloadsShortcutKey(false)})`}
363 >
364 <Icon icon={mdiDownload} size={1.8} />
365 </button>
366 ) : null}
367
343 {!hideSettingsButton && !isMenuCollapsed ? ( 368 {!hideSettingsButton && !isMenuCollapsed ? (
344 <button 369 <button
345 type="button" 370 type="button"
diff --git a/src/components/settings/settings/EditSettingsForm.tsx b/src/components/settings/settings/EditSettingsForm.tsx
index 0b5d4374d..210c8d9e9 100644
--- a/src/components/settings/settings/EditSettingsForm.tsx
+++ b/src/components/settings/settings/EditSettingsForm.tsx
@@ -794,6 +794,8 @@ class EditSettingsForm extends Component<IProps, IState> {
794 794
795 <Toggle {...form.$('hideSettingsButton').bind()} /> 795 <Toggle {...form.$('hideSettingsButton').bind()} />
796 796
797 <Toggle {...form.$('hideDownloadButton').bind()} />
798
797 <Toggle {...form.$('alwaysShowWorkspaces').bind()} /> 799 <Toggle {...form.$('alwaysShowWorkspaces').bind()} />
798 </div> 800 </div>
799 )} 801 )}
diff --git a/src/config.ts b/src/config.ts
index 21a7462b9..d8b028104 100644
--- a/src/config.ts
+++ b/src/config.ts
@@ -424,6 +424,7 @@ export const DEFAULT_APP_SETTINGS = {
424 hideWorkspacesButton: false, 424 hideWorkspacesButton: false,
425 hideNotificationsButton: false, 425 hideNotificationsButton: false,
426 hideSettingsButton: false, 426 hideSettingsButton: false,
427 hideDownloadButton: false,
427 alwaysShowWorkspaces: false, 428 alwaysShowWorkspaces: false,
428 liftSingleInstanceLock: false, 429 liftSingleInstanceLock: false,
429 enableLongPressServiceHint: false, 430 enableLongPressServiceHint: false,
diff --git a/src/containers/download-manager/DownloadManagerScreen.tsx b/src/containers/download-manager/DownloadManagerScreen.tsx
new file mode 100644
index 000000000..5d395a180
--- /dev/null
+++ b/src/containers/download-manager/DownloadManagerScreen.tsx
@@ -0,0 +1,15 @@
1import { Component, ReactElement } from 'react';
2import ErrorBoundary from '../../components/util/ErrorBoundary';
3import DownloadManager from '../../components/downloadManager/DownloadManagerDashboard';
4
5class DownloadManagerScreen extends Component {
6 render(): ReactElement {
7 return (
8 <ErrorBoundary>
9 <DownloadManager {...this.props} />
10 </ErrorBoundary>
11 );
12 }
13}
14
15export default DownloadManagerScreen;
diff --git a/src/containers/download-manager/DownloadManagerWindow.tsx b/src/containers/download-manager/DownloadManagerWindow.tsx
new file mode 100644
index 000000000..e13e51774
--- /dev/null
+++ b/src/containers/download-manager/DownloadManagerWindow.tsx
@@ -0,0 +1,46 @@
1import { inject, observer } from 'mobx-react';
2import { Component, ReactPortal } from 'react';
3import ReactDOM from 'react-dom';
4import { Outlet } from 'react-router-dom';
5
6import { StoresProps } from '../../@types/ferdium-components.types';
7import Layout from '../../components/downloadManager/DownloadManagerLayout';
8import ErrorBoundary from '../../components/util/ErrorBoundary';
9
10interface IProps {}
11
12@inject('stores', 'actions')
13@observer
14class DownloadManagerContainer extends Component<IProps> {
15 portalRoot: any;
16
17 el: HTMLDivElement;
18
19 constructor(props: StoresProps) {
20 super(props);
21
22 this.portalRoot = document.querySelector('#portalContainer');
23 this.el = document.createElement('div');
24 }
25
26 componentDidMount(): void {
27 this.portalRoot.append(this.el);
28 }
29
30 componentWillUnmount(): void {
31 this.el.remove();
32 }
33
34 render(): ReactPortal {
35 return ReactDOM.createPortal(
36 <ErrorBoundary>
37 <Layout>
38 <Outlet />
39 </Layout>
40 </ErrorBoundary>,
41 this.el,
42 );
43 }
44}
45
46export default DownloadManagerContainer;
diff --git a/src/containers/layout/AppLayoutContainer.tsx b/src/containers/layout/AppLayoutContainer.tsx
index e30c0e067..8748f1032 100644
--- a/src/containers/layout/AppLayoutContainer.tsx
+++ b/src/containers/layout/AppLayoutContainer.tsx
@@ -52,7 +52,8 @@ class AppLayoutContainer extends Component<IProps> {
52 const { installUpdate, toggleMuteApp, toggleCollapseMenu } = 52 const { installUpdate, toggleMuteApp, toggleCollapseMenu } =
53 this.props.actions.app; 53 this.props.actions.app;
54 54
55 const { openSettings, closeSettings } = this.props.actions.ui; 55 const { openSettings, closeSettings, openDownloads } =
56 this.props.actions.ui;
56 57
57 const isLoadingFeatures = 58 const isLoadingFeatures =
58 features.featuresRequest.isExecuting && 59 features.featuresRequest.isExecuting &&
@@ -89,6 +90,7 @@ class AppLayoutContainer extends Component<IProps> {
89 isAppMuted={settings.all.app.isAppMuted} 90 isAppMuted={settings.all.app.isAppMuted}
90 isMenuCollapsed={settings.all.app.isMenuCollapsed} 91 isMenuCollapsed={settings.all.app.isMenuCollapsed}
91 openSettings={openSettings} 92 openSettings={openSettings}
93 openDownloads={openDownloads}
92 closeSettings={closeSettings} 94 closeSettings={closeSettings}
93 reorder={reorder} 95 reorder={reorder}
94 reload={reload} 96 reload={reload}
diff --git a/src/containers/settings/EditSettingsScreen.tsx b/src/containers/settings/EditSettingsScreen.tsx
index b9732ead0..8190bef3e 100644
--- a/src/containers/settings/EditSettingsScreen.tsx
+++ b/src/containers/settings/EditSettingsScreen.tsx
@@ -255,6 +255,10 @@ const messages = defineMessages({
255 id: 'settings.app.form.hideSettingsButton', 255 id: 'settings.app.form.hideSettingsButton',
256 defaultMessage: 'Hide Settings button', 256 defaultMessage: 'Hide Settings button',
257 }, 257 },
258 hideDownloadButton: {
259 id: 'settings.app.form.hideDownloadButton',
260 defaultMessage: 'Hide Downloads button',
261 },
258 alwaysShowWorkspaces: { 262 alwaysShowWorkspaces: {
259 id: 'settings.app.form.alwaysShowWorkspaces', 263 id: 'settings.app.form.alwaysShowWorkspaces',
260 defaultMessage: 'Always show workspace drawer', 264 defaultMessage: 'Always show workspace drawer',
@@ -425,6 +429,7 @@ class EditSettingsScreen extends Component<
425 hideWorkspacesButton: Boolean(settingsData.hideWorkspacesButton), 429 hideWorkspacesButton: Boolean(settingsData.hideWorkspacesButton),
426 hideNotificationsButton: Boolean(settingsData.hideNotificationsButton), 430 hideNotificationsButton: Boolean(settingsData.hideNotificationsButton),
427 hideSettingsButton: Boolean(settingsData.hideSettingsButton), 431 hideSettingsButton: Boolean(settingsData.hideSettingsButton),
432 hideDownloadButton: Boolean(settingsData.hideDownloadButton),
428 alwaysShowWorkspaces: Boolean(settingsData.alwaysShowWorkspaces), 433 alwaysShowWorkspaces: Boolean(settingsData.alwaysShowWorkspaces),
429 accentColor: settingsData.accentColor, 434 accentColor: settingsData.accentColor,
430 progressbarAccentColor: settingsData.progressbarAccentColor, 435 progressbarAccentColor: settingsData.progressbarAccentColor,
@@ -1081,6 +1086,15 @@ class EditSettingsScreen extends Component<
1081 default: DEFAULT_APP_SETTINGS.hideSettingsButton, 1086 default: DEFAULT_APP_SETTINGS.hideSettingsButton,
1082 type: 'checkbox', 1087 type: 'checkbox',
1083 }, 1088 },
1089 hideDownloadButton: {
1090 label: intl.formatMessage(messages.hideDownloadButton),
1091 value: ifUndefined<boolean>(
1092 settings.all.app.hideDownloadButton,
1093 DEFAULT_APP_SETTINGS.hideDownloadButton,
1094 ),
1095 default: DEFAULT_APP_SETTINGS.hideDownloadButton,
1096 type: 'checkbox',
1097 },
1084 alwaysShowWorkspaces: { 1098 alwaysShowWorkspaces: {
1085 label: intl.formatMessage(messages.alwaysShowWorkspaces), 1099 label: intl.formatMessage(messages.alwaysShowWorkspaces),
1086 value: ifUndefined<boolean>( 1100 value: ifUndefined<boolean>(
diff --git a/src/environment.ts b/src/environment.ts
index c3b9e7e58..87e2f4f66 100644
--- a/src/environment.ts
+++ b/src/environment.ts
@@ -42,5 +42,7 @@ export const splitModeToggleShortcutKey = (isAccelerator = true) =>
42 `${cmdOrCtrlShortcutKey(isAccelerator)}+${altKey(isAccelerator)}+S`; 42 `${cmdOrCtrlShortcutKey(isAccelerator)}+${altKey(isAccelerator)}+S`;
43export const settingsShortcutKey = (isAccelerator = true) => 43export const settingsShortcutKey = (isAccelerator = true) =>
44 `${cmdOrCtrlShortcutKey(isAccelerator)}+${isMac ? ',' : 'P'}`; 44 `${cmdOrCtrlShortcutKey(isAccelerator)}+${isMac ? ',' : 'P'}`;
45export const downloadsShortcutKey = (isAccelerator = true) =>
46 `${cmdOrCtrlShortcutKey(isAccelerator)}+J`;
45export const toggleFullScreenKey = () => 47export const toggleFullScreenKey = () =>
46 isMac ? `CTRL + ${cmdKey} + F` : 'F11'; 48 isMac ? `CTRL + ${cmdKey} + F` : 'F11';
diff --git a/src/i18n/globalMessages.ts b/src/i18n/globalMessages.ts
index 9f55da57a..1c5e6a4ba 100644
--- a/src/i18n/globalMessages.ts
+++ b/src/i18n/globalMessages.ts
@@ -66,6 +66,10 @@ export default defineMessages({
66 id: 'global.quitConfirmation', 66 id: 'global.quitConfirmation',
67 defaultMessage: 'Do you really want to quit Ferdium?', 67 defaultMessage: 'Do you really want to quit Ferdium?',
68 }, 68 },
69 downloads: {
70 id: 'global.downloads',
71 defaultMessage: 'Downloads',
72 },
69 settings: { 73 settings: {
70 id: 'global.settings', 74 id: 'global.settings',
71 defaultMessage: 'Settings', 75 defaultMessage: 'Settings',
diff --git a/src/i18n/locales/en-US.json b/src/i18n/locales/en-US.json
index 82c0f1b02..da6d825b6 100644
--- a/src/i18n/locales/en-US.json
+++ b/src/i18n/locales/en-US.json
@@ -9,6 +9,8 @@
9 "connectionLostBanner.cta": "Reload Service", 9 "connectionLostBanner.cta": "Reload Service",
10 "connectionLostBanner.informationLink": "What happened?", 10 "connectionLostBanner.informationLink": "What happened?",
11 "connectionLostBanner.message": "Oh no! Ferdium lost the connection to {name}.", 11 "connectionLostBanner.message": "Oh no! Ferdium lost the connection to {name}.",
12 "downloadManager.empty": "Your download list is empty.",
13 "downloadManager.headline": "Download Manager",
12 "feature.basicAuth.signIn": "Sign In", 14 "feature.basicAuth.signIn": "Sign In",
13 "feature.publishDebugInfo.error": "There was an error while trying to publish the debug information. Please try again later or view the console for more information.", 15 "feature.publishDebugInfo.error": "There was an error while trying to publish the debug information. Please try again later or view the console for more information.",
14 "feature.publishDebugInfo.info": "Publishing your debug information helps us find issues and errors in Ferdium. By publishing your debug information you accept Ferdium Debugger's privacy policy and terms of service", 16 "feature.publishDebugInfo.info": "Publishing your debug information helps us find issues and errors in Ferdium. By publishing your debug information you accept Ferdium Debugger's privacy policy and terms of service",
@@ -23,6 +25,7 @@
23 "global.api.unhealthy": "Can't connect to {serverNameParse} online services", 25 "global.api.unhealthy": "Can't connect to {serverNameParse} online services",
24 "global.cancel": "Cancel", 26 "global.cancel": "Cancel",
25 "global.clearCache": "Clear cache", 27 "global.clearCache": "Clear cache",
28 "global.downloads": "Downloads",
26 "global.edit": "Edit", 29 "global.edit": "Edit",
27 "global.no": "No", 30 "global.no": "No",
28 "global.notConnectedToTheInternet": "You are not connected to the internet.", 31 "global.notConnectedToTheInternet": "You are not connected to the internet.",
@@ -219,6 +222,7 @@
219 "settings.app.form.hibernateOnStartup": "Keep services in hibernation on startup", 222 "settings.app.form.hibernateOnStartup": "Keep services in hibernation on startup",
220 "settings.app.form.hibernationStrategy": "Hibernation strategy", 223 "settings.app.form.hibernationStrategy": "Hibernation strategy",
221 "settings.app.form.hideCollapseButton": "Hide Collapse button", 224 "settings.app.form.hideCollapseButton": "Hide Collapse button",
225 "settings.app.form.hideDownloadButton": "Hide Downloads button",
222 "settings.app.form.hideNotificationsButton": "Hide Notifications & Sound button", 226 "settings.app.form.hideNotificationsButton": "Hide Notifications & Sound button",
223 "settings.app.form.hideRecipesButton": "Hide Recipes button", 227 "settings.app.form.hideRecipesButton": "Hide Recipes button",
224 "settings.app.form.hideSettingsButton": "Hide Settings button", 228 "settings.app.form.hideSettingsButton": "Hide Settings button",
diff --git a/src/index.ts b/src/index.ts
index cbc10bdbe..5accc4570 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -674,6 +674,16 @@ ipcMain.on('window.toolbar-double-clicked', () => {
674 } 674 }
675}); 675});
676 676
677ipcMain.on('stop-download', (_e, data) => {
678 debug(`stopping download from main process ${data}`);
679 mainWindow?.webContents.send('stop-download', data);
680});
681
682ipcMain.on('toggle-pause-download', (_e, data) => {
683 debug(`stopping download from main process ${data}`);
684 mainWindow?.webContents.send('toggle-pause-download', data);
685});
686
677// Quit when all windows are closed. 687// Quit when all windows are closed.
678app.on('window-all-closed', () => { 688app.on('window-all-closed', () => {
679 // On OS X it is common for applications and their menu bar 689 // On OS X it is common for applications and their menu bar
diff --git a/src/lib/Menu.ts b/src/lib/Menu.ts
index a90fd8bea..8ad4441d2 100644
--- a/src/lib/Menu.ts
+++ b/src/lib/Menu.ts
@@ -32,6 +32,7 @@ import {
32 nodeVersion, 32 nodeVersion,
33 osArch, 33 osArch,
34 toggleFullScreenKey, 34 toggleFullScreenKey,
35 downloadsShortcutKey,
35} from '../environment'; 36} from '../environment';
36import { CUSTOM_WEBSITE_RECIPE_ID, LIVE_API_FERDIUM_WEBSITE } from '../config'; 37import { CUSTOM_WEBSITE_RECIPE_ID, LIVE_API_FERDIUM_WEBSITE } from '../config';
37import { ferdiumVersion } from '../environment-remote'; 38import { ferdiumVersion } from '../environment-remote';
@@ -875,6 +876,15 @@ class FranzMenu implements StoresProps {
875 type: 'separator', 876 type: 'separator',
876 }, 877 },
877 { 878 {
879 label: intl.formatMessage(globalMessages.downloads),
880 accelerator: `${downloadsShortcutKey()}`,
881 click: () => {
882 this.actions.ui.openDownloads({ path: '/downloadmanager' });
883 },
884 enabled: this.stores.user.isLoggedIn,
885 visible: !locked,
886 },
887 {
878 label: intl.formatMessage(globalMessages.settings), 888 label: intl.formatMessage(globalMessages.settings),
879 accelerator: `${settingsShortcutKey()}`, 889 accelerator: `${settingsShortcutKey()}`,
880 click: () => { 890 click: () => {
@@ -991,6 +1001,15 @@ class FranzMenu implements StoresProps {
991 } else { 1001 } else {
992 tpl[0].submenu = [ 1002 tpl[0].submenu = [
993 { 1003 {
1004 label: intl.formatMessage(globalMessages.downloads),
1005 accelerator: `${downloadsShortcutKey()}`,
1006 click: () => {
1007 this.actions.ui.openDownloads({ path: '/downloadmanager' });
1008 },
1009 enabled: this.stores.user.isLoggedIn,
1010 visible: !locked,
1011 },
1012 {
994 label: intl.formatMessage(globalMessages.settings), 1013 label: intl.formatMessage(globalMessages.settings),
995 accelerator: `${settingsShortcutKey()}`, 1014 accelerator: `${settingsShortcutKey()}`,
996 click: () => { 1015 click: () => {
diff --git a/src/models/Service.ts b/src/models/Service.ts
index 265b3e13c..b1f0bc271 100644
--- a/src/models/Service.ts
+++ b/src/models/Service.ts
@@ -1,9 +1,10 @@
1import { join } from 'node:path'; 1import { join, basename } from 'node:path';
2import { autorun, action, computed, makeObservable, observable } from 'mobx'; 2import { autorun, action, computed, makeObservable, observable } from 'mobx';
3import { ipcRenderer } from 'electron'; 3import { ipcRenderer } from 'electron';
4import { webContents } from '@electron/remote'; 4import { webContents } from '@electron/remote';
5import ElectronWebView from 'react-electron-web-view'; 5import ElectronWebView from 'react-electron-web-view';
6 6
7import { v4 as uuidV4 } from 'uuid';
7import { todosStore } from '../features/todos'; 8import { todosStore } from '../features/todos';
8import { isValidExternalURL, normalizedUrl } from '../helpers/url-helpers'; 9import { isValidExternalURL, normalizedUrl } from '../helpers/url-helpers';
9import UserAgent from './UserAgent'; 10import UserAgent from './UserAgent';
@@ -523,6 +524,84 @@ export default class Service {
523 }); 524 });
524 525
525 if (webviewWebContents) { 526 if (webviewWebContents) {
527 webviewWebContents.session.on('will-download', (event, item) => {
528 event.preventDefault();
529
530 const downloadId = uuidV4();
531
532 window['ferdium'].actions.app.addDownload({
533 id: downloadId,
534 serviceId: this.id,
535 filename: item.getFilename(),
536 url: item.getURL(),
537 savePath: item.getSavePath(),
538 });
539
540 item.addListener('updated', (event, state) => {
541 if (state === 'interrupted') {
542 debug('Download is interrupted but can be resumed');
543 } else if (state === 'progressing') {
544 if (item.isPaused()) {
545 debug('Download is paused');
546 } else {
547 debug(`Received bytes: ${item.getReceivedBytes()}`);
548 }
549 }
550 window['ferdium'].actions.app.updateDownload({
551 id: downloadId,
552 serviceId: this.id,
553 filename: basename(item.getSavePath()),
554 url: item.getURL(),
555 savePath: item.getSavePath(),
556 receivedBytes: item.getReceivedBytes(),
557 totalBytes: item.getTotalBytes(),
558 state,
559 });
560 debug('download updated', event, state);
561 });
562 item.addListener('done', (event, state) => {
563 debug('download done', event, state);
564 if (state === 'completed') {
565 debug('Download successfully');
566 } else {
567 if (state === 'cancelled' && item.getSavePath() === '') {
568 window['ferdium'].actions.app.removeDownload(downloadId);
569 debug('Download is cancelled');
570 }
571 debug(`Download failed: ${state}`);
572 }
573
574 window['ferdium'].actions.app.endedDownload({
575 id: downloadId,
576 serviceId: this.id,
577 receivedBytes: item.getReceivedBytes(),
578 totalBytes: item.getTotalBytes(),
579 state,
580 });
581 });
582
583 ipcRenderer.on('toggle-pause-download', (_, data) => {
584 debug('toggle-pause-download', item.isPaused(), item.getState());
585 if (data.downloadId === downloadId || data.downloadId === undefined) {
586 if (item.isPaused()) {
587 item.resume();
588 } else {
589 item.pause();
590 }
591 }
592 debug('toggle-pause-download', item.isPaused(), item.getState());
593 window['ferdium'].actions.app.updateDownload({
594 id: downloadId,
595 paused: item.isPaused(),
596 });
597 });
598
599 ipcRenderer.on('stop-download', (_, data) => {
600 if (data === undefined || downloadId === data.downloadId) {
601 item.cancel();
602 }
603 });
604 });
526 webviewWebContents.on('login', (event, _, authInfo, callback) => { 605 webviewWebContents.on('login', (event, _, authInfo, callback) => {
527 // const authCallback = callback; 606 // const authCallback = callback;
528 debug('browser login event', authInfo); 607 debug('browser login event', authInfo);
diff --git a/src/routes.tsx b/src/routes.tsx
index 9f81e46d9..436f25ea4 100644
--- a/src/routes.tsx
+++ b/src/routes.tsx
@@ -35,6 +35,8 @@ import { WORKSPACES_ROUTES } from './features/workspaces/constants';
35import { StoresProps } from './@types/ferdium-components.types'; 35import { StoresProps } from './@types/ferdium-components.types';
36import { Actions } from './actions/lib/actions'; 36import { Actions } from './actions/lib/actions';
37import { RealStores } from './stores'; 37import { RealStores } from './stores';
38import DownloadManagerScreen from './containers/download-manager/DownloadManagerScreen';
39import DownloadManagerWindow from './containers/download-manager/DownloadManagerWindow';
38 40
39interface IProps { 41interface IProps {
40 history: HashHistory; 42 history: HashHistory;
@@ -116,6 +118,15 @@ class FerdiumRoutes extends Component<IProps> {
116 /> 118 />
117 </Route> 119 </Route>
118 <Route 120 <Route
121 path="/downloadmanager"
122 element={<DownloadManagerWindow {...this.props} />}
123 >
124 <Route
125 path="/downloadmanager"
126 element={<DownloadManagerScreen {...this.props} />}
127 />
128 </Route>
129 <Route
119 path="/settings" 130 path="/settings"
120 element={<SettingsWindow {...this.props} />} 131 element={<SettingsWindow {...this.props} />}
121 > 132 >
diff --git a/src/stores/AppStore.ts b/src/stores/AppStore.ts
index b317d99fc..1c1336819 100644
--- a/src/stores/AppStore.ts
+++ b/src/stores/AppStore.ts
@@ -61,6 +61,22 @@ const CATALINA_NOTIFICATION_HACK_KEY =
61 61
62const locales = generatedTranslations(); 62const locales = generatedTranslations();
63 63
64interface Download {
65 id: string;
66 serviceId: string;
67 filename: string;
68 url: string;
69 savePath?: string;
70 state?: 'progressing' | 'interrupted' | 'completed' | 'cancelled';
71 paused?: boolean;
72 canResume?: boolean;
73 progress?: number;
74 totalBytes?: number;
75 receivedBytes?: number;
76 startTime?: number;
77 endTime?: number;
78}
79
64export default class AppStore extends TypedStore { 80export default class AppStore extends TypedStore {
65 updateStatusTypes = { 81 updateStatusTypes = {
66 CHECKING: 'CHECKING', 82 CHECKING: 'CHECKING',
@@ -114,6 +130,10 @@ export default class AppStore extends TypedStore {
114 130
115 fetchDataInterval: null | NodeJS.Timer = null; 131 fetchDataInterval: null | NodeJS.Timer = null;
116 132
133 @observable downloads: Download[] = [];
134
135 @observable justFinishedDownloading: boolean = false;
136
117 constructor(stores: Stores, api: ApiInterface, actions: Actions) { 137 constructor(stores: Stores, api: ApiInterface, actions: Actions) {
118 super(stores, api, actions); 138 super(stores, api, actions);
119 139
@@ -136,6 +156,14 @@ export default class AppStore extends TypedStore {
136 this._toggleCollapseMenu.bind(this), 156 this._toggleCollapseMenu.bind(this),
137 ); 157 );
138 this.actions.app.clearAllCache.listen(this._clearAllCache.bind(this)); 158 this.actions.app.clearAllCache.listen(this._clearAllCache.bind(this));
159 this.actions.app.addDownload.listen(this._addDownload.bind(this));
160 this.actions.app.removeDownload.listen(this._removeDownload.bind(this));
161 this.actions.app.updateDownload.listen(this._updateDownload.bind(this));
162 this.actions.app.endedDownload.listen(this._endedDownload.bind(this));
163 this.actions.app.stopDownload.listen(this._stopDownload.bind(this));
164 this.actions.app.togglePauseDownload.listen(
165 this._togglePauseDownload.bind(this),
166 );
139 167
140 this.registerReactions([ 168 this.registerReactions([
141 this._offlineCheck.bind(this), 169 this._offlineCheck.bind(this),
@@ -300,6 +328,10 @@ export default class AppStore extends TypedStore {
300 return this.getAppCacheSizeRequest.execute().result; 328 return this.getAppCacheSizeRequest.execute().result;
301 } 329 }
302 330
331 @computed get isDownloading() {
332 return this.downloads.some(download => download.state === 'progressing');
333 }
334
303 @computed get debugInfo() { 335 @computed get debugInfo() {
304 const settings = cleanseJSObject(this.stores.settings.app); 336 const settings = cleanseJSObject(this.stores.settings.app);
305 settings.lockedPassword = '******'; 337 settings.lockedPassword = '******';
@@ -518,6 +550,78 @@ export default class AppStore extends TypedStore {
518 this.locale = value; 550 this.locale = value;
519 } 551 }
520 552
553 @action _addDownload(download: Download) {
554 this.downloads.unshift(download);
555 debug('Download added', this.downloads);
556 }
557
558 @action _removeDownload(id: string | null) {
559 debug(`Removed download ${id}`);
560 if (id === null) {
561 const indexesToRemove: number[] = [];
562 this.downloads.map(item => {
563 if (!item.state) return;
564 if (item.state === 'completed' || item.state === 'cancelled') {
565 indexesToRemove.push(this.downloads.indexOf(item));
566 }
567 });
568
569 if (indexesToRemove.length === 0) return;
570
571 this.downloads = this.downloads.filter(
572 (_, index) => !indexesToRemove.includes(index),
573 );
574
575 debug('Removed all completed downloads');
576 return;
577 }
578
579 const index = this.downloads.findIndex(item => item.id === id);
580 if (index !== -1) {
581 this.downloads.splice(index, 1);
582 }
583
584 debug(`Removed download ${id}`);
585 }
586
587 @action _updateDownload(download: Download) {
588 const index = this.downloads.findIndex(item => item.id === download.id);
589 if (index !== -1) {
590 this.downloads[index] = { ...this.downloads[index], ...download };
591 }
592
593 debug('Download updated', this.downloads[index]);
594 }
595
596 @action _endedDownload(download: Download) {
597 const index = this.downloads.findIndex(item => item.id === download.id);
598 if (index !== -1) {
599 this.downloads[index] = { ...this.downloads[index], ...download };
600 }
601
602 debug('Download ended', this.downloads[index]);
603
604 if (!this.isDownloading && download.state === 'completed') {
605 this.justFinishedDownloading = true;
606
607 setTimeout(() => {
608 this.justFinishedDownloading = false;
609 }, ms('2s'));
610 }
611 }
612
613 @action _stopDownload(downloadId: string | undefined) {
614 ipcRenderer.send('stop-download', {
615 downloadId,
616 });
617 }
618
619 @action _togglePauseDownload(downloadId: string | undefined) {
620 ipcRenderer.send('toggle-pause-download', {
621 downloadId,
622 });
623 }
624
521 _setLocale() { 625 _setLocale() {
522 if (this.stores.user?.isLoggedIn && this.stores.user?.data.locale) { 626 if (this.stores.user?.isLoggedIn && this.stores.user?.data.locale) {
523 this._changeLocale(this.stores.user.data.locale); 627 this._changeLocale(this.stores.user.data.locale);
diff --git a/src/stores/UIStore.ts b/src/stores/UIStore.ts
index 4ed45fc3b..a3330c2e6 100644
--- a/src/stores/UIStore.ts
+++ b/src/stores/UIStore.ts
@@ -18,6 +18,7 @@ export default class UIStore extends TypedStore {
18 makeObservable(this); 18 makeObservable(this);
19 19
20 // Register action handlers 20 // Register action handlers
21 this.actions.ui.openDownloads.listen(this._openDownloads.bind(this));
21 this.actions.ui.openSettings.listen(this._openSettings.bind(this)); 22 this.actions.ui.openSettings.listen(this._openSettings.bind(this));
22 this.actions.ui.closeSettings.listen(this._closeSettings.bind(this)); 23 this.actions.ui.closeSettings.listen(this._closeSettings.bind(this));
23 this.actions.ui.toggleServiceUpdatedInfoBar.listen( 24 this.actions.ui.toggleServiceUpdatedInfoBar.listen(
@@ -97,6 +98,12 @@ export default class UIStore extends TypedStore {
97 } 98 }
98 99
99 // Actions 100 // Actions
101 @action _openDownloads({ path = '/downloadmanager' }): void {
102 const downloadsPath =
103 path === '/downloadmanager' ? path : `/downloadmanager/${path}`;
104 this.stores.router.push(downloadsPath);
105 }
106
100 @action _openSettings({ path = '/settings' }): void { 107 @action _openSettings({ path = '/settings' }): void {
101 const settingsPath = path === '/settings' ? path : `/settings/${path}`; 108 const settingsPath = path === '/settings' ? path : `/settings/${path}`;
102 this.stores.router.push(settingsPath); 109 this.stores.router.push(settingsPath);
diff --git a/src/styles/layout.scss b/src/styles/layout.scss
index 173ca3184..591f7c54b 100644
--- a/src/styles/layout.scss
+++ b/src/styles/layout.scss
@@ -204,6 +204,27 @@ body.win32:not(.isFullScreen) .app .app__content {
204 } 204 }
205} 205}
206 206
207@keyframes isDownloadingFade {
208 50% {
209 color: $theme-brand-primary;
210 }
211}
212
213@keyframes isDownloadingDoneFade {
214 50% {
215 color: green;
216 }
217}
218
219.sidebar__button--downloading {
220 animation: isDownloadingFade 1s cubic-bezier(0.755, 0.05, 0.855, 0.06)
221 infinite;
222}
223
224.sidebar__button--done {
225 animation: isDownloadingDoneFade 0.2s linear infinite;
226}
227
207.grid .grid__row { 228.grid .grid__row {
208 display: flex; 229 display: flex;
209 flex-direction: row; 230 flex-direction: row;