diff --git a/README.md b/README.md index d90d2e1340c0796559bf7883bda64263eaf9607a..ea27599daa815a02861d4c8d57167a0e7d813f5e 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 55ea31c8f7087cbee758b531c34ad4955312d3ec..b454d8592d678dcf915c142fe5f9b4253ada4cb7 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 8e87b1279af566de44998822ae88d6438662147b..265f647daaf232aa2600dbc4079da53d009bd34f 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 0000000000000000000000000000000000000000..b8288cee8e750d871e3ba52d53c7d79e1aba61c6 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 0000000000000000000000000000000000000000..31c9014bc6f0f164f8a4b7824d009df7f56cd988 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 0000000000000000000000000000000000000000..7be1e70dedf9abeeb0f5ce726f330a0d65ccc87a 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 0000000000000000000000000000000000000000..092119d3465528190daf7fc44d8bd1e3b205ad5f --- /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 e89bc5eadf74756650d296a618d904c7335268a2..cfaa96ce114ca9eb38b34ee0a03899a89c9e7402 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 06d16798d200319bcae52c5f2bf4919ddfb8ec6a..b60e93e0510137e35408861710cf6a046469e860 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 76844f93b2bf9db227c19f9b25bcce2b9dc495cb..bcb7464ca67f244a1058f2e9f4aebc51a7027cc6 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 0000000000000000000000000000000000000000..94478135c91e0b90fcaec1775e81e78faba8687e --- /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 6db198c753ee35f695740d6f169d07b86af221cc..395c55cc6f1e7a62e9c021332d44fba73e1e3b86 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 1d0e328ef95ad6ced3cd2f49eec6fde8bd0edf32..4609f63fdc5ea8e8f71cd64427d38a0a01dee225 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 4f0e96da5483e567faed70bcee9efc5af33ecd50..0450b43fef2590ed1e500f077aa542aab6d91b77 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 41ad96fb10b62bd244224e44aefbac530baacc5e..e48f41cf393f8ef8f18725b4e1429e2b6262faa2 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 41f4b556743a83d34f39d7673535d0ef7546c8e9..11ccc40366a9ff65a607c54187bdcde29671eb1f 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 72a428af1e0f86344b2626fcb10f3eb9e2e9d319..b2157f12cce6fb338bc22fec7c5cf311a5cc1cf4 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 6ecbb72a7ebb1bb769e067e96582e0fc97d75aae..b77813bb2228842f7b82e03d09d79e81c2ac24cf 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 4531df6a99f9b0b8cb2d48dffb28b61610784d3d..d62739a29061f10d417cfcb98d84c4c082d001cf 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 f0d2160f1f88be5fcd9763319f358d735cdc945d..37f5fd6767694ebf5e798bd0b2f79a43dcba70de 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 e802a83a0c85652f419b3e13cc47bce9a22929fa..72667ae1ca25eac71ccee2097dd741900e1cf910 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 24bbe30bd3126a1cf90ca4d540362beedd388eda..5d538f0cb4f72d1c9e5f0166ce19bf365d7cd4e6 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 c314dcd98e2e7098835e84259dfd558a6bd0917c..1d860083ffff05cf22a65598b984659130378225 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); + 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([]); + } - handleCollectionCreationAndUpload( - analysisResult, - props.isFirstUpload - ); + toUploadFiles.current = filterOutSystemFiles(toUploadFiles.current); + if (toUploadFiles.current.length === 0) { props.setLoading(false); + return; } - } - }, [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); - }; + 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 ( +