Skip to content

Commit ac30ab3

Browse files
TaimurAzharggazzotassoevanMartinSchoeler
authored
feat: Download Manager (#1700)
* TrayIcon show and hide on focus * downlads page created * downloads icon and icon position * title and subtitle * basic download item * download item ui rough * progress-bar working * added search and select inputs * download-item-ui nearly done * attach webcontent id to server * server title added to download * insatlled electron-store * download store initial * downloads list loading * cleaning up - checkpoint commit * show in folder button * Remove downloads autoclearing * currentServeUrl reset to normal * pause, resume, cancel * search by filename filter * server and filetype filters testing * all tabs working * retry downloads * date headings and filter by filetype * fixing small eslint codechecks * basic compact view * Review * Review * fix cancelled status bug * tooltips for buttons * added status.all constant * finished status constant * seperated item layout components * modals for destructive actions * initial thumnail images only * added useMutabelCallbacks * fixed network speed estimate * added keyboard shortcut and window-menu option * event string and estimated time left * cancel from dialog box bug fix * filename change bug fix * unimported module removed * code cleanup * progress bar changes color * fixed speed showing while cancelled * progress bar colors changed * changed delete button text * removed logs * key prop warning * small fixes * New UI Design * Update Fuselage * Undo some comments * Rearrange components * Move code to downloads module * Convert modules to TypeScript * Replace currentServerUrl with currentView * Rollback accidental addition of dependencies * Add more number formatters * Delegate download handling to the main process * Update Jest configuration * Remove unused module * Fix fsevents issue affecting Jest * Update filters * Rollback fsevents resolution * Rollback fsevents resolution Co-authored-by: Guilherme Gazzo <guilherme@gazzo.xyz> Co-authored-by: Tasso Evangelista <tasso.evangelista@rocket.chat> Co-authored-by: Martin <martin.schoeler@rocket.chat>
1 parent 01fafff commit ac30ab3

40 files changed

Lines changed: 12070 additions & 10377 deletions

package.json

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -36,9 +36,10 @@
3636
"@bugsnag/js": "^7.3.5",
3737
"@emotion/core": "^10.0.35",
3838
"@emotion/styled": "^10.0.27",
39-
"@rocket.chat/fuselage": "^0.15.1",
40-
"@rocket.chat/fuselage-hooks": "^0.15.1",
41-
"@rocket.chat/icons": "^0.15.1",
39+
"@rocket.chat/css-in-js": "^0.17.2",
40+
"@rocket.chat/fuselage": "^0.17.2",
41+
"@rocket.chat/fuselage-hooks": "^0.17.2",
42+
"@rocket.chat/icons": "^0.17.2",
4243
"abort-controller": "^3.0.0",
4344
"electron-store": "^6.0.0",
4445
"electron-updater": "^4.3.5",
@@ -71,7 +72,6 @@
7172
"@rollup/plugin-replace": "^2.3.3",
7273
"@rollup/plugin-typescript": "^6.0.0",
7374
"@types/electron-devtools-installer": "^2.2.0",
74-
"@types/i18next-node-fs-backend": "^2.1.0",
7575
"@types/jest": "^26.0.14",
7676
"@types/meteor": "^1.4.49",
7777
"@types/node": "^12",
@@ -86,7 +86,7 @@
8686
"@typescript-eslint/parser": "^4.2.0",
8787
"babel-eslint": "^10.1.0",
8888
"builtin-modules": "^3.1.0",
89-
"chokidar": "^3.4.2",
89+
"chokidar": "^3.4.3",
9090
"conventional-changelog-cli": "^2.1.0",
9191
"convert-svg-to-png": "^0.5.0",
9292
"electron": "^10.0.1",
@@ -97,7 +97,7 @@
9797
"eslint-plugin-import": "^2.22.0",
9898
"eslint-plugin-react": "^7.21.1",
9999
"eslint-plugin-react-hooks": "^4.1.0",
100-
"jest": "^26.4.2",
100+
"jest": "^26.6.3",
101101
"jimp": "^0.16.1",
102102
"npm-run-all": "^4.1.5",
103103
"puppeteer": "^5.5.0",
@@ -108,6 +108,9 @@
108108
"typescript": "^4.0.3",
109109
"xvfb-maybe": "^0.2.1"
110110
},
111+
"optionalDependencies": {
112+
"fsevents": "2.2.1"
113+
},
111114
"engines": {
112115
"node": ">=12.8.x"
113116
},

