├── .editorconfig ├── .eslintrc.json ├── .gitignore ├── .gitlab-ci.yml ├── .prettierrc ├── CHANGELOG.md ├── README.md ├── __mocks__ ├── fileMock.js ├── pm-srp.js ├── pmcrypto.js ├── sieve.js └── styleMock.js ├── jest.config.js ├── jest.transform.js ├── package-lock.json ├── package.json ├── po └── lang.json ├── public └── assets │ └── social_logo.png ├── rtl.setup.js ├── src ├── .htaccess ├── app.ejs ├── app │ ├── App.tsx │ ├── PrivateApp.tsx │ ├── api │ │ ├── files.ts │ │ ├── folder.ts │ │ ├── link.ts │ │ ├── share.ts │ │ ├── sharing.ts │ │ ├── userSettings.ts │ │ └── volume.ts │ ├── app.scss │ ├── components │ │ ├── AppErrorBoundary.tsx │ │ ├── CreateFolderModal.tsx │ │ ├── DetailsModal.tsx │ │ ├── DowloadShared │ │ │ ├── DownloadProgressBar.tsx │ │ │ ├── DownloadSharedContainer.tsx │ │ │ ├── DownloadSharedInfo.tsx │ │ │ ├── EnterPasswordInfo.tsx │ │ │ └── LinkDoesNotExistInfo.tsx │ │ ├── Drive │ │ │ ├── Drive.tsx │ │ │ ├── DriveContentProvider.tsx │ │ │ ├── DriveFolderProvider.tsx │ │ │ ├── DriveToolbar.tsx │ │ │ ├── ToolbarButtons │ │ │ │ ├── ActionsDropdown.tsx │ │ │ │ ├── BackButton.tsx │ │ │ │ ├── CreateNewFolderButton.tsx │ │ │ │ ├── LayoutDropdown.tsx │ │ │ │ ├── MoveToFolderButton.tsx │ │ │ │ ├── MoveToTrashButton.tsx │ │ │ │ ├── SortDropdown.tsx │ │ │ │ ├── UploadFileButton.tsx │ │ │ │ ├── UploadFolderButton.tsx │ │ │ │ └── index.ts │ │ │ ├── Trash │ │ │ │ ├── EmptyTrashButton.tsx │ │ │ │ ├── ToolbarButtons │ │ │ │ │ ├── DeletePermanentlyButton.tsx │ │ │ │ │ ├── RestoreFromTrashButton.tsx │ │ │ │ │ └── index.ts │ │ │ │ ├── Trash.tsx │ │ │ │ ├── TrashContentProvider.tsx │ │ │ │ └── TrashToolbar.tsx │ │ │ ├── UserSettings │ │ │ │ └── UserSettingsProvider.tsx │ │ │ └── helpers.tsx │ │ ├── DriveBreadcrumbs.tsx │ │ ├── DriveCache │ │ │ └── DriveCacheProvider.tsx │ │ ├── DriveEventManager │ │ │ └── DriveEventManagerProvider.tsx │ │ ├── FileBrowser │ │ │ ├── EmptyFolder.tsx │ │ │ ├── EmptyShared.tsx │ │ │ ├── EmptyTrash.tsx │ │ │ ├── FileBrowser.scss │ │ │ ├── FileBrowser.tsx │ │ │ ├── FolderContextMenu.tsx │ │ │ ├── GridView │ │ │ │ ├── GridView.tsx │ │ │ │ └── ItemCell.tsx │ │ │ ├── HasNoFilesToShare.tsx │ │ │ ├── HasNoFolders.tsx │ │ │ ├── ItemContextMenu.tsx │ │ │ ├── ListView │ │ │ │ ├── Cells │ │ │ │ │ ├── DescriptiveTypeCell.tsx │ │ │ │ │ ├── LocationCell.tsx │ │ │ │ │ ├── MIMETypeCell.tsx │ │ │ │ │ ├── NameCell.tsx │ │ │ │ │ ├── ShareCell.tsx │ │ │ │ │ ├── SizeCell.tsx │ │ │ │ │ ├── TimeCell.tsx │ │ │ │ │ └── UserNameCell.tsx │ │ │ │ ├── ItemRow.tsx │ │ │ │ ├── ListHeader.tsx │ │ │ │ └── ListView.tsx │ │ │ ├── SharedURLIcon.tsx │ │ │ ├── ToolbarButtons │ │ │ │ ├── DetailsButton.tsx │ │ │ │ ├── DownloadButton.tsx │ │ │ │ ├── PreviewButton.tsx │ │ │ │ ├── RenameButton.tsx │ │ │ │ ├── ShareFileButton.tsx │ │ │ │ ├── ShareLinkButton.tsx │ │ │ │ ├── index.ts │ │ │ │ └── utils.ts │ │ │ ├── interfaces.tsx │ │ │ ├── useFileBrowserColumns.ts │ │ │ ├── useFileBrowserItem.tsx │ │ │ └── useFileBrowserView.tsx │ │ ├── FilesDetailsModal.tsx │ │ ├── FilesRecoveryModal │ │ │ ├── FileRecoveryBanner.tsx │ │ │ ├── FileRecoveryIcon.tsx │ │ │ ├── FilesRecoveryModal.tsx │ │ │ └── FilesRecoveryState.tsx │ │ ├── FolderTree │ │ │ ├── ExpandableRow.tsx │ │ │ ├── FolderTree.scss │ │ │ └── FolderTree.tsx │ │ ├── MoveToFolderModal.tsx │ │ ├── RenameModal.tsx │ │ ├── SelectedFileToShareModal.tsx │ │ ├── SharedLinks │ │ │ ├── ShareFileButton.tsx │ │ │ ├── SharedLinks.tsx │ │ │ ├── SharedLinksContentProvider.tsx │ │ │ ├── SharedLinksToolbar.tsx │ │ │ └── ToolbarButtons │ │ │ │ └── StopSharingButton.tsx │ │ ├── SharingModal │ │ │ ├── DateTime.tsx │ │ │ ├── ErrorState.tsx │ │ │ ├── ExpirationTimeDatePicker.tsx │ │ │ ├── GeneratedLinkState.tsx │ │ │ ├── LoadingState.tsx │ │ │ ├── SharingModal.scss │ │ │ └── SharingModal.tsx │ │ ├── TransferManager │ │ │ ├── Header.tsx │ │ │ ├── ProgressBar.tsx │ │ │ ├── Transfer.tsx │ │ │ ├── TransferControls.tsx │ │ │ ├── TransferManager.scss │ │ │ ├── TransferManager.tsx │ │ │ ├── TransferStateIndicator.tsx │ │ │ └── interfaces.ts │ │ ├── downloads │ │ │ ├── DownloadProvider.tsx │ │ │ ├── download.test.ts │ │ │ └── download.ts │ │ ├── layout │ │ │ ├── DriveHeader.tsx │ │ │ └── DriveSidebar │ │ │ │ ├── DriveSidebar.scss │ │ │ │ ├── DriveSidebar.tsx │ │ │ │ ├── DriveSidebarFooter.tsx │ │ │ │ ├── DriveSidebarList.tsx │ │ │ │ ├── DriveSidebarListItem.tsx │ │ │ │ └── ReloadSpinner.tsx │ │ ├── onboarding │ │ │ ├── DriveOnboardingModal.tsx │ │ │ ├── DriveOnboardingModalNoAccess.tsx │ │ │ └── DriveOnboardingModalNoBeta.tsx │ │ ├── thumbnail │ │ │ ├── image.test.ts │ │ │ ├── image.ts │ │ │ ├── thumbnail.test.ts │ │ │ └── thumbnail.ts │ │ └── uploads │ │ │ ├── ChunkFileReader.ts │ │ │ ├── UploadButton.tsx │ │ │ ├── UploadDragDrop │ │ │ ├── UploadDragDrop.scss │ │ │ └── UploadDragDrop.tsx │ │ │ ├── UploadProvider.tsx │ │ │ └── upload.ts │ ├── constants.ts │ ├── containers │ │ ├── DriveContainer │ │ │ ├── DriveContainer.tsx │ │ │ └── DriveContainerView.tsx │ │ ├── DriveContainerBlurred.tsx │ │ ├── MainContainer.tsx │ │ ├── NoAccessContainer │ │ │ └── NoAccessContainer.tsx │ │ ├── OnboardingContainer.tsx │ │ ├── PreviewContainer.tsx │ │ ├── SharedURLsContainer │ │ │ ├── SharedURLsContainer.tsx │ │ │ └── SharedURLsContainerView.tsx │ │ └── TrashContainer │ │ │ ├── TrashContainer.tsx │ │ │ └── TrashContainerView.tsx │ ├── hooks │ │ ├── drive │ │ │ ├── useDrive.ts │ │ │ ├── useDriveCrypto.ts │ │ │ ├── useDriveDragMove.tsx │ │ │ ├── useDriveSorting.ts │ │ │ ├── useFileUploadInput.ts │ │ │ ├── useFiles.ts │ │ │ ├── useNavigate.ts │ │ │ ├── usePublicSharing.ts │ │ │ ├── useSharing.ts │ │ │ ├── useStatsHistory.ts │ │ │ ├── useToolbarActions.tsx │ │ │ ├── useTrash.ts │ │ │ └── useUserSettings.ts │ │ └── util │ │ │ ├── useConfirm.tsx │ │ │ ├── useDebouncedRequest.test.tsx │ │ │ ├── useDebouncedRequest.ts │ │ │ ├── useListNotifications.tsx │ │ │ ├── useOnScrollEnd.ts │ │ │ ├── useQueuedFunction.ts │ │ │ └── useSelection.ts │ ├── index.tsx │ ├── interfaces │ │ ├── file.ts │ │ ├── folder.ts │ │ ├── link.ts │ │ ├── restore.ts │ │ ├── share.ts │ │ ├── sharing.ts │ │ ├── transfer.ts │ │ ├── userSettings.ts │ │ └── volume.ts │ ├── openpgpConfig.ts │ └── utils │ │ ├── FileSaver │ │ ├── FileSaver.ts │ │ ├── download.ts │ │ └── downloadSW.ts │ │ ├── MimeTypeParser │ │ ├── MimeTypeParser.ts │ │ ├── constants.ts │ │ ├── helpers.ts │ │ └── signatureChecks │ │ │ ├── applicationSignatures.ts │ │ │ ├── archiveSignatures.ts │ │ │ ├── audioSignatures.ts │ │ │ ├── fontSignatures.ts │ │ │ ├── imageSignatures.ts │ │ │ ├── unsafeSignatures.ts │ │ │ └── videoSignatures.ts │ │ ├── async.ts │ │ ├── drive │ │ └── driveCrypto.ts │ │ ├── file.ts │ │ ├── link.test.ts │ │ ├── link.ts │ │ ├── share.ts │ │ ├── stream.ts │ │ ├── transfer.ts │ │ ├── trasfer.test.ts │ │ └── validation.ts └── assets │ ├── logoConfig.js │ └── protondrive.svg ├── tsconfig.json └── typings └── index.d.ts /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_size = 4 6 | indent_style = space 7 | end_of_line = lf 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true 11 | 12 | [*.md] 13 | trim_trailing_whitespace = false 14 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "proton-lint" 4 | ], 5 | "parser": "@typescript-eslint/parser", 6 | "parserOptions": { 7 | "project": "./tsconfig.json" 8 | }, 9 | "rules": { 10 | "react/prop-types": "off", 11 | "no-console": [ 12 | "error", 13 | { 14 | "allow": [ 15 | "warn", 16 | "error" 17 | ] 18 | } 19 | ], 20 | "@typescript-eslint/no-use-before-define": [ 21 | "error", 22 | "nofunc" 23 | ] 24 | }, 25 | "overrides": [ 26 | { 27 | "files": [ 28 | "*.test.ts" 29 | ], 30 | "rules": { 31 | "max-classes-per-file": "off", 32 | "class-methods-use-this": "off" 33 | } 34 | } 35 | ] 36 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .vscode 16 | .DS_Store 17 | .env.local 18 | .env.development.local 19 | .env.test.local 20 | .env.production.local 21 | 22 | npm-debug.log* 23 | yarn-debug.log* 24 | yarn-error.log* 25 | 26 | package-lock.json 27 | yarn.lock 28 | 29 | dist 30 | .eslintcache 31 | .idea 32 | src/app/config.js 33 | src/app/config.ts 34 | appConfig.json 35 | env.json 36 | .env 37 | po/i18n.txt 38 | po/template.pot 39 | -------------------------------------------------------------------------------- /.gitlab-ci.yml: -------------------------------------------------------------------------------- 1 | include: 2 | - project: 'deploy-app/fe-scripts' 3 | ref: master 4 | file: '/jobs/webapp/open-source.gitlab-ci.yaml' 5 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 120, 3 | "arrowParens": "always", 4 | "singleQuote": true, 5 | "tabWidth": 4, 6 | "proseWrap": "never" 7 | } 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Proton Drive 2 | 3 | Proton Drive built with React. 4 | 5 | 6 | >**⚠ If you use Windows plz follow this document before anything else [how to prepare Windows](https://github.com/ProtonMail/proton-shared/wiki/setup-windows)** 7 | 8 | 9 | 10 | ## Basic installation 11 | 12 | > :warning: if you are a proton dev, you will need the file `appConfig.json` 13 | 14 | To set up the project, follow the steps below: 15 | 16 | 1. Clone the repository 17 | 2. `$ npm ci` 18 | 3. `$ npm start` 19 | 20 | It's going to create a server available on https://localhost:8080 21 | 22 | cf: 23 | 24 | ```sh 25 | $ npm start 26 | 27 | > proton-drive@4.0.0-beta.5 start /tmp/proton-drive 28 | > proton-pack dev-server $npm_package_config_publicPathFlag --appMode=standalone 29 | 30 | [proton-pack] Missing file appConfig.json. 31 | [proton-pack] [DEPREACTION NOTICE] Please rename your file env.json to appConfig.json. 32 | [proton-pack] Missing file env.json. 33 | [proton-pack] ✓ generated /tmp/proton-drive/src/app/config.ts 34 | ➙ Dev server: http://localhost:8081/drive/ 35 | ➙ Dev server: http://192.168.1.88:8081/drive/ 36 | ➙ API: https://mail.protonmail.com/api 37 | 38 | 39 | ℹ 「wds」: Project is running at http://localhost/ 40 | ℹ 「wds」: webpack output is served from /drive/ 41 | ℹ 「wds」: Content not from webpack is served from /tmp/proton-drive/dist 42 | ℹ 「wds」: 404s will fallback to /drive/ 43 | ℹ 「wdm」: 3196 modules 44 | ℹ 「wdm」: Compiled successfully. 45 | ``` 46 | 47 | > Here on the port 8081 as the 8080 was not available. We auto detect what is available. 48 | 49 | 50 | ## Commands 51 | 52 | - `$ npm start` 53 | 54 | Run develop server with a login page (mode standalone). It's going to run a server on the port **8080** if available. 55 | > If it is not available we auto detect what is available 56 | 57 | - `$ npm test` 58 | 59 | Run the tests 60 | 61 | - `$ npm run lint` 62 | 63 | Lint the sources via eslint 64 | 65 | - `$ npm run pretty` 66 | 67 | Prettier sources (we have a hook post commit to run it) 68 | 69 | - `$ npm run check-types` 70 | 71 | Validate TS types 72 | 73 | - `$ npm run bundle` 74 | 75 | Create a bundle ready to deploy (prepare app + build + minify) 76 | 77 | [more informations](https://github.com/ProtonMail/proton-bundler) 78 | 79 | - `$ npm run build` 80 | 81 | Build the app (build + minify). Bundle will run this command. 82 | 83 | - `$ npm run build:standalone` 84 | 85 | Same as the previous one BUT with a login page. When we deploy live,the login state is on another app.But when we only deploy this app when we dev, we need to be able to login. 86 | 87 | - `$ npm run deploy` and `$ npm run deploy:standalone` 88 | 89 | It's to deploy to a branch `deploy-branch`. A bundle based on `build` or `build:standalone`. 90 | 91 | Flags: 92 | - `--api `: type of api to use for deploy ex: blue,dev,proxy,prod 93 | - `--branch `: target for the subdomain to deploy 94 | 95 | [more informations](https://github.com/ProtonMail/proton-bundler) 96 | 97 | - `$ npm run i18n:validate**` 98 | 99 | Validate translations (context, format etc.) 100 | 101 | ## Create a new version 102 | 103 | We use the command [npm version](https://docs.npmjs.com/cli/version) 104 | 105 | ## Help us to translate the project 106 | 107 | You can help us to translate the application on [crowdin](https://crowdin.com/project/protonmail) 108 | 109 | -------------------------------------------------------------------------------- /__mocks__/fileMock.js: -------------------------------------------------------------------------------- 1 | // __mocks__/fileMock.js 2 | 3 | module.exports = 'test-file-stub'; 4 | -------------------------------------------------------------------------------- /__mocks__/pm-srp.js: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ProtonMail/proton-drive/62fedc3d3bcfd83c06733b68829366296a826cc9/__mocks__/pm-srp.js -------------------------------------------------------------------------------- /__mocks__/pmcrypto.js: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ProtonMail/proton-drive/62fedc3d3bcfd83c06733b68829366296a826cc9/__mocks__/pmcrypto.js -------------------------------------------------------------------------------- /__mocks__/sieve.js: -------------------------------------------------------------------------------- 1 | // __mocks__/styleMock.js 2 | 3 | module.exports = {}; 4 | -------------------------------------------------------------------------------- /__mocks__/styleMock.js: -------------------------------------------------------------------------------- 1 | // __mocks__/styleMock.js 2 | 3 | module.exports = {}; 4 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | setupFilesAfterEnv: ['./rtl.setup.js'], 3 | verbose: true, 4 | moduleDirectories: ['node_modules'], 5 | transform: { 6 | '^.+\\.(js|tsx?)$': '/jest.transform.js', 7 | }, 8 | collectCoverage: false, 9 | collectCoverageFrom: ['src/**/*.{js,jsx,ts,tsx}'], 10 | transformIgnorePatterns: ['node_modules/(?!(proton-shared|react-components|mutex-browser)/)'], 11 | moduleNameMapper: { 12 | '\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2)$': '/__mocks__/fileMock.js', 13 | '\\.(css|scss|less)$': '/__mocks__/styleMock.js', 14 | pmcrypto: '/__mocks__/pmcrypto.js', 15 | 'sieve.js': '/__mocks__/sieve.js', 16 | }, 17 | }; 18 | -------------------------------------------------------------------------------- /jest.transform.js: -------------------------------------------------------------------------------- 1 | // Custom Jest transform implementation that injects test-specific babel presets. 2 | module.exports = require('babel-jest').createTransformer({ 3 | presets: ['@babel/preset-env', '@babel/preset-react', '@babel/preset-typescript'], 4 | plugins: [ 5 | '@babel/plugin-proposal-object-rest-spread', 6 | '@babel/plugin-transform-runtime', 7 | 'transform-class-properties', 8 | 'transform-require-context' 9 | ] 10 | }); 11 | -------------------------------------------------------------------------------- /po/lang.json: -------------------------------------------------------------------------------- 1 | [] 2 | -------------------------------------------------------------------------------- /public/assets/social_logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ProtonMail/proton-drive/62fedc3d3bcfd83c06733b68829366296a826cc9/public/assets/social_logo.png -------------------------------------------------------------------------------- /rtl.setup.js: -------------------------------------------------------------------------------- 1 | import '@testing-library/jest-dom/extend-expect'; 2 | -------------------------------------------------------------------------------- /src/.htaccess: -------------------------------------------------------------------------------- 1 | RewriteEngine On 2 | 3 | # Redirect to https if not coming from https && not forwarded from https && not curl nor any health check user-agent 4 | RewriteCond %{HTTPS} !=on 5 | RewriteCond %{HTTP:X-Forwarded-Proto} !=https 6 | RewriteCond %{HTTP_USER_AGENT} !(^kube-probe|^GoogleHC|^curl) 7 | RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [L,R=301] 8 | 9 | 10 | # Redirect nothing to app 11 | RewriteRule ^$ /index.html [NC,L] 12 | 13 | # Hide .git stuff 14 | RewriteRule ^.*?\.git.* /index.html [NC,L] 15 | 16 | RewriteCond %{REQUEST_FILENAME} -s [OR] 17 | RewriteCond %{REQUEST_FILENAME} -l [OR] 18 | RewriteCond %{REQUEST_FILENAME} -d 19 | RewriteRule ^.*$ - [NC,L] 20 | 21 | RewriteRule ^(.*) /index.html [NC,L] 22 | 23 | # Error pages 24 | ErrorDocument 403 /assets/errors/403.html 25 | 26 | 27 | FileETag None 28 | Header unset ETag 29 | Header set Cache-Control "max-age=0, no-cache, no-store, must-revalidate" 30 | Header set Pragma "no-cache" 31 | Header set Expires "Wed, 11 Jan 1984 05:00:00 GMT" 32 | 33 | 34 | 35 | AddType application/font-woff2 .woff2 36 | 37 | 38 | 39 | AddOutputFilter INCLUDES;DEFLATE svg 40 | 41 | 42 | 43 | Header set Service-Worker-Allowed "/" 44 | Header set Service-Worker "script" 45 | 46 | 47 | -------------------------------------------------------------------------------- /src/app.ejs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 13 | 14 | 18 | 19 | 20 | 21 | 22 |
23 |
24 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /src/app/App.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import { Route, Switch } from 'react-router-dom'; 3 | 4 | import { ProtonApp, StandardPublicApp, StandardSetup, ModalsChildren, ProminentContainer } from 'react-components'; 5 | import locales from 'proton-shared/lib/i18n/locales'; 6 | import sentry from 'proton-shared/lib/helpers/sentry'; 7 | 8 | import * as config from './config'; 9 | import PrivateApp from './PrivateApp'; 10 | import DownloadSharedContainer from './components/DowloadShared/DownloadSharedContainer'; 11 | import { DownloadProvider } from './components/downloads/DownloadProvider'; 12 | 13 | import './app.scss'; 14 | 15 | const PublicDriveLinkContainer = () => { 16 | return ( 17 | 18 | 19 | 20 | 21 | ); 22 | }; 23 | 24 | const enhancedConfig = { 25 | APP_VERSION_DISPLAY: '4.0.0-beta.16', 26 | ...config, 27 | }; 28 | 29 | sentry(enhancedConfig); 30 | 31 | const App = () => { 32 | const [hasInitialAuth] = useState(() => { 33 | return !window.location.pathname.startsWith('/urls'); 34 | }); 35 | 36 | return ( 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | ); 52 | }; 53 | 54 | export default App; 55 | -------------------------------------------------------------------------------- /src/app/PrivateApp.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { StandardPrivateApp, LoaderPage, useAppTitle } from 'react-components'; 3 | import { 4 | UserModel, 5 | UserSettingsModel, 6 | AddressesModel, 7 | ContactsModel, 8 | ContactEmailsModel, 9 | LabelsModel, 10 | } from 'proton-shared/lib/models'; 11 | import { TtagLocaleMap } from 'proton-shared/lib/interfaces/Locale'; 12 | import { openpgpConfig } from './openpgpConfig'; 13 | import useUserSettings from './hooks/drive/useUserSettings'; 14 | import UserSettingsProvider from './components/Drive/UserSettings/UserSettingsProvider'; 15 | 16 | const getAppContainer = () => import('./containers/MainContainer'); 17 | 18 | interface Props { 19 | onLogout: () => void; 20 | locales: TtagLocaleMap; 21 | } 22 | 23 | const PrivateAppInner = ({ onLogout, locales }: Props) => { 24 | const { loadUserSettings } = useUserSettings(); 25 | useAppTitle(''); 26 | 27 | return ( 28 | } 35 | onInit={loadUserSettings} 36 | noModals 37 | app={getAppContainer} 38 | /> 39 | ); 40 | }; 41 | 42 | const PrivateApp = (props: Props) => { 43 | return ( 44 | 45 | 46 | 47 | ); 48 | }; 49 | 50 | export default PrivateApp; 51 | -------------------------------------------------------------------------------- /src/app/api/files.ts: -------------------------------------------------------------------------------- 1 | import { CreateDriveFile, UpdateFileRevision } from '../interfaces/file'; 2 | import { UPLOAD_TIMEOUT } from '../constants'; 3 | 4 | export const queryCreateFile = (shareId: string, data: CreateDriveFile) => { 5 | return { 6 | method: 'post', 7 | timeout: UPLOAD_TIMEOUT, 8 | url: `drive/shares/${shareId}/files`, 9 | silence: true, 10 | data, 11 | }; 12 | }; 13 | 14 | export const queryFileRevision = ( 15 | shareId: string, 16 | linkId: string, 17 | revisionId: number, 18 | pagination?: { FromBlockIndex: number; PageSize: number } 19 | ) => { 20 | const query = { 21 | method: 'get', 22 | url: `drive/shares/${shareId}/files/${linkId}/revisions/${revisionId}`, 23 | silence: true, 24 | }; 25 | 26 | if (pagination) { 27 | return { 28 | ...query, 29 | params: pagination, 30 | }; 31 | } 32 | 33 | return query; 34 | }; 35 | 36 | export const queryFileRevisionThumbnail = ( 37 | shareId: string, 38 | linkId: string, 39 | revisionId: number, 40 | ) => { 41 | return { 42 | method: 'get', 43 | url: `drive/shares/${shareId}/files/${linkId}/revisions/${revisionId}/thumbnail`, 44 | silence: true, 45 | }; 46 | }; 47 | 48 | export const queryRequestUpload = (data: { 49 | BlockList: { Hash: string; EncSignature: string; Size: number; Index: number }[]; 50 | AddressID: string; 51 | ShareID: string; 52 | LinkID: string; 53 | RevisionID: string; 54 | Thumbnail?: number; 55 | ThumbnailHash?: string; 56 | ThumbnailSize?: number; 57 | }) => { 58 | return { 59 | method: 'post', 60 | url: 'drive/blocks', 61 | data, 62 | }; 63 | }; 64 | 65 | export const queryFileBlock = (url: string) => { 66 | return { 67 | method: 'get', 68 | output: 'stream', 69 | credentials: 'omit', 70 | url, 71 | }; 72 | }; 73 | 74 | export const queryUploadFileBlock = (url: string, chunk: Uint8Array) => { 75 | return { 76 | method: 'put', 77 | input: 'binary', 78 | data: new Blob([chunk]), 79 | url, 80 | }; 81 | }; 82 | 83 | export const queryUpdateFileRevision = ( 84 | shareID: string, 85 | linkID: string, 86 | revisionID: string, 87 | data: UpdateFileRevision 88 | ) => { 89 | return { 90 | method: 'put', 91 | url: `drive/shares/${shareID}/files/${linkID}/revisions/${revisionID}`, 92 | data, 93 | }; 94 | }; 95 | -------------------------------------------------------------------------------- /src/app/api/folder.ts: -------------------------------------------------------------------------------- 1 | import { SORT_DIRECTION } from 'proton-shared/lib/constants'; 2 | import { FOLDER_PAGE_SIZE, DEFAULT_SORT_FIELD, DEFAULT_SORT_ORDER } from '../constants'; 3 | import { CreateNewFolder } from '../interfaces/folder'; 4 | 5 | export const queryFolderChildren = ( 6 | shareID: string, 7 | linkID: string, 8 | { 9 | Page, 10 | PageSize = FOLDER_PAGE_SIZE, 11 | FoldersOnly = 0, 12 | Sort = DEFAULT_SORT_FIELD, 13 | // @ts-ignore 14 | Desc = DEFAULT_SORT_ORDER === SORT_DIRECTION.ASC ? 0 : 1, 15 | }: { Page: number; PageSize?: number; FoldersOnly?: number; Sort?: string; Desc?: 0 | 1 } 16 | ) => ({ 17 | method: 'get', 18 | url: `drive/shares/${shareID}/folders/${linkID}/children`, 19 | params: { Page, PageSize, FoldersOnly, Sort, Desc, Thumbnails: 1 }, 20 | }); 21 | 22 | export const queryCreateFolder = (shareID: string, data: CreateNewFolder) => ({ 23 | method: 'post', 24 | url: `drive/shares/${shareID}/folders`, 25 | data, 26 | }); 27 | -------------------------------------------------------------------------------- /src/app/api/link.ts: -------------------------------------------------------------------------------- 1 | import { EXPENSIVE_REQUEST_TIMEOUT } from '../constants'; 2 | 3 | export const queryCheckAvailableHashes = ( 4 | shareId: string, 5 | linkId: string, 6 | data: { Hashes: string[] }, 7 | suppressErrors = false 8 | ) => { 9 | return { 10 | method: 'post', 11 | timeout: EXPENSIVE_REQUEST_TIMEOUT, 12 | url: `drive/shares/${shareId}/links/${linkId}/checkAvailableHashes`, 13 | suppress: suppressErrors, 14 | data, 15 | }; 16 | }; 17 | 18 | export const queryGetLink = (ShareID: string, LinkID: string) => ({ 19 | method: 'get', 20 | url: `drive/shares/${ShareID}/links/${LinkID}`, 21 | }); 22 | 23 | export const queryTrashLinks = (ShareID: string, ParentLinkID: string, LinkIDs: string[]) => ({ 24 | method: 'post', 25 | url: `drive/shares/${ShareID}/folders/${ParentLinkID}/trash_multiple`, 26 | data: { LinkIDs }, 27 | }); 28 | 29 | export const queryDeleteTrashedLinks = (ShareID: string, LinkIDs: string[]) => ({ 30 | method: 'post', 31 | url: `drive/shares/${ShareID}/trash/delete_multiple`, 32 | data: { LinkIDs }, 33 | }); 34 | 35 | export const queryDeleteChildrenLinks = (ShareID: string, ParentLinkID: string, LinkIDs: string[]) => ({ 36 | method: 'post', 37 | url: `drive/shares/${ShareID}/folders/${ParentLinkID}/delete_multiple`, 38 | data: { LinkIDs }, 39 | }); 40 | 41 | export const queryRestoreLinks = (ShareID: string, LinkIDs: string[]) => ({ 42 | method: 'put', 43 | url: `drive/shares/${ShareID}/trash/restore_multiple`, 44 | data: { LinkIDs }, 45 | }); 46 | 47 | export const queryEmptyTrashOfShare = (ShareID: string) => ({ 48 | method: 'delete', 49 | url: `drive/shares/${ShareID}/trash`, 50 | }); 51 | -------------------------------------------------------------------------------- /src/app/api/share.ts: -------------------------------------------------------------------------------- 1 | import { FOLDER_PAGE_SIZE, EXPENSIVE_REQUEST_TIMEOUT } from '../constants'; 2 | import { MoveLink } from '../interfaces/link'; 3 | import { CreateDriveShare } from '../interfaces/share'; 4 | 5 | export const queryCreateShare = (volumeID: string, data: CreateDriveShare) => ({ 6 | method: 'post', 7 | url: `drive/volumes/${volumeID}/shares`, 8 | data, 9 | }); 10 | 11 | export const queryUserShares = (ShowAll = 1) => ({ 12 | method: 'get', 13 | url: 'drive/shares', 14 | silence: true, 15 | params: { ShowAll }, 16 | }); 17 | 18 | export const queryShareMeta = (shareID: string) => ({ 19 | method: `get`, 20 | url: `drive/shares/${shareID}`, 21 | }); 22 | 23 | export const queryRenameLink = ( 24 | shareID: string, 25 | linkID: string, 26 | data: { Name: string; MIMEType?: string; Hash: string; SignatureAddress: string } 27 | ) => ({ 28 | method: `put`, 29 | url: `drive/shares/${shareID}/links/${linkID}/rename`, 30 | data, 31 | }); 32 | 33 | export const queryTrashList = ( 34 | shareID: string, 35 | { Page, PageSize = FOLDER_PAGE_SIZE }: { Page: number; PageSize?: number } 36 | ) => ({ 37 | method: 'get', 38 | url: `drive/shares/${shareID}/trash`, 39 | params: { Page, PageSize }, 40 | }); 41 | 42 | export const queryMoveLink = (shareID: string, linkID: string, data: MoveLink) => ({ 43 | method: 'put', 44 | url: `drive/shares/${shareID}/links/${linkID}/move`, 45 | data, 46 | }); 47 | 48 | export const queryEvents = (shareID: string, eventID: string) => ({ 49 | timeout: EXPENSIVE_REQUEST_TIMEOUT, 50 | url: `drive/shares/${shareID}/events/${eventID}`, 51 | method: 'get', 52 | }); 53 | 54 | export const queryLatestEvents = (shareID: string) => ({ 55 | url: `drive/shares/${shareID}/events/latest`, 56 | method: 'get', 57 | }); 58 | 59 | export const queryDeleteShare = (shareID: string) => ({ 60 | url: `drive/shares/${shareID}`, 61 | method: 'delete', 62 | }); 63 | -------------------------------------------------------------------------------- /src/app/api/sharing.ts: -------------------------------------------------------------------------------- 1 | import { CreateSharedURL, UpdateSharedURL } from '../interfaces/sharing'; 2 | 3 | export const queryInitSRPHandshake = (token: string) => { 4 | return { 5 | method: 'get', 6 | url: `drive/urls/${token}/info`, 7 | silence: true, 8 | }; 9 | }; 10 | 11 | export const queryGetSharedLinkPayload = ( 12 | token: string, 13 | pagination?: { 14 | FromBlockIndex: number; 15 | PageSize: number; 16 | } 17 | ) => { 18 | return { 19 | method: 'post', 20 | url: `drive/urls/${token}/file`, 21 | silence: true, 22 | data: pagination, 23 | }; 24 | }; 25 | 26 | export const queryCreateSharedLink = (shareId: string, data: CreateSharedURL) => { 27 | return { 28 | method: 'post', 29 | url: `drive/shares/${shareId}/urls`, 30 | data, 31 | }; 32 | }; 33 | 34 | export const querySharedLinks = (shareId: string, params: { Page: number; PageSize?: number; Recursive?: 1 | 0 }) => { 35 | return { 36 | method: 'get', 37 | url: `drive/shares/${shareId}/urls`, 38 | params, 39 | }; 40 | }; 41 | 42 | export const queryUpdateSharedLink = (shareId: string, token: string, data: Partial) => { 43 | return { 44 | method: 'put', 45 | url: `drive/shares/${shareId}/urls/${token}`, 46 | data, 47 | }; 48 | }; 49 | 50 | export const queryDeleteSharedLink = (shareId: string, token: string) => { 51 | return { 52 | method: 'delete', 53 | url: `drive/shares/${shareId}/urls/${token}`, 54 | }; 55 | }; 56 | 57 | export const queryDeleteMultipleSharedLinks = (shareId: string, shareURLIds: string[]) => { 58 | return { 59 | method: 'post', 60 | url: `drive/shares/${shareId}/urls/delete_multiple`, 61 | data: { 62 | ShareURLIDs: shareURLIds, 63 | }, 64 | }; 65 | }; 66 | -------------------------------------------------------------------------------- /src/app/api/userSettings.ts: -------------------------------------------------------------------------------- 1 | import { UserSettings } from '../interfaces/userSettings'; 2 | 3 | export const queryUserSettings = () => { 4 | return { 5 | method: 'get', 6 | url: `drive/me/settings`, 7 | }; 8 | }; 9 | 10 | export const queryUpdateUserSettings = (data: Partial) => { 11 | return { 12 | method: 'put', 13 | url: `drive/me/settings`, 14 | data, 15 | }; 16 | }; 17 | -------------------------------------------------------------------------------- /src/app/api/volume.ts: -------------------------------------------------------------------------------- 1 | import { CreateDriveVolume, RestoreDriveVolume } from '../interfaces/volume'; 2 | 3 | export const queryCreateDriveVolume = (data: CreateDriveVolume) => ({ 4 | method: 'post', 5 | url: 'drive/volumes', 6 | data, 7 | }); 8 | 9 | export const queryRestoreDriveVolume = (encryptedVolumeId: string, data: RestoreDriveVolume) => ({ 10 | method: 'put', 11 | url: `drive/volumes/${encryptedVolumeId}/restore`, 12 | data, 13 | }); 14 | -------------------------------------------------------------------------------- /src/app/app.scss: -------------------------------------------------------------------------------- 1 | @import '~design-system/scss/proton-drive'; 2 | @import '~design-system/scss/specifics/placeholder-loading'; 3 | @import '~design-system/scss/specifics/list'; 4 | 5 | @import './components/FileBrowser/FileBrowser.scss'; 6 | @import './components/TransferManager/TransferManager.scss'; 7 | @import './components/uploads/UploadDragDrop/UploadDragDrop.scss'; 8 | @import './components/FolderTree/FolderTree.scss'; 9 | @import './components/SharingModal/SharingModal.scss'; 10 | @import './components/layout/DriveSidebar/DriveSidebar.scss'; 11 | -------------------------------------------------------------------------------- /src/app/components/AppErrorBoundary.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | import { useLocation } from 'react-router-dom'; 3 | 4 | import { 5 | ErrorBoundary, 6 | GenericError, 7 | generateUID, 8 | InternalServerError, 9 | NotFoundError, 10 | AccessDeniedError, 11 | PrivateMainArea, 12 | } from 'react-components'; 13 | import { ApiError } from 'proton-shared/lib/fetch/ApiError'; 14 | 15 | import { useDriveActiveFolder } from './Drive/DriveFolderProvider'; 16 | import { STATUS_CODE } from '../constants'; 17 | 18 | interface Props { 19 | children: React.ReactNode; 20 | } 21 | 22 | const AppErrorBoundary = ({ children }: Props) => { 23 | const location = useLocation(); 24 | const { setFolder } = useDriveActiveFolder(); 25 | const [state, setState] = useState<{ id: string; error?: Error }>({ 26 | id: generateUID('error-boundary'), 27 | }); 28 | 29 | useEffect(() => { 30 | if (state.error) { 31 | setState({ id: generateUID('error-boundary') }); 32 | } 33 | }, [location]); 34 | 35 | const handleError = (error: Error) => { 36 | setState((prev) => ({ ...prev, error })); 37 | setFolder(undefined); 38 | }; 39 | 40 | const renderError = () => { 41 | const { error } = state; 42 | if (!error) { 43 | return null; 44 | } 45 | 46 | if (error instanceof ApiError) { 47 | if (error.status === STATUS_CODE.INTERNAL_SERVER_ERROR) { 48 | return ; 49 | } 50 | if (error.status === STATUS_CODE.NOT_FOUND) { 51 | return ; 52 | } 53 | if (error.status === STATUS_CODE.FORBIDDEN) { 54 | return ; 55 | } 56 | } 57 | 58 | return ; 59 | }; 60 | 61 | return ( 62 | {renderError()}} 66 | > 67 | {children} 68 | 69 | ); 70 | }; 71 | 72 | export default AppErrorBoundary; 73 | -------------------------------------------------------------------------------- /src/app/components/DowloadShared/DownloadProgressBar.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Progress } from 'react-components'; 3 | 4 | import { TransfersStats } from '../TransferManager/interfaces'; 5 | import { Download } from '../../interfaces/transfer'; 6 | import { calculateProgress, getProgressBarStatus } from '../../utils/transfer'; 7 | 8 | interface Props { 9 | latestStats: TransfersStats; 10 | download: Download; 11 | } 12 | 13 | const DownloadProgressBar = ({ latestStats, download }: Props) => { 14 | const percentageDone = calculateProgress(latestStats, [download]); 15 | const status = getProgressBarStatus(download.state); 16 | return ( 17 | <> 18 | 19 |
{percentageDone} %
20 | 21 | ); 22 | }; 23 | 24 | export default DownloadProgressBar; 25 | -------------------------------------------------------------------------------- /src/app/components/DowloadShared/EnterPasswordInfo.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import { c } from 'ttag'; 3 | 4 | import { Button, Label, PasswordInput, useLoading } from 'react-components'; 5 | 6 | interface Props { 7 | submitPassword: (password: string) => Promise; 8 | } 9 | 10 | const EnterPasswordInfo = ({ submitPassword }: Props) => { 11 | const [loading, withLoading] = useLoading(false); 12 | const [password, setPassword] = useState(''); 13 | 14 | return ( 15 | <> 16 |

{c('Title').t`Enter file password to download`}

17 |
{ 20 | e.preventDefault(); 21 | withLoading(submitPassword(password)).catch(console.error); 22 | }} 23 | > 24 |
25 | 26 |
27 | 28 | setPassword(value)} 37 | /> 38 | 48 | 49 | 50 | ); 51 | }; 52 | 53 | export default EnterPasswordInfo; 54 | -------------------------------------------------------------------------------- /src/app/components/DowloadShared/LinkDoesNotExistInfo.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { c } from 'ttag'; 3 | 4 | import { Icon } from 'react-components'; 5 | 6 | const LinkDoesNotExistInfo = () => { 7 | return ( 8 | <> 9 |

{c('Title').t`The link either does not exist or has expired`}

10 |
14 | 15 |
16 | 17 | ); 18 | }; 19 | 20 | export default LinkDoesNotExistInfo; 21 | -------------------------------------------------------------------------------- /src/app/components/Drive/DriveFolderProvider.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, createContext, useContext } from 'react'; 2 | 3 | export type DriveFolder = { shareId: string; linkId: string }; 4 | 5 | interface DriveFolderProviderState { 6 | folder?: DriveFolder; 7 | setFolder: (folder?: DriveFolder) => void; 8 | } 9 | 10 | const DriveFolderContext = createContext(null); 11 | 12 | interface Props { 13 | children: React.ReactNode; 14 | } 15 | 16 | /** 17 | * Manages drive initialization (starting onboarding). 18 | * Stores open folder shareId and linkID for easy access. 19 | */ 20 | const DriveFolderProvider = ({ children }: Props) => { 21 | const [folder, setFolder] = useState(); 22 | return {children}; 23 | }; 24 | 25 | export const useDriveActiveFolder = () => { 26 | const state = useContext(DriveFolderContext); 27 | if (!state) { 28 | throw new Error('Trying to use uninitialized DriveFolderProvider'); 29 | } 30 | return state; 31 | }; 32 | 33 | export default DriveFolderProvider; 34 | -------------------------------------------------------------------------------- /src/app/components/Drive/ToolbarButtons/BackButton.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { c } from 'ttag'; 3 | 4 | import { Icon, ToolbarButton } from 'react-components'; 5 | 6 | import useNavigate from '../../../hooks/drive/useNavigate'; 7 | import { LinkType } from '../../../interfaces/link'; 8 | 9 | interface Props { 10 | shareId: string; 11 | parentLinkId?: string; 12 | disabled?: boolean; 13 | } 14 | 15 | const BackButton = ({ shareId, parentLinkId, disabled }: Props) => { 16 | const { navigateToLink } = useNavigate(); 17 | const handleBackClick = () => { 18 | if (parentLinkId) { 19 | navigateToLink(shareId, parentLinkId, LinkType.FOLDER); 20 | } 21 | }; 22 | 23 | return ( 24 | } 30 | /> 31 | ); 32 | }; 33 | 34 | export default BackButton; 35 | -------------------------------------------------------------------------------- /src/app/components/Drive/ToolbarButtons/CreateNewFolderButton.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { c } from 'ttag'; 3 | 4 | import { Icon, ToolbarButton } from 'react-components'; 5 | 6 | import useToolbarActions from '../../../hooks/drive/useToolbarActions'; 7 | 8 | interface Props { 9 | disabled?: boolean; 10 | } 11 | 12 | const CreateNewFolderButton = ({ disabled }: Props) => { 13 | const { openCreateFolder } = useToolbarActions(); 14 | 15 | return ( 16 | } 19 | title={c('Action').t`Create new folder`} 20 | onClick={openCreateFolder} 21 | data-testid="toolbar-new-folder" 22 | /> 23 | ); 24 | }; 25 | 26 | export default CreateNewFolderButton; 27 | -------------------------------------------------------------------------------- /src/app/components/Drive/ToolbarButtons/LayoutDropdown.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { c } from 'ttag'; 3 | import { 4 | usePopperAnchor, 5 | Dropdown, 6 | DropdownMenu, 7 | Icon, 8 | DropdownMenuButton, 9 | ToolbarButton, 10 | DropdownCaret, 11 | } from 'react-components'; 12 | import useUserSettings from '../../../hooks/drive/useUserSettings'; 13 | import { LayoutSetting } from '../../../interfaces/userSettings'; 14 | 15 | const LayoutDropdown = () => { 16 | const { layout, changeLayout } = useUserSettings(); 17 | const { anchorRef, isOpen, toggle, close } = usePopperAnchor(); 18 | 19 | const id = `dropdown-layout`; 20 | 21 | return ( 22 | <> 23 | } 29 | data-testid="toolbar-layout" 30 | title={c('Title').t`Change layout`} 31 | > 32 | 33 | 34 | 35 | 36 | changeLayout(LayoutSetting.List)} 40 | > 41 | 42 | {c('Action').t`List layout`} 43 | 44 | changeLayout(LayoutSetting.Grid)} 48 | > 49 | 50 | {c('Action').t`Grid layout`} 51 | 52 | 53 | 54 | 55 | ); 56 | }; 57 | 58 | export default LayoutDropdown; 59 | -------------------------------------------------------------------------------- /src/app/components/Drive/ToolbarButtons/MoveToFolderButton.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { c } from 'ttag'; 3 | 4 | import { Icon, ToolbarButton } from 'react-components'; 5 | 6 | import useToolbarActions from '../../../hooks/drive/useToolbarActions'; 7 | import { DriveFolder } from '../DriveFolderProvider'; 8 | import { FileBrowserItem } from '../../FileBrowser/interfaces'; 9 | 10 | interface Props { 11 | sourceFolder: DriveFolder; 12 | selectedItems: FileBrowserItem[]; 13 | } 14 | 15 | const MoveToFolderButton = ({ sourceFolder, selectedItems }: Props) => { 16 | const { openMoveToFolder } = useToolbarActions(); 17 | 18 | return ( 19 | } 23 | onClick={() => openMoveToFolder(sourceFolder, selectedItems)} 24 | data-testid="toolbar-move" 25 | /> 26 | ); 27 | }; 28 | 29 | export default MoveToFolderButton; 30 | -------------------------------------------------------------------------------- /src/app/components/Drive/ToolbarButtons/MoveToTrashButton.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { c } from 'ttag'; 3 | 4 | import { Icon, ToolbarButton, useLoading } from 'react-components'; 5 | 6 | import useToolbarActions from '../../../hooks/drive/useToolbarActions'; 7 | import { DriveFolder } from '../DriveFolderProvider'; 8 | import { FileBrowserItem } from '../../FileBrowser/interfaces'; 9 | 10 | interface Props { 11 | sourceFolder: DriveFolder; 12 | selectedItems: FileBrowserItem[]; 13 | } 14 | 15 | const MoveToTrashButton = ({ sourceFolder, selectedItems }: Props) => { 16 | const [moveToTrashLoading, withMoveToTrashLoading] = useLoading(); 17 | const { openMoveToTrash } = useToolbarActions(); 18 | 19 | return ( 20 | } 24 | onClick={() => withMoveToTrashLoading(openMoveToTrash(sourceFolder, selectedItems))} 25 | data-testid="toolbar-trash" 26 | /> 27 | ); 28 | }; 29 | 30 | export default MoveToTrashButton; 31 | -------------------------------------------------------------------------------- /src/app/components/Drive/ToolbarButtons/UploadFileButton.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { c } from 'ttag'; 3 | import { Icon, ToolbarButton } from 'react-components'; 4 | import useFileUploadInput from '../../../hooks/drive/useFileUploadInput'; 5 | 6 | const UploadFileButton = () => { 7 | const { inputRef: fileInput, handleClick, handleChange } = useFileUploadInput(); 8 | 9 | return ( 10 | <> 11 | 12 | } 15 | title={c('Action').t`Upload file`} 16 | onClick={handleClick} 17 | /> 18 | 19 | ); 20 | }; 21 | 22 | export default UploadFileButton; 23 | -------------------------------------------------------------------------------- /src/app/components/Drive/ToolbarButtons/UploadFolderButton.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { c } from 'ttag'; 3 | 4 | import { ToolbarButton, Icon } from 'react-components'; 5 | 6 | import useFileUploadInput from '../../../hooks/drive/useFileUploadInput'; 7 | 8 | const UploadFolderButton = () => { 9 | const { inputRef: fileInput, handleClick: handleUploadFolder, handleChange } = useFileUploadInput(true); 10 | 11 | return ( 12 | <> 13 | 14 | } 17 | title={c('Action').t`Upload folder`} 18 | onClick={handleUploadFolder} 19 | /> 20 | 21 | ); 22 | }; 23 | 24 | export default UploadFolderButton; 25 | -------------------------------------------------------------------------------- /src/app/components/Drive/ToolbarButtons/index.ts: -------------------------------------------------------------------------------- 1 | export { default as ActionsDropdown } from './ActionsDropdown'; 2 | export { default as BackButton } from './BackButton'; 3 | export { default as CreateNewFolderButton } from './CreateNewFolderButton'; 4 | export { default as LayoutDropdown } from './LayoutDropdown'; 5 | export { default as MoveToFolderButton } from './MoveToFolderButton'; 6 | export { default as MoveToTrashButton } from './MoveToTrashButton'; 7 | export { default as SortDropdown } from './SortDropdown'; 8 | export { default as UploadFileButton } from './UploadFileButton'; 9 | export { default as UploadFolderButton } from './UploadFolderButton'; 10 | -------------------------------------------------------------------------------- /src/app/components/Drive/Trash/EmptyTrashButton.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { c } from 'ttag'; 3 | import { useNotifications, FloatingButton, SidebarPrimaryButton, Icon } from 'react-components'; 4 | import useTrash from '../../../hooks/drive/useTrash'; 5 | import useConfirm from '../../../hooks/util/useConfirm'; 6 | import { useDriveCache } from '../../DriveCache/DriveCacheProvider'; 7 | import useDrive from '../../../hooks/drive/useDrive'; 8 | 9 | interface Props { 10 | shareId: string; 11 | floating?: boolean; 12 | className?: string; 13 | } 14 | 15 | const EmptyTrashButton = ({ shareId, floating, className }: Props) => { 16 | const cache = useDriveCache(); 17 | const { events } = useDrive(); 18 | const { emptyTrash } = useTrash(); 19 | const { openConfirmModal } = useConfirm(); 20 | const { createNotification } = useNotifications(); 21 | const disabled = !cache.get.trashMetas(shareId).length; 22 | 23 | const handleEmptyTrashClick = () => { 24 | const title = c('Title').t`Empty trash`; 25 | const confirm = c('Action').t`Empty trash`; 26 | const message = c('Info').t`Are you sure you want to empty trash and permanently delete all the items?`; 27 | 28 | openConfirmModal({ 29 | title, 30 | confirm, 31 | message, 32 | onConfirm: async () => { 33 | try { 34 | await emptyTrash(shareId); 35 | const notificationText = c('Notification').t`All items will soon be permanently deleted from trash`; 36 | createNotification({ text: notificationText }); 37 | await events.callAll(shareId); 38 | } catch (e) { 39 | console.error(e); 40 | } 41 | }, 42 | }); 43 | }; 44 | 45 | return ( 46 | <> 47 | {floating ? ( 48 | 54 | 55 | 56 | ) : ( 57 | {c('Action').t`Empty trash`} 63 | )} 64 | 65 | ); 66 | }; 67 | 68 | export default EmptyTrashButton; 69 | -------------------------------------------------------------------------------- /src/app/components/Drive/Trash/ToolbarButtons/DeletePermanentlyButton.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { c } from 'ttag'; 3 | 4 | import { Icon, ToolbarButton } from 'react-components'; 5 | 6 | import { useTrashContent } from '../TrashContentProvider'; 7 | import useToolbarActions from '../../../../hooks/drive/useToolbarActions'; 8 | 9 | interface Props { 10 | shareId: string; 11 | disabled?: boolean; 12 | } 13 | 14 | const DeletePermanentlyButton = ({ shareId, disabled }: Props) => { 15 | const { openDeletePermanently } = useToolbarActions(); 16 | const { fileBrowserControls } = useTrashContent(); 17 | const { selectedItems } = fileBrowserControls; 18 | 19 | return ( 20 | } 24 | onClick={() => openDeletePermanently(shareId, selectedItems)} 25 | data-testid="toolbar-delete" 26 | /> 27 | ); 28 | }; 29 | 30 | export default DeletePermanentlyButton; 31 | -------------------------------------------------------------------------------- /src/app/components/Drive/Trash/ToolbarButtons/RestoreFromTrashButton.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { c } from 'ttag'; 3 | 4 | import { Icon, ToolbarButton, useLoading } from 'react-components'; 5 | 6 | import { useTrashContent } from '../TrashContentProvider'; 7 | import useToolbarActions from '../../../../hooks/drive/useToolbarActions'; 8 | 9 | interface Props { 10 | shareId: string; 11 | disabled?: boolean; 12 | } 13 | 14 | const RestoreFromTrashButton = ({ shareId, disabled }: Props) => { 15 | const [restoreLoading, withRestoreLoading] = useLoading(); 16 | const { restoreFromTrash } = useToolbarActions(); 17 | const { fileBrowserControls } = useTrashContent(); 18 | const { selectedItems } = fileBrowserControls; 19 | 20 | return ( 21 | } 25 | onClick={() => withRestoreLoading(restoreFromTrash(shareId, selectedItems))} 26 | data-testid="toolbar-restore" 27 | /> 28 | ); 29 | }; 30 | 31 | export default RestoreFromTrashButton; 32 | -------------------------------------------------------------------------------- /src/app/components/Drive/Trash/ToolbarButtons/index.ts: -------------------------------------------------------------------------------- 1 | export { default as DeletePermanentlyButton } from './DeletePermanentlyButton'; 2 | export { default as RestoreFromTrashButton } from './RestoreFromTrashButton'; 3 | -------------------------------------------------------------------------------- /src/app/components/Drive/Trash/Trash.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback, useRef } from 'react'; 2 | import { c } from 'ttag'; 3 | import EmptyTrash from '../../FileBrowser/EmptyTrash'; 4 | import useOnScrollEnd from '../../../hooks/util/useOnScrollEnd'; 5 | import { useTrashContent } from './TrashContentProvider'; 6 | import FileBrowser from '../../FileBrowser/FileBrowser'; 7 | import useUserSettings from '../../../hooks/drive/useUserSettings'; 8 | 9 | interface Props { 10 | shareId: string; 11 | } 12 | 13 | function Trash({ shareId }: Props) { 14 | const { layout } = useUserSettings(); 15 | const scrollAreaRef = useRef(null); 16 | const { loadNextPage, loading, initialized, complete, contents, fileBrowserControls } = useTrashContent(); 17 | 18 | const { 19 | clearSelections, 20 | selectedItems, 21 | selectItem, 22 | toggleSelectItem, 23 | toggleAllSelected, 24 | toggleRange, 25 | } = fileBrowserControls; 26 | 27 | const handleScrollEnd = useCallback(() => { 28 | // Only load on scroll after initial load from backend 29 | if (initialized && !complete) { 30 | loadNextPage(); 31 | } 32 | }, [initialized, complete, loadNextPage, layout]); 33 | 34 | // On content change, check scroll end (does not rebind listeners) 35 | useOnScrollEnd(handleScrollEnd, scrollAreaRef, 0.9, [contents, layout]); 36 | 37 | return complete && !contents.length && !loading ? ( 38 | 39 | ) : ( 40 | 55 | ); 56 | } 57 | 58 | export default Trash; 59 | -------------------------------------------------------------------------------- /src/app/components/Drive/Trash/TrashToolbar.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Toolbar, ToolbarSeparator } from 'react-components'; 3 | import { useTrashContent } from './TrashContentProvider'; 4 | import { DeletePermanentlyButton, RestoreFromTrashButton } from './ToolbarButtons'; 5 | import LayoutDropdown from '../ToolbarButtons/LayoutDropdown'; 6 | 7 | interface Props { 8 | shareId: string; 9 | } 10 | 11 | const TrashToolbar = ({ shareId }: Props) => { 12 | const { fileBrowserControls } = useTrashContent(); 13 | const { selectedItems } = fileBrowserControls; 14 | 15 | return ( 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | ); 26 | }; 27 | 28 | export default TrashToolbar; 29 | -------------------------------------------------------------------------------- /src/app/components/Drive/UserSettings/UserSettingsProvider.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, createContext, Dispatch, SetStateAction } from 'react'; 2 | import { UserSettings } from '../../../interfaces/userSettings'; 3 | import { DEFAULT_USER_SETTINGS } from '../../../constants'; 4 | 5 | export const UserSettingsContext = createContext<[UserSettings, Dispatch>] | null>(null); 6 | 7 | const UserSettingsProvider = ({ children }: { children: React.ReactNode }) => { 8 | const userSettingsState = useState(DEFAULT_USER_SETTINGS); 9 | 10 | return {children}; 11 | }; 12 | 13 | export default UserSettingsProvider; 14 | -------------------------------------------------------------------------------- /src/app/components/Drive/helpers.tsx: -------------------------------------------------------------------------------- 1 | import { c } from 'ttag'; 2 | import { getUnixTime } from 'date-fns'; 3 | import { LinkType, LinkMeta } from '../../interfaces/link'; 4 | import { LinkURLType, fileDescriptions, EXPIRATION_DAYS } from '../../constants'; 5 | import { FileBrowserItem } from '../FileBrowser/interfaces'; 6 | 7 | export const selectMessageForItemList = ( 8 | types: LinkType[], 9 | messages: { 10 | allFiles: string; 11 | allFolders: string; 12 | mixed: string; 13 | } 14 | ) => { 15 | const allFiles = types.every((type) => type === LinkType.FILE); 16 | const allFolders = types.every((type) => type === LinkType.FOLDER); 17 | const message = (allFiles && messages.allFiles) || (allFolders && messages.allFolders) || messages.mixed; 18 | 19 | return message; 20 | }; 21 | 22 | export const mapLinksToChildren = ( 23 | decryptedLinks: LinkMeta[], 24 | isDisabled: (linkId: string) => boolean 25 | ): FileBrowserItem[] => { 26 | return decryptedLinks.map( 27 | ({ 28 | LinkID, 29 | Type, 30 | Name, 31 | ModifyTime, 32 | Size, 33 | MIMEType, 34 | ParentLinkID, 35 | Trashed, 36 | UrlsExpired, 37 | ShareIDs, 38 | ShareUrls, 39 | FileProperties, 40 | CachedThumbnailURL, 41 | }) => ({ 42 | Name, 43 | LinkID, 44 | Type, 45 | ModifyTime, 46 | Size, 47 | MIMEType, 48 | ParentLinkID, 49 | Trashed, 50 | Disabled: isDisabled(LinkID), 51 | SharedUrl: ShareUrls[0], // Currently only one share URL is possible 52 | ShareUrlShareID: ShareIDs[0], 53 | UrlsExpired, 54 | HasThumbnail: FileProperties?.ActiveRevision?.Thumbnail === 1, 55 | CachedThumbnailURL, 56 | }) 57 | ); 58 | }; 59 | 60 | export const toLinkURLType = (type: LinkType) => { 61 | const linkType = { 62 | [LinkType.FILE]: LinkURLType.FILE, 63 | [LinkType.FOLDER]: LinkURLType.FOLDER, 64 | }[type]; 65 | 66 | if (!linkType) { 67 | throw new Error(`Type ${type} is unexpected, must be integer representing link type`); 68 | } 69 | 70 | return linkType; 71 | }; 72 | 73 | export const getMimeTypeDescription = (mimeType: string) => { 74 | if (fileDescriptions[mimeType]) { 75 | return fileDescriptions[mimeType]; 76 | } 77 | if (mimeType.startsWith('audio/')) { 78 | return c('Label').t`Audio file`; 79 | } 80 | if (mimeType.startsWith('video/')) { 81 | return c('Label').t`Video file`; 82 | } 83 | if (mimeType.startsWith('text/')) { 84 | return c('Label').t`Text`; 85 | } 86 | if (mimeType.startsWith('image/')) { 87 | return c('Label').t`Image`; 88 | } 89 | 90 | return c('Label').t`Unknown file`; 91 | }; 92 | 93 | // Simple math instead of addDays because we don't need to compensate for DST 94 | export const getDurationInSeconds = (duration: EXPIRATION_DAYS) => 95 | duration === EXPIRATION_DAYS.NEVER ? null : parseInt(duration, 10) * 24 * 60 * 60; 96 | 97 | export const getExpirationTime = (duration: EXPIRATION_DAYS) => 98 | duration === EXPIRATION_DAYS.NEVER ? null : getUnixTime(new Date()) + (getDurationInSeconds(duration) || 0); 99 | -------------------------------------------------------------------------------- /src/app/components/DriveEventManager/DriveEventManagerProvider.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useRef, createContext, ReactNode, useContext } from 'react'; 2 | import { useApi } from 'react-components'; 3 | import eventManager, { EventManager } from 'proton-shared/lib/eventManager/eventManager'; 4 | 5 | import { queryEvents, queryLatestEvents } from '../../api/share'; 6 | import { LinkMeta } from '../../interfaces/link'; 7 | import { EVENT_TYPES } from '../../constants'; 8 | 9 | export interface ShareEvent { 10 | EventType: EVENT_TYPES; 11 | Data: any; 12 | Link: LinkMeta; 13 | } 14 | 15 | interface EventManagersByShares { 16 | [shareId: string]: EventManager; 17 | } 18 | 19 | interface EventManagerProviderState { 20 | getShareEventManager: (shareId: string) => EventManager; 21 | createShareEventManager: (shareId: string) => Promise; 22 | } 23 | 24 | const EventManagerContext = createContext(null); 25 | 26 | const DriveEventManagerProvider = ({ children }: { children: ReactNode }) => { 27 | const api = useApi(); 28 | const shareEventManagers = useRef({}); 29 | 30 | useEffect(() => { 31 | return () => { 32 | if (shareEventManagers.current) { 33 | Object.values(shareEventManagers.current).forEach(({ stop, reset }) => { 34 | stop(); 35 | reset(); 36 | }); 37 | } 38 | }; 39 | }, []); 40 | 41 | const getShareEventManager = (shareId: string) => { 42 | return shareEventManagers.current[shareId]; 43 | }; 44 | 45 | const createShareEventManager = async (shareId: string) => { 46 | const { EventID } = await api<{ EventID: string }>(queryLatestEvents(shareId)); 47 | shareEventManagers.current[shareId] = eventManager({ 48 | api, 49 | eventID: EventID, 50 | query: (eventId: string) => queryEvents(shareId, eventId), 51 | }); 52 | const manager = getShareEventManager(shareId); 53 | manager.start(); 54 | return manager; 55 | }; 56 | 57 | return ( 58 | 64 | {children} 65 | 66 | ); 67 | }; 68 | 69 | export const useDriveEventManager = () => { 70 | const state = useContext(EventManagerContext); 71 | if (!state) { 72 | throw new Error('Trying to use uninitialized DriveEventManagerProvider'); 73 | } 74 | return state; 75 | }; 76 | 77 | export default DriveEventManagerProvider; 78 | -------------------------------------------------------------------------------- /src/app/components/FileBrowser/EmptyFolder.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | import { c } from 'ttag'; 3 | 4 | import { IllustrationPlaceholder, usePopperAnchor } from 'react-components'; 5 | 6 | import noContentSvg from 'design-system/assets/img/placeholders/empty-folder.svg'; 7 | import UploadButton from '../uploads/UploadButton'; 8 | import FolderContextMenu from './FolderContextMenu'; 9 | 10 | const EmptyFolder = () => { 11 | const { anchorRef, isOpen, open, close } = usePopperAnchor(); 12 | const [contextMenuPosition, setContextMenuPosition] = useState<{ top: number; left: number }>(); 13 | 14 | useEffect(() => { 15 | if (!anchorRef.current) { 16 | return; 17 | } 18 | 19 | const handleContextMenu = (ev: MouseEvent) => { 20 | ev.stopPropagation(); 21 | ev.preventDefault(); 22 | 23 | if (isOpen) { 24 | close(); 25 | } 26 | 27 | setContextMenuPosition({ top: ev.clientY, left: ev.clientX }); 28 | }; 29 | 30 | anchorRef.current.addEventListener('contextmenu', handleContextMenu); 31 | 32 | return () => { 33 | anchorRef.current?.removeEventListener('contextmenu', handleContextMenu); 34 | }; 35 | }, [anchorRef, isOpen, close, setContextMenuPosition]); 36 | 37 | return ( 38 | <> 39 |
40 | 41 |

{c('Info').t`Drag and drop a file here or choose to upload.`}

42 |
43 | 44 |
45 |
46 |
47 | 54 | 55 | ); 56 | }; 57 | 58 | export default EmptyFolder; 59 | -------------------------------------------------------------------------------- /src/app/components/FileBrowser/EmptyShared.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { c } from 'ttag'; 3 | 4 | import { IllustrationPlaceholder, PrimaryButton, useModals } from 'react-components'; 5 | 6 | import noLinksSvg from 'design-system/assets/img/placeholders/file-share.svg'; 7 | 8 | import SelectedFileToShareModal from '../SelectedFileToShareModal'; 9 | 10 | type Props = { 11 | shareId: string; 12 | }; 13 | 14 | const EmptyShared = ({ shareId }: Props) => { 15 | const { createModal } = useModals(); 16 | 17 | const onShareFile = () => { 18 | if (shareId) { 19 | createModal(); 20 | } 21 | }; 22 | 23 | return ( 24 |
25 | 26 |

{c('Info').t`Create links and share your files with others.`}

27 |
28 | 29 | {c('Action').t`Share file`} 30 | 31 |
32 |
33 |
34 | ); 35 | }; 36 | 37 | export default EmptyShared; 38 | -------------------------------------------------------------------------------- /src/app/components/FileBrowser/EmptyTrash.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { c } from 'ttag'; 3 | 4 | import { IllustrationPlaceholder } from 'react-components'; 5 | 6 | import noContentSvg from 'design-system/assets/img/placeholders/empty-trash.svg'; 7 | 8 | const EmptyTrash = () => ( 9 |
10 | 11 |
12 | ); 13 | 14 | export default EmptyTrash; 15 | -------------------------------------------------------------------------------- /src/app/components/FileBrowser/FileBrowser.scss: -------------------------------------------------------------------------------- 1 | @import '~design-system/scss/config/'; 2 | 3 | .file-browser-list-item td, 4 | .file-browser-table th { 5 | &:first-of-type { 6 | // Checkbox cell 7 | position: relative; // Because of .increase-click-surface 8 | padding-left: 1em; 9 | padding-right: 1em; 10 | width: calc( #{rem(16)} + 2em); 11 | } 12 | } 13 | 14 | .file-browser-grid-item { 15 | position: relative; 16 | background-color: var(--background-norm); 17 | 18 | &:hover, 19 | &:focus { 20 | background-color: var(--background-weak); 21 | } 22 | } 23 | 24 | .file-browser-grid-item.file-browser-grid-item--highlight { 25 | background-color: var(--background-strong); 26 | } 27 | 28 | .file-browser-grid-item--thumbnail { 29 | max-width: 100%; 30 | max-height: 100%; 31 | object-fit: contain; // To keep original proportion in Safari. 32 | } 33 | 34 | .file-browser-list-item--share { 35 | display: none; 36 | margin: -1em 0 -1em auto; 37 | } 38 | 39 | .file-browser-grid-item--share { 40 | position: absolute; 41 | top: 0.2em; 42 | right: 0.2em; 43 | background-color: var(--background-norm); 44 | border-radius: 25%; 45 | } 46 | 47 | .file-browser-grid-item--select { 48 | position: absolute; 49 | top: 0.2em; 50 | left: 0.2em; 51 | } 52 | 53 | .file-browser-grid-item--select-hover-only { 54 | display: none; 55 | } 56 | 57 | @media (hover: hover) and (pointer: fine) { 58 | .file-browser-list-item:hover .file-browser-list-item--share { 59 | display: flex; 60 | } 61 | 62 | .file-browser-grid-item:hover .file-browser-grid-item--select-hover-only { 63 | display: flex; 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/app/components/FileBrowser/FileBrowser.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { FileBrowserProps } from './interfaces'; 3 | import ListView from './ListView/ListView'; 4 | import GridView from './GridView/GridView'; 5 | import { LayoutSetting } from '../../interfaces/userSettings'; 6 | 7 | /** 8 | * File browser that supports grid view and list view 9 | * If only grid or list view is needed better use them directly 10 | */ 11 | const FileBrowser = ({ 12 | layout, 13 | loading, 14 | caption, 15 | contents, 16 | shareId, 17 | scrollAreaRef, 18 | selectedItems, 19 | type, 20 | isPreview = false, 21 | onToggleItemSelected, 22 | onToggleAllSelected, 23 | onItemClick, 24 | selectItem, 25 | clearSelections, 26 | onShiftClick, 27 | sortParams, 28 | setSorting, 29 | getDragMoveControls, 30 | }: FileBrowserProps) => { 31 | return layout === LayoutSetting.Grid && type !== 'sharing' ? ( 32 | 46 | ) : ( 47 | 66 | ); 67 | }; 68 | 69 | export default FileBrowser; 70 | -------------------------------------------------------------------------------- /src/app/components/FileBrowser/FolderContextMenu.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from 'react'; 2 | import { c } from 'ttag'; 3 | 4 | import { ContextMenu, DropdownMenuButton, Icon } from 'react-components'; 5 | 6 | import useToolbarActions from '../../hooks/drive/useToolbarActions'; 7 | import useFileUploadInput from '../../hooks/drive/useFileUploadInput'; 8 | 9 | interface Props { 10 | anchorRef: React.RefObject; 11 | isOpen: boolean; 12 | position: 13 | | { 14 | top: number; 15 | left: number; 16 | } 17 | | undefined; 18 | open: () => void; 19 | close: () => void; 20 | } 21 | 22 | const FolderContextMenu = ({ anchorRef, isOpen, position, open, close }: Props) => { 23 | const { openCreateFolder } = useToolbarActions(); 24 | const { inputRef: fileInput, handleClick: uploadFile, handleChange: handleFileChange } = useFileUploadInput(); 25 | const { inputRef: folderInput, handleClick: uploadFolder, handleChange: handleFolderChange } = useFileUploadInput( 26 | true 27 | ); 28 | 29 | useEffect(() => { 30 | if (position) { 31 | open(); 32 | } 33 | }, [position]); 34 | 35 | const menuButtons = [ 36 | { 37 | name: c('Action').t`Upload file`, 38 | icon: 'file-upload', 39 | testId: 'context-menu-upload-file', 40 | action: uploadFile, 41 | }, 42 | { 43 | name: c('Action').t`Upload folder`, 44 | icon: 'folder-upload', 45 | testId: 'context-menu-upload-folder', 46 | action: uploadFolder, 47 | }, 48 | { 49 | name: c('Action').t`Create new folder`, 50 | icon: 'folder-new', 51 | testId: 'context-menu-create-folder', 52 | action: openCreateFolder, 53 | }, 54 | ].map((button) => ( 55 | e.stopPropagation()} 58 | className="flex flex-nowrap text-left" 59 | onClick={button.action} 60 | data-testid={button.testId} 61 | > 62 | 63 | {button.name} 64 | 65 | )); 66 | 67 | return ( 68 | <> 69 | 70 | 71 | 72 | {menuButtons} 73 | 74 | 75 | ); 76 | }; 77 | 78 | export default FolderContextMenu; 79 | -------------------------------------------------------------------------------- /src/app/components/FileBrowser/HasNoFilesToShare.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { c } from 'ttag'; 3 | 4 | import noContentSvg from 'design-system/assets/img/placeholders/empty-folder.svg'; 5 | 6 | const HasNoFilesToShare = () => { 7 | const title = c('Title').t`You have no files to share`; 8 | 9 | return ( 10 |
11 | {title} 12 |

{title}

13 |

{c('Info').t`Go to "My files" and upload some files first.`}

14 |
15 | ); 16 | }; 17 | 18 | export default HasNoFilesToShare; 19 | -------------------------------------------------------------------------------- /src/app/components/FileBrowser/HasNoFolders.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { c } from 'ttag'; 3 | 4 | import { Button } from 'react-components'; 5 | 6 | import noContentSvg from 'design-system/assets/img/placeholders/empty-folder.svg'; 7 | 8 | interface Props { 9 | onCreate: () => void; 10 | } 11 | 12 | const HasNoFolders = ({ onCreate }: Props) => { 13 | const title = c('Title').t`You have no folders yet`; 14 | return ( 15 |
16 | {title} 17 |

{title}

18 |

{c('Info').t`Create your first folder and start moving your files.`}

19 |
20 | 23 |
24 |
25 | ); 26 | }; 27 | 28 | export default HasNoFolders; 29 | -------------------------------------------------------------------------------- /src/app/components/FileBrowser/ListView/Cells/DescriptiveTypeCell.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { c } from 'ttag'; 3 | import { LinkType } from '../../../../interfaces/link'; 4 | import { getMimeTypeDescription } from '../../../Drive/helpers'; 5 | 6 | interface Props { 7 | mimeType: string; 8 | linkType: LinkType; 9 | } 10 | 11 | const DescriptiveTypeCell = ({ mimeType, linkType }: Props) => { 12 | const type = linkType === LinkType.FILE ? getMimeTypeDescription(mimeType) : c('Label').t`Folder`; 13 | 14 | return ( 15 |
16 | {type} 17 |
18 | ); 19 | }; 20 | 21 | export default DescriptiveTypeCell; 22 | -------------------------------------------------------------------------------- /src/app/components/FileBrowser/ListView/Cells/LocationCell.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from 'react'; 2 | import { c } from 'ttag'; 3 | import useDrive from '../../../../hooks/drive/useDrive'; 4 | 5 | interface Props { 6 | shareId: string; 7 | parentLinkId: string; 8 | } 9 | 10 | const LocationCell = ({ shareId, parentLinkId }: Props) => { 11 | const [location, setLocation] = useState(); 12 | const { getLinkMeta } = useDrive(); 13 | 14 | useEffect(() => { 15 | const getLocationItems = async (shareId: string, linkId: string): Promise => { 16 | const { ParentLinkID, Name } = await getLinkMeta(shareId, linkId); 17 | if (!ParentLinkID) { 18 | return [c('Title').t`My files`]; 19 | } 20 | 21 | const previous = await getLocationItems(shareId, ParentLinkID); 22 | return [...previous, Name]; 23 | }; 24 | 25 | getLocationItems(shareId, parentLinkId) 26 | .then((items: string[]) => `/${items.join('/')}`) 27 | .then(setLocation) 28 | .catch(console.error); 29 | }, [shareId, parentLinkId]); 30 | 31 | return ( 32 |
33 | {location} 34 |
35 | ); 36 | }; 37 | 38 | export default LocationCell; 39 | -------------------------------------------------------------------------------- /src/app/components/FileBrowser/ListView/Cells/MIMETypeCell.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | interface Props { 4 | mimeType: string; 5 | } 6 | 7 | const MIMETypeCell = ({ mimeType }: Props) => ( 8 |
9 | {mimeType} 10 |
11 | ); 12 | 13 | export default MIMETypeCell; 14 | -------------------------------------------------------------------------------- /src/app/components/FileBrowser/ListView/Cells/NameCell.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { FileNameDisplay } from 'react-components'; 3 | 4 | interface Props { 5 | name: string; 6 | } 7 | 8 | const NameCell = ({ name }: Props) => ( 9 |
10 | 11 |
12 | ); 13 | 14 | export default NameCell; 15 | -------------------------------------------------------------------------------- /src/app/components/FileBrowser/ListView/Cells/ShareCell.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { PrimaryButton, useModals } from 'react-components'; 4 | import { c } from 'ttag'; 5 | import SharingModal from '../../../SharingModal/SharingModal'; 6 | import { FileBrowserItem } from '../../interfaces'; 7 | 8 | interface Props { 9 | shareId: string; 10 | item: FileBrowserItem; 11 | } 12 | 13 | const ShareCell = ({ shareId, item }: Props) => { 14 | const { createModal } = useModals(); 15 | 16 | return ( 17 | { 21 | e.stopPropagation(); 22 | createModal(); 23 | }} 24 | > 25 | {c('Action').t`Share`} 26 | 27 | ); 28 | }; 29 | 30 | export default ShareCell; 31 | -------------------------------------------------------------------------------- /src/app/components/FileBrowser/ListView/Cells/SizeCell.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import humanSize from 'proton-shared/lib/helpers/humanSize'; 3 | 4 | interface Props { 5 | size: number; 6 | } 7 | 8 | const SizeCell = ({ size }: Props) => { 9 | const readableSize = humanSize(size); 10 | return ( 11 |
12 | {readableSize} 13 |
14 | ); 15 | }; 16 | 17 | export default SizeCell; 18 | -------------------------------------------------------------------------------- /src/app/components/FileBrowser/ListView/Cells/TimeCell.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { readableTime } from 'proton-shared/lib/helpers/time'; 3 | import { dateLocale } from 'proton-shared/lib/i18n'; 4 | import { Time } from 'react-components'; 5 | 6 | interface Props { 7 | time: number; 8 | } 9 | 10 | const TimeCell = ({ time: modifyTime }: Props) => { 11 | return ( 12 |
13 | 14 | 17 | 18 |
19 | ); 20 | }; 21 | 22 | export default TimeCell; 23 | -------------------------------------------------------------------------------- /src/app/components/FileBrowser/ListView/Cells/UserNameCell.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useUser } from 'react-components'; 3 | 4 | const UserNameCell = () => { 5 | const [{ Name }] = useUser(); 6 | 7 | return ( 8 |
9 | {Name} 10 |
11 | ); 12 | }; 13 | 14 | export default UserNameCell; 15 | -------------------------------------------------------------------------------- /src/app/components/FileBrowser/SharedURLIcon.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback } from 'react'; 2 | import { c } from 'ttag'; 3 | 4 | import { Icon, Tooltip, useModals } from 'react-components'; 5 | import SharingModal from '../SharingModal/SharingModal'; 6 | import { FileBrowserItem } from './interfaces'; 7 | 8 | interface Props { 9 | shareId: string; 10 | item: FileBrowserItem; 11 | className?: string; 12 | } 13 | 14 | const SharedURLIcon = ({ shareId, item, className }: Props) => { 15 | const { createModal } = useModals(); 16 | 17 | const handleOpeningModal = useCallback( 18 | (e) => { 19 | e.stopPropagation(); // To not show file preview when clicking (to not trigger other click event). 20 | e.preventDefault(); // To not show file preview when pressing enter (to disable click event). 21 | createModal(); 22 | }, 23 | [shareId, item] 24 | ); 25 | 26 | return ( 27 | 28 | 40 | 41 | ); 42 | }; 43 | 44 | export default SharedURLIcon; 45 | -------------------------------------------------------------------------------- /src/app/components/FileBrowser/ToolbarButtons/DetailsButton.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { c } from 'ttag'; 3 | 4 | import { Icon, ToolbarButton } from 'react-components'; 5 | 6 | import useToolbarActions from '../../../hooks/drive/useToolbarActions'; 7 | import { FileBrowserItem } from '../interfaces'; 8 | import { noSelection, isMultiSelect, hasFoldersSelected } from './utils'; 9 | 10 | interface Props { 11 | shareId: string; 12 | selectedItems: FileBrowserItem[]; 13 | } 14 | 15 | const DetailsButton = ({ shareId, selectedItems }: Props) => { 16 | const { openDetails, openFilesDetails } = useToolbarActions(); 17 | 18 | return ( 19 | } 23 | onClick={() => { 24 | if (selectedItems.length === 1) { 25 | openDetails(shareId, selectedItems[0]); 26 | } else if (selectedItems.length > 1) { 27 | openFilesDetails(selectedItems); 28 | } 29 | }} 30 | data-testid="toolbar-details" 31 | /> 32 | ); 33 | }; 34 | 35 | export default DetailsButton; 36 | -------------------------------------------------------------------------------- /src/app/components/FileBrowser/ToolbarButtons/DownloadButton.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { c } from 'ttag'; 3 | 4 | import { Icon, ToolbarButton } from 'react-components'; 5 | 6 | import useToolbarActions from '../../../hooks/drive/useToolbarActions'; 7 | import { FileBrowserItem } from '../interfaces'; 8 | import { noSelection } from './utils'; 9 | 10 | interface Props { 11 | shareId: string; 12 | selectedItems: FileBrowserItem[]; 13 | } 14 | 15 | const DownloadButton = ({ shareId, selectedItems }: Props) => { 16 | const { download } = useToolbarActions(); 17 | 18 | return ( 19 | } 23 | onClick={() => download(shareId, selectedItems)} 24 | data-testid="toolbar-download" 25 | /> 26 | ); 27 | }; 28 | 29 | export default DownloadButton; 30 | -------------------------------------------------------------------------------- /src/app/components/FileBrowser/ToolbarButtons/PreviewButton.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { c } from 'ttag'; 3 | 4 | import { Icon, ToolbarButton, isPreviewAvailable } from 'react-components'; 5 | 6 | import useToolbarActions from '../../../hooks/drive/useToolbarActions'; 7 | import { FileBrowserItem } from '../interfaces'; 8 | import { isMultiSelect, hasFoldersSelected } from './utils'; 9 | 10 | interface Props { 11 | shareId: string; 12 | selectedItems: FileBrowserItem[]; 13 | } 14 | 15 | const PreviewButton = ({ shareId, selectedItems }: Props) => { 16 | const { preview } = useToolbarActions(); 17 | 18 | const disabled = 19 | isMultiSelect(selectedItems) || 20 | hasFoldersSelected(selectedItems) || 21 | !selectedItems[0]?.MIMEType || 22 | !isPreviewAvailable(selectedItems[0].MIMEType); 23 | 24 | return ( 25 | } 29 | onClick={() => { 30 | if (selectedItems.length) { 31 | preview(shareId, selectedItems[0]); 32 | } 33 | }} 34 | data-testid="toolbar-preview" 35 | /> 36 | ); 37 | }; 38 | 39 | export default PreviewButton; 40 | -------------------------------------------------------------------------------- /src/app/components/FileBrowser/ToolbarButtons/RenameButton.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { c } from 'ttag'; 3 | 4 | import { Icon, ToolbarButton } from 'react-components'; 5 | 6 | import useToolbarActions from '../../../hooks/drive/useToolbarActions'; 7 | import { FileBrowserItem } from '../interfaces'; 8 | import { noSelection, isMultiSelect } from './utils'; 9 | 10 | interface Props { 11 | shareId: string; 12 | selectedItems: FileBrowserItem[]; 13 | } 14 | 15 | const RenameButton = ({ shareId, selectedItems }: Props) => { 16 | const { openRename } = useToolbarActions(); 17 | 18 | return ( 19 | } 23 | onClick={() => openRename(shareId, selectedItems[0])} 24 | data-testid="toolbar-rename" 25 | /> 26 | ); 27 | }; 28 | 29 | export default RenameButton; 30 | -------------------------------------------------------------------------------- /src/app/components/FileBrowser/ToolbarButtons/ShareFileButton.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { c } from 'ttag'; 3 | 4 | import { Icon, ToolbarButton } from 'react-components'; 5 | 6 | import useToolbarActions from '../../../hooks/drive/useToolbarActions'; 7 | 8 | interface Props { 9 | shareId: string; 10 | } 11 | 12 | const ShareFileButton = ({ shareId }: Props) => { 13 | const { openFileSharing } = useToolbarActions(); 14 | 15 | return ( 16 | } 20 | onClick={() => { 21 | openFileSharing(shareId); 22 | }} 23 | data-testid="toolbar-shareViaLink" 24 | /> 25 | ); 26 | }; 27 | 28 | export default ShareFileButton; 29 | -------------------------------------------------------------------------------- /src/app/components/FileBrowser/ToolbarButtons/ShareLinkButton.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { c } from 'ttag'; 3 | 4 | import { Icon, ToolbarButton } from 'react-components'; 5 | 6 | import useToolbarActions from '../../../hooks/drive/useToolbarActions'; 7 | import { FileBrowserItem } from '../interfaces'; 8 | import { noSelection, isMultiSelect, hasFoldersSelected } from './utils'; 9 | 10 | interface Props { 11 | shareId: string; 12 | selectedItems: FileBrowserItem[]; 13 | } 14 | 15 | const ShareLinkButton = ({ shareId, selectedItems }: Props) => { 16 | const { openLinkSharing } = useToolbarActions(); 17 | 18 | const hasSharedLink = !!selectedItems[0]?.SharedUrl; 19 | 20 | return ( 21 | } 25 | onClick={() => openLinkSharing(shareId, selectedItems[0])} 26 | data-testid="toolbar-share" 27 | /> 28 | ); 29 | }; 30 | 31 | export default ShareLinkButton; 32 | -------------------------------------------------------------------------------- /src/app/components/FileBrowser/ToolbarButtons/index.ts: -------------------------------------------------------------------------------- 1 | export { default as DetailsButton } from './DetailsButton'; 2 | export { default as DownloadButton } from './DownloadButton'; 3 | export { default as PreviewButton } from './PreviewButton'; 4 | export { default as RenameButton } from './RenameButton'; 5 | export { default as ShareFileButton } from './ShareFileButton'; 6 | export { default as ShareLinkButton } from './ShareLinkButton'; 7 | -------------------------------------------------------------------------------- /src/app/components/FileBrowser/ToolbarButtons/utils.ts: -------------------------------------------------------------------------------- 1 | import { LinkType } from '../../../interfaces/link'; 2 | import { FileBrowserItem } from '../interfaces'; 3 | 4 | export function noSelection(selectedItems: FileBrowserItem[]): boolean { 5 | return selectedItems.length === 0; 6 | } 7 | 8 | export function isMultiSelect(selectedItems: FileBrowserItem[]): boolean { 9 | return selectedItems.length > 1; 10 | } 11 | 12 | export function hasFoldersSelected(selectedItems: FileBrowserItem[]): boolean { 13 | return selectedItems.some((item) => item.Type === LinkType.FOLDER) 14 | } 15 | -------------------------------------------------------------------------------- /src/app/components/FileBrowser/interfaces.tsx: -------------------------------------------------------------------------------- 1 | import { SORT_DIRECTION } from 'proton-shared/lib/constants'; 2 | import { LinkType, SortParams, SortKeys, SharedUrlInfo } from '../../interfaces/link'; 3 | import { LayoutSetting } from '../../interfaces/userSettings'; 4 | 5 | export interface DragMoveControls { 6 | handleDragOver: (event: React.DragEvent) => void; 7 | handleDrop: (e: React.DragEvent) => void; 8 | handleDragLeave: () => void; 9 | handleDragEnter: (e: React.DragEvent) => void; 10 | dragging: boolean; 11 | setDragging: (value: boolean) => void; 12 | isActiveDropTarget: boolean; 13 | } 14 | 15 | export interface FileBrowserItem { 16 | Name: string; 17 | LinkID: string; 18 | Type: LinkType; 19 | ModifyTime: number; 20 | Trashed: number | null; 21 | MIMEType: string; 22 | Size: number; 23 | ParentLinkID: string; 24 | Location?: string; 25 | Disabled?: boolean; 26 | UrlsExpired: boolean; 27 | ShareUrlShareID?: string; 28 | SharedUrl?: SharedUrlInfo; 29 | HasThumbnail: boolean; 30 | // CachedThumbnailURL is computed URL to cached image. This is not part 31 | // of any request and not filled automatically. To get this value, use 32 | // `loadLinkThumbnail` from `useDrive`. 33 | CachedThumbnailURL?: string; 34 | } 35 | 36 | export type ItemRowColumns = 37 | | 'location' 38 | | 'modified' 39 | | 'share_created' 40 | | 'share_expires' 41 | | 'share_num_access' 42 | | 'size' 43 | | 'trashed' 44 | | 'type'; 45 | export type FileBrowserLayouts = 'trash' | 'sharing' | 'drive'; 46 | 47 | export interface ItemProps { 48 | style?: React.CSSProperties; 49 | item: FileBrowserItem; 50 | shareId: string; 51 | layoutType: FileBrowserLayouts; 52 | selectedItems: FileBrowserItem[]; 53 | onToggleSelect: (item: string) => void; 54 | selectItem: (item: string) => void; 55 | onShiftClick?: (item: string) => void; 56 | onClick?: (item: FileBrowserItem) => void; 57 | columns: ItemRowColumns[]; 58 | secondaryActionActive?: boolean; 59 | dragMoveControls?: DragMoveControls; 60 | isPreview?: boolean; 61 | } 62 | 63 | export interface FileBrowserProps { 64 | layout: LayoutSetting; 65 | loading?: boolean; 66 | scrollAreaRef: React.RefObject; 67 | shareId: string; 68 | caption?: string; 69 | contents: FileBrowserItem[]; 70 | selectedItems: FileBrowserItem[]; 71 | type: FileBrowserLayouts; 72 | isPreview?: boolean; 73 | sortParams?: SortParams; 74 | onToggleItemSelected: (item: string) => void; 75 | onItemClick?: (item: FileBrowserItem) => void; 76 | onShiftClick?: (item: string) => void; 77 | selectItem: (item: string) => void; 78 | clearSelections: () => void; 79 | onToggleAllSelected: () => void; 80 | setSorting?: (sortField: SortKeys, sortOrder: SORT_DIRECTION) => void; 81 | getDragMoveControls?: (item: FileBrowserItem) => DragMoveControls; 82 | } 83 | -------------------------------------------------------------------------------- /src/app/components/FileBrowser/useFileBrowserColumns.ts: -------------------------------------------------------------------------------- 1 | import { useActiveBreakpoint } from 'react-components'; 2 | import { FileBrowserLayouts, ItemRowColumns } from './interfaces'; 3 | 4 | const COLUMNS_DESKTOP: Record = { 5 | drive: ['type', 'modified', 'size'], 6 | trash: ['location', 'type', 'trashed', 'size'], 7 | sharing: ['location', 'share_created', 'share_num_access', 'share_expires'], 8 | }; 9 | 10 | const COLUMNS_MOBILE: Record = { 11 | drive: ['type', 'size'], 12 | trash: ['location', 'type', 'size'], 13 | sharing: ['location', 'share_expires'], 14 | }; 15 | 16 | export const useFileBrowserColumns = (fileBrowserType: FileBrowserLayouts) => { 17 | const { isDesktop } = useActiveBreakpoint(); 18 | const columnsSource = isDesktop ? COLUMNS_DESKTOP : COLUMNS_MOBILE; 19 | 20 | return columnsSource[fileBrowserType]; 21 | }; 22 | -------------------------------------------------------------------------------- /src/app/components/FileBrowser/useFileBrowserView.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useCallback, useState } from 'react'; 2 | 3 | interface Options { 4 | clearSelections: () => void; 5 | } 6 | 7 | function useFileBrowserView({ clearSelections }: Options) { 8 | const [secondaryActionActive, setSecondaryActionActive] = useState(false); 9 | const [isContextMenuOpen, setIsOpen] = useState(false); 10 | const [contextMenuPosition, setContextMenuPosition] = useState<{ top: number; left: number }>(); 11 | 12 | useEffect(() => { 13 | const handleKeyDown = (e: KeyboardEvent) => { 14 | const newSecondaryActionIsActive = e.shiftKey || e.metaKey || e.ctrlKey; 15 | if (newSecondaryActionIsActive !== secondaryActionActive) { 16 | setSecondaryActionActive(newSecondaryActionIsActive); 17 | } 18 | }; 19 | 20 | window.addEventListener('keydown', handleKeyDown); 21 | window.addEventListener('keyup', handleKeyDown); 22 | 23 | return () => { 24 | window.removeEventListener('keydown', handleKeyDown); 25 | window.addEventListener('keyup', handleKeyDown); 26 | }; 27 | }, [secondaryActionActive]); 28 | 29 | const openContextMenu = useCallback(() => { 30 | setIsOpen(true); 31 | }, []); 32 | 33 | const closeContextMenu = useCallback(() => { 34 | setIsOpen(false); 35 | }, []); 36 | 37 | const handleContextMenu = useCallback((e: React.MouseEvent) => { 38 | e.stopPropagation(); 39 | e.preventDefault(); 40 | 41 | clearSelections(); 42 | 43 | if (isContextMenuOpen) { 44 | closeContextMenu(); 45 | } 46 | 47 | setContextMenuPosition({ top: e.clientY, left: e.clientX }); 48 | }, []); 49 | 50 | return { 51 | isContextMenuOpen, 52 | handleContextMenu, 53 | openContextMenu, 54 | closeContextMenu, 55 | contextMenuPosition, 56 | secondaryActionActive, 57 | }; 58 | } 59 | 60 | export default useFileBrowserView; 61 | -------------------------------------------------------------------------------- /src/app/components/FilesDetailsModal.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { c } from 'ttag'; 3 | 4 | import { Row, Label, Field, DialogModal, HeaderModal, InnerModal, FooterModal, PrimaryButton } from 'react-components'; 5 | 6 | import { FileBrowserItem } from './FileBrowser/interfaces'; 7 | import SizeCell from './FileBrowser/ListView/Cells/SizeCell'; 8 | 9 | interface Props { 10 | selectedItems: FileBrowserItem[]; 11 | onClose?: () => void; 12 | } 13 | 14 | const FilesDetailsModal = ({ selectedItems, onClose, ...rest }: Props) => { 15 | const modalTitleID = 'files-details-modal'; 16 | const size = selectedItems.reduce((sum, current) => sum + current.Size, 0); 17 | 18 | return ( 19 | 20 | 21 | {c('Title').t`Files details`} 22 | 23 |
24 | 25 | 26 | 27 | 28 | {selectedItems.length} 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | {c('Action').t`Close`} 43 | 44 | 45 |
46 |
47 | ); 48 | }; 49 | 50 | export default FilesDetailsModal; 51 | -------------------------------------------------------------------------------- /src/app/components/FilesRecoveryModal/FileRecoveryIcon.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { c } from 'ttag'; 3 | 4 | import { Icon, Tooltip, useModals } from 'react-components'; 5 | 6 | import { useDriveCache } from '../DriveCache/DriveCacheProvider'; 7 | import FilesRecoveryModal from './FilesRecoveryModal'; 8 | 9 | interface Props { 10 | className?: string; 11 | } 12 | 13 | const FileRecoveryIcon = ({ className }: Props) => { 14 | const cache = useDriveCache(); 15 | const { createModal } = useModals(); 16 | 17 | return cache.sharesReadyToRestore.length ? ( 18 | 19 | { 24 | e.preventDefault(); 25 | e.stopPropagation(); 26 | 27 | createModal(); 28 | }} 29 | /> 30 | 31 | ) : null; 32 | }; 33 | 34 | export default FileRecoveryIcon; 35 | -------------------------------------------------------------------------------- /src/app/components/FilesRecoveryModal/FilesRecoveryModal.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { c } from 'ttag'; 3 | 4 | import { DialogModal, useLoading, useNotifications } from 'react-components'; 5 | 6 | import FilesRecoveryState from './FilesRecoveryState'; 7 | import useDrive from '../../hooks/drive/useDrive'; 8 | import { ShareMeta } from '../../interfaces/share'; 9 | 10 | interface Props { 11 | lockedShareList: { 12 | lockedShareMeta: ShareMeta; 13 | decryptedPassphrase: any; 14 | }[]; 15 | onClose?: () => void; 16 | } 17 | 18 | const FilesRecoveryModal = ({ lockedShareList, onClose, ...rest }: Props) => { 19 | const modalTitleID = 'files-recovery-modal'; 20 | 21 | const { restoreVolumes } = useDrive(); 22 | const [recovering, withRecovering] = useLoading(); 23 | const { createNotification } = useNotifications(); 24 | 25 | const handleRecoveryClick = async () => { 26 | await withRecovering( 27 | restoreVolumes(lockedShareList) 28 | .then(() => { 29 | createNotification({ 30 | text: c('Success').t`Recovery has started.`, 31 | }); 32 | }) 33 | .catch(() => onClose?.()) 34 | ); 35 | 36 | onClose?.(); 37 | }; 38 | 39 | return ( 40 | 41 | 42 | 43 | ); 44 | }; 45 | 46 | export default FilesRecoveryModal; 47 | -------------------------------------------------------------------------------- /src/app/components/FilesRecoveryModal/FilesRecoveryState.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { c } from 'ttag'; 3 | 4 | import { HeaderModal, InnerModal, FooterModal, Button, Alert, PrimaryButton } from 'react-components'; 5 | 6 | import keyAndFileSvg from 'design-system/assets/img/placeholders/file-recovery.svg'; 7 | 8 | interface Props { 9 | onRecovery: () => void; 10 | onClose?: () => void; 11 | recovering?: boolean; 12 | } 13 | 14 | const FilesRecoveryState = ({ onRecovery, onClose, recovering }: Props) => { 15 | const modalTitleID = 'files-recovery-modal'; 16 | const title = c('Title').t`Restore your files`; 17 | 18 | return ( 19 | <> 20 | 21 | {c('Title').t`File recovery process`} 22 | 23 |
24 | 25 |
26 | {title} 27 |
28 | 29 |
{c('Info').jt`Would you like to restore your files?`}
30 |
{c('Info').jt`Recovery process might take some time.`}
31 |
32 |
33 | 34 |
35 | 37 | 38 | {c('Action').t`Start recovering`} 39 | 40 |
41 |
42 |
43 | 44 | ); 45 | }; 46 | 47 | export default FilesRecoveryState; 48 | -------------------------------------------------------------------------------- /src/app/components/FolderTree/FolderTree.scss: -------------------------------------------------------------------------------- 1 | .folder-tree { 2 | @extend .bordered; 3 | @extend .scroll-if-needed; 4 | 5 | border-radius: $global-border-radius; 6 | height: 28em; 7 | 8 | &-table { 9 | width: auto; 10 | min-width: 100%; 11 | max-width: initial; 12 | } 13 | 14 | &-list-item { 15 | td { 16 | padding-top: 0; 17 | padding-bottom: 0; 18 | } 19 | 20 | &-expand { 21 | padding: 0.85em; 22 | 23 | &-button { 24 | @extend .button; 25 | @extend .button-for-icon; 26 | 27 | display: flex; 28 | padding: 0; 29 | } 30 | } 31 | } 32 | 33 | &-list-item-name { 34 | min-width: 10em; 35 | } 36 | 37 | &-list-item-selected { 38 | justify-content: center; 39 | min-width: 3em; 40 | 41 | &-check { 42 | padding: 0.15em; 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/app/components/FolderTree/FolderTree.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { TableRowBusy } from 'react-components'; 3 | import { LinkType } from '../../interfaces/link'; 4 | import ExpandableRow from './ExpandableRow'; 5 | 6 | export interface FolderTreeItem { 7 | linkId: string; 8 | name: string; 9 | type: LinkType; 10 | mimeType: string; 11 | children: { list: FolderTreeItem[]; complete: boolean }; 12 | } 13 | 14 | interface Props { 15 | items: FolderTreeItem[]; 16 | initiallyExpandedFolders: string[]; 17 | selectedItemId?: string; 18 | loading?: boolean; 19 | onSelect: (LinkID: string) => void; 20 | loadChildren: (LinkID: string, loadNextPage?: boolean) => Promise; 21 | rowIsDisabled?: (item: FolderTreeItem) => boolean; 22 | } 23 | 24 | const FolderTree = ({ 25 | items, 26 | initiallyExpandedFolders, 27 | selectedItemId, 28 | loading = false, 29 | onSelect, 30 | loadChildren, 31 | rowIsDisabled, 32 | }: Props) => { 33 | const generateRows = (items: FolderTreeItem[], depth = 0) => { 34 | const rows = items.map((item: FolderTreeItem) => { 35 | const { linkId, name, type, mimeType, children } = item; 36 | const disabled = rowIsDisabled ? rowIsDisabled(item) : false; 37 | const childrenRows = children.list.length ? generateRows(children.list, depth + 1) : null; 38 | const isExpanded = initiallyExpandedFolders.includes(linkId); 39 | 40 | return ( 41 | 55 | {childrenRows} 56 | 57 | ); 58 | }); 59 | 60 | return <>{rows}; 61 | }; 62 | 63 | const rows = generateRows(items); 64 | 65 | return ( 66 |
67 | 68 | {loading ? : rows} 69 |
70 |
71 | ); 72 | }; 73 | 74 | export default FolderTree; 75 | -------------------------------------------------------------------------------- /src/app/components/SharedLinks/ShareFileButton.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { c } from 'ttag'; 3 | import { FloatingButton, Icon, SidebarPrimaryButton, useModals } from 'react-components'; 4 | import SelectedFileToShareModal from '../SelectedFileToShareModal'; 5 | 6 | interface Props { 7 | shareId: string; 8 | floating?: boolean; 9 | className?: string; 10 | } 11 | 12 | const ShareFileButton = ({ shareId, floating, className }: Props) => { 13 | const { createModal } = useModals(); 14 | 15 | const onShareFile = () => { 16 | if (shareId) { 17 | createModal(); 18 | } 19 | }; 20 | 21 | return ( 22 | <> 23 | {floating ? ( 24 | 25 | 26 | 27 | ) : ( 28 | {c('Action') 29 | .t`Share file`} 30 | )} 31 | 32 | ); 33 | }; 34 | 35 | export default ShareFileButton; 36 | -------------------------------------------------------------------------------- /src/app/components/SharedLinks/SharedLinks.tsx: -------------------------------------------------------------------------------- 1 | import React, { useRef, useCallback } from 'react'; 2 | import { c } from 'ttag'; 3 | import useUserSettings from '../../hooks/drive/useUserSettings'; 4 | import FileBrowser from '../FileBrowser/FileBrowser'; 5 | import { useSharedLinksContent } from './SharedLinksContentProvider'; 6 | import useOnScrollEnd from '../../hooks/util/useOnScrollEnd'; 7 | import EmptyShared from '../FileBrowser/EmptyShared'; 8 | 9 | type Props = { 10 | shareId: string; 11 | }; 12 | 13 | const SharedLinks = ({ shareId }: Props) => { 14 | const { layout } = useUserSettings(); 15 | const scrollAreaRef = useRef(null); 16 | const { loadNextPage, loading, initialized, complete, contents, fileBrowserControls } = useSharedLinksContent(); 17 | 18 | const { 19 | clearSelections, 20 | selectedItems, 21 | selectItem, 22 | toggleSelectItem, 23 | toggleAllSelected, 24 | toggleRange, 25 | } = fileBrowserControls; 26 | 27 | const handleScrollEnd = useCallback(() => { 28 | // Only load on scroll after initial load from backend 29 | if (initialized && !complete) { 30 | loadNextPage(); 31 | } 32 | }, [initialized, complete, loadNextPage, layout]); 33 | 34 | // On content change, check scroll end (does not rebind listeners) 35 | useOnScrollEnd(handleScrollEnd, scrollAreaRef, 0.9, [contents, layout]); 36 | 37 | return complete && !contents.length && !loading ? ( 38 | 39 | ) : ( 40 | 55 | ); 56 | }; 57 | 58 | export default SharedLinks; 59 | -------------------------------------------------------------------------------- /src/app/components/SharedLinks/SharedLinksToolbar.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Toolbar, ToolbarSeparator } from 'react-components'; 3 | 4 | import { useSharedLinksContent } from './SharedLinksContentProvider'; 5 | import { 6 | DetailsButton, 7 | DownloadButton, 8 | PreviewButton, 9 | RenameButton, 10 | ShareLinkButton, 11 | } from '../FileBrowser/ToolbarButtons'; 12 | import StopSharingButton from './ToolbarButtons/StopSharingButton'; 13 | 14 | interface Props { 15 | shareId: string; 16 | } 17 | 18 | const SharedLinksToolbar = ({ shareId }: Props) => { 19 | const { fileBrowserControls } = useSharedLinksContent(); 20 | const { selectedItems } = fileBrowserControls; 21 | 22 | return ( 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | ); 34 | }; 35 | 36 | export default SharedLinksToolbar; 37 | -------------------------------------------------------------------------------- /src/app/components/SharedLinks/ToolbarButtons/StopSharingButton.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { c } from 'ttag'; 3 | 4 | import { Icon, ToolbarButton } from 'react-components'; 5 | 6 | import useToolbarActions from '../../../hooks/drive/useToolbarActions'; 7 | import { useSharedLinksContent } from '../SharedLinksContentProvider'; 8 | 9 | interface Props { 10 | shareId: string; 11 | disabled?: boolean; 12 | } 13 | 14 | const StopSharingButton = ({ shareId, disabled }: Props) => { 15 | const { openStopSharing } = useToolbarActions(); 16 | const { fileBrowserControls } = useSharedLinksContent(); 17 | const { selectedItems } = fileBrowserControls; 18 | 19 | return ( 20 | } 24 | onClick={() => openStopSharing(shareId, selectedItems)} 25 | data-testid="toolbar-button-stop-sharing" 26 | /> 27 | ); 28 | }; 29 | 30 | export default StopSharingButton; 31 | -------------------------------------------------------------------------------- /src/app/components/SharingModal/DateTime.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { dateLocale } from 'proton-shared/lib/i18n'; 3 | 4 | import { fromUnixTime, format as formatDate } from 'date-fns'; 5 | 6 | interface Props extends React.HTMLAttributes { 7 | value: number; 8 | format?: string; 9 | } 10 | 11 | const DateTime = ({ value, format = 'PPp', ...rest }: Props) => { 12 | return ; 13 | }; 14 | 15 | export default DateTime; 16 | -------------------------------------------------------------------------------- /src/app/components/SharingModal/ErrorState.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Alert, Button, FooterModal, HeaderModal, InnerModal } from 'react-components'; 3 | import { c } from 'ttag'; 4 | 5 | interface Props { 6 | modalTitleID: string; 7 | onClose?: () => void; 8 | } 9 | 10 | function ErrorState({ modalTitleID, onClose }: Props) { 11 | return ( 12 | <> 13 | 14 | {c('Title').t`Manage secure link`} 15 | 16 |
17 | 18 | {c('Info').t`Failed to generate a secure link. Try again later.`} 19 | 20 | 21 | 22 | 23 |
24 | 25 | ); 26 | } 27 | 28 | export default ErrorState; 29 | -------------------------------------------------------------------------------- /src/app/components/SharingModal/LoadingState.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { InnerModal, Loader, TextLoader } from 'react-components'; 3 | import { c } from 'ttag'; 4 | 5 | interface Props { 6 | generated: boolean; 7 | } 8 | 9 | function LoadingState({ generated }: Props) { 10 | return ( 11 |
12 | 13 |
14 | 15 | 16 | {generated ? c('Info').t`Preparing link to file` : c('Info').t`Creating link to file`} 17 | 18 |
19 |
20 |
21 | ); 22 | } 23 | 24 | export default LoadingState; 25 | -------------------------------------------------------------------------------- /src/app/components/SharingModal/SharingModal.scss: -------------------------------------------------------------------------------- 1 | .field.field--accented, 2 | .accented { 3 | color: var(--primary); 4 | font-weight: bold; 5 | } 6 | -------------------------------------------------------------------------------- /src/app/components/TransferManager/ProgressBar.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { classnames, Progress } from 'react-components'; 3 | 4 | export enum ProgressBarStatus { 5 | Disabled = 'disabled', 6 | Running = 'running', 7 | Success = 'success', 8 | Warning = 'warning', 9 | Error = 'error', 10 | } 11 | 12 | interface Props { 13 | status?: ProgressBarStatus; 14 | value?: number; 15 | max?: number; 16 | } 17 | 18 | const ProgressBar = ({ status = ProgressBarStatus.Success, ...rest }: Props) => ( 19 | 23 | ); 24 | 25 | export default ProgressBar; 26 | -------------------------------------------------------------------------------- /src/app/components/TransferManager/TransferManager.scss: -------------------------------------------------------------------------------- 1 | @import '~design-system/scss/config/'; 2 | 3 | .transfers-manager { 4 | position: fixed; 5 | right: 0; 6 | bottom: 0; 7 | z-index: 80; 8 | box-shadow: var(--shadow-lifted); 9 | color: var(--text-norm); 10 | transition: 0.15s cubic-bezier(0.22, 1, 0.36, 1); 11 | 12 | @include respond-to($breakpoint-small, 'max') { 13 | left: 0; 14 | bottom: 0; 15 | } 16 | 17 | @include respond-to($breakpoint-small, 'min') { 18 | width: 60%; 19 | max-width: 50em; 20 | margin-right: inherit; 21 | } 22 | 23 | &-heading { 24 | background: var(--background-strong); 25 | color: var(--text-norm); 26 | 27 | @include respond-to($breakpoint-small, 'min') { 28 | border-radius: $global-border-radius $global-border-radius 0 0; 29 | } 30 | } 31 | 32 | &-heading-tooltip { 33 | align-self: stretch; 34 | justify-content: stretch; 35 | align-items: stretch; 36 | 37 | &--isDisabled { 38 | pointer-events: none; 39 | } 40 | } 41 | 42 | &-list { 43 | background-color: var(--background-norm); 44 | color: var(--text-norm); 45 | transition: inherit; 46 | } 47 | 48 | &--minimized &-list { 49 | max-height: 0; 50 | visibility: hidden; 51 | } 52 | 53 | &-list-item { 54 | display: grid; 55 | grid-template-rows: 1fr auto; 56 | grid-template-areas: 'name size status controls' 'progress progress progress progress'; 57 | column-gap: 1em; 58 | row-gap: 1em; 59 | align-items: center; 60 | height: 5em; // To avoid height blinking on status changing 61 | 62 | @include respond-to($breakpoint-small, 'max') { 63 | grid-template-columns: 4fr minmax(5em, 2fr) 2.5em minmax(4.5em, 1fr); 64 | } 65 | 66 | @include respond-to($breakpoint-small, 'min') { 67 | grid-template-columns: 4fr 2.5fr minmax(6em, 2fr) minmax(4.5em, 1fr); 68 | } 69 | 70 | &:not(:last-child) { 71 | @extend .border-bottom; 72 | } 73 | 74 | @each $place in (name, size, status, controls, progress) { 75 | &-#{$place} { 76 | grid-area: $place; 77 | } 78 | 79 | @if $place != controls and $place != progress { 80 | &--canceled &-#{$place} { 81 | opacity: 0.5; 82 | } 83 | } 84 | } 85 | 86 | &-icon { 87 | @include respond-to(420, 'max') { 88 | display: none; 89 | } 90 | } 91 | 92 | &-status { 93 | white-space: nowrap; 94 | font-variant-numeric: tabular-nums; 95 | 96 | @include respond-to($breakpoint-small, 'max') { 97 | svg { 98 | margin: 0; 99 | } 100 | } 101 | } 102 | 103 | &-size { 104 | white-space: nowrap; 105 | font-variant-numeric: tabular-nums; 106 | } 107 | 108 | &-controls-button { 109 | padding: 0.375em; 110 | 111 | & + & { 112 | margin-left: 0.5em; 113 | } 114 | } 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /src/app/components/TransferManager/interfaces.ts: -------------------------------------------------------------------------------- 1 | import { Download, Upload } from '../../interfaces/transfer'; 2 | 3 | export interface DownloadProps { 4 | transfer: Download; 5 | type: TransferType.Download; 6 | } 7 | 8 | export interface UploadProps { 9 | transfer: Upload; 10 | type: TransferType.Upload; 11 | } 12 | 13 | export interface TransferProps { 14 | transfer: T extends TransferType.Download ? Download : Upload; 15 | type: T; 16 | } 17 | 18 | export interface TransferStats { 19 | active: boolean; 20 | progress: number; 21 | speed: number; 22 | } 23 | export interface TransfersStats { 24 | timestamp: Date; 25 | stats: { [id: string]: TransferStats }; 26 | } 27 | 28 | export enum TransferType { 29 | Download = 'download', 30 | Upload = 'upload', 31 | } 32 | -------------------------------------------------------------------------------- /src/app/components/layout/DriveHeader.tsx: -------------------------------------------------------------------------------- 1 | import { APPS } from 'proton-shared/lib/constants'; 2 | import React from 'react'; 3 | import { 4 | PrivateHeader, 5 | useActiveBreakpoint, 6 | TopNavbarListItemContactsDropdown, 7 | TopNavbarListItemSettingsDropdown, 8 | } from 'react-components'; 9 | import { c } from 'ttag'; 10 | 11 | interface Props { 12 | isHeaderExpanded: boolean; 13 | toggleHeaderExpanded: () => void; 14 | floatingPrimary: React.ReactNode; 15 | logo: React.ReactNode; 16 | title?: string; 17 | } 18 | 19 | const DriveHeader = ({ 20 | logo, 21 | isHeaderExpanded, 22 | toggleHeaderExpanded, 23 | floatingPrimary, 24 | title = c('Title').t`Drive`, 25 | }: Props) => { 26 | const { isNarrow } = useActiveBreakpoint(); 27 | return ( 28 | } 32 | settingsButton={} 33 | expanded={isHeaderExpanded} 34 | onToggleExpand={toggleHeaderExpanded} 35 | isNarrow={isNarrow} 36 | floatingButton={floatingPrimary} 37 | /> 38 | ); 39 | }; 40 | 41 | export default DriveHeader; 42 | -------------------------------------------------------------------------------- /src/app/components/layout/DriveSidebar/DriveSidebar.scss: -------------------------------------------------------------------------------- 1 | @keyframes rotating { 2 | from { 3 | transform: rotate(0deg); 4 | } 5 | to { 6 | transform: rotate(360deg); 7 | } 8 | } 9 | .location-refresh-rotate { 10 | animation: rotating 0.5s linear infinite; 11 | } 12 | -------------------------------------------------------------------------------- /src/app/components/layout/DriveSidebar/DriveSidebar.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Sidebar, SidebarNav } from 'react-components'; 3 | import { useDriveActiveFolder } from '../../Drive/DriveFolderProvider'; 4 | import DriveSidebarFooter from './DriveSidebarFooter'; 5 | import DriveSidebarList from './DriveSidebarList'; 6 | 7 | interface Props { 8 | isHeaderExpanded: boolean; 9 | toggleHeaderExpanded: () => void; 10 | primary: React.ReactNode; 11 | logo: React.ReactNode; 12 | shareId?: string; 13 | } 14 | 15 | const DriveSidebar = ({ shareId, logo, primary, isHeaderExpanded, toggleHeaderExpanded }: Props) => { 16 | const { folder } = useDriveActiveFolder(); 17 | 18 | return ( 19 | } 25 | > 26 | 27 | 28 | 29 | 30 | ); 31 | }; 32 | 33 | export default DriveSidebar; 34 | -------------------------------------------------------------------------------- /src/app/components/layout/DriveSidebar/DriveSidebarFooter.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { AppVersion } from 'react-components'; 3 | 4 | import changelog from '../../../../../CHANGELOG.md'; 5 | 6 | const DriveSidebarFooter = () => ; 7 | 8 | export default DriveSidebarFooter; 9 | -------------------------------------------------------------------------------- /src/app/components/layout/DriveSidebar/DriveSidebarList.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { c } from 'ttag'; 3 | 4 | import { SidebarList } from 'react-components'; 5 | import DriveSidebarListItem from './DriveSidebarListItem'; 6 | import FileRecoveryIcon from '../../FilesRecoveryModal/FileRecoveryIcon'; 7 | 8 | interface Props { 9 | shareId?: string; 10 | } 11 | 12 | const DriveSidebarList = ({ shareId }: Props) => ( 13 | 14 | 15 | <> 16 | {c('Link').t`My files`} 17 | 18 | 19 | 20 | 21 | {c('Link').t`Shared`} 22 | 23 | 24 | {c('Link').t`Trash`} 25 | 26 | 27 | ); 28 | 29 | export default DriveSidebarList; 30 | -------------------------------------------------------------------------------- /src/app/components/layout/DriveSidebar/DriveSidebarListItem.tsx: -------------------------------------------------------------------------------- 1 | import { noop } from 'proton-shared/lib/helpers/function'; 2 | import { wait } from 'proton-shared/lib/helpers/promise'; 3 | import React from 'react'; 4 | import { 5 | SidebarListItem, 6 | SidebarListItemContent, 7 | SidebarListItemContentIcon, 8 | SidebarListItemLink, 9 | useLoading, 10 | } from 'react-components'; 11 | import { useRouteMatch } from 'react-router-dom'; 12 | import useDrive from '../../../hooks/drive/useDrive'; 13 | import LocationAside from './ReloadSpinner'; 14 | 15 | interface Props { 16 | to: string; 17 | icon: string; 18 | children: React.ReactNode; 19 | shareId?: string; 20 | } 21 | const DriveSidebarListItem = ({ to, children, icon, shareId }: Props) => { 22 | const match = useRouteMatch(); 23 | const { events } = useDrive(); 24 | const [refreshing, withRefreshing] = useLoading(false); 25 | 26 | const isActive = match.path === to; 27 | 28 | const left = icon ? : null; 29 | const right = isActive && shareId && ; 30 | 31 | const handleClick = () => { 32 | if (!refreshing && shareId) { 33 | withRefreshing(Promise.all([events.callAll(shareId), wait(1000)])).catch(noop); 34 | } 35 | }; 36 | 37 | return ( 38 | 39 | isActive}> 40 | 46 | {children} 47 | 48 | 49 | 50 | ); 51 | }; 52 | 53 | export default DriveSidebarListItem; 54 | -------------------------------------------------------------------------------- /src/app/components/layout/DriveSidebar/ReloadSpinner.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Icon, classnames } from 'react-components'; 3 | 4 | interface Props { 5 | refreshing?: boolean; 6 | } 7 | 8 | const ReloadSpinner = ({ refreshing = false }: Props) => { 9 | return ; 10 | }; 11 | 12 | export default ReloadSpinner; 13 | -------------------------------------------------------------------------------- /src/app/components/onboarding/DriveOnboardingModal.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { c } from 'ttag'; 3 | import { getAppName } from 'proton-shared/lib/apps/helper'; 4 | import { OnboardingContent, OnboardingModal, OnboardingStep, OnboardingStepRenderCallback } from 'react-components'; 5 | import { APPS } from 'proton-shared/lib/constants'; 6 | 7 | import onboardingWelcome from 'design-system/assets/img/onboarding/drive-welcome.svg'; 8 | 9 | const DriveOnboardingModal = (props: any) => { 10 | const appName = getAppName(APPS.PROTONDRIVE); 11 | 12 | return ( 13 | 14 | {({ onNext }: OnboardingStepRenderCallback) => ( 15 | 20 | } 25 | /> 26 | 27 | )} 28 | 29 | ); 30 | }; 31 | 32 | export default DriveOnboardingModal; 33 | -------------------------------------------------------------------------------- /src/app/components/onboarding/DriveOnboardingModalNoAccess.tsx: -------------------------------------------------------------------------------- 1 | import { c } from 'ttag'; 2 | import { OnboardingContent, OnboardingModal, OnboardingStep, useSettingsLink } from 'react-components'; 3 | import React from 'react'; 4 | import { APPS } from 'proton-shared/lib/constants'; 5 | import { getAppName } from 'proton-shared/lib/apps/helper'; 6 | 7 | import onboardingWelcome from 'design-system/assets/img/onboarding/drive-upgrade.svg'; 8 | 9 | const DriveOnboardingModalNoAccess = (props: any) => { 10 | const goToSettings = useSettingsLink(); 11 | const appName = getAppName(APPS.PROTONDRIVE); 12 | 13 | const handleBack = () => goToSettings('/dashboard'); 14 | 15 | return ( 16 | 17 | {() => ( 18 | 23 | } 28 | /> 29 | 30 | )} 31 | 32 | ); 33 | }; 34 | 35 | export default DriveOnboardingModalNoAccess; 36 | -------------------------------------------------------------------------------- /src/app/components/onboarding/DriveOnboardingModalNoBeta.tsx: -------------------------------------------------------------------------------- 1 | import { c } from 'ttag'; 2 | import React from 'react'; 3 | 4 | import { OnboardingContent, OnboardingModal, OnboardingStep, EarlyAccessModal, useModals } from 'react-components'; 5 | import { APPS } from 'proton-shared/lib/constants'; 6 | import { getAppName } from 'proton-shared/lib/apps/helper'; 7 | import onboardingWelcome from 'design-system/assets/img/onboarding/drive-upgrade.svg'; 8 | 9 | const DriveOnboardingModalNoBeta = (props: any) => { 10 | const appName = getAppName(APPS.PROTONDRIVE); 11 | const { createModal } = useModals(); 12 | 13 | return ( 14 | 15 | {() => ( 16 | { 20 | createModal(); 21 | }} 22 | > 23 | } 28 | /> 29 | 30 | )} 31 | 32 | ); 33 | }; 34 | 35 | export default DriveOnboardingModalNoBeta; 36 | -------------------------------------------------------------------------------- /src/app/components/thumbnail/image.ts: -------------------------------------------------------------------------------- 1 | import { THUMBNAIL_MAX_SIDE, THUMBNAIL_MAX_SIZE, THUMBNAIL_QUALITIES } from '../../constants'; 2 | 3 | export function scaleImageFile(file: Blob): Promise { 4 | return new Promise((resolve, reject) => { 5 | const img = new Image(); 6 | img.addEventListener('load', () => { 7 | scaleImage(img).then(resolve).catch(reject); 8 | }); 9 | img.addEventListener('error', reject); 10 | img.src = URL.createObjectURL(file); 11 | }); 12 | } 13 | 14 | async function scaleImage(img: HTMLImageElement): Promise { 15 | const canvas = document.createElement('canvas'); 16 | const ctx = canvas.getContext('2d'); 17 | 18 | // Null is returned only when using wrong context type. 19 | if (ctx === null) { 20 | throw new Error("Context is not available"); 21 | } 22 | 23 | const [width, height] = calculateThumbnailSize(img); 24 | canvas.width = width; 25 | canvas.height = height; 26 | ctx.drawImage(img, 0, 0, canvas.width, canvas.height); 27 | 28 | return new Uint8Array(await canvasToThumbnail(canvas)); 29 | } 30 | 31 | export function calculateThumbnailSize(img: { width: number, height: number }): [width: number, height: number] { 32 | // Keep image smaller than our thumbnail as is. 33 | if (!(img.width > THUMBNAIL_MAX_SIDE || img.height > THUMBNAIL_MAX_SIDE)) { 34 | return [img.width, img.height]; 35 | } 36 | 37 | // getSize returns the other side, always as non-zero integer. 38 | const getSize = (ratio: number): number => { 39 | const result = Math.round(ratio * THUMBNAIL_MAX_SIDE); 40 | return result === 0 ? 1 : result; 41 | } 42 | 43 | // Otherwise scale down based on the bigger side. 44 | if (img.width > img.height) { 45 | return [THUMBNAIL_MAX_SIDE, getSize(img.height / img.width)]; 46 | } 47 | return [getSize(img.width / img.height), THUMBNAIL_MAX_SIDE]; 48 | } 49 | 50 | async function canvasToThumbnail(canvas: HTMLCanvasElement): Promise { 51 | // We check clear text thumbnail size but the limit on API is for encrypted 52 | // text. To do the check on proper place would be too dificult for little 53 | // gain. The increase in size is under 10 percent, so limit it to 90% of 54 | // real limit is reasonable. 55 | const maxSize = THUMBNAIL_MAX_SIZE * 0.9; 56 | 57 | for (const quality of THUMBNAIL_QUALITIES) { 58 | const data = await canvasToArrayBuffer(canvas, 'image/jpeg', quality); 59 | if (data.byteLength < maxSize) { 60 | return data; 61 | } 62 | } 63 | throw new Error("Cannot create small enough thumbnail"); 64 | } 65 | 66 | function canvasToArrayBuffer(canvas: HTMLCanvasElement, mime: string, quality: number): Promise { 67 | return new Promise((resolve, reject) => canvas.toBlob((d) => { 68 | if (!d) { 69 | reject(new Error("Blob not available")); 70 | return; 71 | } 72 | 73 | const r = new FileReader(); 74 | r.addEventListener('load', () => { 75 | resolve(r.result as ArrayBuffer); 76 | }); 77 | r.addEventListener('error', reject); 78 | r.readAsArrayBuffer(d); 79 | }, mime, quality)); 80 | } 81 | -------------------------------------------------------------------------------- /src/app/components/thumbnail/thumbnail.test.ts: -------------------------------------------------------------------------------- 1 | import { makeThumbnail } from './thumbnail'; 2 | 3 | describe('makeThumbnail', () => { 4 | it('does nothing when mime type is not supported', async () => { 5 | await expect(makeThumbnail('png', new Blob())).resolves.toEqual(undefined); 6 | await expect(makeThumbnail('image/jpeeg', new Blob())).resolves.toEqual(undefined); 7 | }); 8 | }); 9 | -------------------------------------------------------------------------------- /src/app/components/thumbnail/thumbnail.ts: -------------------------------------------------------------------------------- 1 | import { isSupportedImage } from 'react-components/containers/filePreview/helpers'; 2 | 3 | import { scaleImageFile } from './image'; 4 | 5 | export function makeThumbnail(mimeType: string, file: Blob): Promise { 6 | if (isSupportedImage(mimeType)) { 7 | return scaleImageFile(file); 8 | } 9 | return Promise.resolve(undefined); 10 | } 11 | -------------------------------------------------------------------------------- /src/app/components/uploads/ChunkFileReader.ts: -------------------------------------------------------------------------------- 1 | class ChunkFileReader { 2 | private blob: Blob; 3 | 4 | private chunkSize: number; 5 | 6 | private offset = 0; 7 | 8 | constructor(file: Blob, chunkSize: number) { 9 | this.blob = file; 10 | this.chunkSize = chunkSize; 11 | } 12 | 13 | isEOF() { 14 | return this.offset >= this.blob.size; 15 | } 16 | 17 | async readNextChunk() { 18 | const fileReader = new FileReader(); 19 | const blob = this.blob.slice(this.offset, this.offset + this.chunkSize); 20 | 21 | return new Promise((resolve, reject) => { 22 | fileReader.onload = async (e) => { 23 | if (!e.target || e.target?.error) { 24 | return reject(e.target?.error || new Error('Cannot open file for reading')); 25 | } 26 | 27 | const result = new Uint8Array(e.target.result as ArrayBuffer); 28 | this.offset += result.byteLength; 29 | resolve(result); 30 | }; 31 | 32 | fileReader.readAsArrayBuffer(blob); 33 | }); 34 | } 35 | } 36 | 37 | export default ChunkFileReader; 38 | -------------------------------------------------------------------------------- /src/app/components/uploads/UploadButton.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { c } from 'ttag'; 3 | 4 | import { classnames, FloatingButton, Icon, SidebarPrimaryButton } from 'react-components'; 5 | 6 | import { useDriveActiveFolder } from '../Drive/DriveFolderProvider'; 7 | import useFileUploadInput from '../../hooks/drive/useFileUploadInput'; 8 | import { useDownloadProvider } from '../downloads/DownloadProvider'; 9 | import { useUploadProvider } from './UploadProvider'; 10 | 11 | interface Props { 12 | floating?: boolean; 13 | className?: string; 14 | } 15 | 16 | const UploadButton = ({ floating, className }: Props) => { 17 | const { folder } = useDriveActiveFolder(); 18 | const { inputRef: fileInput, handleClick, handleChange: handleFileChange } = useFileUploadInput(); 19 | 20 | const { downloads } = useDownloadProvider(); 21 | const { uploads } = useUploadProvider(); 22 | const isTransferring = uploads.length > 0 || downloads.length > 0; 23 | 24 | return ( 25 | <> 26 | 27 | {floating ? ( 28 | 34 | 35 | 36 | ) : ( 37 | {c( 38 | 'Action' 39 | ).t`New upload`} 40 | )} 41 | 42 | ); 43 | }; 44 | export default UploadButton; 45 | -------------------------------------------------------------------------------- /src/app/components/uploads/UploadDragDrop/UploadDragDrop.scss: -------------------------------------------------------------------------------- 1 | @import '~design-system/scss/config/'; 2 | 3 | .upload-drag-drop { 4 | position: fixed; 5 | top: 0; 6 | bottom: 0; 7 | right: 0; 8 | left: 0; 9 | background: var(--backdrop-norm); 10 | z-index: 30; 11 | display: flex; 12 | align-items: center; 13 | justify-content: center; 14 | 15 | &-infobox { 16 | width: rem(490); 17 | min-width: rem(380); 18 | background: var(--background-norm); 19 | color: var(--text-norm); 20 | border-radius: $global-border-radius; 21 | box-shadow: var(--shadow-lifted); 22 | text-align: center; 23 | pointer-events: none; 24 | } 25 | 26 | &-image { 27 | max-width: rem(380); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/app/containers/DriveContainer/DriveContainer.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import { PrivateAppContainer, useToggle, MainLogo, TopBanners } from 'react-components'; 3 | import { Route, Redirect, Switch } from 'react-router-dom'; 4 | import DriveHeader from '../../components/layout/DriveHeader'; 5 | import DriveSidebar from '../../components/layout/DriveSidebar/DriveSidebar'; 6 | import DriveContainerView from './DriveContainerView'; 7 | import UploadButton from '../../components/uploads/UploadButton'; 8 | import UploadDragDrop from '../../components/uploads/UploadDragDrop/UploadDragDrop'; 9 | import AppErrorBoundary from '../../components/AppErrorBoundary'; 10 | import FileRecoveryBanner from '../../components/FilesRecoveryModal/FileRecoveryBanner'; 11 | import PreviewContainer from '../PreviewContainer'; 12 | import { LinkURLType } from '../../constants'; 13 | 14 | const DriveContainer = () => { 15 | const { state: expanded, toggle: toggleExpanded } = useToggle(); 16 | const [recoveryBannerVisible, setReoveryBannerVisible] = useState(true); 17 | 18 | const logo = ; 19 | const header = ( 20 | } 23 | isHeaderExpanded={expanded} 24 | toggleHeaderExpanded={toggleExpanded} 25 | /> 26 | ); 27 | 28 | const sidebar = ( 29 | } 32 | isHeaderExpanded={expanded} 33 | toggleHeaderExpanded={toggleExpanded} 34 | /> 35 | ); 36 | 37 | const fileRecoveryBanner = recoveryBannerVisible ? ( 38 | { 40 | setReoveryBannerVisible(false); 41 | }} 42 | /> 43 | ) : null; 44 | 45 | const topBanners = {fileRecoveryBanner}; 46 | 47 | return ( 48 | 49 | <> 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | ); 62 | }; 63 | 64 | export default DriveContainer; 65 | -------------------------------------------------------------------------------- /src/app/containers/DriveContainer/DriveContainerView.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState, useMemo, useRef } from 'react'; 2 | import { RouteComponentProps } from 'react-router'; 3 | import { c } from 'ttag'; 4 | 5 | import { Toolbar, PrivateMainArea, useAppTitle } from 'react-components'; 6 | 7 | import { useDriveActiveFolder } from '../../components/Drive/DriveFolderProvider'; 8 | import { useDriveCache } from '../../components/DriveCache/DriveCacheProvider'; 9 | import Drive from '../../components/Drive/Drive'; 10 | import DriveContentProvider from '../../components/Drive/DriveContentProvider'; 11 | import DriveToolbar from '../../components/Drive/DriveToolbar'; 12 | import DriveBreadcrumbs from '../../components/DriveBreadcrumbs'; 13 | import { LinkURLType } from '../../constants'; 14 | import useNavigate from '../../hooks/drive/useNavigate'; 15 | 16 | function DriveContainerView({ match }: RouteComponentProps<{ shareId?: string; type?: LinkURLType; linkId?: string }>) { 17 | const lastFolderRef = useRef<{ 18 | shareId: string; 19 | linkId: string; 20 | }>(); 21 | const cache = useDriveCache(); 22 | const [, setError] = useState(); 23 | const { setFolder } = useDriveActiveFolder(); 24 | const { navigateToRoot } = useNavigate(); 25 | 26 | const folder = useMemo(() => { 27 | const { shareId, type, linkId } = match.params; 28 | 29 | if (!shareId && !type && !linkId) { 30 | const meta = cache.get.defaultShareMeta(); 31 | 32 | if (meta) { 33 | return { shareId: meta.ShareID, linkId: meta.LinkID }; 34 | } 35 | setError(() => { 36 | throw new Error('Drive is not initilized, cache has been cleared unexpectedly'); 37 | }); 38 | } else if (!shareId || !type || !linkId) { 39 | console.error('Missing parameters, should be none or shareId/type/linkId'); 40 | navigateToRoot(); 41 | } else if (type === LinkURLType.FOLDER) { 42 | return { shareId, linkId }; 43 | } 44 | return lastFolderRef.current; 45 | }, [match.params]); 46 | 47 | // In case we open preview, folder doesn't need to change 48 | lastFolderRef.current = folder; 49 | 50 | useEffect(() => { 51 | const { type } = match.params; 52 | 53 | if (folder) { 54 | setFolder(folder); 55 | } else if (type !== LinkURLType.FILE) { 56 | navigateToRoot(); 57 | } 58 | }, [folder, match.params]); 59 | 60 | useAppTitle(c('Title').t`My files`); 61 | 62 | return ( 63 | 64 | {folder ? : } 65 | 66 |
67 | {folder && } 68 |
69 | {folder && } 70 |
71 |
72 | ); 73 | } 74 | 75 | export default DriveContainerView; 76 | -------------------------------------------------------------------------------- /src/app/containers/NoAccessContainer/NoAccessContainer.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from 'react'; 2 | 3 | import { useModals } from 'react-components'; 4 | 5 | import DriveOnboardingModalNoAccess from '../../components/onboarding/DriveOnboardingModalNoAccess'; 6 | import DriveOnboardingModalNoBeta from '../../components/onboarding/DriveOnboardingModalNoBeta'; 7 | import DriveContainerBlurred from '../DriveContainerBlurred'; 8 | 9 | interface Props { 10 | reason: 'notpaid' | 'notbeta'; 11 | } 12 | 13 | const NoAccessContainer = ({ reason }: Props) => { 14 | const { createModal } = useModals(); 15 | 16 | useEffect(() => { 17 | createModal(reason === 'notbeta' ? : ); 18 | }, [reason]); 19 | 20 | return ; 21 | }; 22 | 23 | export default NoAccessContainer; 24 | -------------------------------------------------------------------------------- /src/app/containers/OnboardingContainer.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from 'react'; 2 | import { useModals } from 'react-components'; 3 | 4 | import DriveOnboardingModal from '../components/onboarding/DriveOnboardingModal'; 5 | import DriveContainerBlurred from './DriveContainerBlurred'; 6 | 7 | interface Props { 8 | onDone: () => void; 9 | } 10 | const OnboardingContainer = ({ onDone }: Props) => { 11 | const { createModal } = useModals(); 12 | 13 | useEffect(() => { 14 | createModal(); 15 | }, []); 16 | 17 | return ; 18 | }; 19 | 20 | export default OnboardingContainer; 21 | -------------------------------------------------------------------------------- /src/app/containers/SharedURLsContainer/SharedURLsContainer.tsx: -------------------------------------------------------------------------------- 1 | import React, { useMemo, useCallback } from 'react'; 2 | import { RouteComponentProps } from 'react-router-dom'; 3 | import { Switch, Route, Redirect } from 'react-router'; 4 | import { PrivateAppContainer, useToggle, MainLogo } from 'react-components'; 5 | import AppErrorBoundary from '../../components/AppErrorBoundary'; 6 | import DriveHeader from '../../components/layout/DriveHeader'; 7 | import DriveSidebar from '../../components/layout/DriveSidebar/DriveSidebar'; 8 | import SharedURLsContainerView from './SharedURLsContainerView'; 9 | import { useDriveCache } from '../../components/DriveCache/DriveCacheProvider'; 10 | import ShareFileButton from '../../components/SharedLinks/ShareFileButton'; 11 | 12 | const SharedURLsContainer = ({ match }: RouteComponentProps<{ shareId?: string }>) => { 13 | const cache = useDriveCache(); 14 | const { state: expanded, toggle: toggleExpanded } = useToggle(); 15 | 16 | const shareId = useMemo(() => { 17 | const shareId = match.params.shareId || cache.get.defaultShareMeta()?.ShareID; 18 | if (!shareId) { 19 | throw new Error('Drive is not initilized, cache has been cleared unexpectedly'); 20 | } 21 | return shareId; 22 | }, [match.params.shareId]); 23 | 24 | const logo = ; 25 | const header = ( 26 | } 29 | isHeaderExpanded={expanded} 30 | toggleHeaderExpanded={toggleExpanded} 31 | /> 32 | ); 33 | 34 | const sidebar = ( 35 | } 39 | isHeaderExpanded={expanded} 40 | toggleHeaderExpanded={toggleExpanded} 41 | /> 42 | ); 43 | 44 | const renderContainerView = useCallback(() => , [shareId]); 45 | 46 | return ( 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | ); 56 | }; 57 | 58 | export default SharedURLsContainer; 59 | -------------------------------------------------------------------------------- /src/app/containers/SharedURLsContainer/SharedURLsContainerView.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { PrivateMainArea, useAppTitle } from 'react-components'; 3 | import { c } from 'ttag'; 4 | import SharedLinks from '../../components/SharedLinks/SharedLinks'; 5 | import SharedLinksContentProvider from '../../components/SharedLinks/SharedLinksContentProvider'; 6 | import SharedLinksToolbar from '../../components/SharedLinks/SharedLinksToolbar'; 7 | 8 | interface Props { 9 | shareId: string; 10 | } 11 | 12 | const SharedURLsContainerView = ({ shareId }: Props) => { 13 | useAppTitle(c('Title').t`Shared`); 14 | 15 | return ( 16 | 17 | 18 | 19 |
{c('Info').t`My Links`}
20 | 21 |
22 |
23 | ); 24 | }; 25 | 26 | export default SharedURLsContainerView; 27 | -------------------------------------------------------------------------------- /src/app/containers/TrashContainer/TrashContainer.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useMemo, useCallback } from 'react'; 2 | import { PrivateAppContainer, useToggle, MainLogo } from 'react-components'; 3 | import { Route, Redirect, Switch, RouteComponentProps } from 'react-router-dom'; 4 | import DriveHeader from '../../components/layout/DriveHeader'; 5 | import DriveSidebar from '../../components/layout/DriveSidebar/DriveSidebar'; 6 | import TrashContainerView from './TrashContainerView'; 7 | import EmptyTrashButton from '../../components/Drive/Trash/EmptyTrashButton'; 8 | import { useDriveCache } from '../../components/DriveCache/DriveCacheProvider'; 9 | import { useDriveActiveFolder } from '../../components/Drive/DriveFolderProvider'; 10 | import AppErrorBoundary from '../../components/AppErrorBoundary'; 11 | 12 | const TrashContainer = ({ match }: RouteComponentProps<{ shareId?: string }>) => { 13 | const cache = useDriveCache(); 14 | const { setFolder } = useDriveActiveFolder(); 15 | const { state: expanded, toggle: toggleExpanded } = useToggle(); 16 | 17 | useEffect(() => { 18 | setFolder(undefined); 19 | }, []); 20 | 21 | const shareId = useMemo(() => { 22 | const shareId = match.params.shareId || cache.get.defaultShareMeta()?.ShareID; 23 | if (!shareId) { 24 | throw new Error('Drive is not initilized, cache has been cleared unexpectedly'); 25 | } 26 | return shareId; 27 | }, [match.params.shareId]); 28 | 29 | const logo = ; 30 | const header = ( 31 | } 34 | isHeaderExpanded={expanded} 35 | toggleHeaderExpanded={toggleExpanded} 36 | /> 37 | ); 38 | 39 | const sidebar = ( 40 | } 44 | isHeaderExpanded={expanded} 45 | toggleHeaderExpanded={toggleExpanded} 46 | /> 47 | ); 48 | 49 | const renderContainerView = useCallback(() => , [shareId]); 50 | 51 | return ( 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | ); 61 | }; 62 | 63 | export default TrashContainer; 64 | -------------------------------------------------------------------------------- /src/app/containers/TrashContainer/TrashContainerView.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { c } from 'ttag'; 3 | import { PrivateMainArea, useAppTitle } from 'react-components'; 4 | import TrashToolbar from '../../components/Drive/Trash/TrashToolbar'; 5 | import Trash from '../../components/Drive/Trash/Trash'; 6 | import TrashContentProvider from '../../components/Drive/Trash/TrashContentProvider'; 7 | 8 | interface Props { 9 | shareId: string; 10 | } 11 | 12 | const TrashContainerView = ({ shareId }: Props) => { 13 | useAppTitle(c('Title').t`Trash`); 14 | 15 | return ( 16 | 17 | 18 | 19 |
{c('Info').t`Trash`}
20 | {shareId && } 21 |
22 |
23 | ); 24 | }; 25 | 26 | export default TrashContainerView; 27 | -------------------------------------------------------------------------------- /src/app/hooks/drive/useDriveCrypto.ts: -------------------------------------------------------------------------------- 1 | import { useGetAddresses, useGetAddressKeys, useNotifications } from 'react-components'; 2 | import { sign as signMessage } from 'proton-shared/lib/keys/driveKeys'; 3 | import { Address } from 'proton-shared/lib/interfaces/Address'; 4 | import { OpenPGPKey } from 'pmcrypto'; 5 | import { useCallback } from 'react'; 6 | import { ADDRESS_STATUS } from 'proton-shared/lib/constants'; 7 | import { c } from 'ttag'; 8 | import { ShareMeta } from '../../interfaces/share'; 9 | import { 10 | decryptSharePassphraseAsync, 11 | getPrimaryAddressAsync, 12 | getPrimaryAddressKeyAsync, 13 | getOwnAddressKeysAsync, 14 | } from '../../utils/drive/driveCrypto'; 15 | 16 | // Special case for drive to allow users with just an external address 17 | export const getActiveAddresses = (addresses: Address[]): Address[] => { 18 | return addresses.filter(({ Status }) => Status === ADDRESS_STATUS.STATUS_ENABLED); 19 | }; 20 | 21 | function useDriveCrypto() { 22 | const { createNotification } = useNotifications(); 23 | const getAddressKeys = useGetAddressKeys(); 24 | const getAddresses = useGetAddresses(); 25 | 26 | const getPrimaryAddress = useCallback(async () => { 27 | return getPrimaryAddressAsync(getAddresses).catch((error) => { 28 | createNotification({ text: c('Error').t`No valid address found`, type: 'error' }); 29 | throw error; 30 | }); 31 | }, [getAddresses]); 32 | 33 | const getPrimaryAddressKey = useCallback(async () => { 34 | return getPrimaryAddressKeyAsync(getPrimaryAddress, getAddressKeys); 35 | }, [getPrimaryAddress, getAddressKeys]); 36 | 37 | const getOwnAddressKeys = useCallback( 38 | async (email: string) => getOwnAddressKeysAsync(email, getAddresses, getAddressKeys), 39 | [getAddresses, getAddressKeys] 40 | ); 41 | 42 | const getVerificationKey = useCallback( 43 | async (email: string) => { 44 | const { publicKeys } = await getOwnAddressKeysAsync(email, getAddresses, getAddressKeys); 45 | return publicKeys; 46 | }, 47 | [getAddresses, getAddressKeys] 48 | ); 49 | 50 | const sign = useCallback( 51 | async (payload: string | Uint8Array, keys?: { privateKey: OpenPGPKey; address: Address }) => { 52 | const { privateKey, address } = keys || (await getPrimaryAddressKey()); 53 | const signature = await signMessage(payload, [privateKey]); 54 | return { signature, address }; 55 | }, 56 | [getPrimaryAddressKey] 57 | ); 58 | 59 | /** 60 | * Decrypts share passphrase. By default decrypts with the same user's keys who encrypted. 61 | * Keys can be passed explicitly if user is different, i.e. in case of sharing between users. 62 | * @param meta share metadata 63 | * @param privateKeys keys to use, when the user is not the same who encrypted 64 | */ 65 | const decryptSharePassphrase = async (meta: ShareMeta, privateKeys?: OpenPGPKey[]) => { 66 | return decryptSharePassphraseAsync( 67 | meta, 68 | privateKeys || (await getOwnAddressKeys(meta.Creator)).privateKeys, 69 | getVerificationKey 70 | ); 71 | }; 72 | 73 | return { getPrimaryAddressKey, getVerificationKey, getPrimaryAddress, sign, decryptSharePassphrase }; 74 | } 75 | 76 | export default useDriveCrypto; 77 | -------------------------------------------------------------------------------- /src/app/hooks/drive/useDriveSorting.ts: -------------------------------------------------------------------------------- 1 | import { useMultiSortedList } from 'react-components'; 2 | import { useMemo } from 'react'; 3 | import { SortConfig } from 'react-components/hooks/useSortedList'; 4 | import { SORT_DIRECTION } from 'proton-shared/lib/constants'; 5 | import { LinkMeta, SortKeys, SortParams } from '../../interfaces/link'; 6 | import useUserSettings from './useUserSettings'; 7 | import { SortSetting } from '../../interfaces/userSettings'; 8 | 9 | const getNameSortConfig = (direction = SORT_DIRECTION.ASC) => ({ 10 | key: 'Name' as SortKeys, 11 | direction, 12 | compare: (a: LinkMeta['Name'], b: LinkMeta['Name']) => a.localeCompare(b), 13 | }); 14 | 15 | const fieldMap: { [key in SortSetting]: SortKeys } = { 16 | [SortSetting.ModifiedAsc]: 'ModifyTime', 17 | [SortSetting.ModifiedDesc]: 'ModifyTime', 18 | [SortSetting.NameAsc]: 'Name', 19 | [SortSetting.NameDesc]: 'Name', 20 | [SortSetting.SizeAsc]: 'Size', 21 | [SortSetting.SizeDesc]: 'Size', 22 | [SortSetting.TypeAsc]: 'MIMEType', 23 | [SortSetting.TypeDesc]: 'MIMEType', 24 | }; 25 | 26 | function useDriveSorting(getList: (sortParams: SortParams) => LinkMeta[]) { 27 | const { sort, changeSort } = useUserSettings(); 28 | 29 | const sortParams = useMemo((): SortParams => { 30 | const sortOrder = sort < 0 ? SORT_DIRECTION.DESC : SORT_DIRECTION.ASC; 31 | const sortField = fieldMap[sort]; 32 | return { 33 | sortOrder, 34 | sortField, 35 | }; 36 | }, [sort]); 37 | 38 | const getConfig = (sortField: SortKeys, direction: SORT_DIRECTION) => { 39 | const configs: { 40 | [key in SortKeys]: SortConfig[]; 41 | } = { 42 | Name: [{ key: 'Type', direction: SORT_DIRECTION.ASC }, getNameSortConfig(direction)], 43 | MIMEType: [{ key: 'MIMEType', direction }, { key: 'Type', direction }, getNameSortConfig()], 44 | ModifyTime: [{ key: 'ModifyTime', direction }, getNameSortConfig()], 45 | Size: [{ key: 'Type', direction }, { key: 'Size', direction }, getNameSortConfig()], 46 | }; 47 | return configs[sortField]; 48 | }; 49 | 50 | const { sortedList, setConfigs } = useMultiSortedList( 51 | getList(sortParams), 52 | getConfig(sortParams.sortField, sortParams.sortOrder) 53 | ); 54 | 55 | const setSorting = async (sortField: SortKeys, sortOrder: SORT_DIRECTION) => { 56 | setConfigs(getConfig(sortField, sortOrder)); 57 | const sortSettingValue = Object.entries(fieldMap).find(([setting, fieldName]) => { 58 | if (fieldName === sortField) { 59 | const settingValue = Number(setting); 60 | const isSameDirection = 61 | (sortOrder === SORT_DIRECTION.ASC && settingValue > 0) || 62 | (sortOrder === SORT_DIRECTION.DESC && settingValue < 0); 63 | return isSameDirection; 64 | } 65 | return false; 66 | })?.[0]; 67 | 68 | if (sortSettingValue) { 69 | await changeSort(Number(sortSettingValue)); 70 | } 71 | }; 72 | 73 | return { 74 | sortParams, 75 | sortedList, 76 | setSorting, 77 | }; 78 | } 79 | 80 | export default useDriveSorting; 81 | -------------------------------------------------------------------------------- /src/app/hooks/drive/useFileUploadInput.ts: -------------------------------------------------------------------------------- 1 | import { useRef, useEffect, ChangeEvent } from 'react'; 2 | import { useDriveActiveFolder } from '../../components/Drive/DriveFolderProvider'; 3 | import { isTransferCancelError } from '../../utils/transfer'; 4 | import useFiles from './useFiles'; 5 | 6 | const useFileUploadInput = (forFolders?: boolean) => { 7 | const DS_STORE = '.DS_Store'; 8 | 9 | const { uploadDriveFiles } = useFiles(); 10 | const { folder: activeFolder } = useDriveActiveFolder(); 11 | 12 | const inputRef = useRef(null); 13 | 14 | useEffect(() => { 15 | if (forFolders && inputRef.current) { 16 | // React types don't allow `webkitdirectory` but it exists and works 17 | inputRef.current.setAttribute('webkitdirectory', 'true'); 18 | } 19 | }, [forFolders]); 20 | 21 | const getFolderItemsToUpload = (files: FileList) => { 22 | const foldersCreated = new Set(); 23 | const filesToUpload: { path: string[]; file?: File }[] = []; 24 | 25 | for (let i = 0; i < files.length; i++) { 26 | const file = files[i]; 27 | 28 | // Skip 'Desktop Services Store' files. 29 | if (file.name === DS_STORE) { 30 | continue; 31 | } 32 | 33 | if ('webkitRelativePath' in file) { 34 | const path = ((file as any).webkitRelativePath as string).split('/'); 35 | for (let j = 1; j < path.length; j++) { 36 | const folderPath = path.slice(0, j); 37 | const folderPathStr = folderPath.join('/'); 38 | if (!foldersCreated.has(folderPathStr)) { 39 | foldersCreated.add(folderPathStr); 40 | filesToUpload.push({ path: folderPath }); 41 | } 42 | } 43 | filesToUpload.push({ path: path.slice(0, -1), file }); 44 | } else { 45 | console.error('No relative path to determine folder structure from'); 46 | } 47 | } 48 | 49 | return filesToUpload; 50 | }; 51 | 52 | const handleClick = () => { 53 | if (!activeFolder || !inputRef.current) { 54 | return; 55 | } 56 | 57 | inputRef.current.value = ''; 58 | inputRef.current.click(); 59 | }; 60 | 61 | const handleChange = (e: ChangeEvent) => { 62 | const { files } = e.target; 63 | if (!activeFolder || !files) { 64 | return; 65 | } 66 | 67 | const filesToUpload = forFolders ? getFolderItemsToUpload(files) : files; 68 | uploadDriveFiles(activeFolder.shareId, activeFolder.linkId, filesToUpload, !forFolders).catch((err) => { 69 | if (!isTransferCancelError(err)) { 70 | console.error(err); 71 | } 72 | }); 73 | }; 74 | 75 | return { inputRef, handleClick, handleChange }; 76 | }; 77 | 78 | export default useFileUploadInput; 79 | -------------------------------------------------------------------------------- /src/app/hooks/drive/useNavigate.ts: -------------------------------------------------------------------------------- 1 | import { useCallback } from 'react'; 2 | import { useHistory, useLocation } from 'react-router-dom'; 3 | import { LinkType } from '../../interfaces/link'; 4 | import { toLinkURLType } from '../../components/Drive/helpers'; 5 | 6 | function useNavigate() { 7 | const history = useHistory(); 8 | const location = useLocation(); 9 | 10 | const navigateToLink = useCallback( 11 | (shareId: string, linkId: string, type: LinkType) => { 12 | history.push(`/${shareId}/${toLinkURLType(type)}/${linkId}?r=${location.pathname}`); 13 | }, 14 | [history, location.pathname] 15 | ); 16 | 17 | const navigateToRoot = useCallback(() => { 18 | history.push(`/`); 19 | }, [history]); 20 | 21 | const navigateToSharedURLs = useCallback(() => { 22 | history.push(`/shared-urls`); 23 | }, [history]); 24 | 25 | return { 26 | navigateToLink, 27 | navigateToRoot, 28 | navigateToSharedURLs, 29 | }; 30 | } 31 | 32 | export default useNavigate; 33 | -------------------------------------------------------------------------------- /src/app/hooks/drive/useStatsHistory.ts: -------------------------------------------------------------------------------- 1 | import { useState, useCallback, useEffect } from 'react'; 2 | 3 | import { TransfersStats, TransferStats } from '../../components/TransferManager/interfaces'; 4 | import { Transfer, TransferProgresses, TransferState } from '../../interfaces/transfer'; 5 | import { isTransferProgress } from '../../utils/transfer'; 6 | 7 | const PROGRESS_UPDATE_INTERVAL = 500; 8 | const SPEED_SNAPSHOTS = 10; // How many snapshots should the speed be average of 9 | 10 | function useStatsHistory(transfers: Transfer[], getTransferProgresses: () => TransferProgresses) { 11 | const [statsHistory, setStatsHistory] = useState([]); 12 | 13 | const getTransfer = useCallback((id: string) => transfers.find((transfer) => transfer.id === id), [transfers]); 14 | 15 | const updateStats = () => { 16 | const timestamp = new Date(); 17 | const transferProgresses = getTransferProgresses(); 18 | 19 | setStatsHistory((prev) => { 20 | const lastStats = (id: string) => prev[0]?.stats[id] || {}; 21 | const stats = Object.entries(transferProgresses).reduce( 22 | (stats, [id, progress]) => ({ 23 | ...stats, 24 | [id]: { 25 | // get speed snapshot based on bytes downloaded since last update 26 | speed: lastStats(id).active 27 | ? (transferProgresses[id] - lastStats(id).progress) * (1000 / PROGRESS_UPDATE_INTERVAL) 28 | : 0, 29 | active: getTransfer(id)?.state === TransferState.Progress, 30 | progress, 31 | }, 32 | }), 33 | {} as { [id: string]: TransferStats } 34 | ); 35 | 36 | return [{ stats, timestamp }, ...prev.slice(0, SPEED_SNAPSHOTS - 1)]; 37 | }); 38 | }; 39 | 40 | useEffect(() => { 41 | updateStats(); 42 | 43 | const transfersInProgress = transfers.filter(isTransferProgress); 44 | 45 | if (!transfersInProgress.length) { 46 | return; 47 | } 48 | 49 | const int = setInterval(updateStats, PROGRESS_UPDATE_INTERVAL); 50 | 51 | return () => { 52 | clearInterval(int); 53 | }; 54 | }, [transfers]); 55 | 56 | return statsHistory; 57 | } 58 | 59 | export default useStatsHistory; 60 | -------------------------------------------------------------------------------- /src/app/hooks/drive/useUserSettings.ts: -------------------------------------------------------------------------------- 1 | import { useContext, useCallback } from 'react'; 2 | import { useApi } from 'react-components'; 3 | import { queryUpdateUserSettings, queryUserSettings } from '../../api/userSettings'; 4 | import { UserSettingsContext } from '../../components/Drive/UserSettings/UserSettingsProvider'; 5 | import { DEFAULT_USER_SETTINGS } from '../../constants'; 6 | import { LayoutSetting, SortSetting, UserSettings } from '../../interfaces/userSettings'; 7 | 8 | type UserSettingsResponse = { UserSettings: Partial }; 9 | 10 | const useUserSettings = () => { 11 | const contextState = useContext(UserSettingsContext); 12 | const api = useApi(); 13 | 14 | if (!contextState) { 15 | throw new Error('Trying to use uninitialized UserSettingsProvider'); 16 | } 17 | 18 | const [userSettings, setUserSettings] = contextState; 19 | 20 | const loadUserSettings = async () => { 21 | const { UserSettings } = await api(queryUserSettings()); 22 | 23 | const userSettingsWithDefaults = Object.entries(UserSettings).reduce((settings, [key, value]) => { 24 | (settings as any)[key] = value ?? (DEFAULT_USER_SETTINGS as any)[key]; 25 | return settings; 26 | }, {} as UserSettings); 27 | 28 | setUserSettings(userSettingsWithDefaults); 29 | }; 30 | 31 | const sort = userSettings.Sort; 32 | const layout = userSettings.Layout; 33 | 34 | const changeLayout = useCallback(async (Layout: LayoutSetting) => { 35 | setUserSettings((settings) => ({ ...settings, Layout })); 36 | await api( 37 | queryUpdateUserSettings({ 38 | Layout, 39 | }) 40 | ); 41 | }, []); 42 | 43 | const changeSort = useCallback(async (Sort: SortSetting) => { 44 | setUserSettings((settings) => ({ ...settings, Sort })); 45 | await api( 46 | queryUpdateUserSettings({ 47 | Sort, 48 | }) 49 | ); 50 | }, []); 51 | 52 | return { 53 | sort, 54 | layout, 55 | loadUserSettings, 56 | changeLayout, 57 | changeSort, 58 | }; 59 | }; 60 | 61 | export default useUserSettings; 62 | -------------------------------------------------------------------------------- /src/app/hooks/util/useConfirm.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { c } from 'ttag'; 3 | 4 | import { useModals, Alert, ConfirmModal, ErrorButton } from 'react-components'; 5 | 6 | const useConfirm = () => { 7 | const { createModal } = useModals(); 8 | 9 | const openConfirmModal = ({ 10 | confirm, 11 | message, 12 | onCancel, 13 | onConfirm, 14 | title, 15 | canUndo = false, 16 | }: { 17 | title: string; 18 | confirm: string; 19 | message: string; 20 | onConfirm: () => any; 21 | onCancel?: () => any; 22 | canUndo?: boolean; 23 | }) => { 24 | const content = ( 25 | <> 26 | {message} 27 |
28 | {!canUndo && c('Info').t`You cannot undo this action.`} 29 | 30 | ); 31 | 32 | createModal( 33 | {confirm}} 37 | onConfirm={onConfirm} 38 | onClose={onCancel} 39 | > 40 | {content} 41 | 42 | ); 43 | }; 44 | 45 | return { openConfirmModal }; 46 | }; 47 | 48 | export default useConfirm; 49 | -------------------------------------------------------------------------------- /src/app/hooks/util/useDebouncedRequest.test.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable react/display-name */ 2 | import React from 'react'; 3 | import { renderHook } from '@testing-library/react-hooks'; 4 | import { useApi, CacheProvider } from 'react-components'; 5 | import createCache from 'proton-shared/lib/helpers/cache'; 6 | import { Api } from 'proton-shared/lib/interfaces'; 7 | import useDebouncedRequest from './useDebouncedRequest'; 8 | 9 | jest.mock('react-components/hooks/useApi'); 10 | 11 | const mockUseApi = useApi as jest.MockedFunction; 12 | 13 | describe('useDebouncedRequest', () => { 14 | let debouncedRequest: Api; 15 | const mockApi = jest.fn().mockImplementation((query: object) => Promise.resolve(query)); 16 | mockUseApi.mockReturnValue(mockApi); 17 | 18 | beforeEach(() => { 19 | const cache = createCache(); 20 | const { result } = renderHook(() => useDebouncedRequest(), { 21 | wrapper: ({ children }: { children?: React.ReactNode }) => ( 22 | {children} 23 | ), 24 | }); 25 | debouncedRequest = result.current; 26 | mockApi.mockClear(); 27 | }); 28 | 29 | it('should initially call debounced function instantly', async () => { 30 | await debouncedRequest({ test: 'test' }); 31 | 32 | expect(mockApi).toHaveBeenCalledTimes(1); 33 | }); 34 | 35 | it('should return initial call result if called while pending', async () => { 36 | const firstCall = debouncedRequest({ test: 'test' }); 37 | const secondCall = debouncedRequest({ test: 'test' }); 38 | const firstCallResult = await firstCall; 39 | const secondCallResult = await secondCall; 40 | 41 | expect(mockApi).toHaveBeenCalledTimes(1); 42 | expect(secondCallResult).toBe(firstCallResult); 43 | }); 44 | 45 | it('should return new call result if called after initial call is completed', async () => { 46 | const firstCall = debouncedRequest({ test: 'test' }); 47 | const firstCallResult = await firstCall; 48 | const secondCall = debouncedRequest({ test: 'test' }); 49 | const secondCallResult = await secondCall; 50 | 51 | expect(mockApi).toHaveBeenCalledTimes(2); 52 | expect(secondCallResult).not.toBe(firstCallResult); 53 | }); 54 | }); 55 | -------------------------------------------------------------------------------- /src/app/hooks/util/useDebouncedRequest.ts: -------------------------------------------------------------------------------- 1 | import { useCache, useApi } from 'react-components'; 2 | import { Api } from 'proton-shared/lib/interfaces'; 3 | 4 | const useDebouncedRequest = () => { 5 | const api = useApi(); 6 | const cache = useCache(); 7 | 8 | /** 9 | * If promise is pending, returns it, otherwise executes query function 10 | */ 11 | const debouncedRequest: Api = (args: object) => { 12 | const key = `request_${JSON.stringify(args)}`; 13 | const existingPromise: Promise | undefined = cache.get(key); 14 | 15 | if (existingPromise) { 16 | return existingPromise; 17 | } 18 | 19 | const promise = api(args); 20 | cache.set(key, promise); 21 | 22 | const cleanup = () => { 23 | cache.delete(key); 24 | }; 25 | promise.then(cleanup).catch(cleanup); 26 | return promise; 27 | }; 28 | 29 | return debouncedRequest; 30 | }; 31 | 32 | export default useDebouncedRequest; 33 | -------------------------------------------------------------------------------- /src/app/hooks/util/useOnScrollEnd.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, MutableRefObject } from 'react'; 2 | import { useElementRect } from 'react-components'; 3 | 4 | const isScrollEnd = (target: HTMLElement | null, offsetRatio: number) => 5 | target && target.scrollHeight - target.scrollTop <= target.clientHeight / offsetRatio; 6 | 7 | function useOnScrollEnd( 8 | callback: () => void, 9 | targetRef: MutableRefObject, 10 | offsetRatio = 1, 11 | deps: React.DependencyList = [] 12 | ) { 13 | const boundingBox = useElementRect(targetRef); 14 | 15 | useEffect(() => { 16 | const handleScroll = ({ target }: Event) => { 17 | if (isScrollEnd(target as HTMLElement | null, offsetRatio)) { 18 | callback(); 19 | } 20 | }; 21 | 22 | if (targetRef.current) { 23 | targetRef.current.addEventListener('scroll', handleScroll); 24 | } 25 | 26 | return () => { 27 | if (targetRef.current) { 28 | targetRef.current.removeEventListener('scroll', handleScroll); 29 | } 30 | }; 31 | }, [targetRef.current, callback]); 32 | 33 | useEffect(() => { 34 | // If initially at the end or no scrollbar execute callback 35 | if (isScrollEnd(targetRef.current, offsetRatio)) { 36 | callback(); 37 | } 38 | }, [callback, boundingBox, targetRef.current, ...deps]); 39 | } 40 | 41 | export default useOnScrollEnd; 42 | -------------------------------------------------------------------------------- /src/app/hooks/util/useQueuedFunction.ts: -------------------------------------------------------------------------------- 1 | import { noop } from 'proton-shared/lib/helpers/function'; 2 | import { useCache } from 'react-components'; 3 | 4 | type FunctionQueue = [number, (() => Promise)[]]; 5 | 6 | /** 7 | * Puts function execution into a queue with a threshold of maximum active functions processing at once 8 | */ 9 | const useQueuedFunction = () => { 10 | const cache = useCache(); 11 | 12 | const queuedFunction = (fnKey: string, fn: (...args: A) => Promise, threshold = 1) => { 13 | const key = `queuedfn_${fnKey}`; 14 | 15 | if (!cache.has(key)) { 16 | cache.set(key, [0, []]); 17 | } 18 | 19 | const runNextQueued = () => { 20 | const [processing, queued]: FunctionQueue = cache.get(key); 21 | 22 | if (queued.length) { 23 | const [next, ...remaining] = queued; 24 | next().catch(noop).finally(runNextQueued); 25 | cache.set(key, [processing, remaining]); 26 | } else { 27 | cache.set(key, [processing - 1, []]); 28 | } 29 | }; 30 | 31 | const run = (...args: A) => { 32 | const [processing, queued]: FunctionQueue = cache.get(key); 33 | cache.set(key, [processing + 1, queued]); 34 | const promise = fn(...args); 35 | promise.catch(noop).finally(runNextQueued); 36 | return promise; 37 | }; 38 | 39 | const enqueue = (...args: A) => 40 | new Promise((resolve) => { 41 | const [processing, queued]: FunctionQueue = cache.get(key); 42 | cache.set(key, [ 43 | processing, 44 | [ 45 | ...queued, 46 | () => { 47 | const promise = fn(...args); 48 | resolve(promise); 49 | return promise; 50 | }, 51 | ], 52 | ]); 53 | }); 54 | 55 | return (...args: A) => { 56 | const [processing]: FunctionQueue = cache.get(key); 57 | return processing < threshold ? run(...args) : enqueue(...args); 58 | }; 59 | }; 60 | 61 | return queuedFunction; 62 | }; 63 | 64 | export default useQueuedFunction; 65 | -------------------------------------------------------------------------------- /src/app/index.tsx: -------------------------------------------------------------------------------- 1 | import ReactDOM from 'react-dom'; 2 | import React from 'react'; 3 | import 'core-js/stable'; 4 | import 'regenerator-runtime/runtime'; 5 | import 'yetch/polyfill'; 6 | import App from './App'; 7 | 8 | ReactDOM.render(, document.querySelector('.app-root')); 9 | -------------------------------------------------------------------------------- /src/app/interfaces/file.ts: -------------------------------------------------------------------------------- 1 | import { ReadableStream } from 'web-streams-polyfill'; 2 | 3 | export enum FileRevisionState { 4 | Draft = 0, 5 | Active = 1, 6 | Inactive = 2, 7 | } 8 | 9 | export interface CreateDriveFile { 10 | Name: string; 11 | Hash: string; 12 | ParentLinkID: string; 13 | NodePassphrase: string; 14 | NodePassphraseSignature: string; 15 | SignatureAddress: string; 16 | NodeKey: string; 17 | MIMEType: string; 18 | ContentKeyPacket: string; 19 | } 20 | 21 | export interface RevisionManifest { 22 | PreviousRootHash: string; 23 | BlockHashes: { 24 | Hash: string; 25 | Index: number; 26 | }[]; 27 | } 28 | 29 | export interface UpdateFileRevision { 30 | State: FileRevisionState; 31 | BlockList: { Index: number; Token: string }[]; 32 | ManifestSignature: string; 33 | SignatureAddress: string; 34 | } 35 | 36 | export interface CreateFileResult { 37 | File: { 38 | ID: string; 39 | RevisionID: string; 40 | }; 41 | } 42 | 43 | export interface UploadLink { 44 | Token: string; 45 | URL: string; 46 | } 47 | 48 | export interface RequestUploadResult { 49 | UploadLinks: UploadLink[]; 50 | ThumbnailLink?: UploadLink; 51 | } 52 | 53 | export interface DriveFileBlock { 54 | Index: number; 55 | URL: string; 56 | EncSignature?: string; 57 | } 58 | 59 | export interface DriveFileRevision { 60 | ID: string; 61 | CreateTime: number; 62 | Size: number; 63 | Hash: string; 64 | State: number; 65 | RootHash: string; 66 | RootHashSignature: string; 67 | SignatureAddress: string; 68 | Blocks: DriveFileBlock[]; 69 | } 70 | 71 | export interface DriveFileRevisionResult { 72 | Revision: DriveFileRevision; 73 | } 74 | 75 | export interface DriveFileRevisionThumbnailResult { 76 | ThumbnailLink: string; 77 | } 78 | 79 | export interface NestedFileStream { 80 | stream: ReadableStream; 81 | parentPath: string; 82 | fileName: string; 83 | } 84 | -------------------------------------------------------------------------------- /src/app/interfaces/folder.ts: -------------------------------------------------------------------------------- 1 | export interface CreateNewFolder { 2 | Name: string; 3 | Hash: string; 4 | ParentLinkID: string; 5 | NodePassphrase: string; 6 | NodePassphraseSignature: string; 7 | SignatureAddress: string; 8 | NodeKey: string; 9 | NodeHashKey: string; 10 | } 11 | -------------------------------------------------------------------------------- /src/app/interfaces/link.ts: -------------------------------------------------------------------------------- 1 | import { SORT_DIRECTION } from 'proton-shared/lib/constants'; 2 | import { FileRevisionState } from './file'; 3 | 4 | export enum LinkType { 5 | FOLDER = 1, 6 | FILE = 2, 7 | } 8 | 9 | export type SharedUrlInfo = { 10 | CreateTime: number; 11 | ExpireTime: number | null; 12 | ShareUrlID: string; 13 | Token: string; 14 | }; 15 | 16 | interface FileProperties { 17 | ContentKeyPacket: string; 18 | ActiveRevision: { 19 | ID: number; 20 | CreateTime: number; 21 | Size: number; 22 | ManifestSignature: string; 23 | SignatureAddress: string; 24 | State: FileRevisionState; 25 | Thumbnail: number; 26 | ThumbnailDownloadUrl: string; 27 | } | null; 28 | } 29 | 30 | interface FolderProperties { 31 | NodeHashKey: string; 32 | } 33 | 34 | interface DriveLink { 35 | LinkID: string; 36 | ParentLinkID: string; 37 | Type: LinkType; 38 | Name: string; 39 | EncryptedName: string; 40 | Size: number; 41 | MIMEType: string; 42 | Hash: string; 43 | CreateTime: number; 44 | ModifyTime: number; 45 | Trashed: number | null; 46 | State: number; 47 | NodeKey: string; 48 | NodePassphrase: string; 49 | NodePassphraseSignature: string; 50 | SignatureAddress: string; 51 | Attributes: number; 52 | Permissions: number; 53 | FileProperties: FileProperties | null; 54 | FolderProperties: FolderProperties | null; 55 | Shared: number; 56 | UrlsExpired: boolean; 57 | ShareIDs: string[]; 58 | ShareUrls: SharedUrlInfo[]; 59 | // CachedThumbnailURL is computed URL to cached image. This is not part 60 | // of any request and not filled automatically. To get this value, use 61 | // `loadLinkThumbnail` from `useDrive`. 62 | CachedThumbnailURL: string; 63 | ThumbnailIsLoading: boolean; 64 | } 65 | 66 | export interface FileLinkMeta extends DriveLink { 67 | Type: LinkType.FILE; 68 | FileProperties: FileProperties; 69 | FolderProperties: null; 70 | } 71 | 72 | export interface FolderLinkMeta extends DriveLink { 73 | Type: LinkType.FOLDER; 74 | FolderProperties: FolderProperties; 75 | FileProperties: null; 76 | } 77 | 78 | export type LinkMeta = FileLinkMeta | FolderLinkMeta; 79 | 80 | export const isFolderLinkMeta = (link: LinkMeta): link is FolderLinkMeta => link.Type === LinkType.FOLDER; 81 | 82 | export interface LinkMetaResult { 83 | Link: LinkMeta; 84 | } 85 | 86 | export interface LinkChildrenResult { 87 | Links: LinkMeta[]; 88 | } 89 | 90 | export interface HashCheckResult { 91 | AvailableHashes: string[]; 92 | } 93 | 94 | export interface MoveLink { 95 | Name: string; 96 | Hash: string; 97 | ParentLinkID: string; 98 | NodePassphrase: string; 99 | NodePassphraseSignature: string; 100 | SignatureAddress: string; 101 | } 102 | 103 | export type SortKeys = keyof Pick; 104 | 105 | export type SortParams = { sortField: SortKeys; sortOrder: SORT_DIRECTION }; 106 | -------------------------------------------------------------------------------- /src/app/interfaces/restore.ts: -------------------------------------------------------------------------------- 1 | import { RESPONSE_CODE } from '../constants'; 2 | 3 | export interface RestoreFromTrashResult { 4 | Responses: { Response: RestoreResponse }[]; 5 | } 6 | 7 | export interface RestoreResponse { 8 | Code: RESPONSE_CODE; 9 | Error?: string; 10 | } 11 | -------------------------------------------------------------------------------- /src/app/interfaces/share.ts: -------------------------------------------------------------------------------- 1 | import { LinkType } from './link'; 2 | 3 | export interface CreateDriveShare { 4 | AddressID: string; 5 | RootLinkID: string; 6 | Name: string; 7 | Type: number; // TODO: UNUSED - remove it when BE removes it 8 | LinkType: LinkType; 9 | PermissionsMask: number; 10 | ShareKey: string; 11 | SharePassphrase: string; 12 | SharePassphraseSignature: string; 13 | PassphraseKeyPacket: string; 14 | NameKeyPacket: string; 15 | } 16 | 17 | export interface UserShareResult { 18 | Shares: ShareMetaShort[]; 19 | } 20 | 21 | export interface ShareMetaShort { 22 | ShareID: string; 23 | Type: number; 24 | LinkID: string; 25 | LinkType: LinkType; 26 | Locked: boolean; 27 | VolumeID: string; 28 | Creator: string; 29 | PermissionsMask: 0; 30 | Flags: number; 31 | BlockSize: number; 32 | PossibleKeyPackets?: { KeyPacket: string }[]; 33 | } 34 | 35 | export interface ShareMeta extends ShareMetaShort { 36 | Key: string; 37 | Passphrase: string; 38 | PassphraseSignature: string; 39 | AddressID: string; 40 | RootLinkRecoveryPassphrase?: string; 41 | } 42 | 43 | export enum ShareFlags { 44 | PrimaryShare = 1, 45 | } 46 | -------------------------------------------------------------------------------- /src/app/interfaces/transfer.ts: -------------------------------------------------------------------------------- 1 | import { LinkType } from './link'; 2 | 3 | export enum TransferState { 4 | Initializing = 'initializing', 5 | Pending = 'pending', 6 | Progress = 'progress', 7 | Finalizing = 'finalizing', 8 | Done = 'done', 9 | Canceled = 'canceled', 10 | Error = 'error', 11 | NetworkError = 'networkError', 12 | Paused = 'paused', 13 | } 14 | 15 | export interface TransferProgresses { 16 | [id: string]: number; 17 | } 18 | 19 | export interface TransferMeta { 20 | filename: string; 21 | mimeType: string; 22 | size?: number; 23 | } 24 | 25 | export interface TransferSummary { 26 | size: number; 27 | progress: number; 28 | } 29 | 30 | export class TransferCancel extends Error { 31 | constructor(options: { id: string } | { message: string }) { 32 | super('id' in options ? `Transfer ${options.id} canceled` : options.message); 33 | this.name = 'TransferCancel'; 34 | } 35 | } 36 | 37 | export interface PreUploadData { 38 | file: File; 39 | ShareID: string; 40 | ParentLinkID: string | Promise; 41 | } 42 | 43 | export interface Upload { 44 | id: string; 45 | meta: TransferMeta; 46 | preUploadData: PreUploadData; 47 | state: TransferState; 48 | startDate: Date; 49 | resumeState?: TransferState; 50 | error?: Error; 51 | ready?: boolean; 52 | } 53 | 54 | export interface DownloadInfo { 55 | LinkID: string; 56 | ShareID: string; 57 | } 58 | 59 | export interface Download { 60 | id: string; 61 | meta: TransferMeta; 62 | downloadInfo: DownloadInfo; 63 | state: TransferState; 64 | type: LinkType; 65 | startDate: Date; 66 | resumeState?: TransferState; 67 | error?: Error; 68 | } 69 | 70 | export interface PartialDownload extends Download { 71 | partOf: string; 72 | } 73 | 74 | export type Transfer = Upload | Download; 75 | -------------------------------------------------------------------------------- /src/app/interfaces/userSettings.ts: -------------------------------------------------------------------------------- 1 | export enum SortSetting { 2 | NameAsc = 1, 3 | SizeAsc = 2, 4 | TypeAsc = 3, 5 | ModifiedAsc = 4, 6 | NameDesc = -1, 7 | SizeDesc = -2, 8 | TypeDesc = -3, 9 | ModifiedDesc = -4, 10 | } 11 | 12 | export enum LayoutSetting { 13 | List = 0, 14 | Grid = 1, 15 | } 16 | 17 | export interface UserSettings { 18 | Sort: SortSetting; 19 | Layout: LayoutSetting; 20 | } 21 | -------------------------------------------------------------------------------- /src/app/interfaces/volume.ts: -------------------------------------------------------------------------------- 1 | export interface CreateDriveVolume { 2 | AddressID: string; 3 | VolumeName: string; 4 | ShareName: string; 5 | FolderName: string; 6 | SharePassphrase: string; 7 | ShareKey: string; 8 | FolderPassphrase: string; 9 | FolderKey: string; 10 | FolderHashKey: string; 11 | } 12 | 13 | export interface DriveVolume { 14 | ID: string; 15 | Share: { 16 | ID: string; 17 | LinkID: string; 18 | }; 19 | } 20 | 21 | export interface CreatedDriveVolumeResult { 22 | Volume: DriveVolume; 23 | } 24 | 25 | export interface RestoreDriveVolume { 26 | Name: string; 27 | SignatureAddress: string; 28 | Hash: string; 29 | NodePassphrase: string; 30 | NodePassphraseSignature: string; 31 | TargetVolumeID: string; 32 | } 33 | -------------------------------------------------------------------------------- /src/app/openpgpConfig.ts: -------------------------------------------------------------------------------- 1 | export const openpgpConfig = { allow_unauthenticated_stream: true }; 2 | -------------------------------------------------------------------------------- /src/app/utils/MimeTypeParser/MimeTypeParser.ts: -------------------------------------------------------------------------------- 1 | import ChunkFileReader from '../../components/uploads/ChunkFileReader'; 2 | import { SupportedMimeTypes } from './constants'; 3 | import { mimetypeFromExtension, SignatureChecker } from './helpers'; 4 | import applicationSignatures from './signatureChecks/applicationSignatures'; 5 | import archiveSignatures from './signatureChecks/archiveSignatures'; 6 | import audioSignatures from './signatureChecks/audioSignatures'; 7 | import fontSignatures from './signatureChecks/fontSignatures'; 8 | import imageSignatures from './signatureChecks/imageSignatures'; 9 | import unsafeSignatures from './signatureChecks/unsafeSignatures'; 10 | import videoSignatures from './signatureChecks/videoSignatures'; 11 | 12 | // Many mime-types can be detected within this range 13 | const minimumBytesToCheck = 4100; 14 | 15 | function mimeTypeFromSignature(checker: ReturnType): SupportedMimeTypes | undefined { 16 | return ( 17 | imageSignatures(checker) || // before audio 18 | audioSignatures(checker) || // before video 19 | videoSignatures(checker) || // before application 20 | applicationSignatures(checker) || 21 | archiveSignatures(checker) || 22 | fontSignatures(checker) || 23 | unsafeSignatures(checker) 24 | ); 25 | } 26 | 27 | export function mimeTypeFromBuffer(input: Uint8Array | ArrayBuffer | Buffer) { 28 | const sourceBuffer = input instanceof Buffer ? input : Buffer.from(input); 29 | 30 | if (sourceBuffer.length < 2) { 31 | return undefined; 32 | } 33 | 34 | return mimeTypeFromSignature(SignatureChecker(sourceBuffer)); 35 | } 36 | 37 | export async function mimeTypeFromFile(input: File, extensionFallback = true) { 38 | const reader = new ChunkFileReader(input, minimumBytesToCheck); 39 | const defaultType = 'application/octet-stream'; 40 | 41 | if (reader.isEOF()) { 42 | return defaultType; 43 | } 44 | 45 | const chunk = await reader.readNextChunk(); 46 | const extension = input.name.split('.').pop(); 47 | const isSVG = extension && extension.toLowerCase() === 'svg'; 48 | 49 | return ( 50 | (isSVG && 'image/svg+xml') || 51 | mimeTypeFromSignature(SignatureChecker(Buffer.from(chunk))) || 52 | (extensionFallback && mimetypeFromExtension(input.name)) || 53 | defaultType 54 | ); 55 | } 56 | -------------------------------------------------------------------------------- /src/app/utils/MimeTypeParser/constants.ts: -------------------------------------------------------------------------------- 1 | export enum SupportedMimeTypes { 2 | flac = 'audio/x-flac', 3 | mp2t = 'video/mp2t', 4 | gzip = 'application/gzip', 5 | bmp = 'image/bmp', 6 | tar = 'application/x-tar', 7 | rar = 'application/vnd.rar', 8 | swf = 'application/x-shockwave-flash', 9 | rtf = 'application/rtf', 10 | bzip2 = 'application/x-bzip2', 11 | eot = 'application/vnd.ms-fontobject', 12 | png = 'image/png', 13 | pdf = 'application/pdf', 14 | apng = 'image/apng', 15 | gif = 'image/gif', 16 | jpg = 'image/jpeg', 17 | ico = 'image/x-icon', 18 | tiff = 'image/tiff', 19 | mpg = 'video/mpeg', 20 | arc = 'application/x-freearc', 21 | otf = 'font/otf', 22 | ogg = 'application/ogg', 23 | ogv = 'video/ogg', 24 | oga = 'audio/ogg', 25 | opus = 'audio/opus', 26 | midi = 'audio/midi', 27 | ttf = 'font/ttf', 28 | wav = 'audio/wav', 29 | avi = 'video/x-msvideo', 30 | qcp = 'audio/qcelp', 31 | webp = 'image/webp', 32 | woff = 'font/woff', 33 | woff2 = 'font/woff2', 34 | xml = 'text/xml', 35 | x7zip = 'application/x-7z-compressed', 36 | aac = 'audio/aac', 37 | mpeg = 'audio/mpeg', 38 | zip = 'application/zip', 39 | docx = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', 40 | pptx = 'application/vnd.openxmlformats-officedocument.presentationml.presentation', 41 | xlsx = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', 42 | epub = 'application/epub+zip', 43 | odt = 'application/vnd.oasis.opendocument.text', 44 | ods = 'application/vnd.oasis.opendocument.spreadsheet', 45 | odp = 'application/vnd.oasis.opendocument.presentation', 46 | flv = 'video/x-flv', 47 | avif = 'image/avif', 48 | heif = 'image/heif', 49 | heifs = 'image/heif-sequence', 50 | heic = 'image/heic', 51 | heics = 'image/heic-sequence', 52 | cr3 = 'image/x-canon-cr3', 53 | qt = 'video/quicktime', 54 | m4v = 'video/x-m4v', 55 | mp4v = 'video/mp4', 56 | m4a = 'audio/x-m4a', 57 | mp4a = 'audio/mp4', 58 | v3g2 = 'video/3gpp2', 59 | v3gp = 'video/3gpp', 60 | mp1s = 'video/MP1S', 61 | mp2p = 'video/MP2P', 62 | } 63 | 64 | export const EXTRA_EXTENSION_TYPES: { [ext: string]: string } = { 65 | py: 'text/x-python', 66 | ts: 'application/typescript', 67 | }; 68 | -------------------------------------------------------------------------------- /src/app/utils/MimeTypeParser/helpers.ts: -------------------------------------------------------------------------------- 1 | import { EXTRA_EXTENSION_TYPES } from './constants'; 2 | 3 | interface Options { 4 | /** 5 | * Offset in source buffer to start checking from 6 | */ 7 | offset?: number; 8 | 9 | mask?: number[]; 10 | } 11 | 12 | export function SignatureChecker(sourceBuffer: Buffer) { 13 | /** 14 | * Checks if source buffer, starting from offset, contains bytes in `signature`. 15 | * Used to check file header bytes for specific mime types. 16 | * @param signature - hexadecimal bytes in signature to match 17 | * @param options - checker options 18 | */ 19 | const check = (signature: number[], { offset = 0, mask }: Options = {}) => { 20 | if (offset + signature.length > sourceBuffer.length) { 21 | return false; 22 | } 23 | 24 | for (const [index, signatureByte] of signature.entries()) { 25 | const sourceByte = sourceBuffer[index + offset]; 26 | const maskedByte = mask ? signatureByte !== (mask[index] & sourceByte) : sourceByte; 27 | 28 | if (signatureByte !== maskedByte) { 29 | return false; 30 | } 31 | } 32 | 33 | return true; 34 | }; 35 | 36 | /** 37 | * Checks if source buffer, starting from offset, contains bytes matching ascii characters in `signature`. 38 | * Used to check file header bytes for specific mime types. 39 | * @param signature - ascii characters to match in signature 40 | * @param options - checker options 41 | */ 42 | const checkString = (signature: string, options?: Options) => { 43 | const bytes = [...signature].map((character) => character.charCodeAt(0)); 44 | return check(bytes, options); 45 | }; 46 | 47 | return { 48 | sourceBuffer, 49 | check, 50 | checkString, 51 | }; 52 | } 53 | 54 | /** 55 | Checks whether the TAR checksum is valid. 56 | @param {Buffer} buffer - The TAR header `[offset ... offset + 512]`. 57 | @param {number} offset - TAR header offset. 58 | @returns {boolean} `true` if the TAR checksum is valid, otherwise `false`. 59 | */ 60 | export function isTarHeaderChecksumValid(buffer: Buffer) { 61 | const readSum = parseInt(buffer.toString('utf8', 148, 154).replace(/\0.*$/, '').trim(), 8); // Read sum in header 62 | 63 | // eslint-disable-next-line no-restricted-globals 64 | if (isNaN(readSum)) { 65 | return false; 66 | } 67 | 68 | let sum = 8 * 0x20; // Initialize signed bit sum 69 | 70 | for (let i = 0; i < 148; i++) { 71 | sum += buffer[i]; 72 | } 73 | 74 | for (let i = 156; i < 512; i++) { 75 | sum += buffer[i]; 76 | } 77 | 78 | return readSum === sum; 79 | } 80 | 81 | export async function mimetypeFromExtension(filename: string) { 82 | const { lookup } = await import('mime-types'); 83 | const extension = filename.split('.').pop(); 84 | return ( 85 | (extension && EXTRA_EXTENSION_TYPES[extension.toLowerCase()]) || lookup(filename) || 'application/octet-stream' 86 | ); 87 | } 88 | -------------------------------------------------------------------------------- /src/app/utils/MimeTypeParser/signatureChecks/applicationSignatures.ts: -------------------------------------------------------------------------------- 1 | import { SupportedMimeTypes } from '../constants'; 2 | import { SignatureChecker } from '../helpers'; 3 | 4 | export default function applicationSignatures({ check, checkString }: ReturnType) { 5 | if (check([0x43, 0x57, 0x53]) || check([0x46, 0x57, 0x53])) { 6 | return SupportedMimeTypes.swf; 7 | } 8 | 9 | if (check([0x46, 0x4c, 0x56, 0x01])) { 10 | return SupportedMimeTypes.flv; 11 | } 12 | 13 | if (checkString('OggS')) { 14 | return SupportedMimeTypes.ogg; 15 | } 16 | 17 | if (checkString('%PDF')) { 18 | return SupportedMimeTypes.pdf; 19 | } 20 | 21 | if (checkString('{\\rtf')) { 22 | return SupportedMimeTypes.rtf; 23 | } 24 | 25 | if (checkString(') { 5 | if (checkString('MThd')) { 6 | return SupportedMimeTypes.midi; 7 | } 8 | 9 | if (checkString('fLaC')) { 10 | return SupportedMimeTypes.flac; 11 | } 12 | 13 | if (check([0x52, 0x49, 0x46, 0x46])) { 14 | if (check([0x57, 0x41, 0x56, 0x45], { offset: 8 })) { 15 | return SupportedMimeTypes.wav; 16 | } 17 | 18 | if (check([0x51, 0x4c, 0x43, 0x4d], { offset: 8 })) { 19 | return SupportedMimeTypes.qcp; 20 | } 21 | } 22 | 23 | if (checkString('OggS')) { 24 | if (check([0x4f, 0x70, 0x75, 0x73, 0x48, 0x65, 0x61, 0x64], { offset: 28 })) { 25 | return SupportedMimeTypes.opus; 26 | } 27 | 28 | if ( 29 | check([0x7f, 0x46, 0x4c, 0x41, 0x43], { offset: 28 }) || 30 | check([0x53, 0x70, 0x65, 0x65, 0x78, 0x20, 0x20], { offset: 28 }) || 31 | check([0x01, 0x76, 0x6f, 0x72, 0x62, 0x69, 0x73], { offset: 28 }) 32 | ) { 33 | return SupportedMimeTypes.oga; 34 | } 35 | } 36 | 37 | if (checkString('ftyp', { offset: 4 }) && (sourceBuffer[8] & 0x60) !== 0x00) { 38 | const brandMajor = sourceBuffer.toString('binary', 8, 12).replace('\0', ' ').trim(); 39 | switch (brandMajor) { 40 | case 'M4A': 41 | return SupportedMimeTypes.m4a; 42 | case 'M4B': 43 | case 'F4A': 44 | case 'F4B': 45 | return SupportedMimeTypes.mp4a; 46 | default: 47 | return undefined; 48 | } 49 | } 50 | 51 | return undefined; 52 | } 53 | -------------------------------------------------------------------------------- /src/app/utils/MimeTypeParser/signatureChecks/fontSignatures.ts: -------------------------------------------------------------------------------- 1 | import { SupportedMimeTypes } from '../constants'; 2 | import { SignatureChecker } from '../helpers'; 3 | 4 | export default function fontSignatures({ check, checkString }: ReturnType) { 5 | if (checkString('wOFF')) { 6 | return SupportedMimeTypes.woff; 7 | } 8 | 9 | if (checkString('wOF2')) { 10 | return SupportedMimeTypes.woff2; 11 | } 12 | 13 | if (check([0x4f, 0x54, 0x54, 0x4f, 0x00])) { 14 | return SupportedMimeTypes.otf; 15 | } 16 | 17 | if ( 18 | check([0x4c, 0x50], { offset: 34 }) && 19 | (check([0x00, 0x00, 0x01], { offset: 8 }) || 20 | check([0x01, 0x00, 0x02], { offset: 8 }) || 21 | check([0x02, 0x00, 0x02], { offset: 8 })) 22 | ) { 23 | return SupportedMimeTypes.eot; 24 | } 25 | 26 | if (check([0x00, 0x01, 0x00, 0x00, 0x00])) { 27 | return SupportedMimeTypes.ttf; 28 | } 29 | 30 | return undefined; 31 | } 32 | -------------------------------------------------------------------------------- /src/app/utils/MimeTypeParser/signatureChecks/imageSignatures.ts: -------------------------------------------------------------------------------- 1 | import { SupportedMimeTypes } from '../constants'; 2 | import { SignatureChecker } from '../helpers'; 3 | 4 | export default function imageSignatures({ check, checkString, sourceBuffer }: ReturnType) { 5 | if (checkString('WEBP', { offset: 8 })) { 6 | return SupportedMimeTypes.webp; 7 | } 8 | 9 | if (check([0x4d, 0x4d, 0x0, 0x2a]) || check([0x49, 0x49, 0x2a, 0x0])) { 10 | return SupportedMimeTypes.tiff; 11 | } 12 | 13 | if (check([0x47, 0x49, 0x46])) { 14 | return SupportedMimeTypes.gif; 15 | } 16 | 17 | if (check([0xff, 0xd8, 0xff])) { 18 | return SupportedMimeTypes.jpg; 19 | } 20 | 21 | if (check([0x42, 0x4d])) { 22 | return SupportedMimeTypes.bmp; 23 | } 24 | 25 | if (checkString('ftyp', { offset: 4 }) && (sourceBuffer[8] & 0x60) !== 0x00) { 26 | const brandMajor = sourceBuffer.toString('binary', 8, 12).replace('\0', ' ').trim(); 27 | switch (brandMajor) { 28 | case 'avif': 29 | return SupportedMimeTypes.avif; 30 | case 'mif1': 31 | return SupportedMimeTypes.heif; 32 | case 'msf1': 33 | return SupportedMimeTypes.heifs; 34 | case 'heic': 35 | case 'heix': 36 | return SupportedMimeTypes.heic; 37 | case 'hevc': 38 | case 'hevx': 39 | return SupportedMimeTypes.heics; 40 | case 'crx': 41 | return SupportedMimeTypes.cr3; 42 | default: 43 | return undefined; 44 | } 45 | } 46 | 47 | if (check([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) { 48 | let offset = 8; // start after png header 49 | 50 | do { 51 | // Read 4-byte uint data length block 52 | const dataLength = sourceBuffer.slice(offset, offset + 4).readInt32BE(0); 53 | offset += 4; 54 | 55 | if (dataLength < 0) { 56 | return; 57 | } 58 | 59 | // Read 4 -byte chunk type block 60 | const chunkType = sourceBuffer.slice(offset, offset + 4).toString('binary'); 61 | offset += 4; 62 | 63 | // if acTL comes first, it's animated png, otherwise static 64 | switch (chunkType) { 65 | case 'IDAT': 66 | return SupportedMimeTypes.png; 67 | case 'acTL': 68 | return SupportedMimeTypes.apng; 69 | default: 70 | offset += dataLength + 4; // ignore data + CRC 71 | } 72 | } while (offset + 8 < sourceBuffer.length); 73 | 74 | return SupportedMimeTypes.png; 75 | } 76 | 77 | return undefined; 78 | } 79 | -------------------------------------------------------------------------------- /src/app/utils/MimeTypeParser/signatureChecks/unsafeSignatures.ts: -------------------------------------------------------------------------------- 1 | import { SupportedMimeTypes } from '../constants'; 2 | import { SignatureChecker } from '../helpers'; 3 | 4 | export default function unsafeSignatures({ check, sourceBuffer }: ReturnType) { 5 | if (check([0x00, 0x00, 0x01, 0x00]) || check([0x00, 0x00, 0x02, 0x00])) { 6 | return SupportedMimeTypes.ico; 7 | } 8 | 9 | if (check([0x0, 0x0, 0x1, 0xba]) || check([0x0, 0x0, 0x1, 0xb3])) { 10 | return SupportedMimeTypes.mpg; 11 | } 12 | 13 | // Every 188th byte is sync byte, which is a reasonable guess 14 | let isMp2t = true; 15 | for (let offset = 0; offset < sourceBuffer.length; offset += 188) { 16 | if (!check([0x47], { offset })) { 17 | isMp2t = false; 18 | break; 19 | } 20 | } 21 | if (isMp2t) { 22 | return SupportedMimeTypes.mp2t; 23 | } 24 | 25 | // Check for MPEG header at different starting offsets 26 | for (let start = 0; start < 2 && start < sourceBuffer.length - 16; start++) { 27 | // Check MPEG 1 or 2 Layer 3 header, or 'layer 0' for ADTS (MPEG sync-word 0xFFE) 28 | if (sourceBuffer.length >= start + 2 && check([0xff, 0xe0], { offset: start, mask: [0xff, 0xe0] })) { 29 | if (check([0x10], { offset: start + 1, mask: [0x16] })) { 30 | return SupportedMimeTypes.aac; 31 | } 32 | // mp3, mp2 or mp1 33 | if ( 34 | check([0x02], { offset: start + 1, mask: [0x06] }) || 35 | check([0x04], { offset: start + 1, mask: [0x06] }) || 36 | check([0x06], { offset: start + 1, mask: [0x06] }) 37 | ) { 38 | return SupportedMimeTypes.mpeg; 39 | } 40 | } 41 | } 42 | 43 | return undefined; 44 | } 45 | -------------------------------------------------------------------------------- /src/app/utils/MimeTypeParser/signatureChecks/videoSignatures.ts: -------------------------------------------------------------------------------- 1 | import { SupportedMimeTypes } from '../constants'; 2 | import { SignatureChecker } from '../helpers'; 3 | 4 | export default function videoSignatures({ check, checkString, sourceBuffer }: ReturnType) { 5 | if (check([0x52, 0x49, 0x46, 0x46]) && check([0x41, 0x56, 0x49], { offset: 8 })) { 6 | return SupportedMimeTypes.avi; 7 | } 8 | 9 | if ( 10 | checkString('OggS') && 11 | (check([0x80, 0x74, 0x68, 0x65, 0x6f, 0x72, 0x61], { offset: 28 }) || 12 | check([0x01, 0x76, 0x69, 0x64, 0x65, 0x6f, 0x00], { offset: 28 })) 13 | ) { 14 | return SupportedMimeTypes.ogv; 15 | } 16 | 17 | if (check([0x00, 0x00, 0x01, 0xba])) { 18 | if (check([0x21], { offset: 4, mask: [0xf1] })) { 19 | return SupportedMimeTypes.mp1s; 20 | } 21 | if (check([0x44], { offset: 4, mask: [0xc4] })) { 22 | return SupportedMimeTypes.mp2p; 23 | } 24 | } 25 | 26 | if ( 27 | check([0x66, 0x72, 0x65, 0x65], { offset: 4 }) || 28 | check([0x6d, 0x64, 0x61, 0x74], { offset: 4 }) || 29 | check([0x6d, 0x6f, 0x6f, 0x76], { offset: 4 }) || 30 | check([0x77, 0x69, 0x64, 0x65], { offset: 4 }) 31 | ) { 32 | return SupportedMimeTypes.qt; 33 | } 34 | 35 | if (checkString('ftyp', { offset: 4 }) && (sourceBuffer[8] & 0x60) !== 0x00) { 36 | const brandMajor = sourceBuffer.toString('binary', 8, 12).replace('\0', ' ').trim(); 37 | switch (brandMajor) { 38 | case 'qt': 39 | return SupportedMimeTypes.qt; 40 | case 'M4V': 41 | case 'M4VH': 42 | case 'M4VP': 43 | return SupportedMimeTypes.m4v; 44 | case 'M4P': 45 | case 'F4V': 46 | case 'F4P': 47 | return SupportedMimeTypes.mp4v; 48 | default: 49 | if (brandMajor.startsWith('3g')) { 50 | if (brandMajor.startsWith('3g2')) { 51 | return SupportedMimeTypes.v3g2; 52 | } 53 | return SupportedMimeTypes.v3gp; 54 | } 55 | return SupportedMimeTypes.mp4v; 56 | } 57 | } 58 | 59 | return undefined; 60 | } 61 | -------------------------------------------------------------------------------- /src/app/utils/async.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Waits for specific condition to be true 3 | */ 4 | export const waitUntil = (conditionFn: () => boolean) => { 5 | return new Promise((resolve) => { 6 | const waitForCondition = () => { 7 | if (conditionFn()) return resolve(); 8 | setTimeout(waitForCondition, 50); 9 | }; 10 | 11 | waitForCondition(); 12 | }); 13 | }; 14 | 15 | export const getSuccessfulSettled = (results: PromiseSettledResult[]) => { 16 | const values: T[] = []; 17 | results.forEach((result) => { 18 | if (result.status === 'fulfilled') { 19 | values.push(result.value); 20 | } else { 21 | console.error(result.reason); 22 | } 23 | }); 24 | return values; 25 | }; 26 | 27 | export const logSettledErrors = (results: PromiseSettledResult[]) => { 28 | results.forEach((result) => { 29 | if (result.status === 'rejected') { 30 | console.error(result.reason); 31 | } 32 | }); 33 | }; 34 | -------------------------------------------------------------------------------- /src/app/utils/file.ts: -------------------------------------------------------------------------------- 1 | import { MB } from '../constants'; 2 | 3 | export const isFile = async (item: File) => { 4 | if (item.type !== '' || item.size > MB) { 5 | return true; 6 | } 7 | 8 | return new Promise((resolve, reject) => { 9 | const reader = new FileReader(); 10 | reader.onload = ({ target }) => { 11 | if (!target?.result) { 12 | return reject(); 13 | } 14 | resolve(); 15 | }; 16 | reader.onerror = reject; 17 | reader.onabort = reject; 18 | reader.readAsBinaryString(item); 19 | }) 20 | .then(() => true) 21 | .catch(() => false); 22 | }; 23 | 24 | export const countFilesToUpload = ( 25 | files: 26 | | FileList 27 | | { 28 | path: string[]; 29 | file?: File | undefined; 30 | }[] 31 | ) => { 32 | let count = 0; 33 | for (const entry of files) { 34 | const file = 'path' in entry ? entry.file : entry; 35 | if (file) { 36 | count += 1; 37 | } 38 | } 39 | return count; 40 | }; 41 | -------------------------------------------------------------------------------- /src/app/utils/link.test.ts: -------------------------------------------------------------------------------- 1 | import { SharedURLFlags } from '../interfaces/sharing'; 2 | import { splitGeneratedAndCustomPassword, adjustName, splitLinkName } from './link'; 3 | 4 | describe('splitGeneratedAndCustomPassword', () => { 5 | it('no custom password returns only generated password', () => { 6 | expect(splitGeneratedAndCustomPassword('1234567890ab', { Flags: 0 })).toEqual(['1234567890ab', '']); 7 | }); 8 | 9 | it('legacy custom password returns only custom password', () => { 10 | expect(splitGeneratedAndCustomPassword('abc', { Flags: SharedURLFlags.CustomPassword })).toEqual(['', 'abc']); 11 | }); 12 | 13 | it('new custom password returns both generated and custom password', () => { 14 | expect( 15 | splitGeneratedAndCustomPassword('1234567890ababc', { 16 | Flags: SharedURLFlags.CustomPassword | SharedURLFlags.GeneratedPasswordIncluded, 17 | }) 18 | ).toEqual(['1234567890ab', 'abc']); 19 | }); 20 | }); 21 | 22 | describe('adjustName', () => { 23 | it('should add index to a file with extension', () => { 24 | expect(adjustName(3, 'filename', 'ext')).toBe('filename (3).ext'); 25 | }); 26 | 27 | it('should add index to a file without extension', () => { 28 | expect(adjustName(3, 'filename')).toBe('filename (3)'); 29 | expect(adjustName(3, 'filename', '')).toBe('filename (3)'); 30 | expect(adjustName(3, 'filename.')).toBe('filename. (3)'); 31 | expect(adjustName(3, '.filename.')).toBe('.filename. (3)'); 32 | }); 33 | 34 | it('should add index to a file without name', () => { 35 | expect(adjustName(3, '', 'ext')).toBe('.ext (3)'); 36 | }); 37 | 38 | it('should leave zero-index filename with extension unchanged', () => { 39 | expect(adjustName(0, 'filename', 'ext')).toBe('filename.ext'); 40 | }); 41 | 42 | it('should leave zero-index filename without extension unchanged', () => { 43 | expect(adjustName(0, 'filename')).toBe('filename'); 44 | expect(adjustName(0, 'filename', '')).toBe('filename'); 45 | expect(adjustName(0, 'filename.')).toBe('filename.'); 46 | expect(adjustName(0, '.filename.')).toBe('.filename.'); 47 | }); 48 | 49 | it('should leave zero-index filename without name unchanged', () => { 50 | expect(adjustName(0, '', 'ext')).toBe('.ext'); 51 | }); 52 | }); 53 | 54 | describe('splitLinkName', () => { 55 | it('should split file name and extension', () => { 56 | expect(splitLinkName('filename.ext')).toEqual(['filename', 'ext']); 57 | }); 58 | 59 | it('should split file name without extension', () => { 60 | expect(splitLinkName('filename')).toEqual(['filename', '']); 61 | expect(splitLinkName('filename.')).toEqual(['filename.', '']); 62 | }); 63 | 64 | it('should split file name without name', () => { 65 | expect(splitLinkName('.ext')).toEqual(['', 'ext']); 66 | }); 67 | }); 68 | -------------------------------------------------------------------------------- /src/app/utils/share.ts: -------------------------------------------------------------------------------- 1 | import { ShareFlags } from '../interfaces/share'; 2 | 3 | export const isPrimaryShare = (meta: { Flags?: number }) => { 4 | return !!(typeof meta.Flags !== 'undefined' && meta.Flags & ShareFlags.PrimaryShare); 5 | }; 6 | -------------------------------------------------------------------------------- /src/app/utils/stream.ts: -------------------------------------------------------------------------------- 1 | import { TransformStream, ReadableStream, ReadResult } from 'web-streams-polyfill'; 2 | 3 | export const untilStreamEnd = async (stream: ReadableStream, action?: (data: T) => Promise) => { 4 | const reader = stream.getReader(); 5 | 6 | const processResponse = async (result: ReadResult): Promise => { 7 | if (result.done) { 8 | return; 9 | } 10 | 11 | await action?.(result.value); 12 | 13 | return processResponse(await reader.read()); 14 | }; 15 | 16 | return processResponse(await reader.read()); 17 | }; 18 | 19 | export const streamToBuffer = async (stream: ReadableStream) => { 20 | const chunks: Uint8Array[] = []; 21 | await untilStreamEnd(stream, async (chunk) => { 22 | chunks.push(chunk); 23 | }); 24 | return chunks; 25 | }; 26 | 27 | export class ObserverStream extends TransformStream { 28 | constructor(fn?: (chunk: Uint8Array) => void) { 29 | super({ 30 | transform(chunk, controller) { 31 | fn?.(chunk); 32 | controller.enqueue(chunk); 33 | }, 34 | }); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/app/utils/validation.ts: -------------------------------------------------------------------------------- 1 | import { c } from 'ttag'; 2 | import { MAX_NAME_LENGTH } from '../constants'; 3 | import { GLOBAL_FORBIDDEN_CHARACTERS } from './link'; 4 | 5 | export class ValidationError extends Error { 6 | constructor(message: string) { 7 | super(message); 8 | this.name = 'ValidationError'; 9 | } 10 | } 11 | 12 | const composeValidators = (validators: ((value: T) => string | undefined)[]) => (value: T) => { 13 | for (const validator of validators) { 14 | const result = validator(value); 15 | if (result) { 16 | return result; 17 | } 18 | } 19 | return undefined; 20 | }; 21 | 22 | const validateSpaceEnd = (str: string) => { 23 | return str.endsWith(' ') ? c('Validation Error').t`Name must not end with a space` : undefined; 24 | }; 25 | 26 | const validateSpaceStart = (str: string) => { 27 | return str.startsWith(' ') ? c('Validation Error').t`Name must not begin with a space` : undefined; 28 | }; 29 | 30 | const validateNameLength = (str: string) => { 31 | return str.length > MAX_NAME_LENGTH 32 | ? c('Validation Error').t`Name must be ${MAX_NAME_LENGTH} characters long at most` 33 | : undefined; 34 | }; 35 | 36 | const validateInvalidName = (str: string) => { 37 | return ['.', '..'].includes(str) ? c('Validation Error').t`"${str}" is not a valid name` : undefined; 38 | }; 39 | 40 | const validateInvalidCharacters = (str: string) => { 41 | return RegExp(GLOBAL_FORBIDDEN_CHARACTERS, 'u').test(str) 42 | ? c('Validation Error').t`Name cannot include invisible characters, / or \\.` 43 | : undefined; 44 | }; 45 | 46 | const validateNameEmpty = (str: string) => { 47 | return !str ? c('Validation Error').t`Name must not be empty` : undefined; 48 | }; 49 | 50 | export const validateLinkName = composeValidators([ 51 | validateNameEmpty, 52 | validateNameLength, 53 | validateSpaceStart, 54 | validateSpaceEnd, 55 | validateInvalidName, 56 | validateInvalidCharacters, 57 | ]); 58 | 59 | export const validateLinkNameField = composeValidators([ 60 | validateNameEmpty, 61 | validateNameLength, 62 | validateInvalidName, 63 | validateInvalidCharacters, 64 | ]); 65 | -------------------------------------------------------------------------------- /src/assets/logoConfig.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | logo: 'src/assets/protondrive.svg', 3 | favicons: { 4 | appName: 'ProtonDrive', 5 | appDescription: 6 | 'ProtonDrive allows you to securely store and share your sensitive documents and access them anywhere.', 7 | developerName: 'Proton Technologies AG', 8 | developerURL: 'https://github.com/ProtonMail/proton-drive', 9 | background: '#1c223d', 10 | theme_color: '#1c223d', 11 | }, 12 | }; 13 | -------------------------------------------------------------------------------- /src/assets/protondrive.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "proton-shared/tsconfig.base.json" 3 | } 4 | -------------------------------------------------------------------------------- /typings/index.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.png'; 2 | 3 | declare module '@transcend-io/conflux'; 4 | 5 | declare module 'service-worker-loader*' { 6 | import { 7 | ServiceWorkerRegister, 8 | ScriptUrl as scriptUrl, 9 | ServiceWorkerNoSupportError, 10 | } from 'service-worker-loader/types.d'; 11 | 12 | const register: ServiceWorkerRegister; 13 | export default register; 14 | export { ServiceWorkerNoSupportError, scriptUrl }; 15 | } 16 | 17 | type UnderlyingByteSource = undefined; 18 | type ReadableStreamBYOBReader = undefined; 19 | declare module '@mattiasbuelens/web-streams-adapter'; 20 | --------------------------------------------------------------------------------