diff --git a/README.md b/README.md index d90d2e134..ea27599da 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,8 @@ We have open-source apps across [Android](https://github.com/ente-io/frame), [iO This repository contains the code for our web app, built with a lot of ❤️, and a little bit of JavaScript.


-![App Screenshots](https://user-images.githubusercontent.com/1161789/154797467-a2c14f13-6b04-4282-ab61-f6a9f60c2026.png) + +![App Screenshots](https://user-images.githubusercontent.com/24503581/189914045-9d4e9c44-37c6-4ac6-9e17-d8c37aee1e08.png) ## ✨ Features @@ -19,10 +20,14 @@ This repository contains the code for our web app, built with a lot of ❤️, a - EXIF viewer - Zero third-party tracking / analytics +
+ ## 💻 Deployed Application The deployed application is accessible @ [web.ente.io](https://web.ente.io). +
+ ## 🧑‍💻 Building from source 1. Clone this repository with `git clone git@github.com:ente-io/bada-frame.git` @@ -32,26 +37,36 @@ The deployed application is accessible @ [web.ente.io](https://web.ente.io). Open [http://localhost:3000](http://localhost:3000) on your browser to see the live application. +
+ ## 🙋 Help We provide human support to our customers. Please write to [support@ente.io](mailto:support@ente.io) sharing as many details as possible about whatever it is that you need help with, and we will get back to you as soon as possible. +
+ ## 🧭 Roadmap We maintain a public roadmap, that's driven by our community @ [roadmap.ente.io](https://roadmap.ente.io). +
+ ## 🤗 Support If you like this project, please consider upgrading to a paid subscription. If you would like to motivate us to keep building, you can do so by [starring](https://github.com/ente-io/bada-frame/stargazers) this project. +
+ ## ❤️ Join the Community Follow us on [Twitter](https://twitter.com/enteio) and join [r/enteio](https://reddit.com/r/enteio) to get regular updates, connect with other customers, and discuss your ideas. An important part of our journey is to build better software by consistently listening to community feedback. Please feel free to [share your thoughts](mailto:feedback@ente.io) with us at any time. +
+ --- Cross-browser testing provided by diff --git a/package.json b/package.json index 55ea31c8f..b454d8592 100644 --- a/package.json +++ b/package.json @@ -5,10 +5,11 @@ "scripts": { "dev": "next dev", "albums": "next dev -p 3002", - "prebuild": "eslint \"src/**/*.{js,jsx,ts,tsx}\"", + "lint": "eslint \"src/**/*.{js,jsx,ts,tsx}\"", + "prebuild": "yarn lint", "build": "next build", - "build-analyze": "ANALYZE=true next build", "postbuild": "next export", + "build-analyze": "ANALYZE=true next build", "start": "next start", "prepare": "husky install" }, diff --git a/public/.well-known/apple-app-site-association b/public/.well-known/apple-app-site-association index 8e87b1279..265f647da 100644 --- a/public/.well-known/apple-app-site-association +++ b/public/.well-known/apple-app-site-association @@ -1,7 +1,8 @@ { "webcredentials": { "apps": [ + "6Z68YJY9Q2.io.ente.frame", "2BUSYC7FN9.io.ente.frame" ] } -} \ No newline at end of file +} diff --git a/public/images/delete-account/1x.png b/public/images/delete-account/1x.png new file mode 100644 index 000000000..b8288cee8 Binary files /dev/null and b/public/images/delete-account/1x.png differ diff --git a/public/images/delete-account/2x.png b/public/images/delete-account/2x.png new file mode 100644 index 000000000..31c9014bc Binary files /dev/null and b/public/images/delete-account/2x.png differ diff --git a/public/images/delete-account/3x.png b/public/images/delete-account/3x.png new file mode 100644 index 000000000..7be1e70de Binary files /dev/null and b/public/images/delete-account/3x.png differ diff --git a/src/components/AuthenticateUserModal.tsx b/src/components/AuthenticateUserModal.tsx new file mode 100644 index 000000000..092119d34 --- /dev/null +++ b/src/components/AuthenticateUserModal.tsx @@ -0,0 +1,88 @@ +import React, { useContext, useEffect, useState } from 'react'; + +import constants from 'utils/strings/constants'; +import { getData, LS_KEYS } from 'utils/storage/localStorage'; +import { AppContext } from 'pages/_app'; +import { KeyAttributes, User } from 'types/user'; +import VerifyMasterPasswordForm, { + VerifyMasterPasswordFormProps, +} from 'components/VerifyMasterPasswordForm'; +import { Dialog, Stack, Typography } from '@mui/material'; +import { logError } from 'utils/sentry'; + +interface Iprops { + open: boolean; + onClose: () => void; + onAuthenticate: () => void; +} + +export default function AuthenticateUserModal({ + open, + onClose, + onAuthenticate, +}: Iprops) { + const { setDialogMessage } = useContext(AppContext); + const [user, setUser] = useState(); + const [keyAttributes, setKeyAttributes] = useState(); + + const somethingWentWrong = () => + setDialogMessage({ + title: constants.ERROR, + close: { variant: 'danger' }, + content: constants.UNKNOWN_ERROR, + }); + + useEffect(() => { + const main = async () => { + try { + const user = getData(LS_KEYS.USER); + if (!user) { + throw Error('User not found'); + } + setUser(user); + const keyAttributes = getData(LS_KEYS.KEY_ATTRIBUTES); + if ( + (!user?.token && !user?.encryptedToken) || + (keyAttributes && !keyAttributes.memLimit) + ) { + throw Error('User not logged in'); + } else if (!keyAttributes) { + throw Error('Key attributes not found'); + } else { + setKeyAttributes(keyAttributes); + } + } catch (e) { + logError(e, 'AuthenticateUserModal initialization failed'); + onClose(); + somethingWentWrong(); + } + }; + main(); + }, []); + + const useMasterPassword: VerifyMasterPasswordFormProps['callback'] = + async () => { + onClose(); + onAuthenticate(); + }; + + return ( + + + + {constants.PASSWORD} + + + + + ); +} diff --git a/src/components/Collections/CollectionOptions/index.tsx b/src/components/Collections/CollectionOptions/index.tsx index e89bc5ead..cfaa96ce1 100644 --- a/src/components/Collections/CollectionOptions/index.tsx +++ b/src/components/Collections/CollectionOptions/index.tsx @@ -119,9 +119,9 @@ const CollectionOptions = (props: CollectionOptionsProps) => { }; }; - const renameCollection = (newName: string) => { + const renameCollection = async (newName: string) => { if (activeCollection.name !== newName) { - CollectionAPI.renameCollection(activeCollection, newName); + await CollectionAPI.renameCollection(activeCollection, newName); } }; diff --git a/src/components/Collections/index.tsx b/src/components/Collections/index.tsx index 06d16798d..b60e93e05 100644 --- a/src/components/Collections/index.tsx +++ b/src/components/Collections/index.tsx @@ -8,7 +8,7 @@ import CollectionShare from 'components/Collections/CollectionShare'; import { SetCollectionNamerAttributes } from 'components/Collections/CollectionNamer'; import { ITEM_TYPE, TimeStampListItem } from 'components/PhotoList'; import { - hasNonEmptyCollections, + hasNonSystemCollections, isSystemCollection, shouldBeShownOnCollectionBar, } from 'utils/collection'; @@ -49,8 +49,13 @@ export default function Collections(props: Iprops) { const collectionsMap = useRef>(new Map()); const activeCollection = useRef(null); - const shouldBeHidden = - isInSearchMode || hasNonEmptyCollections(collectionSummaries); + const shouldBeHidden = useMemo( + () => + isInSearchMode || + (!hasNonSystemCollections(collectionSummaries) && + activeCollectionID === ALL_SECTION), + [isInSearchMode, collectionSummaries, activeCollectionID] + ); useEffect(() => { collectionsMap.current = new Map( @@ -72,31 +77,26 @@ export default function Collections(props: Iprops) { [collectionSortBy, collectionSummaries] ); - useEffect( - () => - !shouldBeHidden && - setPhotoListHeader({ - item: ( - setActiveCollectionID(ALL_SECTION)} - showCollectionShareModal={() => - setCollectionShareModalView(true) - } - /> - ), - itemType: ITEM_TYPE.OTHER, - height: 68, - }), - [collectionSummaries, activeCollectionID, shouldBeHidden] - ); + useEffect(() => { + setPhotoListHeader({ + item: ( + setActiveCollectionID(ALL_SECTION)} + showCollectionShareModal={() => + setCollectionShareModalView(true) + } + /> + ), + itemType: ITEM_TYPE.OTHER, + height: 68, + }); + }, [collectionSummaries, activeCollectionID]); if (shouldBeHidden) { return <>; diff --git a/src/components/Container.ts b/src/components/Container.ts index 76844f93b..bcb7464ca 100644 --- a/src/components/Container.ts +++ b/src/components/Container.ts @@ -73,3 +73,7 @@ export const Overlay = styled(Box)` export const IconButtonWithBG = styled(IconButton)(({ theme }) => ({ backgroundColor: theme.palette.fill.dark, })); + +export const HorizontalFlex = styled(Box)({ + display: 'flex', +}); diff --git a/src/components/DeleteAccountModal.tsx b/src/components/DeleteAccountModal.tsx new file mode 100644 index 000000000..94478135c --- /dev/null +++ b/src/components/DeleteAccountModal.tsx @@ -0,0 +1,165 @@ +import NoAccountsIcon from '@mui/icons-material/NoAccountsOutlined'; +import TickIcon from '@mui/icons-material/Done'; +import { + Dialog, + DialogContent, + Typography, + Button, + Stack, +} from '@mui/material'; +import { AppContext } from 'pages/_app'; +import React, { useContext, useEffect, useState } from 'react'; +import { preloadImage, initiateEmail } from 'utils/common'; +import constants from 'utils/strings/constants'; +import VerticallyCentered from './Container'; +import DialogTitleWithCloseButton from './DialogBox/TitleWithCloseButton'; +import { + deleteAccount, + getAccountDeleteChallenge, + logoutUser, +} from 'services/userService'; +import AuthenticateUserModal from './AuthenticateUserModal'; +import { logError } from 'utils/sentry'; +import { decryptDeleteAccountChallenge } from 'utils/crypto'; + +interface Iprops { + onClose: () => void; + open: boolean; +} +const DeleteAccountModal = ({ open, onClose }: Iprops) => { + const { setDialogMessage, isMobile } = useContext(AppContext); + const [authenticateUserModalView, setAuthenticateUserModalView] = + useState(false); + const [deleteAccountChallenge, setDeleteAccountChallenge] = useState(''); + + const openAuthenticateUserModal = () => setAuthenticateUserModalView(true); + const closeAuthenticateUserModal = () => + setAuthenticateUserModalView(false); + + useEffect(() => { + preloadImage('/images/delete-account'); + }, []); + + const sendFeedbackMail = () => initiateEmail('feedback@ente.io'); + + const somethingWentWrong = () => + setDialogMessage({ + title: constants.ERROR, + close: { variant: 'danger' }, + content: constants.UNKNOWN_ERROR, + }); + + const initiateDelete = async () => { + try { + const deleteChallengeResponse = await getAccountDeleteChallenge(); + setDeleteAccountChallenge( + deleteChallengeResponse.encryptedChallenge + ); + if (deleteChallengeResponse.allowDelete) { + openAuthenticateUserModal(); + } else { + askToMailForDeletion(); + } + } catch (e) { + logError(e, 'Error while initiating account deletion'); + somethingWentWrong(); + } + }; + + const confirmAccountDeletion = () => { + setDialogMessage({ + title: constants.CONFIRM_ACCOUNT_DELETION_TITLE, + content: constants.CONFIRM_ACCOUNT_DELETION_MESSAGE, + proceed: { + text: constants.DELETE, + action: solveChallengeAndDeleteAccount, + variant: 'danger', + }, + close: { text: constants.CANCEL }, + }); + }; + + const askToMailForDeletion = () => { + setDialogMessage({ + title: constants.DELETE_ACCOUNT, + content: constants.DELETE_ACCOUNT_MESSAGE(), + proceed: { + text: constants.DELETE, + action: () => { + initiateEmail('account-deletion@ente.io'); + }, + variant: 'danger', + }, + close: { text: constants.CANCEL }, + }); + }; + + const solveChallengeAndDeleteAccount = async () => { + try { + const decryptedChallenge = await decryptDeleteAccountChallenge( + deleteAccountChallenge + ); + await deleteAccount(decryptedChallenge); + logoutUser(); + } catch (e) { + logError(e, 'solveChallengeAndDeleteAccount failed'); + somethingWentWrong(); + } + }; + + return ( + <> + + + + {constants.DELETE_ACCOUNT} + + + + + + + + + {constants.ASK_FOR_FEEDBACK} + + + + + + + + + + + ); +}; + +export default DeleteAccountModal; diff --git a/src/components/DialogBox/base.tsx b/src/components/DialogBox/base.tsx index 6db198c75..395c55cc6 100644 --- a/src/components/DialogBox/base.tsx +++ b/src/components/DialogBox/base.tsx @@ -18,6 +18,12 @@ const DialogBoxBase = styled(Dialog)(({ theme }) => ({ '.MuiDialogTitle-root + .MuiDialogActions-root': { paddingTop: theme.spacing(3), }, + '& .MuiDialogActions-root': { + flexWrap: 'wrap-reverse', + }, + '& .MuiButton-root': { + margin: theme.spacing(0.5, 0), + }, })); export default DialogBoxBase; diff --git a/src/components/EmptyScreen.tsx b/src/components/EmptyScreen.tsx index 1d0e328ef..4609f63fd 100644 --- a/src/components/EmptyScreen.tsx +++ b/src/components/EmptyScreen.tsx @@ -3,6 +3,7 @@ import { Button, styled, Typography } from '@mui/material'; import constants from 'utils/strings/constants'; import { DeduplicateContext } from 'pages/deduplicate'; import VerticallyCentered from './Container'; +import uploadManager from 'services/upload/uploadManager'; const Wrapper = styled(VerticallyCentered)` & > svg { @@ -34,12 +35,20 @@ export default function EmptyScreen({ openUploader }) { {constants.UPLOAD_FIRST_PHOTO_DESCRIPTION()} - + + + )} diff --git a/src/components/ExportModal.tsx b/src/components/ExportModal.tsx index 4f0e96da5..0450b43fe 100644 --- a/src/components/ExportModal.tsx +++ b/src/components/ExportModal.tsx @@ -72,10 +72,10 @@ export default function ExportModal(props: Props) { } setExportFolder(getData(LS_KEYS.EXPORT)?.folder); - exportService.ElectronAPIs.registerStopExportListener(stopExport); - exportService.ElectronAPIs.registerPauseExportListener(pauseExport); - exportService.ElectronAPIs.registerResumeExportListener(resumeExport); - exportService.ElectronAPIs.registerRetryFailedExportListener( + exportService.electronAPIs.registerStopExportListener(stopExport); + exportService.electronAPIs.registerPauseExportListener(pauseExport); + exportService.electronAPIs.registerResumeExportListener(resumeExport); + exportService.electronAPIs.registerRetryFailedExportListener( retryFailedExport ); }, []); diff --git a/src/components/FullScreenDropZone.tsx b/src/components/FullScreenDropZone.tsx index 41ad96fb1..e48f41cf3 100644 --- a/src/components/FullScreenDropZone.tsx +++ b/src/components/FullScreenDropZone.tsx @@ -1,7 +1,8 @@ -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useState, useContext } from 'react'; import { styled } from '@mui/material'; import constants from 'utils/strings/constants'; import CloseIcon from '@mui/icons-material/Close'; +import { AppContext } from 'pages/_app'; const CloseButtonWrapper = styled('div')` position: absolute; @@ -41,6 +42,8 @@ type Props = React.PropsWithChildren<{ }>; export default function FullScreenDropZone(props: Props) { + const appContext = useContext(AppContext); + const [isDragActive, setIsDragActive] = useState(false); const onDragEnter = () => setIsDragActive(true); const onDragLeave = () => setIsDragActive(false); @@ -52,6 +55,27 @@ export default function FullScreenDropZone(props: Props) { } }); }, []); + + useEffect(() => { + const handleWatchFolderDrop = (e: DragEvent) => { + if (!appContext.watchFolderView) { + return; + } + + e.preventDefault(); + e.stopPropagation(); + const files = e.dataTransfer.files; + if (files.length > 0) { + appContext.setWatchFolderFiles(files); + } + }; + + addEventListener('drop', handleWatchFolderDrop); + return () => { + removeEventListener('drop', handleWatchFolderDrop); + }; + }, [appContext.watchFolderView]); + return ( - {constants.UPLOAD_DROPZONE_MESSAGE} + {appContext.watchFolderView + ? constants.WATCH_FOLDER_DROPZONE_MESSAGE + : constants.UPLOAD_DROPZONE_MESSAGE} )} {props.children} diff --git a/src/components/PhotoFrame.tsx b/src/components/PhotoFrame.tsx index 41f4b5567..11ccc4036 100644 --- a/src/components/PhotoFrame.tsx +++ b/src/components/PhotoFrame.tsx @@ -28,6 +28,8 @@ import { isSameDayAnyYear, isInsideBox } from 'utils/search'; import { Search } from 'types/search'; import { logError } from 'utils/sentry'; import { CustomError } from 'utils/error'; +import { User } from 'types/user'; +import { getData, LS_KEYS } from 'utils/storage/localStorage'; const Container = styled('div')` display: block; @@ -161,6 +163,7 @@ const PhotoFrame = ({ useEffect(() => { const idSet = new Set(); + const user: User = getData(LS_KEYS.USER); filteredDataRef.current = files .map((item, index) => ({ ...item, @@ -218,7 +221,8 @@ const PhotoFrame = ({ if (activeCollection === ARCHIVE_SECTION && !IsArchived(item)) { return false; } - if (isSharedFile(item) && !isSharedCollection) { + + if (isSharedFile(user, item) && !isSharedCollection) { return false; } if (activeCollection === TRASH_SECTION && !item.isTrashed) { diff --git a/src/components/PhotoList.tsx b/src/components/PhotoList.tsx index 72a428af1..b2157f12c 100644 --- a/src/components/PhotoList.tsx +++ b/src/components/PhotoList.tsx @@ -220,7 +220,7 @@ export function PhotoList({ if (!skipMerge) { timeStampList = mergeTimeStampList(timeStampList, columns); } - if (timeStampList.length === 0) { + if (timeStampList.length === 1) { timeStampList.push(getEmptyListItem()); } if ( @@ -573,6 +573,7 @@ export function PhotoList({ return listItem.item; } }; + if (!timeStampList?.length) { return <>; } diff --git a/src/components/Sidebar/ExitSection.tsx b/src/components/Sidebar/ExitSection.tsx index 6ecbb72a7..b77813bb2 100644 --- a/src/components/Sidebar/ExitSection.tsx +++ b/src/components/Sidebar/ExitSection.tsx @@ -1,13 +1,18 @@ -import React, { useContext } from 'react'; +import React, { useContext, useState } from 'react'; import SidebarButton from './Button'; import constants from 'utils/strings/constants'; -import { initiateEmail } from 'utils/common'; import { logoutUser } from 'services/userService'; import { AppContext } from 'pages/_app'; +import DeleteAccountModal from 'components/DeleteAccountModal'; export default function ExitSection() { const { setDialogMessage } = useContext(AppContext); + const [deleteAccountModalView, setDeleteAccountModalView] = useState(false); + + const closeDeleteAccountModal = () => setDeleteAccountModalView(false); + const openDeleteAccountModal = () => setDeleteAccountModalView(true); + const confirmLogout = () => { setDialogMessage({ title: constants.LOGOUT_MESSAGE, @@ -20,29 +25,18 @@ export default function ExitSection() { }); }; - const showDeleteAccountDirections = () => { - setDialogMessage({ - title: constants.DELETE_ACCOUNT, - content: constants.DELETE_ACCOUNT_MESSAGE(), - proceed: { - text: constants.DELETE, - action: () => { - initiateEmail('account-deletion@ente.io'); - }, - variant: 'danger', - }, - close: { text: constants.CANCEL }, - }); - }; - return ( <> {constants.LOGOUT} - + {constants.DELETE_ACCOUNT} + ); } diff --git a/src/components/Sidebar/SubscriptionStatus/index.tsx b/src/components/Sidebar/SubscriptionStatus/index.tsx index 4531df6a9..d62739a29 100644 --- a/src/components/Sidebar/SubscriptionStatus/index.tsx +++ b/src/components/Sidebar/SubscriptionStatus/index.tsx @@ -8,11 +8,13 @@ import { hasExceededStorageQuota, isSubscriptionActive, isSubscriptionCancelled, + hasStripeSubscription, } from 'utils/billing'; import Box from '@mui/material/Box'; import { UserDetails } from 'types/user'; import constants from 'utils/strings/constants'; import { Typography } from '@mui/material'; +import billingService from 'services/billingService'; export default function SubscriptionStatus({ userDetails, @@ -33,13 +35,29 @@ export default function SubscriptionStatus({ } if ( hasPaidSubscription(userDetails.subscription) && - isSubscriptionActive(userDetails.subscription) + !isSubscriptionCancelled(userDetails.subscription) ) { return false; } return true; }, [userDetails]); + const handleClick = useMemo(() => { + if (userDetails) { + if (isSubscriptionActive(userDetails.subscription)) { + if (hasExceededStorageQuota(userDetails)) { + return showPlanSelectorModal; + } + } else { + if (hasStripeSubscription(userDetails.subscription)) { + return billingService.redirectToCustomerPortal; + } else { + return showPlanSelectorModal; + } + } + } + }, [userDetails]); + if (!hasAMessage) { return <>; } @@ -49,8 +67,8 @@ export default function SubscriptionStatus({ + onClick={handleClick && handleClick} + sx={{ cursor: handleClick && 'pointer' }}> {isSubscriptionActive(userDetails.subscription) ? isOnFreePlan(userDetails.subscription) ? constants.FREE_SUBSCRIPTION_INFO( @@ -61,9 +79,13 @@ export default function SubscriptionStatus({ userDetails.subscription?.expiryTime ) : hasExceededStorageQuota(userDetails) && - constants.STORAGE_QUOTA_EXCEEDED_SUBSCRIPTION_INFO + constants.STORAGE_QUOTA_EXCEEDED_SUBSCRIPTION_INFO( + showPlanSelectorModal + ) : constants.SUBSCRIPTION_EXPIRED_MESSAGE( - showPlanSelectorModal + hasStripeSubscription(userDetails.subscription) + ? billingService.redirectToCustomerPortal + : showPlanSelectorModal )} diff --git a/src/components/Sidebar/UtilitySection.tsx b/src/components/Sidebar/UtilitySection.tsx index f0d2160f1..37f5fd676 100644 --- a/src/components/Sidebar/UtilitySection.tsx +++ b/src/components/Sidebar/UtilitySection.tsx @@ -9,6 +9,9 @@ import { useRouter } from 'next/router'; import { AppContext } from 'pages/_app'; import { canEnableMlSearch } from 'utils/machineLearning/compatibility'; import mlIDbStorage from 'utils/storage/mlIDbStorage'; +import isElectron from 'is-electron'; +import WatchFolder from 'components/WatchFolder'; +import { getDownloadAppMessage } from 'utils/ui'; export default function UtilitySection({ closeSidebar }) { const router = useRouter(); @@ -17,6 +20,8 @@ export default function UtilitySection({ closeSidebar }) { startLoading, mlSearchEnabled, updateMlSearchEnabled, + watchFolderView, + setWatchFolderView, } = useContext(AppContext); const [recoverModalView, setRecoveryModalView] = useState(false); @@ -26,8 +31,17 @@ export default function UtilitySection({ closeSidebar }) { const openRecoveryKeyModal = () => setRecoveryModalView(true); const closeRecoveryKeyModal = () => setRecoveryModalView(false); - const openTwoFactorModalView = () => setTwoFactorModalView(true); - const closeTwoFactorModalView = () => setTwoFactorModalView(false); + const openTwoFactorModal = () => setTwoFactorModalView(true); + const closeTwoFactorModal = () => setTwoFactorModalView(false); + + const openWatchFolder = () => { + if (isElectron()) { + setWatchFolderView(true); + } else { + setDialogMessage(getDownloadAppMessage()); + } + }; + const closeWatchFolder = () => setWatchFolderView(false); const redirectToChangePasswordPage = () => { closeSidebar(); @@ -92,10 +106,15 @@ export default function UtilitySection({ closeSidebar }) { }; return ( <> + {isElectron() && ( + + {constants.WATCH_FOLDERS} + + )} {constants.RECOVERY_KEY} - + {constants.TWO_FACTOR} @@ -157,10 +176,11 @@ export default function UtilitySection({ closeSidebar }) { /> + {/* + - + + diff --git a/src/components/Upload/UploadProgress/dialog.tsx b/src/components/Upload/UploadProgress/dialog.tsx index e802a83a0..72667ae1c 100644 --- a/src/components/Upload/UploadProgress/dialog.tsx +++ b/src/components/Upload/UploadProgress/dialog.tsx @@ -19,19 +19,18 @@ export function UploadProgressDialog() { const [hasUnUploadedFiles, setHasUnUploadedFiles] = useState(false); useEffect(() => { - if (!hasUnUploadedFiles) { - if ( - finishedUploads.get(UPLOAD_RESULT.ALREADY_UPLOADED)?.length > - 0 || - finishedUploads.get(UPLOAD_RESULT.BLOCKED)?.length > 0 || - finishedUploads.get(UPLOAD_RESULT.FAILED)?.length > 0 || - finishedUploads.get(UPLOAD_RESULT.LARGER_THAN_AVAILABLE_STORAGE) - ?.length > 0 || - finishedUploads.get(UPLOAD_RESULT.TOO_LARGE)?.length > 0 || - finishedUploads.get(UPLOAD_RESULT.UNSUPPORTED)?.length > 0 - ) { - setHasUnUploadedFiles(true); - } + if ( + finishedUploads.get(UPLOAD_RESULT.ALREADY_UPLOADED)?.length > 0 || + finishedUploads.get(UPLOAD_RESULT.BLOCKED)?.length > 0 || + finishedUploads.get(UPLOAD_RESULT.FAILED)?.length > 0 || + finishedUploads.get(UPLOAD_RESULT.LARGER_THAN_AVAILABLE_STORAGE) + ?.length > 0 || + finishedUploads.get(UPLOAD_RESULT.TOO_LARGE)?.length > 0 || + finishedUploads.get(UPLOAD_RESULT.UNSUPPORTED)?.length > 0 + ) { + setHasUnUploadedFiles(true); + } else { + setHasUnUploadedFiles(false); } }, [finishedUploads]); diff --git a/src/components/Upload/UploadProgress/index.tsx b/src/components/Upload/UploadProgress/index.tsx index 24bbe30bd..5d538f0cb 100644 --- a/src/components/Upload/UploadProgress/index.tsx +++ b/src/components/Upload/UploadProgress/index.tsx @@ -1,6 +1,6 @@ import { UploadProgressDialog } from './dialog'; import { MinimizedUploadProgress } from './minimized'; -import React, { useContext, useState } from 'react'; +import React, { useContext, useEffect, useState } from 'react'; import constants from 'utils/strings/constants'; import { UPLOAD_STAGES } from 'constants/upload'; @@ -12,6 +12,7 @@ import { InProgressUpload, } from 'types/upload/ui'; import UploadProgressContext from 'contexts/uploadProgress'; +import watchFolderService from 'services/watchFolder/watchFolderService'; interface Props { open: boolean; @@ -42,6 +43,16 @@ export default function UploadProgress({ const appContext = useContext(AppContext); const [expanded, setExpanded] = useState(true); + // run watch folder minimized by default + useEffect(() => { + if ( + appContext.isFolderSyncRunning && + watchFolderService.isUploadRunning() + ) { + setExpanded(false); + } + }, [appContext.isFolderSyncRunning]); + function confirmCancelUpload() { appContext.setDialogMessage({ title: constants.STOP_UPLOADS_HEADER, diff --git a/src/components/Upload/UploadProgress/minimized.tsx b/src/components/Upload/UploadProgress/minimized.tsx index c314dcd98..1d860083f 100644 --- a/src/components/Upload/UploadProgress/minimized.tsx +++ b/src/components/Upload/UploadProgress/minimized.tsx @@ -1,10 +1,10 @@ import { Snackbar, Paper } from '@mui/material'; import React from 'react'; import { UploadProgressHeader } from './header'; -export function MinimizedUploadProgress(props) { +export function MinimizedUploadProgress() { return ( Promise; closeCollectionSelector: () => void; + closeUploadTypeSelector: () => void; setCollectionSelectorAttributes: SetCollectionSelectorAttributes; setCollectionNamerAttributes: SetCollectionNamerAttributes; setLoading: SetLoading; - uploadInProgress: boolean; - setUploadInProgress: (value: boolean) => void; + setShouldDisableDropzone: (value: boolean) => void; showCollectionSelector: () => void; setFiles: SetFiles; + setCollections: SetCollections; isFirstUpload: boolean; - electronFiles: ElectronFile[]; - setElectronFiles: (files: ElectronFile[]) => void; - webFiles: File[]; - setWebFiles: (files: File[]) => void; uploadTypeSelectorView: boolean; - setUploadTypeSelectorView: (open: boolean) => void; showSessionExpiredMessage: () => void; showUploadFilesDialog: () => void; showUploadDirsDialog: () => void; + webFolderSelectorFiles: File[]; + webFileSelectorFiles: File[]; + dragAndDropFiles: File[]; } -enum UPLOAD_STRATEGY { - SINGLE_COLLECTION, - COLLECTION_PER_FOLDER, -} - -export enum UPLOAD_TYPE { - FILES = 'files', - FOLDERS = 'folders', - ZIPS = 'zips', -} - -interface AnalysisResult { - suggestedCollectionName: string; - multipleFolders: boolean; -} - -const NULL_ANALYSIS_RESULT = { - suggestedCollectionName: '', - multipleFolders: false, -}; - export default function Uploader(props: Props) { const [uploadProgressView, setUploadProgressView] = useState(false); - const [uploadStage, setUploadStage] = useState(); + const [uploadStage, setUploadStage] = useState( + UPLOAD_STAGES.START + ); const [uploadFileNames, setUploadFileNames] = useState(); const [uploadCounter, setUploadCounter] = useState({ finished: 0, @@ -97,19 +92,33 @@ export default function Uploader(props: Props) { const [hasLivePhotos, setHasLivePhotos] = useState(false); const [choiceModalView, setChoiceModalView] = useState(false); - const [analysisResult, setAnalysisResult] = - useState(NULL_ANALYSIS_RESULT); + const [importSuggestion, setImportSuggestion] = useState( + DEFAULT_IMPORT_SUGGESTION + ); const appContext = useContext(AppContext); const galleryContext = useContext(GalleryContext); const toUploadFiles = useRef(null); const isPendingDesktopUpload = useRef(false); const pendingDesktopUploadCollectionName = useRef(''); - const uploadType = useRef(null); + // This is set when the user choses a type to upload from the upload type selector dialog + const pickedUploadType = useRef(null); const zipPaths = useRef(null); + const currentUploadPromise = useRef>(null); + const [electronFiles, setElectronFiles] = useState(null); + const [webFiles, setWebFiles] = useState([]); + + const closeUploadProgress = () => setUploadProgressView(false); + + const setCollectionName = (collectionName: string) => { + isPendingDesktopUpload.current = true; + pendingDesktopUploadCollectionName.current = collectionName; + }; + + const uploadRunning = useRef(false); useEffect(() => { - UploadManager.initUploader( + UploadManager.init( { setPercentComplete, setUploadCounter, @@ -128,19 +137,60 @@ export default function Uploader(props: Props) { resumeDesktopUpload(type, electronFiles, collectionName); } ); + watchFolderService.init( + setElectronFiles, + setCollectionName, + props.syncWithRemote, + appContext.setIsFolderSyncRunning + ); } }, []); + // this handles the change of selectorFiles changes on web when user selects + // files for upload through the opened file/folder selector or dragAndDrop them + // the webFiles state is update which triggers the upload of those files + useEffect(() => { + if (appContext.watchFolderView) { + // if watch folder dialog is open don't catch the dropped file + // as they are folder being dropped for watching + return; + } + if ( + pickedUploadType.current === PICKED_UPLOAD_TYPE.FOLDERS && + props.webFolderSelectorFiles?.length > 0 + ) { + setWebFiles(props.webFolderSelectorFiles); + } else if ( + pickedUploadType.current === PICKED_UPLOAD_TYPE.FILES && + props.webFileSelectorFiles?.length > 0 + ) { + setWebFiles(props.webFileSelectorFiles); + } else if (props.dragAndDropFiles?.length > 0) { + setWebFiles(props.dragAndDropFiles); + } + }, [ + props.dragAndDropFiles, + props.webFileSelectorFiles, + props.webFolderSelectorFiles, + ]); + useEffect(() => { if ( - props.electronFiles?.length > 0 || - props.webFiles?.length > 0 || + electronFiles?.length > 0 || + webFiles?.length > 0 || appContext.sharedFiles?.length > 0 ) { - if (props.uploadInProgress) { - // no-op - // a upload is already in progress - } else if (isCanvasBlocked()) { + if (uploadRunning.current) { + if (watchFolderService.isUploadRunning()) { + // pause watch folder service on user upload + watchFolderService.pauseRunningSync(); + } else { + // no-op + // a user upload is already in progress + return; + } + } + if (isCanvasBlocked()) { appContext.setDialogMessage({ title: constants.CANVAS_BLOCKED_TITLE, @@ -152,126 +202,79 @@ export default function Uploader(props: Props) { variant: 'accent', }, }); - } else { - props.setLoading(true); - if (props.webFiles?.length > 0) { - // File selection by drag and drop or selection of file. - toUploadFiles.current = props.webFiles; - props.setWebFiles([]); - } else if (appContext.sharedFiles?.length > 0) { - toUploadFiles.current = appContext.sharedFiles; - appContext.resetSharedFiles(); - } else if (props.electronFiles?.length > 0) { - // File selection from desktop app - toUploadFiles.current = props.electronFiles; - props.setElectronFiles([]); - } - const analysisResult = analyseUploadFiles(); - setAnalysisResult(analysisResult); - - handleCollectionCreationAndUpload( - analysisResult, - props.isFirstUpload - ); - props.setLoading(false); + return; + } + uploadRunning.current = true; + props.closeUploadTypeSelector(); + props.setLoading(true); + if (webFiles?.length > 0) { + // File selection by drag and drop or selection of file. + toUploadFiles.current = webFiles; + setWebFiles([]); + } else if (appContext.sharedFiles?.length > 0) { + toUploadFiles.current = appContext.sharedFiles; + appContext.resetSharedFiles(); + } else if (electronFiles?.length > 0) { + // File selection from desktop app + toUploadFiles.current = electronFiles; + setElectronFiles([]); } - } - }, [props.webFiles, appContext.sharedFiles, props.electronFiles]); - const uploadInit = function () { - setUploadStage(UPLOAD_STAGES.START); - setUploadCounter({ finished: 0, total: 0 }); - setInProgressUploads([]); - setFinishedUploads(new Map()); - setPercentComplete(0); - props.closeCollectionSelector(); - setUploadProgressView(true); - }; + toUploadFiles.current = filterOutSystemFiles(toUploadFiles.current); + if (toUploadFiles.current.length === 0) { + props.setLoading(false); + return; + } + + const importSuggestion = getImportSuggestion( + pickedUploadType.current, + toUploadFiles.current + ); + setImportSuggestion(importSuggestion); + + handleCollectionCreationAndUpload( + importSuggestion, + props.isFirstUpload, + pickedUploadType.current + ); + pickedUploadType.current = null; + props.setLoading(false); + } + }, [webFiles, appContext.sharedFiles, electronFiles]); const resumeDesktopUpload = async ( - type: UPLOAD_TYPE, + type: PICKED_UPLOAD_TYPE, electronFiles: ElectronFile[], collectionName: string ) => { if (electronFiles && electronFiles?.length > 0) { isPendingDesktopUpload.current = true; pendingDesktopUploadCollectionName.current = collectionName; - uploadType.current = type; - props.setElectronFiles(electronFiles); + pickedUploadType.current = type; + setElectronFiles(electronFiles); } }; - function analyseUploadFiles(): AnalysisResult { - if (isElectron() && uploadType.current === UPLOAD_TYPE.FILES) { - return NULL_ANALYSIS_RESULT; - } - - const paths: string[] = toUploadFiles.current.map( - (file) => file['path'] - ); - const getCharCount = (str: string) => (str.match(/\//g) ?? []).length; - paths.sort((path1, path2) => getCharCount(path1) - getCharCount(path2)); - const firstPath = paths[0]; - const lastPath = paths[paths.length - 1]; - - const L = firstPath.length; - let i = 0; - const firstFileFolder = firstPath.substring( - 0, - firstPath.lastIndexOf('/') - ); - const lastFileFolder = lastPath.substring(0, lastPath.lastIndexOf('/')); - while (i < L && firstPath.charAt(i) === lastPath.charAt(i)) i++; - let commonPathPrefix = firstPath.substring(0, i); - - if (commonPathPrefix) { - commonPathPrefix = commonPathPrefix.substring( - 0, - commonPathPrefix.lastIndexOf('/') - ); - if (commonPathPrefix) { - commonPathPrefix = commonPathPrefix.substring( - commonPathPrefix.lastIndexOf('/') + 1 - ); - } - } - return { - suggestedCollectionName: commonPathPrefix || null, - multipleFolders: firstFileFolder !== lastFileFolder, - }; - } - function getCollectionWiseFiles() { - const collectionWiseFiles = new Map(); - for (const file of toUploadFiles.current) { - const filePath = file['path'] as string; - - let folderPath = filePath.substring(0, filePath.lastIndexOf('/')); - if (folderPath.endsWith(METADATA_FOLDER_NAME)) { - folderPath = folderPath.substring( - 0, - folderPath.lastIndexOf('/') - ); - } - const folderName = folderPath.substring( - folderPath.lastIndexOf('/') + 1 - ); - if (!collectionWiseFiles.has(folderName)) { - collectionWiseFiles.set(folderName, []); - } - collectionWiseFiles.get(folderName).push(file); - } - return collectionWiseFiles; - } + const preCollectionCreationAction = async () => { + props.closeCollectionSelector(); + props.setShouldDisableDropzone(!uploadManager.shouldAllowNewUpload()); + setUploadStage(UPLOAD_STAGES.START); + setUploadProgressView(true); + }; const uploadFilesToExistingCollection = async (collection: Collection) => { try { + await preCollectionCreationAction(); const filesWithCollectionToUpload: FileWithCollection[] = toUploadFiles.current.map((file, index) => ({ file, localID: index, collectionID: collection.id, })); - await uploadFiles(filesWithCollectionToUpload, [collection]); + waitInQueueAndUploadFiles(filesWithCollectionToUpload, [ + collection, + ]); + toUploadFiles.current = null; } catch (e) { logError(e, 'Failed to upload files to existing collections'); } @@ -282,27 +285,41 @@ export default function Uploader(props: Props) { collectionName?: string ) => { try { + await preCollectionCreationAction(); const filesWithCollectionToUpload: FileWithCollection[] = []; const collections: Collection[] = []; - let collectionWiseFiles = new Map< + let collectionNameToFilesMap = new Map< string, (File | ElectronFile)[] >(); if (strategy === UPLOAD_STRATEGY.SINGLE_COLLECTION) { - collectionWiseFiles.set(collectionName, toUploadFiles.current); + collectionNameToFilesMap.set( + collectionName, + toUploadFiles.current + ); } else { - collectionWiseFiles = getCollectionWiseFiles(); + collectionNameToFilesMap = groupFilesBasedOnParentFolder( + toUploadFiles.current + ); } try { - const existingCollection = await syncCollections(); + const existingCollection = getUserOwnedCollections( + await syncCollections() + ); let index = 0; - for (const [collectionName, files] of collectionWiseFiles) { + for (const [ + collectionName, + files, + ] of collectionNameToFilesMap) { const collection = await createAlbum( collectionName, existingCollection ); collections.push(collection); - + props.setCollections([ + ...existingCollection, + ...collections, + ]); filesWithCollectionToUpload.push( ...files.map((file) => ({ localID: index++, @@ -312,7 +329,7 @@ export default function Uploader(props: Props) { ); } } catch (e) { - setUploadProgressView(false); + closeUploadProgress(); logError(e, 'Failed to create album'); appContext.setDialogMessage({ title: constants.ERROR, @@ -322,64 +339,105 @@ export default function Uploader(props: Props) { }); throw e; } - await uploadFiles(filesWithCollectionToUpload, collections); + waitInQueueAndUploadFiles(filesWithCollectionToUpload, collections); + toUploadFiles.current = null; } catch (e) { logError(e, 'Failed to upload files to new collections'); } }; + const waitInQueueAndUploadFiles = ( + filesWithCollectionToUploadIn: FileWithCollection[], + collections: Collection[] + ) => { + const currentPromise = currentUploadPromise.current; + currentUploadPromise.current = waitAndRun( + currentPromise, + async () => + await uploadFiles(filesWithCollectionToUploadIn, collections) + ); + }; + + const preUploadAction = async () => { + uploadManager.prepareForNewUpload(); + setUploadProgressView(true); + await props.syncWithRemote(true, true); + }; + + function postUploadAction() { + props.setShouldDisableDropzone(false); + uploadRunning.current = false; + props.syncWithRemote(); + } + const uploadFiles = async ( - filesWithCollectionToUpload: FileWithCollection[], + filesWithCollectionToUploadIn: FileWithCollection[], collections: Collection[] ) => { try { - uploadInit(); - props.setUploadInProgress(true); - props.closeCollectionSelector(); - await props.syncWithRemote(true, true); - if (isElectron() && !isPendingDesktopUpload.current) { + preUploadAction(); + if ( + isElectron() && + !isPendingDesktopUpload.current && + !watchFolderService.isUploadRunning() + ) { await ImportService.setToUploadCollection(collections); if (zipPaths.current) { await ImportService.setToUploadFiles( - UPLOAD_TYPE.ZIPS, + PICKED_UPLOAD_TYPE.ZIPS, zipPaths.current ); zipPaths.current = null; } await ImportService.setToUploadFiles( - UPLOAD_TYPE.FILES, - filesWithCollectionToUpload.map( + PICKED_UPLOAD_TYPE.FILES, + filesWithCollectionToUploadIn.map( ({ file }) => (file as ElectronFile).path ) ); } - await uploadManager.queueFilesForUpload( - filesWithCollectionToUpload, - collections - ); + const shouldCloseUploadProgress = + await uploadManager.queueFilesForUpload( + filesWithCollectionToUploadIn, + collections + ); + if (shouldCloseUploadProgress) { + closeUploadProgress(); + } + if (isElectron()) { + if (watchFolderService.isUploadRunning()) { + await watchFolderService.allFileUploadsDone( + filesWithCollectionToUploadIn, + collections + ); + } else if (watchFolderService.isSyncPaused()) { + // resume the service after user upload is done + watchFolderService.resumePausedSync(); + } + } } catch (err) { showUserFacingError(err.message); - setUploadProgressView(false); + closeUploadProgress(); throw err; } finally { - props.setUploadInProgress(false); - props.syncWithRemote(); + postUploadAction(); } }; const retryFailed = async () => { try { - props.setUploadInProgress(true); - uploadInit(); - await props.syncWithRemote(true, true); - await uploadManager.retryFailedFiles(); + const filesWithCollections = + await uploadManager.getFailedFilesWithCollections(); + await preUploadAction(); + await uploadManager.queueFilesForUpload( + filesWithCollections.files, + filesWithCollections.collections + ); } catch (err) { showUserFacingError(err.message); - - setUploadProgressView(false); + closeUploadProgress(); } finally { - props.setUploadInProgress(false); - props.syncWithRemote(); + postUploadAction(); } }; @@ -393,8 +451,8 @@ export default function Uploader(props: Props) { variant: 'danger', message: constants.SUBSCRIPTION_EXPIRED, action: { - text: constants.UPGRADE_NOW, - callback: galleryContext.showPlanSelectorModal, + text: constants.RENEW_NOW, + callback: billingService.redirectToCustomerPortal, }, }; break; @@ -403,7 +461,7 @@ export default function Uploader(props: Props) { variant: 'danger', message: constants.STORAGE_QUOTA_EXCEEDED, action: { - text: constants.RENEW_NOW, + text: constants.UPGRADE_NOW, callback: galleryContext.showPlanSelectorModal, }, icon: , @@ -438,8 +496,9 @@ export default function Uploader(props: Props) { }; const handleCollectionCreationAndUpload = ( - analysisResult: AnalysisResult, - isFirstUpload: boolean + importSuggestion: ImportSuggestion, + isFirstUpload: boolean, + pickedUploadType: PICKED_UPLOAD_TYPE ) => { if (isPendingDesktopUpload.current) { isPendingDesktopUpload.current = false; @@ -455,21 +514,19 @@ export default function Uploader(props: Props) { } return; } - if (isElectron() && uploadType.current === UPLOAD_TYPE.ZIPS) { + if (isElectron() && pickedUploadType === PICKED_UPLOAD_TYPE.ZIPS) { uploadFilesToNewCollections(UPLOAD_STRATEGY.COLLECTION_PER_FOLDER); return; } - if (isFirstUpload && !analysisResult.suggestedCollectionName) { - analysisResult.suggestedCollectionName = FIRST_ALBUM_NAME; + if (isFirstUpload && !importSuggestion.rootFolderName) { + importSuggestion.rootFolderName = FIRST_ALBUM_NAME; } let showNextModal = () => {}; - if (analysisResult.multipleFolders) { + if (importSuggestion.hasNestedFolders) { showNextModal = () => setChoiceModalView(true); } else { showNextModal = () => - uploadToSingleNewCollection( - analysisResult.suggestedCollectionName - ); + uploadToSingleNewCollection(importSuggestion.rootFolderName); } props.setCollectionSelectorAttributes({ callback: uploadFilesToExistingCollection, @@ -477,12 +534,12 @@ export default function Uploader(props: Props) { title: constants.UPLOAD_TO_COLLECTION, }); }; - const handleDesktopUpload = async (type: UPLOAD_TYPE) => { + const handleDesktopUpload = async (type: PICKED_UPLOAD_TYPE) => { let files: ElectronFile[]; - uploadType.current = type; - if (type === UPLOAD_TYPE.FILES) { + pickedUploadType.current = type; + if (type === PICKED_UPLOAD_TYPE.FILES) { files = await ImportService.showUploadFilesDialog(); - } else if (type === UPLOAD_TYPE.FOLDERS) { + } else if (type === PICKED_UPLOAD_TYPE.FOLDERS) { files = await ImportService.showUploadDirsDialog(); } else { const response = await ImportService.showUploadZipDialog(); @@ -490,33 +547,26 @@ export default function Uploader(props: Props) { zipPaths.current = response.zipPaths; } if (files?.length > 0) { - props.setElectronFiles(files); - props.setUploadTypeSelectorView(false); + setElectronFiles(files); + props.closeUploadTypeSelector(); } }; - const handleWebUpload = async (type: UPLOAD_TYPE) => { - uploadType.current = type; - if (type === UPLOAD_TYPE.FILES) { + const handleWebUpload = async (type: PICKED_UPLOAD_TYPE) => { + pickedUploadType.current = type; + if (type === PICKED_UPLOAD_TYPE.FILES) { props.showUploadFilesDialog(); - } else if (type === UPLOAD_TYPE.FOLDERS) { + } else if (type === PICKED_UPLOAD_TYPE.FOLDERS) { props.showUploadDirsDialog(); } else { appContext.setDialogMessage(getDownloadAppMessage()); } }; - const cancelUploads = async () => { - setUploadProgressView(false); - if (isElectron()) { - ImportService.cancelRemainingUploads(); - } - props.setUploadInProgress(false); - Router.reload(); + const cancelUploads = () => { + uploadManager.cancelRunningUpload(); }; - const closeUploadProgress = () => setUploadProgressView(false); - const handleUpload = (type) => () => { if (isElectron() && importService.checkAllElectronAPIsExists()) { handleDesktopUpload(type); @@ -525,11 +575,9 @@ export default function Uploader(props: Props) { } }; - const handleFileUpload = handleUpload(UPLOAD_TYPE.FILES); - const handleFolderUpload = handleUpload(UPLOAD_TYPE.FOLDERS); - const handleZipUpload = handleUpload(UPLOAD_TYPE.ZIPS); - const closeUploadTypeSelector = () => - props.setUploadTypeSelectorView(false); + const handleFileUpload = handleUpload(PICKED_UPLOAD_TYPE.FILES); + const handleFolderUpload = handleUpload(PICKED_UPLOAD_TYPE.FOLDERS); + const handleZipUpload = handleUpload(PICKED_UPLOAD_TYPE.ZIPS); return ( <> @@ -537,9 +585,7 @@ export default function Uploader(props: Props) { open={choiceModalView} onClose={() => setChoiceModalView(false)} uploadToSingleCollection={() => - uploadToSingleNewCollection( - analysisResult.suggestedCollectionName - ) + uploadToSingleNewCollection(importSuggestion.rootFolderName) } uploadToMultipleCollection={() => uploadFilesToNewCollections( @@ -549,7 +595,7 @@ export default function Uploader(props: Props) { /> void; + buttonText: string; +} + +export default function VerifyMasterPasswordForm({ + user, + keyAttributes, + callback, + buttonText, +}: VerifyMasterPasswordFormProps) { + const verifyPassphrase: SingleInputFormProps['callback'] = async ( + passphrase, + setFieldError + ) => { + try { + const cryptoWorker = await new CryptoWorker(); + let kek: string = null; + try { + kek = await cryptoWorker.deriveKey( + passphrase, + keyAttributes.kekSalt, + keyAttributes.opsLimit, + keyAttributes.memLimit + ); + } catch (e) { + logError(e, 'failed to derive key'); + throw Error(CustomError.WEAK_DEVICE); + } + try { + const key: string = await cryptoWorker.decryptB64( + keyAttributes.encryptedKey, + keyAttributes.keyDecryptionNonce, + kek + ); + callback(key, passphrase); + } catch (e) { + logError(e, 'user entered a wrong password'); + throw Error(CustomError.INCORRECT_PASSWORD); + } + } catch (e) { + switch (e.message) { + case CustomError.WEAK_DEVICE: + setFieldError(constants.WEAK_DEVICE); + break; + case CustomError.INCORRECT_PASSWORD: + setFieldError(constants.INCORRECT_PASSPHRASE); + break; + default: + setFieldError(`${constants.UNKNOWN_ERROR} ${e.message}`); + } + } + }; + + return ( +