src/app/actions.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,10 @@
1+
import { downloads } from '../downloads/reducers/downloads';
12
import {
23
externalProtocols,
34
trustedCertificates,
45
} from '../navigation/reducers';
5-
import {
6-
currentServerUrl,
7-
servers,
8-
} from '../servers/reducers';
6+
import { servers } from '../servers/reducers';
7+
import { currentView } from '../ui/reducers/currentView';
98
import { isMenuBarEnabled } from '../ui/reducers/isMenuBarEnabled';
109
import { isShowWindowOnUnreadChangedEnabled } from '../ui/reducers/isShowWindowOnUnreadChangedEnabled';
1110
import { isSideBarEnabled } from '../ui/reducers/isSideBarEnabled';
@@ -28,8 +27,9 @@ export type AppActionTypeToPayloadMap = {
2827
[APP_PATH_SET]: string;
2928
[APP_VERSION_SET]: string;
3029
[APP_SETTINGS_LOADED]: {
31-
currentServerUrl: ReturnType<typeof currentServerUrl>;
30+
currentView: ReturnType<typeof currentView>;
3231
doCheckForUpdatesOnStartup: ReturnType<typeof doCheckForUpdatesOnStartup>;
32+
downloads: ReturnType<typeof downloads>;
3333
externalProtocols: ReturnType<typeof externalProtocols>;
3434
isEachUpdatesSettingConfigurable: ReturnType<typeof isEachUpdatesSettingConfigurable>;
3535
isMenuBarEnabled: ReturnType<typeof isMenuBarEnabled>;

src/app/main/persistence.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,17 @@ import { selectPersistableValues } from '../selectors';
55

66
type PersistableValues = ReturnType<typeof selectPersistableValues>;
77

8-
const migrations = {};
8+
const migrations = {
9+
'>=3.1.0': (store: ElectronStore<PersistableValues & { currentServerUrl: string }>) => {
10+
if (!store.has('currentServerUrl')) {
11+
return;
12+
}
13+
14+
const currentServerUrl = store.get('currentServerUrl');
15+
store.set('currentView', currentServerUrl ? { url: currentServerUrl } : 'add-new-server');
16+
store.delete('currentServerUrl');
17+
},
18+
};
919

1020
let electronStore: ElectronStore<PersistableValues>;
1121

src/app/selectors.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,9 @@ import { RootState } from '../store/rootReducer';
55
import { APP_SETTINGS_LOADED } from './actions';
66

77
export const selectPersistableValues = createStructuredSelector<Partial<RootState>, ActionOf<typeof APP_SETTINGS_LOADED>['payload']>({
8-
currentServerUrl: ({ currentServerUrl }) => currentServerUrl,
8+
currentView: ({ currentView }) => currentView,
99
doCheckForUpdatesOnStartup: ({ doCheckForUpdatesOnStartup }) => doCheckForUpdatesOnStartup,
10+
downloads: ({ downloads }) => downloads,
1011
isMenuBarEnabled: ({ isMenuBarEnabled }) => isMenuBarEnabled,
1112
isShowWindowOnUnreadChangedEnabled: ({ isShowWindowOnUnreadChangedEnabled }) => isShowWindowOnUnreadChangedEnabled,
1213
isSideBarEnabled: ({ isSideBarEnabled }) => isSideBarEnabled,

src/downloads/actions.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { Download } from './common';
2+
3+
export const DOWNLOAD_CREATED = 'downloads/created';
4+
export const DOWNLOAD_REMOVED = 'dowloads/removed';
5+
export const DOWNLOADS_CLEARED = 'downloads/cleared';
6+
export const DOWNLOAD_UPDATED = 'downloads/updated';
7+
8+
export type DownloadsActionTypeToPayloadMap = {
9+
[DOWNLOAD_CREATED]: Download;
10+
[DOWNLOAD_UPDATED]: Pick<Download, 'itemId'> & Partial<Download>;
11+
[DOWNLOAD_REMOVED]: Download['itemId'];
12+
[DOWNLOADS_CLEARED]: void;
13+
}

src/downloads/common.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { Server } from '../servers/common';
2+
3+
export const DownloadStatus = {
4+
ALL: 'All',
5+
PAUSED: 'Paused',
6+
CANCELLED: 'Cancelled',
7+
} as const;
8+
9+
export type Download = {
10+
itemId: number;
11+
state: 'progressing' | 'paused' | 'completed' | 'cancelled' | 'interrupted';
12+
status: typeof DownloadStatus[keyof typeof DownloadStatus];
13+
fileName: string;
14+
receivedBytes: number;
15+
totalBytes: number;
16+
startTime: number;
17+
endTime: number | undefined;
18+
url: string;
19+
serverUrl: Server['url'];
20+
serverTitle: Server['title'];
21+
savePath: string;
22+
mimeType: string;
23+
};

src/downloads/main.ts

Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
import path from 'path';
2+
3+
import { clipboard, DownloadItem, Event, shell, WebContents, webContents } from 'electron';
4+
5+
import { handle } from '../ipc/main';
6+
import { dispatch, select } from '../store';
7+
import {
8+
DOWNLOAD_CREATED,
9+
DOWNLOAD_REMOVED,
10+
DOWNLOAD_UPDATED,
11+
} from './actions';
12+
import { Download, DownloadStatus } from './common';
13+
14+
const items = new Map<Download['itemId'], DownloadItem>();
15+
16+
export const handleWillDownloadEvent = async (_event: Event, item: DownloadItem, serverWebContents: WebContents): Promise<void> => {
17+
const itemId = Date.now();
18+
19+
items.set(itemId, item);
20+
21+
const fileName = item.getFilename();
22+
23+
const extension = path.extname(fileName)?.slice(1).toLowerCase();
24+
25+
if (extension) {
26+
item.setSaveDialogOptions({
27+
filters: [
28+
{
29+
name: `*.${ extension }`,
30+
extensions: [extension],
31+
},
32+
{
33+
name: '*.*',
34+
extensions: ['*'],
35+
},
36+
],
37+
});
38+
}
39+
40+
const server = select(({ servers }) => servers.find((server) => server.webContentsId === serverWebContents.id));
41+
42+
dispatch({
43+
type: DOWNLOAD_CREATED,
44+
payload: {
45+
itemId,
46+
state: item.isPaused() ? 'paused' : item.getState(),
47+
status: item.isPaused() ? DownloadStatus.PAUSED : DownloadStatus.ALL,
48+
fileName: item.getFilename(),
49+
receivedBytes: item.getReceivedBytes(),
50+
totalBytes: item.getTotalBytes(),
51+
startTime: item.getStartTime() * 1000,
52+
endTime: undefined,
53+
url: item.getURL(),
54+
serverUrl: server?.url,
55+
serverTitle: server?.title,
56+
mimeType: item.getMimeType(),
57+
savePath: item.getSavePath(),
58+
},
59+
});
60+
61+
item.on('updated', () => {
62+
dispatch({
63+
type: DOWNLOAD_UPDATED,
64+
payload: {
65+
itemId,
66+
state: item.isPaused() ? 'paused' : item.getState(),
67+
status: item.isPaused() ? DownloadStatus.PAUSED : DownloadStatus.ALL,
68+
fileName: item.getFilename(),
69+
receivedBytes: item.getReceivedBytes(),
70+
totalBytes: item.getTotalBytes(),
71+
startTime: item.getStartTime() * 1000,
72+
endTime: Date.now(),
73+
url: item.getURL(),
74+
mimeType: item.getMimeType(),
75+
savePath: item.getSavePath(),
76+
},
77+
});
78+
});
79+
80+
item.on('done', () => {
81+
dispatch({
82+
type: DOWNLOAD_UPDATED,
83+
payload: {
84+
itemId,
85+
state: item.isPaused() ? 'paused' : item.getState(),
86+
status: item.getState() === 'cancelled' ? DownloadStatus.CANCELLED : DownloadStatus.ALL,
87+
fileName: item.getFilename(),
88+
receivedBytes: item.getReceivedBytes(),
89+
totalBytes: item.getTotalBytes(),
90+
startTime: item.getStartTime() * 1000,
91+
endTime: Date.now(),
92+
url: item.getURL(),
93+
mimeType: item.getMimeType(),
94+
savePath: item.getSavePath(),
95+
},
96+
});
97+
98+
items.delete(itemId);
99+
});
100+
};
101+
102+
export const setupDownloads = (): void => {
103+
handle('downloads/show-in-folder', async (_webContents, itemId) => {
104+
const download = select(({ downloads }) => downloads[itemId]);
105+
106+
if (!download) {
107+
return;
108+
}
109+
110+
shell.showItemInFolder(download.savePath);
111+
});
112+
113+
handle('downloads/copy-link', async (_webContent, itemId) => {
114+
const download = select(({ downloads }) => downloads[itemId]);
115+
116+
if (!download) {
117+
return;
118+
}
119+
120+
clipboard.write({ text: download.url });
121+
});
122+
123+
handle('downloads/pause', async (_webContent, itemId) => {
124+
if (!items.has(itemId)) {
125+
return;
126+
}
127+
128+
const item = items.get(itemId);
129+
130+
if (item.isPaused()) {
131+
return;
132+
}
133+
134+
item.pause();
135+
});
136+
137+
handle('downloads/resume', async (_webContent, itemId) => {
138+
if (!items.has(itemId)) {
139+
return;
140+
}
141+
142+
const item = items.get(itemId);
143+
144+
if (!item.canResume()) {
145+
return;
146+
}
147+
148+
item.resume();
149+
});
150+
151+
handle('downloads/cancel', async (_webContent, itemId) => {
152+
if (!items.has(itemId)) {
153+
return;
154+
}
155+
156+
const item = items.get(itemId);
157+
item.cancel();
158+
});
159+
160+
handle('downloads/retry', async (_webContent, itemId) => {
161+
const { url, webContentsId } = select(({ downloads, servers }) => {
162+
const { url, serverUrl } = downloads[itemId];
163+
const { webContentsId } = servers.find((server) => server.url === serverUrl);
164+
return { url, webContentsId };
165+
});
166+
167+
dispatch({
168+
type: DOWNLOAD_REMOVED,
169+
payload: itemId,
170+
});
171+
172+
webContents.fromId(webContentsId).downloadURL(url);
173+
});
174+
175+
handle('downloads/remove', async (_webContent, itemId) => {
176+
if (items.has(itemId)) {
177+
const item = items.get(itemId);
178+
item.cancel();
179+
}
180+
181+
dispatch({
182+
type: DOWNLOAD_REMOVED,
183+
payload: itemId,
184+
});
185+
});
186+
};
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { APP_SETTINGS_LOADED } from '../../app/actions';
2+
import { ActionOf } from '../../store/actions';
3+
import {
4+
DOWNLOADS_CLEARED,
5+
DOWNLOAD_CREATED,
6+
DOWNLOAD_REMOVED,
7+
DOWNLOAD_UPDATED,
8+
} from '../actions';
9+
import { Download } from '../common';
10+
11+
type DownloadsAction = (
12+
ActionOf<typeof APP_SETTINGS_LOADED>
13+
| ActionOf<typeof DOWNLOAD_CREATED>
14+
| ActionOf<typeof DOWNLOAD_UPDATED>
15+
| ActionOf<typeof DOWNLOADS_CLEARED>
16+
| ActionOf<typeof DOWNLOAD_REMOVED>
17+
);
18+
19+
export const downloads = (
20+
state: Record<Download['itemId'], Download> = {},
21+
action: DownloadsAction,
22+
): Record<Download['itemId'], Download> => {
23+
switch (action.type) {
24+
case APP_SETTINGS_LOADED:
25+
return action.payload.downloads ?? {};
26+
27+
case DOWNLOAD_CREATED: {
28+
const download = action.payload;
29+
return {
30+
...state,
31+
[download.itemId]: download,
32+
};
33+
}
34+
35+
case DOWNLOAD_UPDATED: {
36+
const newState = { ...state };
37+
newState[action.payload.itemId] = {
38+
...newState[action.payload.itemId],
39+
...action.payload,
40+
};
41+
return newState;
42+
}
43+
44+
case DOWNLOAD_REMOVED: {
45+
const newState = { ...state };
46+
delete newState[action.payload];
47+
return newState;
48+
}
49+
50+
case DOWNLOADS_CLEARED:
51+
return {};
52+
53+
default:
54+
return state;
55+
}
56+
};

0 commit comments

Comments
 (0)