瀏覽代碼

Merge branch 'master' into ui-redesign

Abhinav 3 年之前
父節點
當前提交
40ad1d2db2
共有 61 個文件被更改,包括 1804 次插入565 次删除
  1. 72 0
      .github/workflows/codeql-analysis.yml
  2. 1 1
      .gitmodules
  3. 1 1
      package.json
  4. 1 0
      public/robots.txt
  5. 41 11
      src/components/PhotoFrame.tsx
  6. 5 1
      src/components/Sidebar/HelpSection.tsx
  7. 93 1
      src/components/Sidebar/SubscriptionDetails.tsx
  8. 4 1
      src/components/Sidebar/index.tsx
  9. 7 4
      src/components/Sidebar/userDetailsSection.tsx
  10. 13 2
      src/components/UploadProgress/dialog.tsx
  11. 4 1
      src/components/UploadProgress/index.tsx
  12. 20 13
      src/components/pages/dedupe/SelectedFileOptions.tsx
  13. 72 57
      src/components/pages/gallery/PlanSelector.tsx
  14. 12 7
      src/components/pages/gallery/PreviewCard.tsx
  15. 118 66
      src/components/pages/gallery/Upload.tsx
  16. 5 1
      src/constants/upload/index.ts
  17. 1 1
      src/constants/user/index.ts
  18. 34 12
      src/pages/_app.tsx
  19. 6 0
      src/pages/deduplicate/index.tsx
  20. 7 1
      src/pages/gallery/index.tsx
  21. 3 4
      src/pages/two-factor/verify/index.tsx
  22. 16 1
      src/services/billingService.ts
  23. 104 4
      src/services/deduplicationService.ts
  24. 2 6
      src/services/ffmpeg/ffmpegClient.ts
  25. 12 4
      src/services/ffmpeg/ffmpegService.ts
  26. 18 1
      src/services/fileService.ts
  27. 2 1
      src/services/heicConverter/heicConverterClient.ts
  28. 41 11
      src/services/importService.ts
  29. 16 9
      src/services/migrateThumbnailService.ts
  30. 64 27
      src/services/readerService.ts
  31. 1 1
      src/services/trashService.ts
  32. 6 7
      src/services/typeDetectionService.ts
  33. 1 2
      src/services/updateCreationTimeWithExif.ts
  34. 11 2
      src/services/upload/exifService.ts
  35. 4 6
      src/services/upload/fileService.ts
  36. 111 95
      src/services/upload/livePhotoService.ts
  37. 51 30
      src/services/upload/metadataService.ts
  38. 20 7
      src/services/upload/multiPartUploadService.ts
  39. 4 5
      src/services/upload/thumbnailService.ts
  40. 76 1
      src/services/upload/uploadHttpClient.ts
  41. 107 73
      src/services/upload/uploadManager.ts
  42. 32 15
      src/services/upload/uploadService.ts
  43. 29 12
      src/services/upload/uploader.ts
  44. 10 5
      src/services/upload/videoMetadataService.ts
  45. 73 8
      src/services/userService.ts
  46. 3 0
      src/types/upload/index.ts
  47. 14 0
      src/types/user/index.ts
  48. 68 13
      src/utils/billing/index.ts
  49. 16 0
      src/utils/common/apiUtil.ts
  50. 16 1
      src/utils/common/deviceDetection.ts
  51. 40 2
      src/utils/common/index.ts
  52. 15 0
      src/utils/crypto/index.ts
  53. 30 0
      src/utils/crypto/libsodium.ts
  54. 2 0
      src/utils/error/index.ts
  55. 10 12
      src/utils/file/index.ts
  56. 11 2
      src/utils/sentry/index.ts
  57. 10 2
      src/utils/storage/localStorage.ts
  58. 36 1
      src/utils/strings/englishConstants.tsx
  59. 87 0
      src/utils/time/index.ts
  60. 62 14
      src/utils/upload/index.ts
  61. 53 0
      src/utils/upload/isCanvasBlocked.ts

+ 72 - 0
.github/workflows/codeql-analysis.yml

@@ -0,0 +1,72 @@
+# For most projects, this workflow file will not need changing; you simply need
+# to commit it to your repository.
+#
+# You may wish to alter this file to override the set of languages analyzed,
+# or to provide custom queries or build logic.
+#
+# ******** NOTE ********
+# We have attempted to detect the languages in your repository. Please check
+# the `language` matrix defined below to confirm you have the correct set of
+# supported CodeQL languages.
+#
+name: "CodeQL"
+
+on:
+  push:
+    branches: [ master, release ]
+  pull_request:
+    # The branches below must be a subset of the branches above
+    branches: [ master ]
+  schedule:
+    - cron: '34 0 * * 2'
+
+jobs:
+  analyze:
+    name: Analyze
+    runs-on: ubuntu-latest
+    permissions:
+      actions: read
+      contents: read
+      security-events: write
+
+    strategy:
+      fail-fast: false
+      matrix:
+        language: [ 'javascript' ]
+        # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ]
+        # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support
+
+    steps:
+    - name: Checkout repository
+      uses: actions/checkout@v3
+
+    # Initializes the CodeQL tools for scanning.
+    - name: Initialize CodeQL
+      uses: github/codeql-action/init@v2
+      with:
+        languages: ${{ matrix.language }}
+        # If you wish to specify custom queries, you can do so here or in a config file.
+        # By default, queries listed here will override any specified in a config file.
+        # Prefix the list here with "+" to use these queries and those in the config file.
+        
+        # Details on CodeQL's query packs refer to : https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs
+        # queries: security-extended,security-and-quality
+
+        
+    # Autobuild attempts to build any compiled languages  (C/C++, C#, or Java).
+    # If this step fails, then you should remove it and run the build manually (see below)
+    - name: Autobuild
+      uses: github/codeql-action/autobuild@v2
+
+    # ℹ️ Command-line programs to run using the OS shell.
+    # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
+
+    #   If the Autobuild fails above, remove it and uncomment the following three lines. 
+    #   modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance.
+
+    # - run: |
+    #   echo "Run, Build Application using script"
+    #   ./location_of_script_within_repo/buildscript.sh
+
+    - name: Perform CodeQL Analysis
+      uses: github/codeql-action/analyze@v2

+ 1 - 1
.gitmodules

@@ -4,5 +4,5 @@
 	branch = master 
 	branch = master 
 [submodule "ffmpeg-wasm"]
 [submodule "ffmpeg-wasm"]
 	path = thirdparty/ffmpeg-wasm
 	path = thirdparty/ffmpeg-wasm
-	url = git@github.com:abhinavkgrd/ffmpeg.wasm.git
+	url = https://github.com/abhinavkgrd/ffmpeg.wasm.git
 	branch = single-thread
 	branch = single-thread

+ 1 - 1
package.json

@@ -1,6 +1,6 @@
 {
 {
   "name": "bada-frame",
   "name": "bada-frame",
-  "version": "0.9.0",
+  "version": "0.9.1",
   "private": true,
   "private": true,
   "scripts": {
   "scripts": {
     "dev": "next dev",
     "dev": "next dev",

+ 1 - 0
public/robots.txt

@@ -1,2 +1,3 @@
 User-agent: *
 User-agent: *
+Allow: /.well-known/*
 Disallow:
 Disallow:

+ 41 - 11
src/components/PhotoFrame.tsx

@@ -27,6 +27,8 @@ import { DeduplicateContext } from 'pages/deduplicate';
 import { IsArchived } from 'utils/magicMetadata';
 import { IsArchived } from 'utils/magicMetadata';
 import { isSameDayAnyYear, isInsideBox } from 'utils/search';
 import { isSameDayAnyYear, isInsideBox } from 'utils/search';
 import { Search } from 'types/search';
 import { Search } from 'types/search';
+import { logError } from 'utils/sentry';
+import { CustomError } from 'utils/error';
 
 
 const Container = styled.div`
 const Container = styled.div`
     display: block;
     display: block;
@@ -61,6 +63,7 @@ interface Props {
     activeCollection: number;
     activeCollection: number;
     isSharedCollection?: boolean;
     isSharedCollection?: boolean;
     enableDownload?: boolean;
     enableDownload?: boolean;
+    isDeduplicating?: boolean;
 }
 }
 
 
 type SourceURL = {
 type SourceURL = {
@@ -84,6 +87,7 @@ const PhotoFrame = ({
     activeCollection,
     activeCollection,
     isSharedCollection,
     isSharedCollection,
     enableDownload,
     enableDownload,
+    isDeduplicating,
 }: Props) => {
 }: Props) => {
     const [open, setOpen] = useState(false);
     const [open, setOpen] = useState(false);
     const [currentIndex, setCurrentIndex] = useState<number>(0);
     const [currentIndex, setCurrentIndex] = useState<number>(0);
@@ -192,6 +196,7 @@ const PhotoFrame = ({
                     return false;
                     return false;
                 }
                 }
                 if (
                 if (
+                    !isDeduplicating &&
                     activeCollection === ALL_SECTION &&
                     activeCollection === ALL_SECTION &&
                     (IsArchived(item) ||
                     (IsArchived(item) ||
                         archivedCollections?.has(item.collectionID))
                         archivedCollections?.has(item.collectionID))
@@ -242,7 +247,15 @@ const PhotoFrame = ({
         }
         }
     }, [open]);
     }, [open]);
 
 
-    const updateURL = (index: number) => (url: string) => {
+    const getFileIndexFromID = (files: EnteFile[], id: number) => {
+        const index = files.findIndex((file) => file.id === id);
+        if (index === -1) {
+            throw CustomError.FILE_ID_NOT_FOUND;
+        }
+        return index;
+    };
+
+    const updateURL = (id: number) => (url: string) => {
         const updateFile = (file: EnteFile) => {
         const updateFile = (file: EnteFile) => {
             file = {
             file = {
                 ...file,
                 ...file,
@@ -280,13 +293,15 @@ const PhotoFrame = ({
             return file;
             return file;
         };
         };
         setFiles((files) => {
         setFiles((files) => {
+            const index = getFileIndexFromID(files, id);
             files[index] = updateFile(files[index]);
             files[index] = updateFile(files[index]);
             return files;
             return files;
         });
         });
+        const index = getFileIndexFromID(files, id);
         return updateFile(files[index]);
         return updateFile(files[index]);
     };
     };
 
 
-    const updateSrcURL = async (index: number, srcURL: SourceURL) => {
+    const updateSrcURL = async (id: number, srcURL: SourceURL) => {
         const { videoURL, imageURL } = srcURL;
         const { videoURL, imageURL } = srcURL;
         const isPlayable = videoURL && (await isPlaybackPossible(videoURL));
         const isPlayable = videoURL && (await isPlaybackPossible(videoURL));
         const updateFile = (file: EnteFile) => {
         const updateFile = (file: EnteFile) => {
@@ -342,10 +357,12 @@ const PhotoFrame = ({
             return file;
             return file;
         };
         };
         setFiles((files) => {
         setFiles((files) => {
+            const index = getFileIndexFromID(files, id);
             files[index] = updateFile(files[index]);
             files[index] = updateFile(files[index]);
             return files;
             return files;
         });
         });
         setIsSourceLoaded(true);
         setIsSourceLoaded(true);
+        const index = getFileIndexFromID(files, id);
         return updateFile(files[index]);
         return updateFile(files[index]);
     };
     };
 
 
@@ -416,7 +433,7 @@ const PhotoFrame = ({
                     selected[files[index].id] ?? false
                     selected[files[index].id] ?? false
                 }`}
                 }`}
                 file={files[index]}
                 file={files[index]}
-                updateURL={updateURL(files[index].dataIndex)}
+                updateURL={updateURL(files[index].id)}
                 onClick={onThumbnailClick(index)}
                 onClick={onThumbnailClick(index)}
                 selectable={!isSharedCollection}
                 selectable={!isSharedCollection}
                 onSelect={handleSelect(files[index].id, index)}
                 onSelect={handleSelect(files[index].id, index)}
@@ -462,7 +479,7 @@ const PhotoFrame = ({
                     }
                     }
                     galleryContext.thumbs.set(item.id, url);
                     galleryContext.thumbs.set(item.id, url);
                 }
                 }
-                const newFile = updateURL(item.dataIndex)(url);
+                const newFile = updateURL(item.id)(url);
                 item.msrc = newFile.msrc;
                 item.msrc = newFile.msrc;
                 item.html = newFile.html;
                 item.html = newFile.html;
                 item.src = newFile.src;
                 item.src = newFile.src;
@@ -471,17 +488,23 @@ const PhotoFrame = ({
 
 
                 try {
                 try {
                     instance.invalidateCurrItems();
                     instance.invalidateCurrItems();
-                    instance.updateSize(true);
+                    if (instance.isOpen()) {
+                        instance.updateSize(true);
+                    }
                 } catch (e) {
                 } catch (e) {
+                    logError(
+                        e,
+                        'updating photoswipe after msrc url update failed'
+                    );
                     // ignore
                     // ignore
                 }
                 }
             } catch (e) {
             } catch (e) {
-                // no-op
+                logError(e, 'getSlideData failed get msrc url failed');
             }
             }
         }
         }
-        if (!fetching[item.dataIndex]) {
+        if (!fetching[item.id]) {
             try {
             try {
-                fetching[item.dataIndex] = true;
+                fetching[item.id] = true;
                 let urls: string[];
                 let urls: string[];
                 if (galleryContext.files.has(item.id)) {
                 if (galleryContext.files.has(item.id)) {
                     const mergedURL = galleryContext.files.get(item.id);
                     const mergedURL = galleryContext.files.get(item.id);
@@ -514,7 +537,7 @@ const PhotoFrame = ({
                     [imageURL] = urls;
                     [imageURL] = urls;
                 }
                 }
                 setIsSourceLoaded(false);
                 setIsSourceLoaded(false);
-                const newFile = await updateSrcURL(item.dataIndex, {
+                const newFile = await updateSrcURL(item.id, {
                     imageURL,
                     imageURL,
                     videoURL,
                     videoURL,
                 });
                 });
@@ -525,14 +548,21 @@ const PhotoFrame = ({
                 item.h = newFile.h;
                 item.h = newFile.h;
                 try {
                 try {
                     instance.invalidateCurrItems();
                     instance.invalidateCurrItems();
-                    instance.updateSize(true);
+                    if (instance.isOpen()) {
+                        instance.updateSize(true);
+                    }
                 } catch (e) {
                 } catch (e) {
+                    logError(
+                        e,
+                        'updating photoswipe after src url update failed'
+                    );
                     // ignore
                     // ignore
                 }
                 }
             } catch (e) {
             } catch (e) {
+                logError(e, 'getSlideData failed get src url failed');
                 // no-op
                 // no-op
             } finally {
             } finally {
-                fetching[item.dataIndex] = false;
+                fetching[item.id] = false;
             }
             }
         }
         }
     };
     };

+ 5 - 1
src/components/Sidebar/HelpSection.tsx

@@ -56,7 +56,11 @@ export default function HelpSection() {
                 {constants.REQUEST_FEATURE}
                 {constants.REQUEST_FEATURE}
             </SidebarButton>
             </SidebarButton>
             <SidebarButton onClick={initToSupportMail}>
             <SidebarButton onClick={initToSupportMail}>
-                {constants.SUPPORT}
+                <a
+                    style={{ textDecoration: 'none', color: 'inherit' }}
+                    href="mailto:contact@ente.io">
+                    {constants.SUPPORT}
+                </a>
             </SidebarButton>
             </SidebarButton>
             <SidebarButton onClick={exportFiles}>
             <SidebarButton onClick={exportFiles}>
                 <div style={{ display: 'flex' }}>
                 <div style={{ display: 'flex' }}>

+ 93 - 1
src/components/Sidebar/SubscriptionDetails.tsx

@@ -14,16 +14,41 @@ import { convertBytesToHumanReadable } from 'utils/billing';
 
 
 interface Iprops {
 interface Iprops {
     userDetails: UserDetails;
     userDetails: UserDetails;
+    closeSidebar: () => void;
 }
 }
 
 
 export default function SubscriptionDetails({ userDetails }: Iprops) {
 export default function SubscriptionDetails({ userDetails }: Iprops) {
+    // const { setDialogMessage } = useContext(AppContext);
+
+    // async function onLeaveFamilyClick() {
+    //     try {
+    //         await billingService.leaveFamily();
+    //         closeSidebar();
+    //     } catch (e) {
+    //         setDialogMessage({
+    //             title: constants.ERROR,
+    //             staticBackdrop: true,
+    //             close: { variant: 'danger' },
+    //             content: constants.UNKNOWN_ERROR,
+    //         });
+    //     }
+    // }
+
+    // const { showPlanSelectorModal } = useContext(GalleryContext);
+
+    // function onManageClick() {
+    //     closeSidebar();
+    //     showPlanSelectorModal();
+    // }
     return (
     return (
         <Box
         <Box
             display="flex"
             display="flex"
             flexDirection={'column'}
             flexDirection={'column'}
             height={160}
             height={160}
             bgcolor="accent.main"
             bgcolor="accent.main"
-            position={'relative'}>
+            // position={'relative'}
+            // onClick={onManageClick}
+        >
             {userDetails ? (
             {userDetails ? (
                 <>
                 <>
                     <Box padding={2}>
                     <Box padding={2}>
@@ -92,4 +117,71 @@ export default function SubscriptionDetails({ userDetails }: Iprops) {
             )}
             )}
         </Box>
         </Box>
     );
     );
+    {
+        /* {!hasNonAdminFamilyMembers(userDetails.familyData) ||
+            isFamilyAdmin(userDetails.familyData) ? (
+                <div style={{ color: '#959595' }}>
+                    {isSubscriptionActive(userDetails.subscription) ? (
+                        isOnFreePlan(userDetails.subscription) ? (
+                            constants.FREE_SUBSCRIPTION_INFO(
+                                userDetails.subscription?.expiryTime
+                            )
+                        ) : isSubscriptionCancelled(
+                              userDetails.subscription
+                          ) ? (
+                            constants.RENEWAL_CANCELLED_SUBSCRIPTION_INFO(
+                                userDetails.subscription?.expiryTime
+                            )
+                        ) : (
+                            constants.RENEWAL_ACTIVE_SUBSCRIPTION_INFO(
+                                userDetails.subscription?.expiryTime
+                            )
+                        )
+                    ) : (
+                        <p>{constants.SUBSCRIPTION_EXPIRED(onManageClick)}</p>
+                    )}
+                    <Button onClick={onManageClick}>
+                        {isSubscribed(userDetails.subscription)
+                            ? constants.MANAGE
+                            : constants.SUBSCRIBE}
+                    </Button>
+                </div>
+            ) : (
+                <div style={{ color: '#959595' }}>
+                    {constants.FAMILY_PLAN_MANAGE_ADMIN_ONLY(
+                        getFamilyPlanAdmin(userDetails.familyData)?.email
+                    )}
+                    <Button
+                        onClick={() =>
+                            setDialogMessage({
+                                title: `${constants.LEAVE_FAMILY}`,
+                                content: constants.LEAVE_FAMILY_CONFIRM,
+                                staticBackdrop: true,
+                                proceed: {
+                                    text: constants.LEAVE_FAMILY,
+                                    action: onLeaveFamilyClick,
+                                    variant: 'danger',
+                                },
+                                close: { text: constants.CANCEL },
+                            })
+                        }>
+                        {constants.LEAVE_FAMILY}
+                    </Button>
+                </div>
+            )}
+
+            {hasNonAdminFamilyMembers(userDetails.familyData)
+                ? constants.FAMILY_USAGE_INFO(
+                      userDetails.usage,
+                      convertBytesToHumanReadable(
+                          getStorage(userDetails.familyData)
+                      )
+                  )
+                : constants.USAGE_INFO(
+                      userDetails.usage,
+                      convertBytesToHumanReadable(
+                          userDetails.subscription?.storage
+                      )
+                  )} */
+    }
 }
 }

+ 4 - 1
src/components/Sidebar/index.tsx

@@ -21,7 +21,10 @@ export default function Sidebar({ collectionSummaries }: Iprops) {
         <DrawerSidebar open={sidebarView} onClose={closeSidebar}>
         <DrawerSidebar open={sidebarView} onClose={closeSidebar}>
             <HeaderSection closeSidebar={closeSidebar} />
             <HeaderSection closeSidebar={closeSidebar} />
             <PaddedDivider spaced />
             <PaddedDivider spaced />
-            <UserDetailsSection sidebarView={sidebarView} />
+            <UserDetailsSection
+                sidebarView={sidebarView}
+                closeSidebar={closeSidebar}
+            />
             <PaddedDivider invisible />
             <PaddedDivider invisible />
             <NavigationSection
             <NavigationSection
                 closeSidebar={closeSidebar}
                 closeSidebar={closeSidebar}

+ 7 - 4
src/components/Sidebar/userDetailsSection.tsx

@@ -2,7 +2,7 @@ import React, { useEffect } from 'react';
 import { SpaceBetweenFlex } from 'components/Container';
 import { SpaceBetweenFlex } from 'components/Container';
 import { PaddedDivider } from './styledComponents';
 import { PaddedDivider } from './styledComponents';
 import SubscriptionDetails from './SubscriptionDetails';
 import SubscriptionDetails from './SubscriptionDetails';
-import { getUserDetails } from 'services/userService';
+import { getUserDetailsV2 } from 'services/userService';
 import { UserDetails } from 'types/user';
 import { UserDetails } from 'types/user';
 import { LS_KEYS } from 'utils/storage/localStorage';
 import { LS_KEYS } from 'utils/storage/localStorage';
 import { useLocalState } from 'hooks/useLocalState';
 import { useLocalState } from 'hooks/useLocalState';
@@ -10,7 +10,7 @@ import { THEMES } from 'types/theme';
 import ThemeSwitcher from './ThemeSwitcher';
 import ThemeSwitcher from './ThemeSwitcher';
 import Typography from '@mui/material/Typography';
 import Typography from '@mui/material/Typography';
 
 
-export default function UserDetailsSection({ sidebarView }) {
+export default function UserDetailsSection({ sidebarView, closeSidebar }) {
     const [userDetails, setUserDetails] = useLocalState<UserDetails>(
     const [userDetails, setUserDetails] = useLocalState<UserDetails>(
         LS_KEYS.USER_DETAILS
         LS_KEYS.USER_DETAILS
     );
     );
@@ -21,7 +21,7 @@ export default function UserDetailsSection({ sidebarView }) {
             return;
             return;
         }
         }
         const main = async () => {
         const main = async () => {
-            const userDetails = await getUserDetails();
+            const userDetails = await getUserDetailsV2();
             setUserDetails(userDetails);
             setUserDetails(userDetails);
         };
         };
         main();
         main();
@@ -34,7 +34,10 @@ export default function UserDetailsSection({ sidebarView }) {
                 <ThemeSwitcher theme={theme} setTheme={setTheme} />
                 <ThemeSwitcher theme={theme} setTheme={setTheme} />
             </SpaceBetweenFlex>
             </SpaceBetweenFlex>
             <PaddedDivider invisible />
             <PaddedDivider invisible />
-            <SubscriptionDetails userDetails={userDetails} />
+            <SubscriptionDetails
+                userDetails={userDetails}
+                closeSidebar={closeSidebar}
+            />
         </>
         </>
     );
     );
 }
 }

+ 13 - 2
src/components/UploadProgress/dialog.tsx

@@ -7,7 +7,7 @@ import { UploadProgressHeader } from './header';
 import { InProgressSection } from './inProgressSection';
 import { InProgressSection } from './inProgressSection';
 import { ResultSection } from './resultSection';
 import { ResultSection } from './resultSection';
 import { NotUploadSectionHeader } from './styledComponents';
 import { NotUploadSectionHeader } from './styledComponents';
-import { DESKTOP_APP_DOWNLOAD_URL } from 'utils/common';
+import { getOSSpecificDesktopAppDownloadLink } from 'utils/common';
 import DialogBoxBase from 'components/DialogBox/base';
 import DialogBoxBase from 'components/DialogBox/base';
 export function UploadProgressDialog({
 export function UploadProgressDialog({
     handleClose,
     handleClose,
@@ -47,6 +47,17 @@ export function UploadProgressDialog({
                         fileUploadResult={FileUploadResults.UPLOADED}
                         fileUploadResult={FileUploadResults.UPLOADED}
                         sectionTitle={constants.SUCCESSFUL_UPLOADS}
                         sectionTitle={constants.SUCCESSFUL_UPLOADS}
                     />
                     />
+                    <ResultSection
+                        filenames={props.filenames}
+                        fileUploadResultMap={fileUploadResultMap}
+                        fileUploadResult={
+                            FileUploadResults.UPLOADED_WITH_STATIC_THUMBNAIL
+                        }
+                        sectionTitle={
+                            constants.THUMBNAIL_GENERATION_FAILED_UPLOADS
+                        }
+                        sectionInfo={constants.THUMBNAIL_GENERATION_FAILED_INFO}
+                    />
 
 
                     {props.uploadStage === UPLOAD_STAGES.FINISH &&
                     {props.uploadStage === UPLOAD_STAGES.FINISH &&
                         filesNotUploaded && (
                         filesNotUploaded && (
@@ -61,7 +72,7 @@ export function UploadProgressDialog({
                         fileUploadResult={FileUploadResults.BLOCKED}
                         fileUploadResult={FileUploadResults.BLOCKED}
                         sectionTitle={constants.BLOCKED_UPLOADS}
                         sectionTitle={constants.BLOCKED_UPLOADS}
                         sectionInfo={constants.ETAGS_BLOCKED(
                         sectionInfo={constants.ETAGS_BLOCKED(
-                            DESKTOP_APP_DOWNLOAD_URL
+                            getOSSpecificDesktopAppDownloadLink()
                         )}
                         )}
                     />
                     />
                     <ResultSection
                     <ResultSection

+ 4 - 1
src/components/UploadProgress/index.tsx

@@ -45,7 +45,10 @@ export default function UploadProgress(props: Props) {
             if (!fileUploadResultMap.has(progress)) {
             if (!fileUploadResultMap.has(progress)) {
                 fileUploadResultMap.set(progress, []);
                 fileUploadResultMap.set(progress, []);
             }
             }
-            if (progress !== FileUploadResults.UPLOADED) {
+            if (
+                progress !== FileUploadResults.UPLOADED &&
+                progress !== FileUploadResults.UPLOADED_WITH_STATIC_THUMBNAIL
+            ) {
                 filesNotUploaded = true;
                 filesNotUploaded = true;
             }
             }
             const fileList = fileUploadResultMap.get(progress);
             const fileList = fileUploadResultMap.get(progress);

+ 20 - 13
src/components/pages/dedupe/SelectedFileOptions.tsx

@@ -5,9 +5,10 @@ import DeleteIcon from 'components/icons/DeleteIcon';
 import React, { useContext } from 'react';
 import React, { useContext } from 'react';
 import styled from 'styled-components';
 import styled from 'styled-components';
 import { DeduplicateContext } from 'pages/deduplicate';
 import { DeduplicateContext } from 'pages/deduplicate';
-import LeftArrow from 'components/icons/LeftArrow';
 import { IconWithMessage } from 'components/IconWithMessage';
 import { IconWithMessage } from 'components/IconWithMessage';
 import { AppContext } from 'pages/_app';
 import { AppContext } from 'pages/_app';
+import CloseIcon from '@mui/icons-material/Close';
+import BackButton from '@mui/icons-material/ArrowBackOutlined';
 
 
 const VerticalLine = styled.div`
 const VerticalLine = styled.div`
     position: absolute;
     position: absolute;
@@ -17,16 +18,24 @@ const VerticalLine = styled.div`
     background: #303030;
     background: #303030;
 `;
 `;
 
 
+const CheckboxText = styled.div`
+    margin-left: 0.5em;
+    font-size: 16px;
+    margin-right: 0.8em;
+`;
+
 interface IProps {
 interface IProps {
     deleteFileHelper: () => void;
     deleteFileHelper: () => void;
     close: () => void;
     close: () => void;
     count: number;
     count: number;
+    clearSelection: () => void;
 }
 }
 
 
 export default function DeduplicateOptions({
 export default function DeduplicateOptions({
     deleteFileHelper,
     deleteFileHelper,
     close,
     close,
     count,
     count,
+    clearSelection,
 }: IProps) {
 }: IProps) {
     const deduplicateContext = useContext(DeduplicateContext);
     const deduplicateContext = useContext(DeduplicateContext);
     const { setDialogMessage } = useContext(AppContext);
     const { setDialogMessage } = useContext(AppContext);
@@ -47,14 +56,19 @@ export default function DeduplicateOptions({
     return (
     return (
         <SelectionBar>
         <SelectionBar>
             <FluidContainer>
             <FluidContainer>
-                <IconButton onClick={close}>
-                    <LeftArrow />
-                </IconButton>
+                {count ? (
+                    <IconButton onClick={clearSelection}>
+                        <CloseIcon />
+                    </IconButton>
+                ) : (
+                    <IconButton onClick={close}>
+                        <BackButton />
+                    </IconButton>
+                )}
                 <div>
                 <div>
                     {count} {constants.SELECTED}
                     {count} {constants.SELECTED}
                 </div>
                 </div>
             </FluidContainer>
             </FluidContainer>
-
             <input
             <input
                 type="checkbox"
                 type="checkbox"
                 style={{
                 style={{
@@ -69,14 +83,7 @@ export default function DeduplicateOptions({
                         !deduplicateContext.clubSameTimeFilesOnly
                         !deduplicateContext.clubSameTimeFilesOnly
                     );
                     );
                 }}></input>
                 }}></input>
-            <div
-                style={{
-                    marginLeft: '0.5em',
-                    fontSize: '16px',
-                    marginRight: '0.8em',
-                }}>
-                {constants.CLUB_BY_CAPTURE_TIME}
-            </div>
+            <CheckboxText>{constants.CLUB_BY_CAPTURE_TIME}</CheckboxText>
             <div>
             <div>
                 <VerticalLine />
                 <VerticalLine />
             </div>
             </div>

+ 72 - 57
src/components/pages/gallery/PlanSelector.tsx

@@ -18,6 +18,7 @@ import {
     planForSubscription,
     planForSubscription,
     hasMobileSubscription,
     hasMobileSubscription,
     hasPaypalSubscription,
     hasPaypalSubscription,
+    manageFamilyMethod,
 } from 'utils/billing';
 } from 'utils/billing';
 import { reverseString } from 'utils/common';
 import { reverseString } from 'utils/common';
 import ArrowEast from 'components/icons/ArrowEast';
 import ArrowEast from 'components/icons/ArrowEast';
@@ -313,72 +314,86 @@ function PlanSelector(props: Props) {
                     {plans && PlanIcons}
                     {plans && PlanIcons}
                 </div>
                 </div>
                 <DeadCenter style={{ marginBottom: '30px' }}>
                 <DeadCenter style={{ marginBottom: '30px' }}>
-                    {hasStripeSubscription(subscription) ? (
+                    {hasPaidSubscription(subscription) ? (
                         <>
                         <>
-                            {isSubscriptionCancelled(subscription) ? (
-                                <LinkButton
-                                    color={'success'}
-                                    onClick={() =>
-                                        appContext.setDialogMessage({
-                                            title: constants.CONFIRM_ACTIVATE_SUBSCRIPTION,
-                                            content:
-                                                constants.ACTIVATE_SUBSCRIPTION_MESSAGE(
-                                                    subscription.expiryTime
-                                                ),
-                                            staticBackdrop: true,
-                                            proceed: {
-                                                text: constants.ACTIVATE_SUBSCRIPTION,
-                                                action: activateSubscription.bind(
-                                                    null,
-                                                    appContext.setDialogMessage,
-                                                    props.closeModal,
-                                                    props.setLoading
-                                                ),
-                                                variant: 'success',
-                                            },
-                                            close: {
-                                                text: constants.CANCEL,
-                                            },
-                                        })
-                                    }>
-                                    {constants.ACTIVATE_SUBSCRIPTION}
-                                </LinkButton>
-                            ) : (
-                                <LinkButton
-                                    color="danger"
-                                    onClick={() =>
-                                        appContext.setDialogMessage({
-                                            title: constants.CONFIRM_CANCEL_SUBSCRIPTION,
-                                            content:
-                                                constants.CANCEL_SUBSCRIPTION_MESSAGE(),
-                                            staticBackdrop: true,
-                                            proceed: {
-                                                text: constants.CANCEL_SUBSCRIPTION,
-                                                action: cancelSubscription.bind(
-                                                    null,
-                                                    appContext.setDialogMessage,
-                                                    props.closeModal,
-                                                    props.setLoading
-                                                ),
-                                                variant: 'danger',
-                                            },
-                                            close: {
-                                                text: constants.CANCEL,
-                                            },
-                                        })
-                                    }>
-                                    {constants.CANCEL_SUBSCRIPTION}
-                                </LinkButton>
+                            {hasStripeSubscription(subscription) && (
+                                <>
+                                    {isSubscriptionCancelled(subscription) ? (
+                                        <LinkButton
+                                            color="success"
+                                            onClick={() =>
+                                                appContext.setDialogMessage({
+                                                    title: constants.CONFIRM_ACTIVATE_SUBSCRIPTION,
+                                                    content:
+                                                        constants.ACTIVATE_SUBSCRIPTION_MESSAGE(
+                                                            subscription.expiryTime
+                                                        ),
+                                                    staticBackdrop: true,
+                                                    proceed: {
+                                                        text: constants.ACTIVATE_SUBSCRIPTION,
+                                                        action: activateSubscription.bind(
+                                                            null,
+                                                            appContext.setDialogMessage,
+                                                            props.closeModal,
+                                                            props.setLoading
+                                                        ),
+                                                        variant: 'success',
+                                                    },
+                                                    close: {
+                                                        text: constants.CANCEL,
+                                                    },
+                                                })
+                                            }>
+                                            {constants.ACTIVATE_SUBSCRIPTION}
+                                        </LinkButton>
+                                    ) : (
+                                        <LinkButton
+                                            color="danger"
+                                            onClick={() =>
+                                                appContext.setDialogMessage({
+                                                    title: constants.CONFIRM_CANCEL_SUBSCRIPTION,
+                                                    content:
+                                                        constants.CANCEL_SUBSCRIPTION_MESSAGE(),
+                                                    staticBackdrop: true,
+                                                    proceed: {
+                                                        text: constants.CANCEL_SUBSCRIPTION,
+                                                        action: cancelSubscription.bind(
+                                                            null,
+                                                            appContext.setDialogMessage,
+                                                            props.closeModal,
+                                                            props.setLoading
+                                                        ),
+                                                        variant: 'danger',
+                                                    },
+                                                    close: {
+                                                        text: constants.CANCEL,
+                                                    },
+                                                })
+                                            }>
+                                            {constants.CANCEL_SUBSCRIPTION}
+                                        </LinkButton>
+                                    )}
+                                    <LinkButton
+                                        color="primary"
+                                        onClick={updatePaymentMethod.bind(
+                                            null,
+                                            appContext.setDialogMessage,
+                                            props.setLoading
+                                        )}
+                                        style={{ marginTop: '20px' }}>
+                                        {constants.MANAGEMENT_PORTAL}
+                                    </LinkButton>
+                                </>
                             )}
                             )}
                             <LinkButton
                             <LinkButton
                                 color="primary"
                                 color="primary"
-                                onClick={updatePaymentMethod.bind(
+                                onClick={manageFamilyMethod.bind(
                                     null,
                                     null,
                                     appContext.setDialogMessage,
                                     appContext.setDialogMessage,
                                     props.setLoading
                                     props.setLoading
                                 )}
                                 )}
                                 style={{ marginTop: '20px' }}>
                                 style={{ marginTop: '20px' }}>
-                                {constants.MANAGEMENT_PORTAL}
+                                {constants.MANAGE_FAMILY_PORTAL}
                             </LinkButton>
                             </LinkButton>
                         </>
                         </>
                     ) : (
                     ) : (

+ 12 - 7
src/components/pages/gallery/PreviewCard.tsx

@@ -11,6 +11,7 @@ import PublicCollectionDownloadManager from 'services/publicCollectionDownloadMa
 import LivePhotoIndicatorOverlay from 'components/icons/LivePhotoIndicatorOverlay';
 import LivePhotoIndicatorOverlay from 'components/icons/LivePhotoIndicatorOverlay';
 import { isLivePhoto } from 'utils/file';
 import { isLivePhoto } from 'utils/file';
 import { DeduplicateContext } from 'pages/deduplicate';
 import { DeduplicateContext } from 'pages/deduplicate';
+import { logError } from 'utils/sentry';
 
 
 interface IProps {
 interface IProps {
     file: EnteFile;
     file: EnteFile;
@@ -100,7 +101,7 @@ export const HoverOverlay = styled.div<{ checked: boolean }>`
 `;
 `;
 
 
 export const InSelectRangeOverLay = styled.div<{ active: boolean }>`
 export const InSelectRangeOverLay = styled.div<{ active: boolean }>`
-    opacity: ${(props) => (!props.active ? 0 : 1)});
+    opacity: ${(props) => (!props.active ? 0 : 1)};
     left: 0;
     left: 0;
     top: 0;
     top: 0;
     outline: none;
     outline: none;
@@ -115,6 +116,7 @@ export const FileAndCollectionNameOverlay = styled.div`
     bottom: 0;
     bottom: 0;
     left: 0;
     left: 0;
     max-height: 40%;
     max-height: 40%;
+    width: 100%;
     background: linear-gradient(rgba(0, 0, 0, 0), rgba(0, 0, 0, 2));
     background: linear-gradient(rgba(0, 0, 0, 0), rgba(0, 0, 0, 2));
     & > p {
     & > p {
         max-width: calc(${IMAGE_CONTAINER_MAX_WIDTH}px - 10px);
         max-width: calc(${IMAGE_CONTAINER_MAX_WIDTH}px - 10px);
@@ -227,14 +229,17 @@ export default function PreviewCard(props: IProps) {
                     if (isMounted.current) {
                     if (isMounted.current) {
                         setImgSrc(url);
                         setImgSrc(url);
                         thumbs.set(file.id, url);
                         thumbs.set(file.id, url);
-                        const newFile = updateURL(url);
-                        file.msrc = newFile.msrc;
-                        file.html = newFile.html;
-                        file.src = newFile.src;
-                        file.w = newFile.w;
-                        file.h = newFile.h;
+                        if (updateURL) {
+                            const newFile = updateURL(url);
+                            file.msrc = newFile.msrc;
+                            file.html = newFile.html;
+                            file.src = newFile.src;
+                            file.w = newFile.w;
+                            file.h = newFile.h;
+                        }
                     }
                     }
                 } catch (e) {
                 } catch (e) {
+                    logError(e, 'preview card useEffect failed');
                     // no-op
                     // no-op
                 }
                 }
             };
             };

+ 118 - 66
src/components/pages/gallery/Upload.tsx

@@ -22,18 +22,23 @@ import { SetLoading, SetFiles } from 'types/gallery';
 import { FileUploadResults, UPLOAD_STAGES } from 'constants/upload';
 import { FileUploadResults, UPLOAD_STAGES } from 'constants/upload';
 import { ElectronFile, FileWithCollection } from 'types/upload';
 import { ElectronFile, FileWithCollection } from 'types/upload';
 import UploadTypeSelector from '../../UploadTypeSelector';
 import UploadTypeSelector from '../../UploadTypeSelector';
+import Router from 'next/router';
+import { isCanvasBlocked } from 'utils/upload/isCanvasBlocked';
+import { downloadApp } from 'utils/common';
 
 
 const FIRST_ALBUM_NAME = 'My First Album';
 const FIRST_ALBUM_NAME = 'My First Album';
 
 
 interface Props {
 interface Props {
     syncWithRemote: (force?: boolean, silent?: boolean) => Promise<void>;
     syncWithRemote: (force?: boolean, silent?: boolean) => Promise<void>;
     setBannerMessage: (message: string | JSX.Element) => void;
     setBannerMessage: (message: string | JSX.Element) => void;
-    acceptedFiles: File[];
+    droppedFiles: File[];
+    clearDroppedFiles: () => void;
     closeCollectionSelector: () => void;
     closeCollectionSelector: () => void;
     setCollectionSelectorAttributes: SetCollectionSelectorAttributes;
     setCollectionSelectorAttributes: SetCollectionSelectorAttributes;
     setCollectionNamerAttributes: SetCollectionNamerAttributes;
     setCollectionNamerAttributes: SetCollectionNamerAttributes;
     setLoading: SetLoading;
     setLoading: SetLoading;
-    setUploadInProgress: any;
+    uploadInProgress: boolean;
+    setUploadInProgress: (value: boolean) => void;
     showCollectionSelector: () => void;
     showCollectionSelector: () => void;
     fileRejections: FileRejection[];
     fileRejections: FileRejection[];
     setFiles: SetFiles;
     setFiles: SetFiles;
@@ -49,9 +54,10 @@ enum UPLOAD_STRATEGY {
     COLLECTION_PER_FOLDER,
     COLLECTION_PER_FOLDER,
 }
 }
 
 
-enum DESKTOP_UPLOAD_TYPE {
-    FILES,
-    FOLDERS,
+export enum DESKTOP_UPLOAD_TYPE {
+    FILES = 'files',
+    FOLDERS = 'folders',
+    ZIPS = 'zips',
 }
 }
 
 
 interface AnalysisResult {
 interface AnalysisResult {
@@ -59,6 +65,11 @@ interface AnalysisResult {
     multipleFolders: boolean;
     multipleFolders: boolean;
 }
 }
 
 
+const NULL_ANALYSIS_RESULT = {
+    suggestedCollectionName: '',
+    multipleFolders: false,
+};
+
 export default function Upload(props: Props) {
 export default function Upload(props: Props) {
     const [progressView, setProgressView] = useState(false);
     const [progressView, setProgressView] = useState(false);
     const [uploadStage, setUploadStage] = useState<UPLOAD_STAGES>(
     const [uploadStage, setUploadStage] = useState<UPLOAD_STAGES>(
@@ -74,10 +85,8 @@ export default function Upload(props: Props) {
     const [hasLivePhotos, setHasLivePhotos] = useState(false);
     const [hasLivePhotos, setHasLivePhotos] = useState(false);
 
 
     const [choiceModalView, setChoiceModalView] = useState(false);
     const [choiceModalView, setChoiceModalView] = useState(false);
-    const [analysisResult, setAnalysisResult] = useState<AnalysisResult>({
-        suggestedCollectionName: '',
-        multipleFolders: false,
-    });
+    const [analysisResult, setAnalysisResult] =
+        useState<AnalysisResult>(NULL_ANALYSIS_RESULT);
     const appContext = useContext(AppContext);
     const appContext = useContext(AppContext);
     const galleryContext = useContext(GalleryContext);
     const galleryContext = useContext(GalleryContext);
 
 
@@ -85,6 +94,7 @@ export default function Upload(props: Props) {
     const isPendingDesktopUpload = useRef(false);
     const isPendingDesktopUpload = useRef(false);
     const pendingDesktopUploadCollectionName = useRef<string>('');
     const pendingDesktopUploadCollectionName = useRef<string>('');
     const desktopUploadType = useRef<DESKTOP_UPLOAD_TYPE>(null);
     const desktopUploadType = useRef<DESKTOP_UPLOAD_TYPE>(null);
+    const zipPaths = useRef<string[]>(null);
 
 
     useEffect(() => {
     useEffect(() => {
         UploadManager.initUploader(
         UploadManager.initUploader(
@@ -100,10 +110,10 @@ export default function Upload(props: Props) {
             props.setFiles
             props.setFiles
         );
         );
 
 
-        if (isElectron()) {
+        if (isElectron() && ImportService.checkAllElectronAPIsExists()) {
             ImportService.getPendingUploads().then(
             ImportService.getPendingUploads().then(
-                ({ files: electronFiles, collectionName }) => {
-                    resumeDesktopUpload(electronFiles, collectionName);
+                ({ files: electronFiles, collectionName, type }) => {
+                    resumeDesktopUpload(type, electronFiles, collectionName);
                 }
                 }
             );
             );
         }
         }
@@ -111,39 +121,50 @@ export default function Upload(props: Props) {
 
 
     useEffect(() => {
     useEffect(() => {
         if (
         if (
-            props.acceptedFiles?.length > 0 ||
-            appContext.sharedFiles?.length > 0 ||
-            props.electronFiles?.length > 0
+            props.electronFiles?.length > 0 ||
+            props.droppedFiles?.length > 0 ||
+            appContext.sharedFiles?.length > 0
         ) {
         ) {
-            props.setLoading(true);
-
-            let analysisResult: AnalysisResult;
-            if (
-                props.acceptedFiles?.length > 0 ||
-                props.electronFiles?.length > 0
-            ) {
-                if (props.acceptedFiles?.length > 0) {
+            if (props.uploadInProgress) {
+                // no-op
+                // a upload is already in progress
+            } else if (isCanvasBlocked()) {
+                appContext.setDialogMessage({
+                    title: constants.CANVAS_BLOCKED_TITLE,
+                    staticBackdrop: true,
+                    content: constants.CANVAS_BLOCKED_MESSAGE(),
+                    close: { text: constants.CLOSE },
+                    proceed: {
+                        text: constants.DOWNLOAD_APP,
+                        action: downloadApp,
+                        variant: 'success',
+                    },
+                });
+            } else {
+                props.setLoading(true);
+                if (props.droppedFiles?.length > 0) {
                     // File selection by drag and drop or selection of file.
                     // File selection by drag and drop or selection of file.
-                    toUploadFiles.current = props.acceptedFiles;
-                } else {
+                    toUploadFiles.current = props.droppedFiles;
+                    props.clearDroppedFiles();
+                } else if (appContext.sharedFiles?.length > 0) {
+                    toUploadFiles.current = appContext.sharedFiles;
+                    appContext.resetSharedFiles();
+                } else if (props.electronFiles?.length > 0) {
                     // File selection from desktop app
                     // File selection from desktop app
                     toUploadFiles.current = props.electronFiles;
                     toUploadFiles.current = props.electronFiles;
+                    props.setElectronFiles([]);
                 }
                 }
+                const analysisResult = analyseUploadFiles();
+                setAnalysisResult(analysisResult);
 
 
-                analysisResult = analyseUploadFiles();
-                if (analysisResult) {
-                    setAnalysisResult(analysisResult);
-                }
-            } else if (appContext.sharedFiles.length > 0) {
-                toUploadFiles.current = appContext.sharedFiles;
+                handleCollectionCreationAndUpload(
+                    analysisResult,
+                    props.isFirstUpload
+                );
+                props.setLoading(false);
             }
             }
-            handleCollectionCreationAndUpload(
-                analysisResult,
-                props.isFirstUpload
-            );
-            props.setLoading(false);
         }
         }
-    }, [props.acceptedFiles, appContext.sharedFiles, props.electronFiles]);
+    }, [props.droppedFiles, appContext.sharedFiles, props.electronFiles]);
 
 
     const uploadInit = function () {
     const uploadInit = function () {
         setUploadStage(UPLOAD_STAGES.START);
         setUploadStage(UPLOAD_STAGES.START);
@@ -156,24 +177,27 @@ export default function Upload(props: Props) {
     };
     };
 
 
     const resumeDesktopUpload = async (
     const resumeDesktopUpload = async (
+        type: DESKTOP_UPLOAD_TYPE,
         electronFiles: ElectronFile[],
         electronFiles: ElectronFile[],
         collectionName: string
         collectionName: string
     ) => {
     ) => {
         if (electronFiles && electronFiles?.length > 0) {
         if (electronFiles && electronFiles?.length > 0) {
             isPendingDesktopUpload.current = true;
             isPendingDesktopUpload.current = true;
             pendingDesktopUploadCollectionName.current = collectionName;
             pendingDesktopUploadCollectionName.current = collectionName;
+            desktopUploadType.current = type;
             props.setElectronFiles(electronFiles);
             props.setElectronFiles(electronFiles);
         }
         }
     };
     };
 
 
     function analyseUploadFiles(): AnalysisResult {
     function analyseUploadFiles(): AnalysisResult {
-        if (toUploadFiles.current.length === 0) {
-            return null;
-        }
-        if (desktopUploadType.current === DESKTOP_UPLOAD_TYPE.FILES) {
-            desktopUploadType.current = null;
-            return { suggestedCollectionName: '', multipleFolders: false };
+        if (
+            isElectron() &&
+            (!desktopUploadType.current ||
+                desktopUploadType.current === DESKTOP_UPLOAD_TYPE.FILES)
+        ) {
+            return NULL_ANALYSIS_RESULT;
         }
         }
+
         const paths: string[] = toUploadFiles.current.map(
         const paths: string[] = toUploadFiles.current.map(
             (file) => file['path']
             (file) => file['path']
         );
         );
@@ -181,19 +205,24 @@ export default function Upload(props: Props) {
         paths.sort((path1, path2) => getCharCount(path1) - getCharCount(path2));
         paths.sort((path1, path2) => getCharCount(path1) - getCharCount(path2));
         const firstPath = paths[0];
         const firstPath = paths[0];
         const lastPath = paths[paths.length - 1];
         const lastPath = paths[paths.length - 1];
+
         const L = firstPath.length;
         const L = firstPath.length;
         let i = 0;
         let i = 0;
-        const firstFileFolder = firstPath.substr(0, firstPath.lastIndexOf('/'));
-        const lastFileFolder = lastPath.substr(0, lastPath.lastIndexOf('/'));
+        const firstFileFolder = firstPath.substring(
+            0,
+            firstPath.lastIndexOf('/')
+        );
+        const lastFileFolder = lastPath.substring(0, lastPath.lastIndexOf('/'));
         while (i < L && firstPath.charAt(i) === lastPath.charAt(i)) i++;
         while (i < L && firstPath.charAt(i) === lastPath.charAt(i)) i++;
         let commonPathPrefix = firstPath.substring(0, i);
         let commonPathPrefix = firstPath.substring(0, i);
+
         if (commonPathPrefix) {
         if (commonPathPrefix) {
-            commonPathPrefix = commonPathPrefix.substr(
-                1,
-                commonPathPrefix.lastIndexOf('/') - 1
+            commonPathPrefix = commonPathPrefix.substring(
+                0,
+                commonPathPrefix.lastIndexOf('/')
             );
             );
             if (commonPathPrefix) {
             if (commonPathPrefix) {
-                commonPathPrefix = commonPathPrefix.substr(
+                commonPathPrefix = commonPathPrefix.substring(
                     commonPathPrefix.lastIndexOf('/') + 1
                     commonPathPrefix.lastIndexOf('/') + 1
                 );
                 );
             }
             }
@@ -208,11 +237,14 @@ export default function Upload(props: Props) {
         for (const file of toUploadFiles.current) {
         for (const file of toUploadFiles.current) {
             const filePath = file['path'] as string;
             const filePath = file['path'] as string;
 
 
-            let folderPath = filePath.substr(0, filePath.lastIndexOf('/'));
+            let folderPath = filePath.substring(0, filePath.lastIndexOf('/'));
             if (folderPath.endsWith(METADATA_FOLDER_NAME)) {
             if (folderPath.endsWith(METADATA_FOLDER_NAME)) {
-                folderPath = folderPath.substr(0, folderPath.lastIndexOf('/'));
+                folderPath = folderPath.substring(
+                    0,
+                    folderPath.lastIndexOf('/')
+                );
             }
             }
-            const folderName = folderPath.substr(
+            const folderName = folderPath.substring(
                 folderPath.lastIndexOf('/') + 1
                 folderPath.lastIndexOf('/') + 1
             );
             );
             if (!collectionWiseFiles.has(folderName)) {
             if (!collectionWiseFiles.has(folderName)) {
@@ -225,7 +257,6 @@ export default function Upload(props: Props) {
 
 
     const uploadFilesToExistingCollection = async (collection: Collection) => {
     const uploadFilesToExistingCollection = async (collection: Collection) => {
         try {
         try {
-            uploadInit();
             const filesWithCollectionToUpload: FileWithCollection[] =
             const filesWithCollectionToUpload: FileWithCollection[] =
                 toUploadFiles.current.map((file, index) => ({
                 toUploadFiles.current.map((file, index) => ({
                     file,
                     file,
@@ -243,8 +274,6 @@ export default function Upload(props: Props) {
         collectionName?: string
         collectionName?: string
     ) => {
     ) => {
         try {
         try {
-            uploadInit();
-
             const filesWithCollectionToUpload: FileWithCollection[] = [];
             const filesWithCollectionToUpload: FileWithCollection[] = [];
             const collections: Collection[] = [];
             const collections: Collection[] = [];
             let collectionWiseFiles = new Map<
             let collectionWiseFiles = new Map<
@@ -296,13 +325,24 @@ export default function Upload(props: Props) {
         collections: Collection[]
         collections: Collection[]
     ) => {
     ) => {
         try {
         try {
+            uploadInit();
             props.setUploadInProgress(true);
             props.setUploadInProgress(true);
             props.closeCollectionSelector();
             props.closeCollectionSelector();
             await props.syncWithRemote(true, true);
             await props.syncWithRemote(true, true);
-            if (isElectron()) {
+            if (isElectron() && !isPendingDesktopUpload.current) {
+                await ImportService.setToUploadCollection(collections);
+                if (zipPaths.current) {
+                    await ImportService.setToUploadFiles(
+                        DESKTOP_UPLOAD_TYPE.ZIPS,
+                        zipPaths.current
+                    );
+                    zipPaths.current = null;
+                }
                 await ImportService.setToUploadFiles(
                 await ImportService.setToUploadFiles(
-                    filesWithCollectionToUpload,
-                    collections
+                    DESKTOP_UPLOAD_TYPE.FILES,
+                    filesWithCollectionToUpload.map(
+                        ({ file }) => (file as ElectronFile).path
+                    )
                 );
                 );
             }
             }
             await uploadManager.queueFilesForUpload(
             await uploadManager.queueFilesForUpload(
@@ -318,7 +358,6 @@ export default function Upload(props: Props) {
             setProgressView(false);
             setProgressView(false);
             throw err;
             throw err;
         } finally {
         } finally {
-            appContext.resetSharedFiles();
             props.setUploadInProgress(false);
             props.setUploadInProgress(false);
             props.syncWithRemote();
             props.syncWithRemote();
         }
         }
@@ -372,6 +411,7 @@ export default function Upload(props: Props) {
                 uploadToSingleNewCollection(
                 uploadToSingleNewCollection(
                     pendingDesktopUploadCollectionName.current
                     pendingDesktopUploadCollectionName.current
                 );
                 );
+                pendingDesktopUploadCollectionName.current = null;
             } else {
             } else {
                 uploadFilesToNewCollections(
                 uploadFilesToNewCollections(
                     UPLOAD_STRATEGY.COLLECTION_PER_FOLDER
                     UPLOAD_STRATEGY.COLLECTION_PER_FOLDER
@@ -379,6 +419,13 @@ export default function Upload(props: Props) {
             }
             }
             return;
             return;
         }
         }
+        if (
+            isElectron() &&
+            desktopUploadType.current === DESKTOP_UPLOAD_TYPE.ZIPS
+        ) {
+            uploadFilesToNewCollections(UPLOAD_STRATEGY.COLLECTION_PER_FOLDER);
+            return;
+        }
         if (isFirstUpload && !analysisResult.suggestedCollectionName) {
         if (isFirstUpload && !analysisResult.suggestedCollectionName) {
             analysisResult.suggestedCollectionName = FIRST_ALBUM_NAME;
             analysisResult.suggestedCollectionName = FIRST_ALBUM_NAME;
         }
         }
@@ -402,21 +449,26 @@ export default function Upload(props: Props) {
         desktopUploadType.current = type;
         desktopUploadType.current = type;
         if (type === DESKTOP_UPLOAD_TYPE.FILES) {
         if (type === DESKTOP_UPLOAD_TYPE.FILES) {
             files = await ImportService.showUploadFilesDialog();
             files = await ImportService.showUploadFilesDialog();
-        } else {
+        } else if (type === DESKTOP_UPLOAD_TYPE.FOLDERS) {
             files = await ImportService.showUploadDirsDialog();
             files = await ImportService.showUploadDirsDialog();
+        } else {
+            const response = await ImportService.showUploadZipDialog();
+            files = response.files;
+            zipPaths.current = response.zipPaths;
+        }
+        if (files?.length > 0) {
+            props.setElectronFiles(files);
+            props.setUploadTypeSelectorView(false);
         }
         }
-        props.setElectronFiles(files);
-        props.setUploadTypeSelectorView(false);
     };
     };
 
 
     const cancelUploads = async () => {
     const cancelUploads = async () => {
         setProgressView(false);
         setProgressView(false);
-        UploadManager.cancelRemainingUploads();
         if (isElectron()) {
         if (isElectron()) {
-            ImportService.updatePendingUploads([]);
+            ImportService.cancelRemainingUploads();
         }
         }
         await props.setUploadInProgress(false);
         await props.setUploadInProgress(false);
-        await props.syncWithRemote();
+        Router.reload();
     };
     };
 
 
     return (
     return (
@@ -445,7 +497,7 @@ export default function Upload(props: Props) {
                     handleDesktopUploadTypes(DESKTOP_UPLOAD_TYPE.FOLDERS)
                     handleDesktopUploadTypes(DESKTOP_UPLOAD_TYPE.FOLDERS)
                 }
                 }
                 uploadGoogleTakeoutZips={() =>
                 uploadGoogleTakeoutZips={() =>
-                    handleDesktopUploadTypes(DESKTOP_UPLOAD_TYPE.FOLDERS)
+                    handleDesktopUploadTypes(DESKTOP_UPLOAD_TYPE.ZIPS)
                 }
                 }
             />
             />
             <UploadProgress
             <UploadProgress

+ 5 - 1
src/constants/upload/index.ts

@@ -7,6 +7,7 @@ export const FORMAT_MISSED_BY_FILE_TYPE_LIB = [
     { fileType: FILE_TYPE.IMAGE, exactType: 'jpeg', mimeType: 'image/jpeg' },
     { fileType: FILE_TYPE.IMAGE, exactType: 'jpeg', mimeType: 'image/jpeg' },
     { fileType: FILE_TYPE.IMAGE, exactType: 'jpg', mimeType: 'image/jpeg' },
     { fileType: FILE_TYPE.IMAGE, exactType: 'jpg', mimeType: 'image/jpeg' },
     { fileType: FILE_TYPE.VIDEO, exactType: 'webm', mimeType: 'video/webm' },
     { fileType: FILE_TYPE.VIDEO, exactType: 'webm', mimeType: 'video/webm' },
+    { fileType: FILE_TYPE.VIDEO, exactType: 'mod', mimeType: 'video/mpeg' },
 ];
 ];
 
 
 // this is the chunk size of the un-encrypted file which is read and encrypted before uploading it as a single part.
 // this is the chunk size of the un-encrypted file which is read and encrypted before uploading it as a single part.
@@ -38,9 +39,10 @@ export enum FileUploadResults {
     TOO_LARGE,
     TOO_LARGE,
     LARGER_THAN_AVAILABLE_STORAGE,
     LARGER_THAN_AVAILABLE_STORAGE,
     UPLOADED,
     UPLOADED,
+    UPLOADED_WITH_STATIC_THUMBNAIL,
 }
 }
 
 
-export const MAX_FILE_SIZE_SUPPORTED = 5 * 1024 * 1024 * 1024; // 5 GB
+export const MAX_FILE_SIZE_SUPPORTED = 4 * 1024 * 1024 * 1024; // 4 GB
 
 
 export const LIVE_PHOTO_ASSET_SIZE_LIMIT = 20 * 1024 * 1024; // 20MB
 export const LIVE_PHOTO_ASSET_SIZE_LIMIT = 20 * 1024 * 1024; // 20MB
 
 
@@ -50,3 +52,5 @@ export const NULL_EXTRACTED_METADATA: ParsedExtractedMetadata = {
 };
 };
 
 
 export const A_SEC_IN_MICROSECONDS = 1e6;
 export const A_SEC_IN_MICROSECONDS = 1e6;
+
+export const USE_CF_PROXY = false;

+ 1 - 1
src/constants/user/index.ts

@@ -1,3 +1,3 @@
 export const FIX_CREATION_TIME_VISIBLE_TO_USER_IDS = [
 export const FIX_CREATION_TIME_VISIBLE_TO_USER_IDS = [
-    1, 125, 243, 341, 1580559962387273, 1580559962388564,
+    1, 125, 243, 341, 1071, 1580559962387273, 1580559962388564,
 ];
 ];

+ 34 - 12
src/pages/_app.tsx

@@ -10,7 +10,6 @@ import 'styles/global.css';
 import EnteSpinner from 'components/EnteSpinner';
 import EnteSpinner from 'components/EnteSpinner';
 import { logError } from '../utils/sentry';
 import { logError } from '../utils/sentry';
 // import { Workbox } from 'workbox-window';
 // import { Workbox } from 'workbox-window';
-import { getEndpoint } from 'utils/common/apiUtil';
 import { getData, LS_KEYS } from 'utils/storage/localStorage';
 import { getData, LS_KEYS } from 'utils/storage/localStorage';
 import HTTPService from 'services/HTTPService';
 import HTTPService from 'services/HTTPService';
 import FlashMessageBar from 'components/FlashMessageBar';
 import FlashMessageBar from 'components/FlashMessageBar';
@@ -24,6 +23,11 @@ import { CssBaseline } from '@mui/material';
 // eslint-disable-next-line @typescript-eslint/no-unused-vars
 // eslint-disable-next-line @typescript-eslint/no-unused-vars
 import * as types from 'styled-components/cssprop'; // need to css prop on styled component
 import * as types from 'styled-components/cssprop'; // need to css prop on styled component
 import { SetDialogBoxAttributes, DialogBoxAttributes } from 'types/dialogBox';
 import { SetDialogBoxAttributes, DialogBoxAttributes } from 'types/dialogBox';
+import {
+    getFamilyPortalRedirectURL,
+    getRoadmapRedirectURL,
+} from 'services/userService';
+import { CustomError } from 'utils/error';
 
 
 export const LogoImage = styled.img`
 export const LogoImage = styled.img`
     max-height: 28px;
     max-height: 28px;
@@ -68,10 +72,10 @@ export interface FlashMessage {
 }
 }
 export const AppContext = createContext<AppContextType>(null);
 export const AppContext = createContext<AppContextType>(null);
 
 
-const redirectMap = {
-    roadmap: (token: string) =>
-        `${getEndpoint()}/users/roadmap?token=${encodeURIComponent(token)}`,
-};
+const redirectMap = new Map([
+    ['roadmap', getRoadmapRedirectURL],
+    ['families', getFamilyPortalRedirectURL],
+]);
 
 
 export default function App({ Component, err }) {
 export default function App({ Component, err }) {
     const router = useRouter();
     const router = useRouter();
@@ -140,14 +144,30 @@ export default function App({ Component, err }) {
                 'font-size: 20px;'
                 'font-size: 20px;'
             );
             );
         }
         }
+
+        const redirectTo = async (redirect) => {
+            if (
+                redirectMap.has(redirect) &&
+                typeof redirectMap.get(redirect) === 'function'
+            ) {
+                const redirectAction = redirectMap.get(redirect);
+                const url = await redirectAction();
+                window.location.href = url;
+            } else {
+                logError(CustomError.BAD_REQUEST, 'invalid redirection', {
+                    redirect,
+                });
+            }
+        };
+
         const query = new URLSearchParams(window.location.search);
         const query = new URLSearchParams(window.location.search);
-        const redirect = query.get('redirect');
-        if (redirect && redirectMap[redirect]) {
+        const redirectName = query.get('redirect');
+        if (redirectName) {
             const user = getData(LS_KEYS.USER);
             const user = getData(LS_KEYS.USER);
             if (user?.token) {
             if (user?.token) {
-                window.location.href = redirectMap[redirect](user.token);
+                redirectTo(redirectName);
             } else {
             } else {
-                setRedirectName(redirect);
+                setRedirectName(redirectName);
             }
             }
         }
         }
 
 
@@ -159,9 +179,11 @@ export default function App({ Component, err }) {
             if (redirectName) {
             if (redirectName) {
                 const user = getData(LS_KEYS.USER);
                 const user = getData(LS_KEYS.USER);
                 if (user?.token) {
                 if (user?.token) {
-                    window.location.href = redirectMap[redirectName](
-                        user.token
-                    );
+                    redirectTo(redirectName);
+
+                    // https://github.com/vercel/next.js/issues/2476#issuecomment-573460710
+                    // eslint-disable-next-line no-throw-literal
+                    throw 'Aborting route change, redirection in process....';
                 }
                 }
             }
             }
         });
         });

+ 6 - 0
src/pages/deduplicate/index.tsx

@@ -138,6 +138,10 @@ export default function Deduplicate() {
         }
         }
     };
     };
 
 
+    const clearSelection = function () {
+        setSelected({ count: 0, collectionID: 0 });
+    };
+
     if (!duplicateFiles) {
     if (!duplicateFiles) {
         return <></>;
         return <></>;
     }
     }
@@ -166,11 +170,13 @@ export default function Deduplicate() {
                 setSelected={setSelected}
                 setSelected={setSelected}
                 selected={selected}
                 selected={selected}
                 activeCollection={ALL_SECTION}
                 activeCollection={ALL_SECTION}
+                isDeduplicating
             />
             />
             <DeduplicateOptions
             <DeduplicateOptions
                 deleteFileHelper={deleteFileHelper}
                 deleteFileHelper={deleteFileHelper}
                 count={selected.count}
                 count={selected.count}
                 close={closeDeduplication}
                 close={closeDeduplication}
+                clearSelection={clearSelection}
             />
             />
         </DeduplicateContext.Provider>
         </DeduplicateContext.Provider>
     );
     );

+ 7 - 1
src/pages/gallery/index.tsx

@@ -211,6 +211,7 @@ export default function Gallery() {
 
 
     const closeSidebar = () => setSidebarView(false);
     const closeSidebar = () => setSidebarView(false);
     const openSidebar = () => setSidebarView(true);
     const openSidebar = () => setSidebarView(true);
+    const [droppedFiles, setDroppedFiles] = useState([]);
 
 
     useEffect(() => {
     useEffect(() => {
         appContext.showNavBar(false);
         appContext.showNavBar(false);
@@ -258,6 +259,8 @@ export default function Gallery() {
         [fixCreationTimeAttributes]
         [fixCreationTimeAttributes]
     );
     );
 
 
+    useEffect(() => setDroppedFiles(acceptedFiles), [acceptedFiles]);
+
     useEffect(() => {
     useEffect(() => {
         if (typeof activeCollection === 'undefined') {
         if (typeof activeCollection === 'undefined') {
             return;
             return;
@@ -309,6 +312,7 @@ export default function Gallery() {
             files.push(...getTrashedFiles(trash));
             files.push(...getTrashedFiles(trash));
             await setDerivativeState(collections, files);
             await setDerivativeState(collections, files);
         } catch (e) {
         } catch (e) {
+            logError(e, 'syncWithRemote failed');
             switch (e.message) {
             switch (e.message) {
                 case ServerErrorCodes.SESSION_EXPIRED:
                 case ServerErrorCodes.SESSION_EXPIRED:
                     setBannerMessage(constants.SESSION_EXPIRED_MESSAGE);
                     setBannerMessage(constants.SESSION_EXPIRED_MESSAGE);
@@ -648,7 +652,8 @@ export default function Gallery() {
                 <Upload
                 <Upload
                     syncWithRemote={syncWithRemote}
                     syncWithRemote={syncWithRemote}
                     setBannerMessage={setBannerMessage}
                     setBannerMessage={setBannerMessage}
-                    acceptedFiles={acceptedFiles}
+                    droppedFiles={droppedFiles}
+                    clearDroppedFiles={() => setDroppedFiles([])}
                     showCollectionSelector={setCollectionSelectorView.bind(
                     showCollectionSelector={setCollectionSelectorView.bind(
                         null,
                         null,
                         true
                         true
@@ -662,6 +667,7 @@ export default function Gallery() {
                     )}
                     )}
                     setLoading={setBlockingLoad}
                     setLoading={setBlockingLoad}
                     setCollectionNamerAttributes={setCollectionNamerAttributes}
                     setCollectionNamerAttributes={setCollectionNamerAttributes}
+                    uploadInProgress={uploadInProgress}
                     setUploadInProgress={setUploadInProgress}
                     setUploadInProgress={setUploadInProgress}
                     fileRejections={fileRejections}
                     fileRejections={fileRejections}
                     setFiles={setFiles}
                     setFiles={setFiles}

+ 3 - 4
src/pages/two-factor/verify/index.tsx

@@ -19,14 +19,13 @@ export default function Home() {
         const main = async () => {
         const main = async () => {
             router.prefetch(PAGES.CREDENTIALS);
             router.prefetch(PAGES.CREDENTIALS);
             const user: User = getData(LS_KEYS.USER);
             const user: User = getData(LS_KEYS.USER);
-            if (
-                user &&
+            if (!user?.email || !user.twoFactorSessionID) {
+                router.push(PAGES.ROOT);
+            } else if (
                 !user.isTwoFactorEnabled &&
                 !user.isTwoFactorEnabled &&
                 (user.encryptedToken || user.token)
                 (user.encryptedToken || user.token)
             ) {
             ) {
                 router.push(PAGES.CREDENTIALS);
                 router.push(PAGES.CREDENTIALS);
-            } else if (!user?.email || !user.twoFactorSessionID) {
-                router.push(PAGES.ROOT);
             } else {
             } else {
                 setSessionID(user.twoFactorSessionID);
                 setSessionID(user.twoFactorSessionID);
             }
             }

+ 16 - 1
src/services/billingService.ts

@@ -1,6 +1,6 @@
 import { getEndpoint, getPaymentsURL } from 'utils/common/apiUtil';
 import { getEndpoint, getPaymentsURL } from 'utils/common/apiUtil';
 import { getToken } from 'utils/common/key';
 import { getToken } from 'utils/common/key';
-import { setData, LS_KEYS } from 'utils/storage/localStorage';
+import { setData, LS_KEYS, removeData } from 'utils/storage/localStorage';
 import HTTPService from './HTTPService';
 import HTTPService from './HTTPService';
 import { logError } from 'utils/sentry';
 import { logError } from 'utils/sentry';
 import { getPaymentToken } from './userService';
 import { getPaymentToken } from './userService';
@@ -147,6 +147,21 @@ class billingService {
         }
         }
     }
     }
 
 
+    public async leaveFamily() {
+        if (!getToken()) {
+            return;
+        }
+        try {
+            await HTTPService.delete(`${ENDPOINT}/family/leave`, null, null, {
+                'X-Auth-Token': getToken(),
+            });
+            removeData(LS_KEYS.FAMILY_DATA);
+        } catch (e) {
+            logError(e, '/family/leave failed');
+            throw e;
+        }
+    }
+
     public async redirectToPayments(
     public async redirectToPayments(
         paymentToken: string,
         paymentToken: string,
         productID: string,
         productID: string,

+ 104 - 4
src/services/deduplicationService.ts

@@ -1,7 +1,10 @@
+import { FILE_TYPE } from 'constants/file';
 import { EnteFile } from 'types/file';
 import { EnteFile } from 'types/file';
+import { Metadata } from 'types/upload';
 import { getEndpoint } from 'utils/common/apiUtil';
 import { getEndpoint } from 'utils/common/apiUtil';
 import { getToken } from 'utils/common/key';
 import { getToken } from 'utils/common/key';
 import { logError } from 'utils/sentry';
 import { logError } from 'utils/sentry';
+import { hasFileHash } from 'utils/upload';
 import HTTPService from './HTTPService';
 import HTTPService from './HTTPService';
 
 
 const ENDPOINT = getEndpoint();
 const ENDPOINT = getEndpoint();
@@ -55,10 +58,12 @@ export async function getDuplicateFiles(
             );
             );
 
 
             if (duplicateFiles.length > 1) {
             if (duplicateFiles.length > 1) {
-                result.push({
-                    files: duplicateFiles,
-                    size: dupe.size,
-                });
+                result.push(
+                    ...getDupesGroupedBySameFileHashes(
+                        duplicateFiles,
+                        dupe.size
+                    )
+                );
             }
             }
         }
         }
 
 
@@ -68,6 +73,90 @@ export async function getDuplicateFiles(
     }
     }
 }
 }
 
 
+function getDupesGroupedBySameFileHashes(files: EnteFile[], size: number) {
+    const clubbedDupesByFileHash = clubDuplicatesBySameFileHashes([
+        { files, size },
+    ]);
+
+    const clubbedFileIDs = new Set<number>();
+    for (const dupe of clubbedDupesByFileHash) {
+        for (const file of dupe.files) {
+            clubbedFileIDs.add(file.id);
+        }
+    }
+
+    files = files.filter((file) => {
+        return !clubbedFileIDs.has(file.id);
+    });
+
+    if (files.length > 1) {
+        clubbedDupesByFileHash.push({
+            files: [...files],
+            size,
+        });
+    }
+
+    return clubbedDupesByFileHash;
+}
+
+function clubDuplicatesBySameFileHashes(dupes: DuplicateFiles[]) {
+    const result: DuplicateFiles[] = [];
+
+    for (const dupe of dupes) {
+        let files: EnteFile[] = [];
+
+        const filteredFiles = dupe.files.filter((file) => {
+            return hasFileHash(file.metadata);
+        });
+
+        if (filteredFiles.length <= 1) {
+            continue;
+        }
+
+        const dupesSortedByFileHash = filteredFiles
+            .map((file) => {
+                return {
+                    file,
+                    hash:
+                        file.metadata.hash ??
+                        `${file.metadata.imageHash}_${file.metadata.videoHash}`,
+                };
+            })
+            .sort((firstFile, secondFile) => {
+                return firstFile.hash.localeCompare(secondFile.hash);
+            });
+
+        files.push(dupesSortedByFileHash[0].file);
+        for (let i = 1; i < dupesSortedByFileHash.length; i++) {
+            if (
+                areFileHashesSame(
+                    dupesSortedByFileHash[i - 1].file.metadata,
+                    dupesSortedByFileHash[i].file.metadata
+                )
+            ) {
+                files.push(dupesSortedByFileHash[i].file);
+            } else {
+                if (files.length > 1) {
+                    result.push({
+                        files: [...files],
+                        size: dupe.size,
+                    });
+                }
+                files = [dupesSortedByFileHash[i].file];
+            }
+        }
+
+        if (files.length > 1) {
+            result.push({
+                files,
+                size: dupe.size,
+            });
+        }
+    }
+
+    return result;
+}
+
 export function clubDuplicatesByTime(dupes: DuplicateFiles[]) {
 export function clubDuplicatesByTime(dupes: DuplicateFiles[]) {
     const result: DuplicateFiles[] = [];
     const result: DuplicateFiles[] = [];
     for (const dupe of dupes) {
     for (const dupe of dupes) {
@@ -150,3 +239,14 @@ async function sortDuplicateFiles(
         return secondFileRanking - firstFileRanking;
         return secondFileRanking - firstFileRanking;
     });
     });
 }
 }
+
+function areFileHashesSame(firstFile: Metadata, secondFile: Metadata) {
+    if (firstFile.fileType === FILE_TYPE.LIVE_PHOTO) {
+        return (
+            firstFile.imageHash === secondFile.imageHash &&
+            firstFile.videoHash === secondFile.videoHash
+        );
+    } else {
+        return firstFile.hash === secondFile.hash;
+    }
+}

+ 2 - 6
src/services/ffmpeg/ffmpegClient.ts

@@ -4,7 +4,6 @@ import { parseFFmpegExtractedMetadata } from 'utils/ffmpeg';
 
 
 class FFmpegClient {
 class FFmpegClient {
     private ffmpeg: FFmpeg;
     private ffmpeg: FFmpeg;
-    private fileReader: FileReader;
     private ready: Promise<void> = null;
     private ready: Promise<void> = null;
     constructor() {
     constructor() {
         this.ffmpeg = createFFmpeg({
         this.ffmpeg = createFFmpeg({
@@ -19,9 +18,6 @@ class FFmpegClient {
         if (!this.ffmpeg.isLoaded()) {
         if (!this.ffmpeg.isLoaded()) {
             await this.ffmpeg.load();
             await this.ffmpeg.load();
         }
         }
-        if (!this.fileReader) {
-            this.fileReader = new FileReader();
-        }
     }
     }
 
 
     async generateThumbnail(file: File) {
     async generateThumbnail(file: File) {
@@ -31,7 +27,7 @@ class FFmpegClient {
         this.ffmpeg.FS(
         this.ffmpeg.FS(
             'writeFile',
             'writeFile',
             inputFileName,
             inputFileName,
-            await getUint8ArrayView(this.fileReader, file)
+            await getUint8ArrayView(file)
         );
         );
         let seekTime = 1.0;
         let seekTime = 1.0;
         let thumb = null;
         let thumb = null;
@@ -66,7 +62,7 @@ class FFmpegClient {
         this.ffmpeg.FS(
         this.ffmpeg.FS(
             'writeFile',
             'writeFile',
             inputFileName,
             inputFileName,
-            await getUint8ArrayView(this.fileReader, file)
+            await getUint8ArrayView(file)
         );
         );
         let metadata = null;
         let metadata = null;
 
 

+ 12 - 4
src/services/ffmpeg/ffmpegService.ts

@@ -4,7 +4,9 @@ import QueueProcessor from 'services/queueProcessor';
 import { ParsedExtractedMetadata } from 'types/upload';
 import { ParsedExtractedMetadata } from 'types/upload';
 
 
 import { FFmpegWorker } from 'utils/comlink';
 import { FFmpegWorker } from 'utils/comlink';
+import { promiseWithTimeout } from 'utils/common';
 
 
+const FFMPEG_EXECUTION_WAIT_TIME = 10 * 1000;
 class FFmpegService {
 class FFmpegService {
     private ffmpegWorker = null;
     private ffmpegWorker = null;
     private ffmpegTaskQueue = new QueueProcessor<any>(1);
     private ffmpegTaskQueue = new QueueProcessor<any>(1);
@@ -18,8 +20,11 @@ class FFmpegService {
             await this.init();
             await this.init();
         }
         }
 
 
-        const response = this.ffmpegTaskQueue.queueUpRequest(
-            async () => await this.ffmpegWorker.generateThumbnail(file)
+        const response = this.ffmpegTaskQueue.queueUpRequest(() =>
+            promiseWithTimeout(
+                this.ffmpegWorker.generateThumbnail(file),
+                FFMPEG_EXECUTION_WAIT_TIME
+            )
         );
         );
         try {
         try {
             return await response.promise;
             return await response.promise;
@@ -39,8 +44,11 @@ class FFmpegService {
             await this.init();
             await this.init();
         }
         }
 
 
-        const response = this.ffmpegTaskQueue.queueUpRequest(
-            async () => await this.ffmpegWorker.extractVideoMetadata(file)
+        const response = this.ffmpegTaskQueue.queueUpRequest(() =>
+            promiseWithTimeout(
+                this.ffmpegWorker.extractVideoMetadata(file),
+                FFMPEG_EXECUTION_WAIT_TIME
+            )
         );
         );
         try {
         try {
             return await response.promise;
             return await response.promise;

+ 18 - 1
src/services/fileService.ts

@@ -17,6 +17,7 @@ import { EnteFile, TrashRequest } from 'types/file';
 import { SetFiles } from 'types/gallery';
 import { SetFiles } from 'types/gallery';
 import { MAX_TRASH_BATCH_SIZE } from 'constants/file';
 import { MAX_TRASH_BATCH_SIZE } from 'constants/file';
 import { BulkUpdateMagicMetadataRequest } from 'types/magicMetadata';
 import { BulkUpdateMagicMetadataRequest } from 'types/magicMetadata';
+import { logUploadInfo } from 'utils/upload';
 
 
 const ENDPOINT = getEndpoint();
 const ENDPOINT = getEndpoint();
 const FILES_TABLE = 'files';
 const FILES_TABLE = 'files';
@@ -28,7 +29,23 @@ export const getLocalFiles = async () => {
 };
 };
 
 
 export const setLocalFiles = async (files: EnteFile[]) => {
 export const setLocalFiles = async (files: EnteFile[]) => {
-    await localForage.setItem(FILES_TABLE, files);
+    try {
+        await localForage.setItem(FILES_TABLE, files);
+    } catch (e1) {
+        try {
+            const storageEstimate = await navigator.storage.estimate();
+            logError(e1, 'failed to save files to indexedDB', {
+                storageEstimate,
+            });
+            logUploadInfo(
+                `storage estimate ${JSON.stringify(storageEstimate)}`
+            );
+        } catch (e2) {
+            logError(e1, 'failed to save files to indexedDB');
+            logError(e2, 'failed to get storage stats');
+        }
+        throw e1;
+    }
 };
 };
 
 
 const getCollectionLastSyncTime = async (collection: Collection) =>
 const getCollectionLastSyncTime = async (collection: Collection) =>

+ 2 - 1
src/services/heicConverter/heicConverterClient.ts

@@ -1,10 +1,11 @@
 import * as HeicConvert from 'heic-convert';
 import * as HeicConvert from 'heic-convert';
+import { getUint8ArrayView } from 'services/readerService';
 
 
 export async function convertHEIC(
 export async function convertHEIC(
     fileBlob: Blob,
     fileBlob: Blob,
     format: string
     format: string
 ): Promise<Blob> {
 ): Promise<Blob> {
-    const filedata = new Uint8Array(await fileBlob.arrayBuffer());
+    const filedata = await getUint8ArrayView(fileBlob);
     const result = await HeicConvert({ buffer: filedata, format });
     const result = await HeicConvert({ buffer: filedata, format });
     const convertedFileData = new Uint8Array(result);
     const convertedFileData = new Uint8Array(result);
     const convertedFileBlob = new Blob([convertedFileData]);
     const convertedFileBlob = new Blob([convertedFileData]);

+ 41 - 11
src/services/importService.ts

@@ -1,3 +1,4 @@
+import { DESKTOP_UPLOAD_TYPE } from 'components/pages/gallery/Upload';
 import { Collection } from 'types/collection';
 import { Collection } from 'types/collection';
 import { ElectronFile, FileWithCollection } from 'types/upload';
 import { ElectronFile, FileWithCollection } from 'types/upload';
 import { runningInBrowser } from 'utils/common';
 import { runningInBrowser } from 'utils/common';
@@ -6,6 +7,12 @@ import { logError } from 'utils/sentry';
 interface PendingUploads {
 interface PendingUploads {
     files: ElectronFile[];
     files: ElectronFile[];
     collectionName: string;
     collectionName: string;
+    type: DESKTOP_UPLOAD_TYPE;
+}
+
+interface selectZipResult {
+    files: ElectronFile[];
+    zipPaths: string[];
 }
 }
 class ImportService {
 class ImportService {
     ElectronAPIs: any;
     ElectronAPIs: any;
@@ -16,6 +23,14 @@ class ImportService {
         this.allElectronAPIsExist = !!this.ElectronAPIs?.getPendingUploads;
         this.allElectronAPIsExist = !!this.ElectronAPIs?.getPendingUploads;
     }
     }
 
 
+    async getElectronFilesFromGoogleZip(
+        zipPath: string
+    ): Promise<ElectronFile[]> {
+        if (this.allElectronAPIsExist) {
+            return this.ElectronAPIs.getElectronFilesFromGoogleZip(zipPath);
+        }
+    }
+
     checkAllElectronAPIsExists = () => this.allElectronAPIsExist;
     checkAllElectronAPIsExists = () => this.allElectronAPIsExist;
 
 
     async showUploadFilesDialog(): Promise<ElectronFile[]> {
     async showUploadFilesDialog(): Promise<ElectronFile[]> {
@@ -30,6 +45,11 @@ class ImportService {
         }
         }
     }
     }
 
 
+    async showUploadZipDialog(): Promise<selectZipResult> {
+        if (this.allElectronAPIsExist) {
+            return this.ElectronAPIs.showUploadZipDialog();
+        }
+    }
     async getPendingUploads(): Promise<PendingUploads> {
     async getPendingUploads(): Promise<PendingUploads> {
         try {
         try {
             if (this.allElectronAPIsExist) {
             if (this.allElectronAPIsExist) {
@@ -39,16 +59,13 @@ class ImportService {
             }
             }
         } catch (e) {
         } catch (e) {
             logError(e, 'failed to getPendingUploads ');
             logError(e, 'failed to getPendingUploads ');
-            return { files: [], collectionName: null };
+            return { files: [], collectionName: null, type: null };
         }
         }
     }
     }
 
 
-    async setToUploadFiles(
-        files: FileWithCollection[],
-        collections: Collection[]
-    ) {
+    async setToUploadCollection(collections: Collection[]) {
         if (this.allElectronAPIsExist) {
         if (this.allElectronAPIsExist) {
-            let collectionName: string;
+            let collectionName: string = null;
             /* collection being one suggest one of two things
             /* collection being one suggest one of two things
                 1. Either the user has upload to a single existing collection
                 1. Either the user has upload to a single existing collection
                 2. Created a new single collection to upload to 
                 2. Created a new single collection to upload to 
@@ -61,13 +78,19 @@ class ImportService {
             if (collections.length === 1) {
             if (collections.length === 1) {
                 collectionName = collections[0].name;
                 collectionName = collections[0].name;
             }
             }
-            const filePaths = files.map(
-                (file) => (file.file as ElectronFile).path
-            );
-            this.ElectronAPIs.setToUploadFiles(filePaths);
             this.ElectronAPIs.setToUploadCollection(collectionName);
             this.ElectronAPIs.setToUploadCollection(collectionName);
         }
         }
     }
     }
+
+    async setToUploadFiles(
+        type: DESKTOP_UPLOAD_TYPE.FILES | DESKTOP_UPLOAD_TYPE.ZIPS,
+        filePaths: string[]
+    ) {
+        if (this.allElectronAPIsExist) {
+            this.ElectronAPIs.setToUploadFiles(type, filePaths);
+        }
+    }
+
     updatePendingUploads(files: FileWithCollection[]) {
     updatePendingUploads(files: FileWithCollection[]) {
         if (this.allElectronAPIsExist) {
         if (this.allElectronAPIsExist) {
             const filePaths = [];
             const filePaths = [];
@@ -89,7 +112,14 @@ class ImportService {
                     );
                     );
                 }
                 }
             }
             }
-            this.ElectronAPIs.setToUploadFiles(filePaths);
+            this.setToUploadFiles(DESKTOP_UPLOAD_TYPE.FILES, filePaths);
+        }
+    }
+    cancelRemainingUploads() {
+        if (this.allElectronAPIsExist) {
+            this.ElectronAPIs.setToUploadCollection(null);
+            this.ElectronAPIs.setToUploadFiles(DESKTOP_UPLOAD_TYPE.ZIPS, []);
+            this.ElectronAPIs.setToUploadFiles(DESKTOP_UPLOAD_TYPE.FILES, []);
         }
         }
     }
     }
 }
 }

+ 16 - 9
src/services/migrateThumbnailService.ts

@@ -12,6 +12,7 @@ import { getFileType } from 'services/typeDetectionService';
 import { getLocalTrash, getTrashedFiles } from './trashService';
 import { getLocalTrash, getTrashedFiles } from './trashService';
 import { EncryptionResult, UploadURL } from 'types/upload';
 import { EncryptionResult, UploadURL } from 'types/upload';
 import { fileAttribute } from 'types/file';
 import { fileAttribute } from 'types/file';
+import { USE_CF_PROXY } from 'constants/upload';
 
 
 const ENDPOINT = getEndpoint();
 const ENDPOINT = getEndpoint();
 const REPLACE_THUMBNAIL_THRESHOLD = 500 * 1024; // 500KB
 const REPLACE_THUMBNAIL_THRESHOLD = 500 * 1024; // 500KB
@@ -44,7 +45,6 @@ export async function replaceThumbnail(
     try {
     try {
         const token = getToken();
         const token = getToken();
         const worker = await new CryptoWorker();
         const worker = await new CryptoWorker();
-        const reader = new FileReader();
         const files = await getLocalFiles();
         const files = await getLocalFiles();
         const trash = await getLocalTrash();
         const trash = await getLocalTrash();
         const trashFiles = getTrashedFiles(trash);
         const trashFiles = getTrashedFiles(trash);
@@ -77,9 +77,8 @@ export async function replaceThumbnail(
                     [originalThumbnail],
                     [originalThumbnail],
                     file.metadata.title
                     file.metadata.title
                 );
                 );
-                const fileTypeInfo = await getFileType(reader, dummyImageFile);
+                const fileTypeInfo = await getFileType(dummyImageFile);
                 const { thumbnail: newThumbnail } = await generateThumbnail(
                 const { thumbnail: newThumbnail } = await generateThumbnail(
-                    reader,
                     dummyImageFile,
                     dummyImageFile,
                     fileTypeInfo
                     fileTypeInfo
                 );
                 );
@@ -110,12 +109,20 @@ export async function uploadThumbnail(
 ): Promise<fileAttribute> {
 ): Promise<fileAttribute> {
     const { file: encryptedThumbnail }: EncryptionResult =
     const { file: encryptedThumbnail }: EncryptionResult =
         await worker.encryptThumbnail(updatedThumbnail, fileKey);
         await worker.encryptThumbnail(updatedThumbnail, fileKey);
-
-    const thumbnailObjectKey = await uploadHttpClient.putFile(
-        uploadURL,
-        encryptedThumbnail.encryptedData as Uint8Array,
-        () => {}
-    );
+    let thumbnailObjectKey: string = null;
+    if (USE_CF_PROXY) {
+        thumbnailObjectKey = await uploadHttpClient.putFileV2(
+            uploadURL,
+            encryptedThumbnail.encryptedData as Uint8Array,
+            () => {}
+        );
+    } else {
+        thumbnailObjectKey = await uploadHttpClient.putFile(
+            uploadURL,
+            encryptedThumbnail.encryptedData as Uint8Array,
+            () => {}
+        );
+    }
     return {
     return {
         objectKey: thumbnailObjectKey,
         objectKey: thumbnailObjectKey,
         decryptionHeader: encryptedThumbnail.decryptionHeader,
         decryptionHeader: encryptedThumbnail.decryptionHeader,

+ 64 - 27
src/services/readerService.ts

@@ -1,30 +1,21 @@
 import { ElectronFile } from 'types/upload';
 import { ElectronFile } from 'types/upload';
+import { logError } from 'utils/sentry';
 
 
 export async function getUint8ArrayView(
 export async function getUint8ArrayView(
-    reader: FileReader,
-    file: Blob
+    file: Blob | ElectronFile
 ): Promise<Uint8Array> {
 ): Promise<Uint8Array> {
-    return await new Promise((resolve, reject) => {
-        reader.onabort = () => reject(Error('file reading was aborted'));
-        reader.onerror = () => reject(Error('file reading has failed'));
-        reader.onload = () => {
-            // Do whatever you want with the file contents
-            const result =
-                typeof reader.result === 'string'
-                    ? new TextEncoder().encode(reader.result)
-                    : new Uint8Array(reader.result);
-            resolve(result);
-        };
-        reader.readAsArrayBuffer(file);
-    });
+    try {
+        return new Uint8Array(await file.arrayBuffer());
+    } catch (e) {
+        logError(e, 'reading file blob failed', {
+            fileSize: convertBytesToHumanReadable(file.size),
+        });
+        throw e;
+    }
 }
 }
 
 
-export function getFileStream(
-    reader: FileReader,
-    file: File,
-    chunkSize: number
-) {
-    const fileChunkReader = fileChunkReaderMaker(reader, file, chunkSize);
+export function getFileStream(file: File, chunkSize: number) {
+    const fileChunkReader = fileChunkReaderMaker(file, chunkSize);
 
 
     const stream = new ReadableStream<Uint8Array>({
     const stream = new ReadableStream<Uint8Array>({
         async pull(controller: ReadableStreamDefaultController) {
         async pull(controller: ReadableStreamDefaultController) {
@@ -54,17 +45,63 @@ export async function getElectronFileStream(
     };
     };
 }
 }
 
 
-async function* fileChunkReaderMaker(
-    reader: FileReader,
-    file: File,
-    chunkSize: number
-) {
+async function* fileChunkReaderMaker(file: File, chunkSize: number) {
     let offset = 0;
     let offset = 0;
     while (offset < file.size) {
     while (offset < file.size) {
         const blob = file.slice(offset, chunkSize + offset);
         const blob = file.slice(offset, chunkSize + offset);
-        const fileChunk = await getUint8ArrayView(reader, blob);
+        const fileChunk = await getUint8ArrayView(blob);
         yield fileChunk;
         yield fileChunk;
         offset += chunkSize;
         offset += chunkSize;
     }
     }
     return null;
     return null;
 }
 }
+
+// Temporary fix for window not defined caused on importing from utils/billing
+// because this file is accessed inside worker and util/billing imports constants
+// which has reference to  window object, which cause error inside worker
+//  TODO: update worker to not read file themselves but rather have filedata passed to them
+
+function convertBytesToHumanReadable(bytes: number, precision = 2): string {
+    if (bytes === 0) {
+        return '0 MB';
+    }
+    const i = Math.floor(Math.log(bytes) / Math.log(1024));
+    const sizes = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
+
+    return (bytes / Math.pow(1024, i)).toFixed(precision) + ' ' + sizes[i];
+}
+
+// depreciated
+// eslint-disable-next-line @typescript-eslint/no-unused-vars
+async function getUint8ArrayViewOld(
+    reader: FileReader,
+    file: Blob
+): Promise<Uint8Array> {
+    return await new Promise((resolve, reject) => {
+        reader.onabort = () =>
+            reject(
+                Error(
+                    `file reading was aborted, file size= ${convertBytesToHumanReadable(
+                        file.size
+                    )}`
+                )
+            );
+        reader.onerror = () =>
+            reject(
+                Error(
+                    `file reading has failed, file size= ${convertBytesToHumanReadable(
+                        file.size
+                    )} , reason= ${reader.error}`
+                )
+            );
+        reader.onload = () => {
+            // Do whatever you want with the file contents
+            const result =
+                typeof reader.result === 'string'
+                    ? new TextEncoder().encode(reader.result)
+                    : new Uint8Array(reader.result);
+            resolve(result);
+        };
+        reader.readAsArrayBuffer(file);
+    });
+}

+ 1 - 1
src/services/trashService.ts

@@ -91,7 +91,7 @@ export const updateTrash = async (
                 break;
                 break;
             }
             }
             resp = await HTTPService.get(
             resp = await HTTPService.get(
-                `${ENDPOINT}/trash/diff`,
+                `${ENDPOINT}/trash/v2/diff`,
                 {
                 {
                     sinceTime: time,
                     sinceTime: time,
                 },
                 },

+ 6 - 7
src/services/typeDetectionService.ts

@@ -12,7 +12,6 @@ const TYPE_IMAGE = 'image';
 const CHUNK_SIZE_FOR_TYPE_DETECTION = 4100;
 const CHUNK_SIZE_FOR_TYPE_DETECTION = 4100;
 
 
 export async function getFileType(
 export async function getFileType(
-    reader: FileReader,
     receivedFile: File | ElectronFile
     receivedFile: File | ElectronFile
 ): Promise<FileTypeInfo> {
 ): Promise<FileTypeInfo> {
     try {
     try {
@@ -20,7 +19,7 @@ export async function getFileType(
         let typeResult: FileTypeResult;
         let typeResult: FileTypeResult;
 
 
         if (receivedFile instanceof File) {
         if (receivedFile instanceof File) {
-            typeResult = await extractFileType(reader, receivedFile);
+            typeResult = await extractFileType(receivedFile);
         } else {
         } else {
             typeResult = await extractElectronFileType(receivedFile);
             typeResult = await extractElectronFileType(receivedFile);
         }
         }
@@ -48,7 +47,7 @@ export async function getFileType(
     } catch (e) {
     } catch (e) {
         const fileFormat = getFileExtension(receivedFile.name);
         const fileFormat = getFileExtension(receivedFile.name);
         const formatMissedByTypeDetection = FORMAT_MISSED_BY_FILE_TYPE_LIB.find(
         const formatMissedByTypeDetection = FORMAT_MISSED_BY_FILE_TYPE_LIB.find(
-            (a) => a.exactType === fileFormat
+            (a) => a.exactType === fileFormat.toLocaleLowerCase()
         );
         );
         if (formatMissedByTypeDetection) {
         if (formatMissedByTypeDetection) {
             return formatMissedByTypeDetection;
             return formatMissedByTypeDetection;
@@ -64,9 +63,9 @@ export async function getFileType(
     }
     }
 }
 }
 
 
-async function extractFileType(reader: FileReader, file: File) {
+async function extractFileType(file: File) {
     const fileChunkBlob = file.slice(0, CHUNK_SIZE_FOR_TYPE_DETECTION);
     const fileChunkBlob = file.slice(0, CHUNK_SIZE_FOR_TYPE_DETECTION);
-    return getFileTypeFromBlob(reader, fileChunkBlob);
+    return getFileTypeFromBlob(fileChunkBlob);
 }
 }
 
 
 async function extractElectronFileType(file: ElectronFile) {
 async function extractElectronFileType(file: ElectronFile) {
@@ -77,9 +76,9 @@ async function extractElectronFileType(file: ElectronFile) {
     return fileTypeResult;
     return fileTypeResult;
 }
 }
 
 
-async function getFileTypeFromBlob(reader: FileReader, fileBlob: Blob) {
+async function getFileTypeFromBlob(fileBlob: Blob) {
     try {
     try {
-        const initialFiledata = await getUint8ArrayView(reader, fileBlob);
+        const initialFiledata = await getUint8ArrayView(fileBlob);
         return await FileType.fromBuffer(initialFiledata);
         return await FileType.fromBuffer(initialFiledata);
     } catch (e) {
     } catch (e) {
         throw Error(CustomError.TYPE_DETECTION_FAILED);
         throw Error(CustomError.TYPE_DETECTION_FAILED);

+ 1 - 2
src/services/updateCreationTimeWithExif.ts

@@ -38,8 +38,7 @@ export async function updateCreationTimeWithExif(
                 } else {
                 } else {
                     const fileURL = await downloadManager.getFile(file)[0];
                     const fileURL = await downloadManager.getFile(file)[0];
                     const fileObject = await getFileFromURL(fileURL);
                     const fileObject = await getFileFromURL(fileURL);
-                    const reader = new FileReader();
-                    const fileTypeInfo = await getFileType(reader, fileObject);
+                    const fileTypeInfo = await getFileType(fileObject);
                     const exifData = await getRawExif(fileObject, fileTypeInfo);
                     const exifData = await getRawExif(fileObject, fileTypeInfo);
                     if (fixOption === FIX_OPTIONS.DATE_TIME_ORIGINAL) {
                     if (fixOption === FIX_OPTIONS.DATE_TIME_ORIGINAL) {
                         correctCreationTime = getUnixTimeInMicroSeconds(
                         correctCreationTime = getUnixTimeInMicroSeconds(

+ 11 - 2
src/services/upload/exifService.ts

@@ -1,5 +1,5 @@
 import { NULL_EXTRACTED_METADATA, NULL_LOCATION } from 'constants/upload';
 import { NULL_EXTRACTED_METADATA, NULL_LOCATION } from 'constants/upload';
-import { Location } from 'types/upload';
+import { ElectronFile, Location } from 'types/upload';
 import exifr from 'exifr';
 import exifr from 'exifr';
 import piexif from 'piexifjs';
 import piexif from 'piexifjs';
 import { FileTypeInfo } from 'types/upload';
 import { FileTypeInfo } from 'types/upload';
@@ -28,11 +28,20 @@ interface Exif {
 }
 }
 
 
 export async function getExifData(
 export async function getExifData(
-    receivedFile: File,
+    receivedFile: File | ElectronFile,
     fileTypeInfo: FileTypeInfo
     fileTypeInfo: FileTypeInfo
 ): Promise<ParsedExtractedMetadata> {
 ): Promise<ParsedExtractedMetadata> {
     let parsedEXIFData = NULL_EXTRACTED_METADATA;
     let parsedEXIFData = NULL_EXTRACTED_METADATA;
     try {
     try {
+        if (!(receivedFile instanceof File)) {
+            receivedFile = new File(
+                [await receivedFile.blob()],
+                receivedFile.name,
+                {
+                    lastModified: receivedFile.lastModified,
+                }
+            );
+        }
         const exifData = await getRawExif(receivedFile, fileTypeInfo);
         const exifData = await getRawExif(receivedFile, fileTypeInfo);
         if (!exifData) {
         if (!exifData) {
             return parsedEXIFData;
             return parsedEXIFData;

+ 4 - 6
src/services/upload/fileService.ts

@@ -34,16 +34,14 @@ export function getFilename(file: File | ElectronFile) {
 }
 }
 
 
 export async function readFile(
 export async function readFile(
-    reader: FileReader,
     fileTypeInfo: FileTypeInfo,
     fileTypeInfo: FileTypeInfo,
     rawFile: File | ElectronFile
     rawFile: File | ElectronFile
 ): Promise<FileInMemory> {
 ): Promise<FileInMemory> {
     const { thumbnail, hasStaticThumbnail } = await generateThumbnail(
     const { thumbnail, hasStaticThumbnail } = await generateThumbnail(
-        reader,
         rawFile,
         rawFile,
         fileTypeInfo
         fileTypeInfo
     );
     );
-    logUploadInfo(`reading file datal${getFileNameSize(rawFile)} `);
+    logUploadInfo(`reading file data ${getFileNameSize(rawFile)} `);
     let filedata: Uint8Array | DataStream;
     let filedata: Uint8Array | DataStream;
     if (!(rawFile instanceof File)) {
     if (!(rawFile instanceof File)) {
         if (rawFile.size > MULTIPART_PART_SIZE) {
         if (rawFile.size > MULTIPART_PART_SIZE) {
@@ -52,12 +50,12 @@ export async function readFile(
                 FILE_READER_CHUNK_SIZE
                 FILE_READER_CHUNK_SIZE
             );
             );
         } else {
         } else {
-            filedata = await rawFile.arrayBuffer();
+            filedata = await getUint8ArrayView(rawFile);
         }
         }
     } else if (rawFile.size > MULTIPART_PART_SIZE) {
     } else if (rawFile.size > MULTIPART_PART_SIZE) {
-        filedata = getFileStream(reader, rawFile, FILE_READER_CHUNK_SIZE);
+        filedata = getFileStream(rawFile, FILE_READER_CHUNK_SIZE);
     } else {
     } else {
-        filedata = await getUint8ArrayView(reader, rawFile);
+        filedata = await getUint8ArrayView(rawFile);
     }
     }
 
 
     logUploadInfo(`read file data successfully ${getFileNameSize(rawFile)} `);
     logUploadInfo(`read file data successfully ${getFileNameSize(rawFile)} `);

+ 111 - 95
src/services/upload/livePhotoService.ts

@@ -47,11 +47,17 @@ export function getLivePhotoFileType(
     };
     };
 }
 }
 
 
-export function getLivePhotoMetadata(imageMetadata: Metadata) {
+export function getLivePhotoMetadata(
+    imageMetadata: Metadata,
+    videoMetadata: Metadata
+) {
     return {
     return {
         ...imageMetadata,
         ...imageMetadata,
         title: getLivePhotoName(imageMetadata.title),
         title: getLivePhotoName(imageMetadata.title),
         fileType: FILE_TYPE.LIVE_PHOTO,
         fileType: FILE_TYPE.LIVE_PHOTO,
+        imageHash: imageMetadata.hash,
+        videoHash: videoMetadata.hash,
+        hash: undefined,
     };
     };
 }
 }
 
 
@@ -66,12 +72,10 @@ export function getLivePhotoName(imageTitle: string) {
 }
 }
 
 
 export async function readLivePhoto(
 export async function readLivePhoto(
-    reader: FileReader,
     fileTypeInfo: FileTypeInfo,
     fileTypeInfo: FileTypeInfo,
     livePhotoAssets: LivePhotoAssets
     livePhotoAssets: LivePhotoAssets
 ) {
 ) {
     const { thumbnail, hasStaticThumbnail } = await generateThumbnail(
     const { thumbnail, hasStaticThumbnail } = await generateThumbnail(
-        reader,
         livePhotoAssets.image,
         livePhotoAssets.image,
         {
         {
             exactType: fileTypeInfo.imageType,
             exactType: fileTypeInfo.imageType,
@@ -79,15 +83,9 @@ export async function readLivePhoto(
         }
         }
     );
     );
 
 
-    const image =
-        livePhotoAssets.image instanceof File
-            ? await getUint8ArrayView(reader, livePhotoAssets.image)
-            : await livePhotoAssets.image.arrayBuffer();
+    const image = await getUint8ArrayView(livePhotoAssets.image);
 
 
-    const video =
-        livePhotoAssets.video instanceof File
-            ? await getUint8ArrayView(reader, livePhotoAssets.video)
-            : await livePhotoAssets.video.arrayBuffer();
+    const video = await getUint8ArrayView(livePhotoAssets.video);
 
 
     return {
     return {
         filedata: await encodeMotionPhoto({
         filedata: await encodeMotionPhoto({
@@ -102,101 +100,119 @@ export async function readLivePhoto(
 }
 }
 
 
 export function clusterLivePhotoFiles(mediaFiles: FileWithCollection[]) {
 export function clusterLivePhotoFiles(mediaFiles: FileWithCollection[]) {
-    const analysedMediaFiles: FileWithCollection[] = [];
-    mediaFiles
-        .sort((firstMediaFile, secondMediaFile) =>
-            splitFilenameAndExtension(
-                firstMediaFile.file.name
-            )[0].localeCompare(
-                splitFilenameAndExtension(secondMediaFile.file.name)[0]
+    try {
+        const analysedMediaFiles: FileWithCollection[] = [];
+        mediaFiles
+            .sort((firstMediaFile, secondMediaFile) =>
+                splitFilenameAndExtension(
+                    firstMediaFile.file.name
+                )[0].localeCompare(
+                    splitFilenameAndExtension(secondMediaFile.file.name)[0]
+                )
             )
             )
-        )
-        .sort(
-            (firstMediaFile, secondMediaFile) =>
-                firstMediaFile.collectionID - secondMediaFile.collectionID
-        );
-    let index = 0;
-    while (index < mediaFiles.length - 1) {
-        const firstMediaFile = mediaFiles[index];
-        const secondMediaFile = mediaFiles[index + 1];
-        const { fileTypeInfo: firstFileTypeInfo, metadata: firstFileMetadata } =
-            UploadService.getFileMetadataAndFileTypeInfo(
+            .sort(
+                (firstMediaFile, secondMediaFile) =>
+                    firstMediaFile.collectionID - secondMediaFile.collectionID
+            );
+        let index = 0;
+        while (index < mediaFiles.length - 1) {
+            const firstMediaFile = mediaFiles[index];
+            const secondMediaFile = mediaFiles[index + 1];
+            const {
+                fileTypeInfo: firstFileTypeInfo,
+                metadata: firstFileMetadata,
+            } = UploadService.getFileMetadataAndFileTypeInfo(
                 firstMediaFile.localID
                 firstMediaFile.localID
             );
             );
-        const {
-            fileTypeInfo: secondFileFileInfo,
-            metadata: secondFileMetadata,
-        } = UploadService.getFileMetadataAndFileTypeInfo(
-            secondMediaFile.localID
-        );
-        const firstFileIdentifier: LivePhotoIdentifier = {
-            collectionID: firstMediaFile.collectionID,
-            fileType: firstFileTypeInfo.fileType,
-            name: firstMediaFile.file.name,
-            size: firstMediaFile.file.size,
-        };
-        const secondFileIdentifier: LivePhotoIdentifier = {
-            collectionID: secondMediaFile.collectionID,
-            fileType: secondFileFileInfo.fileType,
-            name: secondMediaFile.file.name,
-            size: secondMediaFile.file.size,
-        };
-        const firstAsset = {
-            file: firstMediaFile.file,
-            metadata: firstFileMetadata,
-            fileTypeInfo: firstFileTypeInfo,
-        };
-        const secondAsset = {
-            file: secondMediaFile.file,
-            metadata: secondFileMetadata,
-            fileTypeInfo: secondFileFileInfo,
-        };
-        if (
-            areFilesLivePhotoAssets(firstFileIdentifier, secondFileIdentifier)
-        ) {
-            let imageAsset: Asset;
-            let videoAsset: Asset;
+            const {
+                fileTypeInfo: secondFileFileInfo,
+                metadata: secondFileMetadata,
+            } = UploadService.getFileMetadataAndFileTypeInfo(
+                secondMediaFile.localID
+            );
+            const firstFileIdentifier: LivePhotoIdentifier = {
+                collectionID: firstMediaFile.collectionID,
+                fileType: firstFileTypeInfo.fileType,
+                name: firstMediaFile.file.name,
+                size: firstMediaFile.file.size,
+            };
+            const secondFileIdentifier: LivePhotoIdentifier = {
+                collectionID: secondMediaFile.collectionID,
+                fileType: secondFileFileInfo.fileType,
+                name: secondMediaFile.file.name,
+                size: secondMediaFile.file.size,
+            };
+            const firstAsset = {
+                file: firstMediaFile.file,
+                metadata: firstFileMetadata,
+                fileTypeInfo: firstFileTypeInfo,
+            };
+            const secondAsset = {
+                file: secondMediaFile.file,
+                metadata: secondFileMetadata,
+                fileTypeInfo: secondFileFileInfo,
+            };
             if (
             if (
-                firstFileTypeInfo.fileType === FILE_TYPE.IMAGE &&
-                secondFileFileInfo.fileType === FILE_TYPE.VIDEO
+                areFilesLivePhotoAssets(
+                    firstFileIdentifier,
+                    secondFileIdentifier
+                )
             ) {
             ) {
-                imageAsset = firstAsset;
-                videoAsset = secondAsset;
+                let imageAsset: Asset;
+                let videoAsset: Asset;
+                if (
+                    firstFileTypeInfo.fileType === FILE_TYPE.IMAGE &&
+                    secondFileFileInfo.fileType === FILE_TYPE.VIDEO
+                ) {
+                    imageAsset = firstAsset;
+                    videoAsset = secondAsset;
+                } else {
+                    videoAsset = firstAsset;
+                    imageAsset = secondAsset;
+                }
+                const livePhotoLocalID = firstMediaFile.localID;
+                analysedMediaFiles.push({
+                    localID: livePhotoLocalID,
+                    collectionID: firstMediaFile.collectionID,
+                    isLivePhoto: true,
+                    livePhotoAssets: {
+                        image: imageAsset.file,
+                        video: videoAsset.file,
+                    },
+                });
+                const livePhotoFileTypeInfo: FileTypeInfo =
+                    getLivePhotoFileType(
+                        imageAsset.fileTypeInfo,
+                        videoAsset.fileTypeInfo
+                    );
+                const livePhotoMetadata: Metadata = getLivePhotoMetadata(
+                    imageAsset.metadata,
+                    videoAsset.metadata
+                );
+                uploadService.setFileMetadataAndFileTypeInfo(livePhotoLocalID, {
+                    fileTypeInfo: { ...livePhotoFileTypeInfo },
+                    metadata: { ...livePhotoMetadata },
+                });
+                index += 2;
             } else {
             } else {
-                videoAsset = firstAsset;
-                imageAsset = secondAsset;
+                analysedMediaFiles.push({
+                    ...firstMediaFile,
+                    isLivePhoto: false,
+                });
+                index += 1;
             }
             }
-            const livePhotoLocalID = firstMediaFile.localID;
+        }
+        if (index === mediaFiles.length - 1) {
             analysedMediaFiles.push({
             analysedMediaFiles.push({
-                localID: livePhotoLocalID,
-                collectionID: firstMediaFile.collectionID,
-                isLivePhoto: true,
-                livePhotoAssets: {
-                    image: imageAsset.file,
-                    video: videoAsset.file,
-                },
+                ...mediaFiles[index],
+                isLivePhoto: false,
             });
             });
-            const livePhotoFileTypeInfo: FileTypeInfo = getLivePhotoFileType(
-                imageAsset.fileTypeInfo,
-                videoAsset.fileTypeInfo
-            );
-            const livePhotoMetadata: Metadata = getLivePhotoMetadata(
-                imageAsset.metadata
-            );
-            uploadService.setFileMetadataAndFileTypeInfo(livePhotoLocalID, {
-                fileTypeInfo: { ...livePhotoFileTypeInfo },
-                metadata: { ...livePhotoMetadata },
-            });
-            index += 2;
-        } else {
-            analysedMediaFiles.push({ ...firstMediaFile, isLivePhoto: false });
-            index += 1;
         }
         }
+        return analysedMediaFiles;
+    } catch (e) {
+        logError(e, 'failed to cluster live photo');
+        throw e;
     }
     }
-    if (index === mediaFiles.length - 1) {
-        analysedMediaFiles.push({ ...mediaFiles[index], isLivePhoto: false });
-    }
-    return analysedMediaFiles;
 }
 }
 
 
 function areFilesLivePhotoAssets(
 function areFilesLivePhotoAssets(

+ 51 - 30
src/services/upload/metadataService.ts

@@ -10,10 +10,15 @@ import {
     ElectronFile,
     ElectronFile,
 } from 'types/upload';
 } from 'types/upload';
 import { NULL_EXTRACTED_METADATA, NULL_LOCATION } from 'constants/upload';
 import { NULL_EXTRACTED_METADATA, NULL_LOCATION } from 'constants/upload';
-import { splitFilenameAndExtension } from 'utils/file';
 import { getVideoMetadata } from './videoMetadataService';
 import { getVideoMetadata } from './videoMetadataService';
 import { getFileNameSize } from 'utils/upload';
 import { getFileNameSize } from 'utils/upload';
 import { logUploadInfo } from 'utils/upload';
 import { logUploadInfo } from 'utils/upload';
+import {
+    parseDateFromFusedDateString,
+    getUnixTimeInMicroSeconds,
+    tryToParseDateTime,
+} from 'utils/time';
+import { getFileHash } from 'utils/crypto';
 
 
 interface ParsedMetadataJSONWithTitle {
 interface ParsedMetadataJSONWithTitle {
     title: string;
     title: string;
@@ -32,15 +37,6 @@ export async function extractMetadata(
 ) {
 ) {
     let extractedMetadata: ParsedExtractedMetadata = NULL_EXTRACTED_METADATA;
     let extractedMetadata: ParsedExtractedMetadata = NULL_EXTRACTED_METADATA;
     if (fileTypeInfo.fileType === FILE_TYPE.IMAGE) {
     if (fileTypeInfo.fileType === FILE_TYPE.IMAGE) {
-        if (!(receivedFile instanceof File)) {
-            receivedFile = new File(
-                [await receivedFile.blob()],
-                receivedFile.name,
-                {
-                    lastModified: receivedFile.lastModified,
-                }
-            );
-        }
         extractedMetadata = await getExifData(receivedFile, fileTypeInfo);
         extractedMetadata = await getExifData(receivedFile, fileTypeInfo);
     } else if (fileTypeInfo.fileType === FILE_TYPE.VIDEO) {
     } else if (fileTypeInfo.fileType === FILE_TYPE.VIDEO) {
         logUploadInfo(
         logUploadInfo(
@@ -54,16 +50,19 @@ export async function extractMetadata(
         );
         );
     }
     }
 
 
+    const fileHash = await getFileHash(receivedFile);
+
     const metadata: Metadata = {
     const metadata: Metadata = {
-        title: `${splitFilenameAndExtension(receivedFile.name)[0]}.${
-            fileTypeInfo.exactType
-        }`,
+        title: receivedFile.name,
         creationTime:
         creationTime:
-            extractedMetadata.creationTime ?? receivedFile.lastModified * 1000,
+            extractedMetadata.creationTime ??
+            extractDateFromFileName(receivedFile.name) ??
+            receivedFile.lastModified * 1000,
         modificationTime: receivedFile.lastModified * 1000,
         modificationTime: receivedFile.lastModified * 1000,
         latitude: extractedMetadata.location.latitude,
         latitude: extractedMetadata.location.latitude,
         longitude: extractedMetadata.location.longitude,
         longitude: extractedMetadata.location.longitude,
         fileType: fileTypeInfo.fileType,
         fileType: fileTypeInfo.fileType,
+        hash: fileHash,
     };
     };
     return metadata;
     return metadata;
 }
 }
@@ -74,10 +73,7 @@ export const getMetadataJSONMapKey = (
     title: string
     title: string
 ) => `${collectionID}-${title}`;
 ) => `${collectionID}-${title}`;
 
 
-export async function parseMetadataJSON(
-    reader: FileReader,
-    receivedFile: File | ElectronFile
-) {
+export async function parseMetadataJSON(receivedFile: File | ElectronFile) {
     try {
     try {
         if (!(receivedFile instanceof File)) {
         if (!(receivedFile instanceof File)) {
             receivedFile = new File(
             receivedFile = new File(
@@ -85,18 +81,7 @@ export async function parseMetadataJSON(
                 receivedFile.name
                 receivedFile.name
             );
             );
         }
         }
-        const metadataJSON: object = await new Promise((resolve, reject) => {
-            reader.onabort = () => reject(Error('file reading was aborted'));
-            reader.onerror = () => reject(Error('file reading has failed'));
-            reader.onload = () => {
-                const result =
-                    typeof reader.result !== 'string'
-                        ? new TextDecoder().decode(reader.result)
-                        : reader.result;
-                resolve(JSON.parse(result));
-            };
-            reader.readAsText(receivedFile as File);
-        });
+        const metadataJSON: object = JSON.parse(await receivedFile.text());
 
 
         const parsedMetadataJSON: ParsedMetadataJSON =
         const parsedMetadataJSON: ParsedMetadataJSON =
             NULL_PARSED_METADATA_JSON;
             NULL_PARSED_METADATA_JSON;
@@ -149,3 +134,39 @@ export async function parseMetadataJSON(
         // ignore
         // ignore
     }
     }
 }
 }
+
+// tries to extract date from file name if available else returns null
+export function extractDateFromFileName(filename: string): number {
+    try {
+        filename = filename.trim();
+        let parsedDate: Date;
+        if (filename.startsWith('IMG-') || filename.startsWith('VID-')) {
+            // Whatsapp media files
+            // sample name IMG-20171218-WA0028.jpg
+            parsedDate = parseDateFromFusedDateString(filename.split('-')[1]);
+        } else if (filename.startsWith('Screenshot_')) {
+            // Screenshots on droid
+            // sample name Screenshot_20181227-152914.jpg
+            parsedDate = parseDateFromFusedDateString(
+                filename.replaceAll('Screenshot_', '')
+            );
+        } else if (filename.startsWith('signal-')) {
+            // signal images
+            // sample name :signal-2018-08-21-100217.jpg
+            const dateString = convertSignalNameToFusedDateString(filename);
+            parsedDate = parseDateFromFusedDateString(dateString);
+        }
+        if (!parsedDate) {
+            parsedDate = tryToParseDateTime(filename);
+        }
+        return getUnixTimeInMicroSeconds(parsedDate);
+    } catch (e) {
+        logError(e, 'failed to extract date From FileName ');
+        return null;
+    }
+}
+
+function convertSignalNameToFusedDateString(filename: string) {
+    const dateStringParts = filename.split('-');
+    return `${dateStringParts[1]}${dateStringParts[2]}${dateStringParts[3]}-${dateStringParts[4]}`;
+}

+ 20 - 7
src/services/upload/multiPartUploadService.ts

@@ -1,6 +1,7 @@
 import {
 import {
     FILE_CHUNKS_COMBINED_FOR_A_UPLOAD_PART,
     FILE_CHUNKS_COMBINED_FOR_A_UPLOAD_PART,
     RANDOM_PERCENTAGE_PROGRESS_FOR_PUT,
     RANDOM_PERCENTAGE_PROGRESS_FOR_PUT,
+    USE_CF_PROXY,
 } from 'constants/upload';
 } from 'constants/upload';
 import UIService from './uiService';
 import UIService from './uiService';
 import UploadHttpClient from './uploadHttpClient';
 import UploadHttpClient from './uploadHttpClient';
@@ -56,12 +57,20 @@ export async function uploadStreamInParts(
             percentPerPart,
             percentPerPart,
             index
             index
         );
         );
-
-        const eTag = await UploadHttpClient.putFilePart(
-            fileUploadURL,
-            uploadChunk,
-            progressTracker
-        );
+        let eTag = null;
+        if (USE_CF_PROXY) {
+            eTag = await UploadHttpClient.putFilePartV2(
+                fileUploadURL,
+                uploadChunk,
+                progressTracker
+            );
+        } else {
+            eTag = await UploadHttpClient.putFilePart(
+                fileUploadURL,
+                uploadChunk,
+                progressTracker
+            );
+        }
         partEtags.push({ PartNumber: index + 1, ETag: eTag });
         partEtags.push({ PartNumber: index + 1, ETag: eTag });
     }
     }
     const { done } = await streamReader.read();
     const { done } = await streamReader.read();
@@ -103,5 +112,9 @@ async function completeMultipartUpload(
         { CompleteMultipartUpload: { Part: partEtags } },
         { CompleteMultipartUpload: { Part: partEtags } },
         options
         options
     );
     );
-    await UploadHttpClient.completeMultipartUpload(completeURL, body);
+    if (USE_CF_PROXY) {
+        await UploadHttpClient.completeMultipartUploadV2(completeURL, body);
+    } else {
+        await UploadHttpClient.completeMultipartUpload(completeURL, body);
+    }
 }
 }

+ 4 - 5
src/services/upload/thumbnailService.ts

@@ -24,7 +24,6 @@ interface Dimension {
 }
 }
 
 
 export async function generateThumbnail(
 export async function generateThumbnail(
-    reader: FileReader,
     file: File | ElectronFile,
     file: File | ElectronFile,
     fileTypeInfo: FileTypeInfo
     fileTypeInfo: FileTypeInfo
 ): Promise<{ thumbnail: Uint8Array; hasStaticThumbnail: boolean }> {
 ): Promise<{ thumbnail: Uint8Array; hasStaticThumbnail: boolean }> {
@@ -33,10 +32,10 @@ export async function generateThumbnail(
         let hasStaticThumbnail = false;
         let hasStaticThumbnail = false;
         let canvas = document.createElement('canvas');
         let canvas = document.createElement('canvas');
         let thumbnail: Uint8Array;
         let thumbnail: Uint8Array;
-        if (!(file instanceof File)) {
-            file = new File([await file.blob()], file.name);
-        }
         try {
         try {
+            if (!(file instanceof File)) {
+                file = new File([await file.blob()], file.name);
+            }
             if (fileTypeInfo.fileType === FILE_TYPE.IMAGE) {
             if (fileTypeInfo.fileType === FILE_TYPE.IMAGE) {
                 const isHEIC = isFileHEIC(fileTypeInfo.exactType);
                 const isHEIC = isFileHEIC(fileTypeInfo.exactType);
                 canvas = await generateImageThumbnail(file, isHEIC);
                 canvas = await generateImageThumbnail(file, isHEIC);
@@ -72,7 +71,7 @@ export async function generateThumbnail(
                 }
                 }
             }
             }
             const thumbnailBlob = await thumbnailCanvasToBlob(canvas);
             const thumbnailBlob = await thumbnailCanvasToBlob(canvas);
-            thumbnail = await getUint8ArrayView(reader, thumbnailBlob);
+            thumbnail = await getUint8ArrayView(thumbnailBlob);
             if (thumbnail.length === 0) {
             if (thumbnail.length === 0) {
                 throw Error('EMPTY THUMBNAIL');
                 throw Error('EMPTY THUMBNAIL');
             }
             }

+ 76 - 1
src/services/upload/uploadHttpClient.ts

@@ -1,5 +1,5 @@
 import HTTPService from 'services/HTTPService';
 import HTTPService from 'services/HTTPService';
-import { getEndpoint } from 'utils/common/apiUtil';
+import { getEndpoint, getUploadEndpoint } from 'utils/common/apiUtil';
 import { getToken } from 'utils/common/key';
 import { getToken } from 'utils/common/key';
 import { logError } from 'utils/sentry';
 import { logError } from 'utils/sentry';
 import { EnteFile } from 'types/file';
 import { EnteFile } from 'types/file';
@@ -8,6 +8,8 @@ import { UploadFile, UploadURL, MultipartUploadURLs } from 'types/upload';
 import { retryHTTPCall } from 'utils/upload/uploadRetrier';
 import { retryHTTPCall } from 'utils/upload/uploadRetrier';
 
 
 const ENDPOINT = getEndpoint();
 const ENDPOINT = getEndpoint();
+const UPLOAD_ENDPOINT = getUploadEndpoint();
+
 const MAX_URL_REQUESTS = 50;
 const MAX_URL_REQUESTS = 50;
 
 
 class UploadHttpClient {
 class UploadHttpClient {
@@ -106,6 +108,30 @@ class UploadHttpClient {
         }
         }
     }
     }
 
 
+    async putFileV2(
+        fileUploadURL: UploadURL,
+        file: Uint8Array,
+        progressTracker
+    ): Promise<string> {
+        try {
+            await retryHTTPCall(() =>
+                HTTPService.put(
+                    `${UPLOAD_ENDPOINT}/file-upload`,
+                    file,
+                    null,
+                    {
+                        'UPLOAD-URL': fileUploadURL.url,
+                    },
+                    progressTracker
+                )
+            );
+            return fileUploadURL.objectKey;
+        } catch (e) {
+            logError(e, 'putFile to dataStore failed ');
+            throw e;
+        }
+    }
+
     async putFilePart(
     async putFilePart(
         partUploadURL: string,
         partUploadURL: string,
         filePart: Uint8Array,
         filePart: Uint8Array,
@@ -134,6 +160,36 @@ class UploadHttpClient {
         }
         }
     }
     }
 
 
+    async putFilePartV2(
+        partUploadURL: string,
+        filePart: Uint8Array,
+        progressTracker
+    ) {
+        try {
+            const response = await retryHTTPCall(async () => {
+                const resp = await HTTPService.put(
+                    `${UPLOAD_ENDPOINT}/multipart-upload`,
+                    filePart,
+                    null,
+                    {
+                        'UPLOAD-URL': partUploadURL,
+                    },
+                    progressTracker
+                );
+                if (!resp?.data?.etag) {
+                    const err = Error(CustomError.ETAG_MISSING);
+                    logError(err, 'putFile in parts failed');
+                    throw err;
+                }
+                return resp;
+            });
+            return response.data.etag as string;
+        } catch (e) {
+            logError(e, 'put filePart failed');
+            throw e;
+        }
+    }
+
     async completeMultipartUpload(completeURL: string, reqBody: any) {
     async completeMultipartUpload(completeURL: string, reqBody: any) {
         try {
         try {
             await retryHTTPCall(() =>
             await retryHTTPCall(() =>
@@ -146,6 +202,25 @@ class UploadHttpClient {
             throw e;
             throw e;
         }
         }
     }
     }
+
+    async completeMultipartUploadV2(completeURL: string, reqBody: any) {
+        try {
+            await retryHTTPCall(() =>
+                HTTPService.post(
+                    `${UPLOAD_ENDPOINT}/multipart-complete`,
+                    reqBody,
+                    null,
+                    {
+                        'content-type': 'text/xml',
+                        'UPLOAD-URL': completeURL,
+                    }
+                )
+            );
+        } catch (e) {
+            logError(e, 'put file in parts failed');
+            throw e;
+        }
+    }
 }
 }
 
 
 export default new UploadHttpClient();
 export default new UploadHttpClient();

+ 107 - 73
src/services/upload/uploadManager.ts

@@ -5,6 +5,7 @@ import {
     sortFilesIntoCollections,
     sortFilesIntoCollections,
     sortFiles,
     sortFiles,
     preservePhotoswipeProps,
     preservePhotoswipeProps,
+    decryptFile,
 } from 'utils/file';
 } from 'utils/file';
 import { logError } from 'utils/sentry';
 import { logError } from 'utils/sentry';
 import { getMetadataJSONMapKey, parseMetadataJSON } from './metadataService';
 import { getMetadataJSONMapKey, parseMetadataJSON } from './metadataService';
@@ -35,9 +36,6 @@ import {
 import { ComlinkWorker } from 'utils/comlink';
 import { ComlinkWorker } from 'utils/comlink';
 import { FILE_TYPE } from 'constants/file';
 import { FILE_TYPE } from 'constants/file';
 import uiService from './uiService';
 import uiService from './uiService';
-import { getData, LS_KEYS, setData } from 'utils/storage/localStorage';
-import { dedupe } from 'utils/export';
-import { convertBytesToHumanReadable } from 'utils/billing';
 import { logUploadInfo } from 'utils/upload';
 import { logUploadInfo } from 'utils/upload';
 import isElectron from 'is-electron';
 import isElectron from 'is-electron';
 import ImportService from 'services/importService';
 import ImportService from 'services/importService';
@@ -60,10 +58,8 @@ class UploadManager {
         UIService.init(progressUpdater);
         UIService.init(progressUpdater);
         this.setFiles = setFiles;
         this.setFiles = setFiles;
     }
     }
-    private uploadCancelled: boolean;
 
 
     private resetState() {
     private resetState() {
-        this.uploadCancelled = false;
         this.filesToBeUploaded = [];
         this.filesToBeUploaded = [];
         this.remainingFiles = [];
         this.remainingFiles = [];
         this.failedFiles = [];
         this.failedFiles = [];
@@ -115,13 +111,42 @@ class UploadManager {
                 UploadService.setMetadataAndFileTypeInfoMap(
                 UploadService.setMetadataAndFileTypeInfoMap(
                     this.metadataAndFileTypeInfoMap
                     this.metadataAndFileTypeInfoMap
                 );
                 );
+
                 UIService.setUploadStage(UPLOAD_STAGES.START);
                 UIService.setUploadStage(UPLOAD_STAGES.START);
                 logUploadInfo(`clusterLivePhotoFiles called`);
                 logUploadInfo(`clusterLivePhotoFiles called`);
+
+                // filter out files whose metadata detection failed or those that have been skipped because the files are too large,
+                // as they will be rejected during upload and are not valid upload files which we need to clustering
+                const rejectedFileLocalIDs = new Set(
+                    [...this.metadataAndFileTypeInfoMap.entries()].map(
+                        ([localID, metadataAndFileTypeInfo]) => {
+                            if (
+                                !metadataAndFileTypeInfo.metadata ||
+                                !metadataAndFileTypeInfo.fileTypeInfo
+                            ) {
+                                return localID;
+                            }
+                        }
+                    )
+                );
+                const rejectedFiles = [];
+                const filesWithMetadata = [];
+                mediaFiles.forEach((m) => {
+                    if (rejectedFileLocalIDs.has(m.localID)) {
+                        rejectedFiles.push(m);
+                    } else {
+                        filesWithMetadata.push(m);
+                    }
+                });
+
                 const analysedMediaFiles =
                 const analysedMediaFiles =
-                    UploadService.clusterLivePhotoFiles(mediaFiles);
+                    UploadService.clusterLivePhotoFiles(filesWithMetadata);
+
+                const allFiles = [...rejectedFiles, ...analysedMediaFiles];
+
                 uiService.setFilenames(
                 uiService.setFilenames(
                     new Map<number, string>(
                     new Map<number, string>(
-                        analysedMediaFiles.map((mediaFile) => [
+                        allFiles.map((mediaFile) => [
                             mediaFile.localID,
                             mediaFile.localID,
                             UploadService.getAssetName(mediaFile),
                             UploadService.getAssetName(mediaFile),
                         ])
                         ])
@@ -129,20 +154,22 @@ class UploadManager {
                 );
                 );
 
 
                 UIService.setHasLivePhoto(
                 UIService.setHasLivePhoto(
-                    mediaFiles.length !== analysedMediaFiles.length
+                    mediaFiles.length !== allFiles.length
                 );
                 );
                 logUploadInfo(
                 logUploadInfo(
-                    `got live photos: ${
-                        mediaFiles.length !== analysedMediaFiles.length
-                    }`
+                    `got live photos: ${mediaFiles.length !== allFiles.length}`
                 );
                 );
 
 
-                await this.uploadMediaFiles(analysedMediaFiles);
+                await this.uploadMediaFiles(allFiles);
             }
             }
             UIService.setUploadStage(UPLOAD_STAGES.FINISH);
             UIService.setUploadStage(UPLOAD_STAGES.FINISH);
             UIService.setPercentComplete(FILE_UPLOAD_COMPLETED);
             UIService.setPercentComplete(FILE_UPLOAD_COMPLETED);
         } catch (e) {
         } catch (e) {
             logError(e, 'uploading failed with error');
             logError(e, 'uploading failed with error');
+            logUploadInfo(
+                `uploading failed with error -> ${e.message}
+                ${(e as Error).stack}`
+            );
             throw e;
             throw e;
         } finally {
         } finally {
             for (let i = 0; i < MAX_CONCURRENT_UPLOADS; i++) {
             for (let i = 0; i < MAX_CONCURRENT_UPLOADS; i++) {
@@ -156,18 +183,14 @@ class UploadManager {
             logUploadInfo(`parseMetadataJSONFiles function executed `);
             logUploadInfo(`parseMetadataJSONFiles function executed `);
 
 
             UIService.reset(metadataFiles.length);
             UIService.reset(metadataFiles.length);
-            const reader = new FileReader();
+
             for (const { file, collectionID } of metadataFiles) {
             for (const { file, collectionID } of metadataFiles) {
                 try {
                 try {
-                    if (this.uploadCancelled) {
-                        break;
-                    }
                     logUploadInfo(
                     logUploadInfo(
                         `parsing metadata json file ${getFileNameSize(file)}`
                         `parsing metadata json file ${getFileNameSize(file)}`
                     );
                     );
 
 
                     const parsedMetadataJSONWithTitle = await parseMetadataJSON(
                     const parsedMetadataJSONWithTitle = await parseMetadataJSON(
-                        reader,
                         file
                         file
                     );
                     );
                     if (parsedMetadataJSONWithTitle) {
                     if (parsedMetadataJSONWithTitle) {
@@ -187,7 +210,7 @@ class UploadManager {
                 } catch (e) {
                 } catch (e) {
                     logError(e, 'parsing failed for a file');
                     logError(e, 'parsing failed for a file');
                     logUploadInfo(
                     logUploadInfo(
-                        `successfully parsed metadata json file ${getFileNameSize(
+                        `failed to parse metadata json file ${getFileNameSize(
                             file
                             file
                         )} error: ${e.message}`
                         )} error: ${e.message}`
                     );
                     );
@@ -203,12 +226,8 @@ class UploadManager {
         try {
         try {
             logUploadInfo(`extractMetadataFromFiles executed`);
             logUploadInfo(`extractMetadataFromFiles executed`);
             UIService.reset(mediaFiles.length);
             UIService.reset(mediaFiles.length);
-            const reader = new FileReader();
             for (const { file, localID, collectionID } of mediaFiles) {
             for (const { file, localID, collectionID } of mediaFiles) {
                 try {
                 try {
-                    if (this.uploadCancelled) {
-                        break;
-                    }
                     const { fileTypeInfo, metadata } = await (async () => {
                     const { fileTypeInfo, metadata } = await (async () => {
                         if (file.size >= MAX_FILE_SIZE_SUPPORTED) {
                         if (file.size >= MAX_FILE_SIZE_SUPPORTED) {
                             logUploadInfo(
                             logUploadInfo(
@@ -220,7 +239,6 @@ class UploadManager {
                             return { fileTypeInfo: null, metadata: null };
                             return { fileTypeInfo: null, metadata: null };
                         }
                         }
                         const fileTypeInfo = await UploadService.getFileType(
                         const fileTypeInfo = await UploadService.getFileType(
-                            reader,
                             file
                             file
                         );
                         );
                         if (fileTypeInfo.fileType === FILE_TYPE.OTHERS) {
                         if (fileTypeInfo.fileType === FILE_TYPE.OTHERS) {
@@ -264,14 +282,11 @@ class UploadManager {
             }
             }
         } catch (e) {
         } catch (e) {
             logError(e, 'error extracting metadata');
             logError(e, 'error extracting metadata');
-            // silently ignore the error
+            throw e;
         }
         }
     }
     }
 
 
     private async uploadMediaFiles(mediaFiles: FileWithCollection[]) {
     private async uploadMediaFiles(mediaFiles: FileWithCollection[]) {
-        if (this.uploadCancelled) {
-            return;
-        }
         logUploadInfo(`uploadMediaFiles called`);
         logUploadInfo(`uploadMediaFiles called`);
         this.filesToBeUploaded.push(...mediaFiles);
         this.filesToBeUploaded.push(...mediaFiles);
 
 
@@ -298,60 +313,83 @@ class UploadManager {
             this.cryptoWorkers[i] = cryptoWorker;
             this.cryptoWorkers[i] = cryptoWorker;
             uploadProcesses.push(
             uploadProcesses.push(
                 this.uploadNextFileInQueue(
                 this.uploadNextFileInQueue(
-                    await new this.cryptoWorkers[i].comlink(),
-                    new FileReader()
+                    await new this.cryptoWorkers[i].comlink()
                 )
                 )
             );
             );
         }
         }
         await Promise.all(uploadProcesses);
         await Promise.all(uploadProcesses);
     }
     }
 
 
-    private async uploadNextFileInQueue(worker: any, reader: FileReader) {
+    private async uploadNextFileInQueue(worker: any) {
         while (this.filesToBeUploaded.length > 0) {
         while (this.filesToBeUploaded.length > 0) {
-            if (this.uploadCancelled) {
-                return;
-            }
-            const fileWithCollection = this.filesToBeUploaded.pop();
+            let fileWithCollection = this.filesToBeUploaded.pop();
             const { collectionID } = fileWithCollection;
             const { collectionID } = fileWithCollection;
             const existingFilesInCollection =
             const existingFilesInCollection =
                 this.existingFilesCollectionWise.get(collectionID) ?? [];
                 this.existingFilesCollectionWise.get(collectionID) ?? [];
             const collection = this.collections.get(collectionID);
             const collection = this.collections.get(collectionID);
-
-            const { fileUploadResult, file } = await uploader(
-                worker,
-                reader,
-                existingFilesInCollection,
-                this.existingFiles,
-                { ...fileWithCollection, collection }
+            fileWithCollection = { ...fileWithCollection, collection };
+            const { fileUploadResult, uploadedFile, skipDecryption } =
+                await uploader(
+                    worker,
+                    existingFilesInCollection,
+                    this.existingFiles,
+                    fileWithCollection
+                );
+            UIService.moveFileToResultList(
+                fileWithCollection.localID,
+                fileUploadResult
             );
             );
-            if (fileUploadResult === FileUploadResults.UPLOADED) {
-                this.existingFiles.push(file);
+            UploadService.reducePendingUploadCount();
+            await this.postUploadTask(
+                fileUploadResult,
+                uploadedFile,
+                skipDecryption,
+                fileWithCollection
+            );
+        }
+    }
+
+    async postUploadTask(
+        fileUploadResult: FileUploadResults,
+        uploadedFile: EnteFile,
+        skipDecryption: boolean,
+        fileWithCollection: FileWithCollection
+    ) {
+        try {
+            logUploadInfo(`uploadedFile ${JSON.stringify(uploadedFile)}`);
+
+            if (
+                (fileUploadResult === FileUploadResults.UPLOADED ||
+                    fileUploadResult ===
+                        FileUploadResults.UPLOADED_WITH_STATIC_THUMBNAIL) &&
+                !skipDecryption
+            ) {
+                const decryptedFile = await decryptFile(
+                    uploadedFile,
+                    fileWithCollection.collection.key
+                );
+                this.existingFiles.push(decryptedFile);
                 this.existingFiles = sortFiles(this.existingFiles);
                 this.existingFiles = sortFiles(this.existingFiles);
                 await setLocalFiles(this.existingFiles);
                 await setLocalFiles(this.existingFiles);
                 this.setFiles(preservePhotoswipeProps(this.existingFiles));
                 this.setFiles(preservePhotoswipeProps(this.existingFiles));
-                if (!this.existingFilesCollectionWise.has(file.collectionID)) {
-                    this.existingFilesCollectionWise.set(file.collectionID, []);
+                if (
+                    !this.existingFilesCollectionWise.has(
+                        decryptedFile.collectionID
+                    )
+                ) {
+                    this.existingFilesCollectionWise.set(
+                        decryptedFile.collectionID,
+                        []
+                    );
                 }
                 }
                 this.existingFilesCollectionWise
                 this.existingFilesCollectionWise
-                    .get(file.collectionID)
-                    .push(file);
+                    .get(decryptedFile.collectionID)
+                    .push(decryptedFile);
             }
             }
-            if (fileUploadResult === FileUploadResults.FAILED) {
-                this.failedFiles.push(fileWithCollection);
-                setData(LS_KEYS.FAILED_UPLOADS, {
-                    files: dedupe([
-                        ...(getData(LS_KEYS.FAILED_UPLOADS)?.files ?? []),
-                        ...this.failedFiles.map(
-                            (file) =>
-                                `${
-                                    file.file.name
-                                }_${convertBytesToHumanReadable(
-                                    file.file.size
-                                )}`
-                        ),
-                    ]),
-                });
-            } else if (fileUploadResult === FileUploadResults.BLOCKED) {
+            if (
+                fileUploadResult === FileUploadResults.FAILED ||
+                fileUploadResult === FileUploadResults.BLOCKED
+            ) {
                 this.failedFiles.push(fileWithCollection);
                 this.failedFiles.push(fileWithCollection);
             }
             }
 
 
@@ -362,12 +400,13 @@ class UploadManager {
                 );
                 );
                 ImportService.updatePendingUploads(this.remainingFiles);
                 ImportService.updatePendingUploads(this.remainingFiles);
             }
             }
-
-            UIService.moveFileToResultList(
-                fileWithCollection.localID,
-                fileUploadResult
+        } catch (e) {
+            logError(e, 'failed to do post file upload action');
+            logUploadInfo(
+                `failed to do post file upload action -> ${e.message}
+                ${(e as Error).stack}`
             );
             );
-            UploadService.reducePendingUploadCount();
+            throw e;
         }
         }
     }
     }
 
 
@@ -376,11 +415,6 @@ class UploadManager {
             ...this.collections.values(),
             ...this.collections.values(),
         ]);
         ]);
     }
     }
-
-    cancelRemainingUploads() {
-        this.remainingFiles = [];
-        this.uploadCancelled = true;
-    }
 }
 }
 
 
 export default new UploadManager();
 export default new UploadManager();

+ 32 - 15
src/services/upload/uploadService.ts

@@ -32,6 +32,7 @@ import {
 import { encryptFile, getFileSize, readFile } from './fileService';
 import { encryptFile, getFileSize, readFile } from './fileService';
 import { uploadStreamUsingMultipart } from './multiPartUploadService';
 import { uploadStreamUsingMultipart } from './multiPartUploadService';
 import UIService from './uiService';
 import UIService from './uiService';
+import { USE_CF_PROXY } from 'constants/upload';
 
 
 class UploadService {
 class UploadService {
     private uploadURLs: UploadURL[] = [];
     private uploadURLs: UploadURL[] = [];
@@ -76,18 +77,17 @@ class UploadService {
             : getFilename(file);
             : getFilename(file);
     }
     }
 
 
-    async getFileType(reader: FileReader, file: File | ElectronFile) {
-        return getFileType(reader, file);
+    async getFileType(file: File | ElectronFile) {
+        return getFileType(file);
     }
     }
 
 
     async readAsset(
     async readAsset(
-        reader: FileReader,
         fileTypeInfo: FileTypeInfo,
         fileTypeInfo: FileTypeInfo,
         { isLivePhoto, file, livePhotoAssets }: UploadAsset
         { isLivePhoto, file, livePhotoAssets }: UploadAsset
     ) {
     ) {
         return isLivePhoto
         return isLivePhoto
-            ? await readLivePhoto(reader, fileTypeInfo, livePhotoAssets)
-            : await readFile(reader, fileTypeInfo, file);
+            ? await readLivePhoto(fileTypeInfo, livePhotoAssets)
+            : await readFile(fileTypeInfo, file);
     }
     }
 
 
     async extractFileMetadata(
     async extractFileMetadata(
@@ -142,18 +142,35 @@ class UploadService {
                     file.localID
                     file.localID
                 );
                 );
                 const fileUploadURL = await this.getUploadURL();
                 const fileUploadURL = await this.getUploadURL();
-                fileObjectKey = await UploadHttpClient.putFile(
-                    fileUploadURL,
-                    file.file.encryptedData,
-                    progressTracker
-                );
+                if (USE_CF_PROXY) {
+                    fileObjectKey = await UploadHttpClient.putFileV2(
+                        fileUploadURL,
+                        file.file.encryptedData,
+                        progressTracker
+                    );
+                } else {
+                    fileObjectKey = await UploadHttpClient.putFile(
+                        fileUploadURL,
+                        file.file.encryptedData,
+                        progressTracker
+                    );
+                }
             }
             }
             const thumbnailUploadURL = await this.getUploadURL();
             const thumbnailUploadURL = await this.getUploadURL();
-            const thumbnailObjectKey = await UploadHttpClient.putFile(
-                thumbnailUploadURL,
-                file.thumbnail.encryptedData as Uint8Array,
-                null
-            );
+            let thumbnailObjectKey: string = null;
+            if (USE_CF_PROXY) {
+                thumbnailObjectKey = await UploadHttpClient.putFileV2(
+                    thumbnailUploadURL,
+                    file.thumbnail.encryptedData as Uint8Array,
+                    null
+                );
+            } else {
+                thumbnailObjectKey = await UploadHttpClient.putFile(
+                    thumbnailUploadURL,
+                    file.thumbnail.encryptedData as Uint8Array,
+                    null
+                );
+            }
 
 
             const backupedFile: BackupedFile = {
             const backupedFile: BackupedFile = {
                 file: {
                 file: {

+ 29 - 12
src/services/upload/uploader.ts

@@ -1,9 +1,9 @@
 import { EnteFile } from 'types/file';
 import { EnteFile } from 'types/file';
 import { handleUploadError, CustomError } from 'utils/error';
 import { handleUploadError, CustomError } from 'utils/error';
-import { decryptFile } from 'utils/file';
 import { logError } from 'utils/sentry';
 import { logError } from 'utils/sentry';
 import {
 import {
     fileAlreadyInCollection,
     fileAlreadyInCollection,
+    findSameFileInOtherCollection,
     shouldDedupeAcrossCollection,
     shouldDedupeAcrossCollection,
 } from 'utils/upload';
 } from 'utils/upload';
 import UploadHttpClient from './uploadHttpClient';
 import UploadHttpClient from './uploadHttpClient';
@@ -15,14 +15,15 @@ import { FileWithCollection, BackupedFile, UploadFile } from 'types/upload';
 import { logUploadInfo } from 'utils/upload';
 import { logUploadInfo } from 'utils/upload';
 import { convertBytesToHumanReadable } from 'utils/billing';
 import { convertBytesToHumanReadable } from 'utils/billing';
 import { sleep } from 'utils/common';
 import { sleep } from 'utils/common';
+import { addToCollection } from 'services/collectionService';
 
 
 interface UploadResponse {
 interface UploadResponse {
     fileUploadResult: FileUploadResults;
     fileUploadResult: FileUploadResults;
-    file?: EnteFile;
+    uploadedFile?: EnteFile;
+    skipDecryption?: boolean;
 }
 }
 export default async function uploader(
 export default async function uploader(
     worker: any,
     worker: any,
-    reader: FileReader,
     existingFilesInCollection: EnteFile[],
     existingFilesInCollection: EnteFile[],
     existingFiles: EnteFile[],
     existingFiles: EnteFile[],
     fileWithCollection: FileWithCollection
     fileWithCollection: FileWithCollection
@@ -54,6 +55,25 @@ export default async function uploader(
             return { fileUploadResult: FileUploadResults.ALREADY_UPLOADED };
             return { fileUploadResult: FileUploadResults.ALREADY_UPLOADED };
         }
         }
 
 
+        const sameFileInOtherCollection = findSameFileInOtherCollection(
+            existingFiles,
+            metadata
+        );
+
+        if (sameFileInOtherCollection) {
+            logUploadInfo(
+                `same file in other collection found for  ${fileNameSize}`
+            );
+            const resultFile = Object.assign({}, sameFileInOtherCollection);
+            resultFile.collectionID = collection.id;
+            await addToCollection(collection, [resultFile]);
+            return {
+                fileUploadResult: FileUploadResults.UPLOADED,
+                uploadedFile: resultFile,
+                skipDecryption: true,
+            };
+        }
+
         // iOS exports via album doesn't export files without collection and if user exports all photos, album info is not preserved.
         // iOS exports via album doesn't export files without collection and if user exports all photos, album info is not preserved.
         // This change allow users to export by albums, upload to ente. And export all photos -> upload files which are not already uploaded
         // This change allow users to export by albums, upload to ente. And export all photos -> upload files which are not already uploaded
         // as part of the albums
         // as part of the albums
@@ -66,11 +86,7 @@ export default async function uploader(
         }
         }
         logUploadInfo(`reading asset ${fileNameSize}`);
         logUploadInfo(`reading asset ${fileNameSize}`);
 
 
-        const file = await UploadService.readAsset(
-            reader,
-            fileTypeInfo,
-            uploadAsset
-        );
+        const file = await UploadService.readAsset(fileTypeInfo, uploadAsset);
 
 
         if (file.hasStaticThumbnail) {
         if (file.hasStaticThumbnail) {
             metadata.hasStaticThumbnail = true;
             metadata.hasStaticThumbnail = true;
@@ -103,14 +119,15 @@ export default async function uploader(
         logUploadInfo(`uploadFile ${fileNameSize}`);
         logUploadInfo(`uploadFile ${fileNameSize}`);
 
 
         const uploadedFile = await UploadHttpClient.uploadFile(uploadFile);
         const uploadedFile = await UploadHttpClient.uploadFile(uploadFile);
-        const decryptedFile = await decryptFile(uploadedFile, collection.key);
 
 
         UIService.increaseFileUploaded();
         UIService.increaseFileUploaded();
         logUploadInfo(`${fileNameSize} successfully uploaded`);
         logUploadInfo(`${fileNameSize} successfully uploaded`);
 
 
         return {
         return {
-            fileUploadResult: FileUploadResults.UPLOADED,
-            file: decryptedFile,
+            fileUploadResult: metadata.hasStaticThumbnail
+                ? FileUploadResults.UPLOADED_WITH_STATIC_THUMBNAIL
+                : FileUploadResults.UPLOADED,
+            uploadedFile: uploadedFile,
         };
         };
     } catch (e) {
     } catch (e) {
         logUploadInfo(
         logUploadInfo(
@@ -118,7 +135,7 @@ export default async function uploader(
         );
         );
 
 
         logError(e, 'file upload failed', {
         logError(e, 'file upload failed', {
-            fileFormat: fileTypeInfo.exactType,
+            fileFormat: fileTypeInfo?.exactType,
         });
         });
         const error = handleUploadError(e);
         const error = handleUploadError(e);
         switch (error.message) {
         switch (error.message) {

+ 10 - 5
src/services/upload/videoMetadataService.ts

@@ -2,15 +2,20 @@ import { NULL_EXTRACTED_METADATA } from 'constants/upload';
 import ffmpegService from 'services/ffmpeg/ffmpegService';
 import ffmpegService from 'services/ffmpeg/ffmpegService';
 import { ElectronFile } from 'types/upload';
 import { ElectronFile } from 'types/upload';
 import { logError } from 'utils/sentry';
 import { logError } from 'utils/sentry';
+import { logUploadInfo } from 'utils/upload';
 
 
 export async function getVideoMetadata(file: File | ElectronFile) {
 export async function getVideoMetadata(file: File | ElectronFile) {
     let videoMetadata = NULL_EXTRACTED_METADATA;
     let videoMetadata = NULL_EXTRACTED_METADATA;
-    if (!(file instanceof File)) {
-        file = new File([await file.blob()], file.name, {
-            lastModified: file.lastModified,
-        });
-    }
     try {
     try {
+        if (!(file instanceof File)) {
+            logUploadInfo('get file blob for video metadata extraction');
+            file = new File([await file.blob()], file.name, {
+                lastModified: file.lastModified,
+            });
+            logUploadInfo(
+                'get file blob for video metadata extraction successfully'
+            );
+        }
         videoMetadata = await ffmpegService.extractMetadata(file);
         videoMetadata = await ffmpegService.extractMetadata(file);
     } catch (e) {
     } catch (e) {
         logError(e, 'failed to get video metadata');
         logError(e, 'failed to get video metadata');

+ 73 - 8
src/services/userService.ts

@@ -1,5 +1,5 @@
 import { PAGES } from 'constants/pages';
 import { PAGES } from 'constants/pages';
-import { getEndpoint } from 'utils/common/apiUtil';
+import { getEndpoint, getFamilyPortalURL } from 'utils/common/apiUtil';
 import { clearKeys } from 'utils/storage/sessionStorage';
 import { clearKeys } from 'utils/storage/sessionStorage';
 import router from 'next/router';
 import router from 'next/router';
 import { clearData, getData, LS_KEYS } from 'utils/storage/localStorage';
 import { clearData, getData, LS_KEYS } from 'utils/storage/localStorage';
@@ -17,6 +17,8 @@ import {
     TwoFactorRecoveryResponse,
     TwoFactorRecoveryResponse,
     UserDetails,
     UserDetails,
 } from 'types/user';
 } from 'types/user';
+import { getFamilyData, isPartOfFamily } from 'utils/billing';
+import { ServerErrorCodes } from 'utils/error';
 
 
 const ENDPOINT = getEndpoint();
 const ENDPOINT = getEndpoint();
 
 
@@ -53,6 +55,42 @@ export const getPaymentToken = async () => {
     return resp.data['paymentToken'];
     return resp.data['paymentToken'];
 };
 };
 
 
+export const getFamiliesToken = async () => {
+    try {
+        const token = getToken();
+
+        const resp = await HTTPService.get(
+            `${ENDPOINT}/users/families-token`,
+            null,
+            {
+                'X-Auth-Token': token,
+            }
+        );
+        return resp.data['familiesToken'];
+    } catch (e) {
+        logError(e, 'failed to get family token');
+        throw e;
+    }
+};
+
+export const getRoadmapRedirectURL = async () => {
+    try {
+        const token = getToken();
+
+        const resp = await HTTPService.get(
+            `${ENDPOINT}/users/roadmap/v2`,
+            null,
+            {
+                'X-Auth-Token': token,
+            }
+        );
+        return resp.data['url'];
+    } catch (e) {
+        logError(e, 'failed to get roadmap url');
+        throw e;
+    }
+};
+
 export const verifyOtt = (email: string, ott: string) =>
 export const verifyOtt = (email: string, ott: string) =>
     HTTPService.post(`${ENDPOINT}/users/verify-email`, { email, ott });
     HTTPService.post(`${ENDPOINT}/users/verify-email`, { email, ott });
 
 
@@ -124,7 +162,12 @@ export const isTokenValid = async () => {
         }
         }
         return true;
         return true;
     } catch (e) {
     } catch (e) {
-        return false;
+        logError(e, 'session-validity api call failed');
+        if (e.status?.toString() === ServerErrorCodes.SESSION_EXPIRED) {
+            return false;
+        } else {
+            return true;
+        }
     }
     }
 };
 };
 
 
@@ -245,11 +288,33 @@ export const changeEmail = async (email: string, ott: string) => {
     );
     );
 };
 };
 
 
-export const getUserDetails = async (): Promise<UserDetails> => {
-    const token = getToken();
+export const getUserDetailsV2 = async (): Promise<UserDetails> => {
+    try {
+        const token = getToken();
 
 
-    const resp = await HTTPService.get(`${ENDPOINT}/users/details`, null, {
-        'X-Auth-Token': token,
-    });
-    return resp.data['details'];
+        const resp = await HTTPService.get(
+            `${ENDPOINT}/users/details/v2?memoryCount=false`,
+            null,
+            {
+                'X-Auth-Token': token,
+            }
+        );
+        return resp.data;
+    } catch (e) {
+        logError(e, 'failed to get user details v2');
+        throw e;
+    }
+};
+
+export const getFamilyPortalRedirectURL = async () => {
+    try {
+        const jwtToken = await getFamiliesToken();
+        const isFamilyCreated = isPartOfFamily(getFamilyData());
+        return `${getFamilyPortalURL()}?token=${jwtToken}&isFamilyCreated=${isFamilyCreated}&redirectURL=${
+            window.location.origin
+        }/gallery`;
+    } catch (e) {
+        logError(e, 'unable to generate to family portal URL');
+        throw e;
+    }
 };
 };

+ 3 - 0
src/types/upload/index.ts

@@ -25,6 +25,9 @@ export interface Metadata {
     longitude: number;
     longitude: number;
     fileType: FILE_TYPE;
     fileType: FILE_TYPE;
     hasStaticThumbnail?: boolean;
     hasStaticThumbnail?: boolean;
+    hash?: string;
+    imageHash?: string;
+    videoHash?: string;
 }
 }
 
 
 export interface Location {
 export interface Location {

+ 14 - 0
src/types/user/index.ts

@@ -70,10 +70,24 @@ export interface TwoFactorRecoveryResponse {
     secretDecryptionNonce: string;
     secretDecryptionNonce: string;
 }
 }
 
 
+export interface FamilyMember {
+    email: string;
+    usage: number;
+    id: string;
+    isAdmin: boolean;
+}
+
+export interface FamilyData {
+    storage: number;
+    expiry: number;
+    members: FamilyMember[];
+}
+
 export interface UserDetails {
 export interface UserDetails {
     email: string;
     email: string;
     usage: number;
     usage: number;
     fileCount: number;
     fileCount: number;
     sharedCollectionCount: number;
     sharedCollectionCount: number;
     subscription: Subscription;
     subscription: Subscription;
+    familyData?: FamilyData;
 }
 }

+ 68 - 13
src/utils/billing/index.ts

@@ -7,6 +7,8 @@ import { getData, LS_KEYS } from '../storage/localStorage';
 import { CustomError } from '../error';
 import { CustomError } from '../error';
 import { logError } from '../sentry';
 import { logError } from '../sentry';
 import { SetDialogBoxAttributes } from 'types/dialogBox';
 import { SetDialogBoxAttributes } from 'types/dialogBox';
+import { getFamilyPortalRedirectURL } from 'services/userService';
+import { FamilyData, FamilyMember, User } from 'types/user';
 
 
 const PAYMENT_PROVIDER_STRIPE = 'stripe';
 const PAYMENT_PROVIDER_STRIPE = 'stripe';
 const PAYMENT_PROVIDER_APPSTORE = 'appstore';
 const PAYMENT_PROVIDER_APPSTORE = 'appstore';
@@ -44,8 +46,7 @@ export function convertBytesToHumanReadable(
     return (bytes / Math.pow(1024, i)).toFixed(precision) + ' ' + sizes[i];
     return (bytes / Math.pow(1024, i)).toFixed(precision) + ' ' + sizes[i];
 }
 }
 
 
-export function hasPaidSubscription(subscription?: Subscription) {
-    subscription = subscription ?? getUserSubscription();
+export function hasPaidSubscription(subscription: Subscription) {
     return (
     return (
         subscription &&
         subscription &&
         isSubscriptionActive(subscription) &&
         isSubscriptionActive(subscription) &&
@@ -53,20 +54,17 @@ export function hasPaidSubscription(subscription?: Subscription) {
     );
     );
 }
 }
 
 
-export function isSubscribed(subscription?: Subscription) {
-    subscription = subscription ?? getUserSubscription();
+export function isSubscribed(subscription: Subscription) {
     return (
     return (
         hasPaidSubscription(subscription) &&
         hasPaidSubscription(subscription) &&
         !isSubscriptionCancelled(subscription)
         !isSubscriptionCancelled(subscription)
     );
     );
 }
 }
-export function isSubscriptionActive(subscription?: Subscription): boolean {
-    subscription = subscription ?? getUserSubscription();
+export function isSubscriptionActive(subscription: Subscription): boolean {
     return subscription && subscription.expiryTime > Date.now() * 1000;
     return subscription && subscription.expiryTime > Date.now() * 1000;
 }
 }
 
 
-export function isOnFreePlan(subscription?: Subscription) {
-    subscription = subscription ?? getUserSubscription();
+export function isOnFreePlan(subscription: Subscription) {
     return (
     return (
         subscription &&
         subscription &&
         isSubscriptionActive(subscription) &&
         isSubscriptionActive(subscription) &&
@@ -74,15 +72,56 @@ export function isOnFreePlan(subscription?: Subscription) {
     );
     );
 }
 }
 
 
-export function isSubscriptionCancelled(subscription?: Subscription) {
-    subscription = subscription ?? getUserSubscription();
+export function isSubscriptionCancelled(subscription: Subscription) {
     return subscription && subscription.attributes.isCancelled;
     return subscription && subscription.attributes.isCancelled;
 }
 }
 
 
+// isPartOfFamily return true if the current user is part of some family plan
+export function isPartOfFamily(familyData: FamilyData): boolean {
+    return Boolean(
+        familyData && familyData.members && familyData.members.length > 0
+    );
+}
+
+// hasNonAdminFamilyMembers return true if the admin user has members in his family
+export function hasNonAdminFamilyMembers(familyData: FamilyData): boolean {
+    return Boolean(isPartOfFamily(familyData) && familyData.members.length > 1);
+}
+
+export function isFamilyAdmin(familyData: FamilyData): boolean {
+    const familyAdmin: FamilyMember = getFamilyPlanAdmin(familyData);
+    const user: User = getData(LS_KEYS.USER);
+    return familyAdmin.email === user.email;
+}
+
+export function getFamilyPlanAdmin(familyData: FamilyData): FamilyMember {
+    if (isPartOfFamily(familyData)) {
+        return familyData.members.find((x) => x.isAdmin);
+    } else {
+        logError(
+            Error(
+                'verify user is part of family plan before calling this method'
+            ),
+            'invalid getFamilyPlanAdmin call'
+        );
+    }
+}
+
+export function getStorage(familyData: FamilyData): number {
+    const subscription: Subscription = getUserSubscription();
+    return isPartOfFamily(familyData)
+        ? familyData.storage
+        : subscription.storage;
+}
+
 export function getUserSubscription(): Subscription {
 export function getUserSubscription(): Subscription {
     return getData(LS_KEYS.SUBSCRIPTION);
     return getData(LS_KEYS.SUBSCRIPTION);
 }
 }
 
 
+export function getFamilyData(): FamilyData {
+    return getData(LS_KEYS.FAMILY_DATA);
+}
+
 export function getPlans(): Plan[] {
 export function getPlans(): Plan[] {
     return getData(LS_KEYS.PLANS);
     return getData(LS_KEYS.PLANS);
 }
 }
@@ -207,6 +246,25 @@ export async function updatePaymentMethod(
     }
     }
 }
 }
 
 
+export async function manageFamilyMethod(
+    setDialogMessage: SetDialogBoxAttributes,
+    setLoading: SetLoading
+) {
+    try {
+        setLoading(true);
+        const url = await getFamilyPortalRedirectURL();
+        window.location.href = url;
+    } catch (error) {
+        logError(error, 'failed to redirect to family portal');
+        setLoading(false);
+        setDialogMessage({
+            title: constants.ERROR,
+            content: constants.UNKNOWN_ERROR,
+            close: { variant: 'danger' },
+        });
+    }
+}
+
 export async function checkSubscriptionPurchase(
 export async function checkSubscriptionPurchase(
     setDialogMessage: SetDialogBoxAttributes,
     setDialogMessage: SetDialogBoxAttributes,
     router: NextRouter,
     router: NextRouter,
@@ -303,9 +361,6 @@ function handleFailureReason(
 }
 }
 
 
 export function planForSubscription(subscription: Subscription) {
 export function planForSubscription(subscription: Subscription) {
-    if (!subscription) {
-        return null;
-    }
     return {
     return {
         id: subscription.productID,
         id: subscription.productID,
         storage: subscription.storage,
         storage: subscription.storage,

+ 16 - 0
src/utils/common/apiUtil.ts

@@ -42,3 +42,19 @@ export const getPaymentsURL = () => {
     }
     }
     return `https://payments.ente.io`;
     return `https://payments.ente.io`;
 };
 };
+
+// getFamilyPortalURL returns the endpoint for the family dashboard which can be used to
+// create or manage family.
+export const getFamilyPortalURL = () => {
+    if (process.env.NEXT_PUBLIC_ENTE_FAMILY_PORTAL_ENDPOINT !== undefined) {
+        return process.env.NEXT_PUBLIC_ENTE_FAMILY_PORTAL_ENDPOINT;
+    }
+    return `https://family.ente.io`;
+};
+
+export const getUploadEndpoint = () => {
+    if (process.env.NEXT_PUBLIC_ENTE_UPLOAD_ENDPOINT !== undefined) {
+        return process.env.NEXT_PUBLIC_ENTE_UPLOAD_ENDPOINT;
+    }
+    return `https://uploader.ente.io`;
+};

+ 16 - 1
src/utils/common/deviceDetection.ts

@@ -3,6 +3,9 @@ export enum OS {
     ANDROID = 'android',
     ANDROID = 'android',
     IOS = 'ios',
     IOS = 'ios',
     UNKNOWN = 'unknown',
     UNKNOWN = 'unknown',
+    WINDOWS = 'windows',
+    MAC = 'mac',
+    LINUX = 'linux',
 }
 }
 
 
 declare global {
 declare global {
@@ -30,10 +33,22 @@ const GetDeviceOS = () => {
     }
     }
 
 
     // iOS detection from: http://stackoverflow.com/a/9039885/177710
     // iOS detection from: http://stackoverflow.com/a/9039885/177710
-    if (/iPad|iPhone|iPod/.test(userAgent) && !window.MSStream) {
+    if (/(iPad|iPhone|iPod)/g.test(userAgent) && !window.MSStream) {
         return OS.IOS;
         return OS.IOS;
     }
     }
 
 
+    // credit: https://github.com/MikeKovarik/platform-detect/blob/master/os.mjs
+    if (userAgent.includes('Windows')) {
+        return OS.WINDOWS;
+    }
+    if (userAgent.includes('Macintosh')) {
+        return OS.MAC;
+    }
+    // Linux must be last
+    if (userAgent.includes('Linux')) {
+        return OS.LINUX;
+    }
+
     return OS.UNKNOWN;
     return OS.UNKNOWN;
 };
 };
 
 

+ 40 - 2
src/utils/common/index.ts

@@ -1,8 +1,12 @@
 import constants from 'utils/strings/constants';
 import constants from 'utils/strings/constants';
+import { CustomError } from 'utils/error';
+import GetDeviceOS, { OS } from './deviceDetection';
 
 
-export const DESKTOP_APP_DOWNLOAD_URL =
+const DESKTOP_APP_GITHUB_DOWNLOAD_URL =
     'https://github.com/ente-io/bhari-frame/releases/latest';
     'https://github.com/ente-io/bhari-frame/releases/latest';
 
 
+const APP_DOWNLOAD_ENTE_URL_PREFIX = 'https://ente.io/download';
+
 export function checkConnectivity() {
 export function checkConnectivity() {
     if (navigator.onLine) {
     if (navigator.onLine) {
         return true;
         return true;
@@ -20,8 +24,21 @@ export async function sleep(time: number) {
     });
     });
 }
 }
 
 
+export function getOSSpecificDesktopAppDownloadLink() {
+    const os = GetDeviceOS();
+    let url = '';
+    if (os === OS.WINDOWS) {
+        url = `${APP_DOWNLOAD_ENTE_URL_PREFIX}/exe`;
+    } else if (os === OS.MAC) {
+        url = `${APP_DOWNLOAD_ENTE_URL_PREFIX}/dmg`;
+    } else {
+        url = DESKTOP_APP_GITHUB_DOWNLOAD_URL;
+    }
+    return url;
+}
 export function downloadApp() {
 export function downloadApp() {
-    const win = window.open(DESKTOP_APP_DOWNLOAD_URL, '_blank');
+    const link = getOSSpecificDesktopAppDownloadLink();
+    const win = window.open(link, '_blank');
     win.focus();
     win.focus();
 }
 }
 
 
@@ -37,3 +54,24 @@ export function initiateEmail(email: string) {
     a.rel = 'noreferrer noopener';
     a.rel = 'noreferrer noopener';
     a.click();
     a.click();
 }
 }
+export const promiseWithTimeout = async (
+    request: Promise<any>,
+    timeout: number
+) => {
+    const timeoutRef = { current: null };
+    const rejectOnTimeout = new Promise((_, reject) => {
+        timeoutRef.current = setTimeout(
+            () => reject(Error(CustomError.WAIT_TIME_EXCEEDED)),
+            timeout
+        );
+    });
+    const requestWithTimeOutCancellation = async () => {
+        const resp = await request;
+        clearTimeout(timeoutRef.current);
+        return resp;
+    };
+    return await Promise.race([
+        requestWithTimeOutCancellation(),
+        rejectOnTimeout,
+    ]);
+};

+ 15 - 0
src/utils/crypto/index.ts

@@ -7,6 +7,10 @@ import { getActualKey, getToken } from 'utils/common/key';
 import { setRecoveryKey } from 'services/userService';
 import { setRecoveryKey } from 'services/userService';
 import { logError } from 'utils/sentry';
 import { logError } from 'utils/sentry';
 import { ComlinkWorker } from 'utils/comlink';
 import { ComlinkWorker } from 'utils/comlink';
+import { DataStream, ElectronFile } from 'types/upload';
+import { cryptoGenericHash } from './libsodium';
+import { getElectronFileStream, getFileStream } from 'services/readerService';
+import { FILE_READER_CHUNK_SIZE } from 'constants/upload';
 
 
 export interface B64EncryptionResult {
 export interface B64EncryptionResult {
     encryptedData: string;
     encryptedData: string;
@@ -196,3 +200,14 @@ export async function encryptWithRecoveryKey(key: string) {
     return encryptedKey;
     return encryptedKey;
 }
 }
 export default CryptoWorker;
 export default CryptoWorker;
+
+export async function getFileHash(file: File | ElectronFile) {
+    let filedata: DataStream;
+    if (file instanceof File) {
+        filedata = getFileStream(file, FILE_READER_CHUNK_SIZE);
+    } else {
+        filedata = await getElectronFileStream(file, FILE_READER_CHUNK_SIZE);
+    }
+    const hash = await cryptoGenericHash(filedata.stream);
+    return hash;
+}

+ 30 - 0
src/utils/crypto/libsodium.ts

@@ -252,6 +252,36 @@ export async function hash(input: string) {
     );
     );
 }
 }
 
 
+export async function cryptoGenericHash(stream: ReadableStream) {
+    await sodium.ready;
+
+    const state = sodium.crypto_generichash_init(
+        null,
+        sodium.crypto_generichash_BYTES_MAX
+    );
+
+    const reader = stream.getReader();
+
+    let isDone = false;
+    while (!isDone) {
+        const { done, value: chunk } = await reader.read();
+        if (done) {
+            isDone = true;
+            break;
+        }
+        const buffer = Uint8Array.from(chunk);
+        sodium.crypto_generichash_update(state, buffer);
+    }
+
+    const hash = sodium.crypto_generichash_final(
+        state,
+        sodium.crypto_generichash_BYTES_MAX
+    );
+    const hashString = sodium.to_base64(hash, sodium.base64_variants.ORIGINAL);
+
+    return hashString;
+}
+
 export async function deriveKey(
 export async function deriveKey(
     passphrase: string,
     passphrase: string,
     salt: string,
     salt: string,

+ 2 - 0
src/utils/error/index.ts

@@ -42,6 +42,7 @@ export enum CustomError {
     NO_METADATA = 'no metadata',
     NO_METADATA = 'no metadata',
     TOO_LARGE_LIVE_PHOTO_ASSETS = 'too large live photo assets',
     TOO_LARGE_LIVE_PHOTO_ASSETS = 'too large live photo assets',
     NOT_A_DATE = 'not a date',
     NOT_A_DATE = 'not a date',
+    FILE_ID_NOT_FOUND = 'file with id not found',
 }
 }
 
 
 function parseUploadErrorCodes(error) {
 function parseUploadErrorCodes(error) {
@@ -107,6 +108,7 @@ export function errorWithContext(originalError: Error, context: string) {
         originalError.stack;
         originalError.stack;
     return errorWithContext;
     return errorWithContext;
 }
 }
+
 export const parseSharingErrorCodes = (error) => {
 export const parseSharingErrorCodes = (error) => {
     let parsedMessage = null;
     let parsedMessage = null;
     if (error?.status) {
     if (error?.status) {

+ 10 - 12
src/utils/file/index.ts

@@ -78,7 +78,6 @@ export async function downloadFile(
     }
     }
 
 
     const fileType = await getFileType(
     const fileType = await getFileType(
-        fileReader,
         new File([fileBlob], file.metadata.title)
         new File([fileBlob], file.metadata.title)
     );
     );
     if (
     if (
@@ -99,12 +98,12 @@ export async function downloadFile(
         const originalName = fileNameWithoutExtension(file.metadata.title);
         const originalName = fileNameWithoutExtension(file.metadata.title);
         const motionPhoto = await decodeMotionPhoto(fileBlob, originalName);
         const motionPhoto = await decodeMotionPhoto(fileBlob, originalName);
         const image = new File([motionPhoto.image], motionPhoto.imageNameTitle);
         const image = new File([motionPhoto.image], motionPhoto.imageNameTitle);
-        const imageType = await getFileType(fileReader, image);
+        const imageType = await getFileType(image);
         tempImageURL = URL.createObjectURL(
         tempImageURL = URL.createObjectURL(
             new Blob([motionPhoto.image], { type: imageType.mimeType })
             new Blob([motionPhoto.image], { type: imageType.mimeType })
         );
         );
         const video = new File([motionPhoto.video], motionPhoto.videoNameTitle);
         const video = new File([motionPhoto.video], motionPhoto.videoNameTitle);
-        const videoType = await getFileType(fileReader, video);
+        const videoType = await getFileType(video);
         tempVideoURL = URL.createObjectURL(
         tempVideoURL = URL.createObjectURL(
             new Blob([motionPhoto.video], { type: videoType.mimeType })
             new Blob([motionPhoto.video], { type: videoType.mimeType })
         );
         );
@@ -309,25 +308,25 @@ export const preservePhotoswipeProps =
         return fileWithPreservedProperty;
         return fileWithPreservedProperty;
     };
     };
 
 
-export function fileNameWithoutExtension(filename) {
+export function fileNameWithoutExtension(filename: string) {
     const lastDotPosition = filename.lastIndexOf('.');
     const lastDotPosition = filename.lastIndexOf('.');
     if (lastDotPosition === -1) return filename;
     if (lastDotPosition === -1) return filename;
-    else return filename.substr(0, lastDotPosition);
+    else return filename.slice(0, lastDotPosition);
 }
 }
 
 
-export function fileExtensionWithDot(filename) {
+export function fileExtensionWithDot(filename: string) {
     const lastDotPosition = filename.lastIndexOf('.');
     const lastDotPosition = filename.lastIndexOf('.');
     if (lastDotPosition === -1) return '';
     if (lastDotPosition === -1) return '';
-    else return filename.substr(lastDotPosition);
+    else return filename.slice(lastDotPosition);
 }
 }
 
 
-export function splitFilenameAndExtension(filename): [string, string] {
+export function splitFilenameAndExtension(filename: string): [string, string] {
     const lastDotPosition = filename.lastIndexOf('.');
     const lastDotPosition = filename.lastIndexOf('.');
     if (lastDotPosition === -1) return [filename, null];
     if (lastDotPosition === -1) return [filename, null];
     else
     else
         return [
         return [
-            filename.substr(0, lastDotPosition),
-            filename.substr(lastDotPosition + 1),
+            filename.slice(0, lastDotPosition),
+            filename.slice(lastDotPosition + 1),
         ];
         ];
 }
 }
 
 
@@ -349,9 +348,8 @@ export async function convertForPreview(
     fileBlob: Blob
     fileBlob: Blob
 ): Promise<Blob[]> {
 ): Promise<Blob[]> {
     const convertIfHEIC = async (fileName: string, fileBlob: Blob) => {
     const convertIfHEIC = async (fileName: string, fileBlob: Blob) => {
-        const reader = new FileReader();
         const mimeType = (
         const mimeType = (
-            await getFileType(reader, new File([fileBlob], file.metadata.title))
+            await getFileType(new File([fileBlob], file.metadata.title))
         ).exactType;
         ).exactType;
         if (isFileHEIC(mimeType)) {
         if (isFileHEIC(mimeType)) {
             fileBlob = await HEICConverter.convert(fileBlob);
             fileBlob = await HEICConverter.convert(fileBlob);

+ 11 - 2
src/utils/sentry/index.ts

@@ -1,5 +1,4 @@
 import * as Sentry from '@sentry/nextjs';
 import * as Sentry from '@sentry/nextjs';
-import { errorWithContext } from 'utils/error';
 import { getUserAnonymizedID } from 'utils/user';
 import { getUserAnonymizedID } from 'utils/user';
 
 
 export const logError = (
 export const logError = (
@@ -18,7 +17,17 @@ export const logError = (
             ...(info && {
             ...(info && {
                 info: info,
                 info: info,
             }),
             }),
-            rootCause: { message: error?.message },
+            rootCause: { message: error?.message, completeError: error },
         },
         },
     });
     });
 };
 };
+
+// copy of errorWithContext to prevent importing error util
+function errorWithContext(originalError: Error, context: string) {
+    const errorWithContext = new Error(context);
+    errorWithContext.stack =
+        errorWithContext.stack.split('\n').slice(2, 4).join('\n') +
+        '\n' +
+        originalError.stack;
+    return errorWithContext;
+}

+ 10 - 2
src/utils/storage/localStorage.ts

@@ -6,6 +6,7 @@ export enum LS_KEYS {
     KEY_ATTRIBUTES = 'keyAttributes',
     KEY_ATTRIBUTES = 'keyAttributes',
     ORIGINAL_KEY_ATTRIBUTES = 'originalKeyAttributes',
     ORIGINAL_KEY_ATTRIBUTES = 'originalKeyAttributes',
     SUBSCRIPTION = 'subscription',
     SUBSCRIPTION = 'subscription',
+    FAMILY_DATA = 'familyData',
     PLANS = 'plans',
     PLANS = 'plans',
     IS_FIRST_LOGIN = 'isFirstLogin',
     IS_FIRST_LOGIN = 'isFirstLogin',
     JUST_SIGNED_UP = 'justSignedUp',
     JUST_SIGNED_UP = 'justSignedUp',
@@ -14,7 +15,7 @@ export enum LS_KEYS {
     AnonymizeUserID = 'anonymizedUserID',
     AnonymizeUserID = 'anonymizedUserID',
     THUMBNAIL_FIX_STATE = 'thumbnailFixState',
     THUMBNAIL_FIX_STATE = 'thumbnailFixState',
     LIVE_PHOTO_INFO_SHOWN_COUNT = 'livePhotoInfoShownCount',
     LIVE_PHOTO_INFO_SHOWN_COUNT = 'livePhotoInfoShownCount',
-    FAILED_UPLOADS = 'failedUploads',
+
     LOGS = 'logs',
     LOGS = 'logs',
     USER_DETAILS = 'userDetails',
     USER_DETAILS = 'userDetails',
     COLLECTION_SORT_BY = 'collectionSortBy',
     COLLECTION_SORT_BY = 'collectionSortBy',
@@ -28,6 +29,13 @@ export const setData = (key: LS_KEYS, value: object) => {
     localStorage.setItem(key, JSON.stringify(value));
     localStorage.setItem(key, JSON.stringify(value));
 };
 };
 
 
+export const removeData = (key: LS_KEYS) => {
+    if (typeof localStorage === 'undefined') {
+        return null;
+    }
+    localStorage.removeItem(key);
+};
+
 export const getData = (key: LS_KEYS) => {
 export const getData = (key: LS_KEYS) => {
     try {
     try {
         if (
         if (
@@ -40,7 +48,7 @@ export const getData = (key: LS_KEYS) => {
         const data = localStorage.getItem(key);
         const data = localStorage.getItem(key);
         return data && JSON.parse(data);
         return data && JSON.parse(data);
     } catch (e) {
     } catch (e) {
-        logError(e, 'Failed to Parse JSON');
+        logError(e, 'Failed to Parse JSON for key ' + key);
     }
     }
 };
 };
 
 

+ 36 - 1
src/utils/strings/englishConstants.tsx

@@ -272,6 +272,9 @@ const englishConstants = {
     USAGE_DETAILS: 'usage',
     USAGE_DETAILS: 'usage',
     MANAGE: 'manage',
     MANAGE: 'manage',
     MANAGEMENT_PORTAL: 'manage payment method',
     MANAGEMENT_PORTAL: 'manage payment method',
+    MANAGE_FAMILY_PORTAL: 'manage family',
+    LEAVE_FAMILY: 'leave family',
+    LEAVE_FAMILY_CONFIRM: 'are you sure that you want to leave family?',
     CHOOSE_PLAN: 'choose your subscription plan',
     CHOOSE_PLAN: 'choose your subscription plan',
     MANAGE_PLAN: 'manage your subscription',
     MANAGE_PLAN: 'manage your subscription',
     CHOOSE_PLAN_BTN: 'choose plan',
     CHOOSE_PLAN_BTN: 'choose plan',
@@ -286,6 +289,15 @@ const englishConstants = {
             </p>
             </p>
         </>
         </>
     ),
     ),
+
+    FAMILY_PLAN_MANAGE_ADMIN_ONLY: (adminEmail) => (
+        <>
+            <p>
+                only your family plan admin <strong>{adminEmail}</strong> can
+                change the plan
+            </p>
+        </>
+    ),
     RENEWAL_ACTIVE_SUBSCRIPTION_INFO: (expiryTime) => (
     RENEWAL_ACTIVE_SUBSCRIPTION_INFO: (expiryTime) => (
         <p>your subscription will renew on {dateString(expiryTime)}</p>
         <p>your subscription will renew on {dateString(expiryTime)}</p>
     ),
     ),
@@ -304,6 +316,12 @@ const englishConstants = {
         </p>
         </p>
     ),
     ),
 
 
+    FAMILY_USAGE_INFO: (usage, quota) => (
+        <p>
+            you have used {usage} out of your family's {quota} quota
+        </p>
+    ),
+
     SUBSCRIPTION_PURCHASE_SUCCESS: (expiryTime) => (
     SUBSCRIPTION_PURCHASE_SUCCESS: (expiryTime) => (
         <>
         <>
             <p>we've received your payment</p>
             <p>we've received your payment</p>
@@ -569,6 +587,7 @@ const englishConstants = {
     RETRY_FAILED: 'retry failed uploads',
     RETRY_FAILED: 'retry failed uploads',
     FAILED_UPLOADS: 'failed uploads ',
     FAILED_UPLOADS: 'failed uploads ',
     SKIPPED_FILES: 'ignored uploads',
     SKIPPED_FILES: 'ignored uploads',
+    THUMBNAIL_GENERATION_FAILED_UPLOADS: 'thumbnail generation failed',
     UNSUPPORTED_FILES: 'unsupported files',
     UNSUPPORTED_FILES: 'unsupported files',
     SUCCESSFUL_UPLOADS: 'successful uploads',
     SUCCESSFUL_UPLOADS: 'successful uploads',
     SKIPPED_INFO:
     SKIPPED_INFO:
@@ -582,6 +601,8 @@ const englishConstants = {
         'these files were not uploaded as they exceed the maximum size limit for your storage plan',
         'these files were not uploaded as they exceed the maximum size limit for your storage plan',
     TOO_LARGE_INFO:
     TOO_LARGE_INFO:
         'these files were not uploaded as they exceed our maximum file size limit',
         'these files were not uploaded as they exceed our maximum file size limit',
+    THUMBNAIL_GENERATION_FAILED_INFO:
+        'these files were uploaded, but unfortunately we could not generate the thumbnails for them.',
     UPLOAD_TO_COLLECTION: 'upload to album',
     UPLOAD_TO_COLLECTION: 'upload to album',
     ARCHIVE: 'Hidden',
     ARCHIVE: 'Hidden',
     ALL_SECTION_NAME: 'All Photos',
     ALL_SECTION_NAME: 'All Photos',
@@ -732,12 +753,13 @@ const englishConstants = {
     DEDUPLICATE_FILES: 'Deduplicate files',
     DEDUPLICATE_FILES: 'Deduplicate files',
     NO_DUPLICATES_FOUND: "you've no duplicate files that can be cleared",
     NO_DUPLICATES_FOUND: "you've no duplicate files that can be cleared",
     CLUB_BY_CAPTURE_TIME: 'club by capture time',
     CLUB_BY_CAPTURE_TIME: 'club by capture time',
+    CLUB_BY_FILE_HASH: 'club by file hashes',
     FILES: 'files',
     FILES: 'files',
     EACH: 'each',
     EACH: 'each',
     DEDUPLICATION_LOGIC_MESSAGE: (captureTime: boolean) => (
     DEDUPLICATION_LOGIC_MESSAGE: (captureTime: boolean) => (
         <>
         <>
             the following files were clubbed based on their sizes
             the following files were clubbed based on their sizes
-            {captureTime && ` and capture time`}, please review and delete items
+            {captureTime && ' and capture time'}, please review and delete items
             you believe are duplicates{' '}
             you believe are duplicates{' '}
         </>
         </>
     ),
     ),
@@ -754,6 +776,19 @@ const englishConstants = {
         ' enter the 6-digit code from your authenticator app.',
         ' enter the 6-digit code from your authenticator app.',
     CREATE_ACCOUNT: 'Create account',
     CREATE_ACCOUNT: 'Create account',
     COPIED: 'copied',
     COPIED: 'copied',
+    CANVAS_BLOCKED_TITLE: 'unable to generate thumbnail',
+    CANVAS_BLOCKED_MESSAGE: () => (
+        <>
+            <p>
+                it looks like your browser has disabled access to canvas, which
+                is necessary to generate thumbnails for your photos
+            </p>
+            <p>
+                please enable access to your browser's canvas, or check out our
+                desktop app
+            </p>
+        </>
+    ),
 };
 };
 
 
 export default englishConstants;
 export default englishConstants;

+ 87 - 0
src/utils/time/index.ts

@@ -5,6 +5,15 @@ export interface TimeDelta {
     years?: number;
     years?: number;
 }
 }
 
 
+interface DateComponent<T = number> {
+    year: T;
+    month: T;
+    day: T;
+    hour: T;
+    minute: T;
+    second: T;
+}
+
 export function dateStringWithMMH(unixTimeInMicroSeconds: number): string {
 export function dateStringWithMMH(unixTimeInMicroSeconds: number): string {
     return new Date(unixTimeInMicroSeconds / 1000).toLocaleDateString('en-US', {
     return new Date(unixTimeInMicroSeconds / 1000).toLocaleDateString('en-US', {
         year: 'numeric',
         year: 'numeric',
@@ -76,3 +85,81 @@ function _addYears(date: Date, years: number) {
     result.setFullYear(date.getFullYear() + years);
     result.setFullYear(date.getFullYear() + years);
     return result;
     return result;
 }
 }
+
+/*
+generates data component for date in format YYYYMMDD-HHMMSS
+ */
+export function parseDateFromFusedDateString(dateTime: string) {
+    const dateComponent: DateComponent<string> = {
+        year: dateTime.slice(0, 4),
+        month: dateTime.slice(4, 6),
+        day: dateTime.slice(6, 8),
+        hour: dateTime.slice(9, 11),
+        minute: dateTime.slice(11, 13),
+        second: dateTime.slice(13, 15),
+    };
+    return getDateFromComponents(dateComponent);
+}
+
+/* sample date format = 2018-08-19 12:34:45
+ the date has six symbol separated number values
+ which we would extract and use to form the date
+ */
+export function tryToParseDateTime(dateTime: string): Date {
+    const dateComponent = getDateComponentsFromSymbolJoinedString(dateTime);
+    if (isDateComponentValid(dateComponent)) {
+        return getDateFromComponents(dateComponent);
+    } else if (
+        dateComponent.year?.length === 8 &&
+        dateComponent.month?.length === 6
+    ) {
+        // the filename has size 8 consecutive and then 6 consecutive digits
+        // high possibility that the it is some unhandled date time encoding
+        const possibleDateTime = dateComponent.year + '-' + dateComponent.month;
+        return parseDateFromFusedDateString(possibleDateTime);
+    } else {
+        return null;
+    }
+}
+
+function getDateComponentsFromSymbolJoinedString(
+    dateTime: string
+): DateComponent<string> {
+    const [year, month, day, hour, minute, second] =
+        dateTime.match(/\d+/g) ?? [];
+
+    return { year, month, day, hour, minute, second };
+}
+
+//  has length number of digits in the components
+function isDateComponentValid(dateComponent: DateComponent<string>) {
+    return (
+        dateComponent.year?.length === 4 &&
+        dateComponent.month?.length === 2 &&
+        dateComponent.day?.length === 2
+    );
+}
+
+function parseDateComponentToNumber(
+    dateComponent: DateComponent<string>
+): DateComponent<number> {
+    return {
+        year: parseInt(dateComponent.year),
+        // https://stackoverflow.com/questions/2552483/why-does-the-month-argument-range-from-0-to-11-in-javascripts-date-constructor
+        month: parseInt(dateComponent.month) - 1,
+        day: parseInt(dateComponent.day),
+        hour: parseInt(dateComponent.hour),
+        minute: parseInt(dateComponent.minute),
+        second: parseInt(dateComponent.second),
+    };
+}
+
+function getDateFromComponents(dateComponent: DateComponent<string>) {
+    const { year, month, day, hour, minute, second } =
+        parseDateComponentToNumber(dateComponent);
+    const hasTimeValues = hour && minute && second;
+
+    return hasTimeValues
+        ? new Date(year, month, day, hour, minute, second)
+        : new Date(year, month, day);
+}

+ 62 - 14
src/utils/upload/index.ts

@@ -4,6 +4,7 @@ import { convertBytesToHumanReadable } from 'utils/billing';
 import { formatDateTime } from 'utils/file';
 import { formatDateTime } from 'utils/file';
 import { getLogs, saveLogLine } from 'utils/storage';
 import { getLogs, saveLogLine } from 'utils/storage';
 import { A_SEC_IN_MICROSECONDS } from 'constants/upload';
 import { A_SEC_IN_MICROSECONDS } from 'constants/upload';
+import { FILE_TYPE } from 'constants/file';
 
 
 const TYPE_JSON = 'json';
 const TYPE_JSON = 'json';
 const DEDUPE_COLLECTION = new Set(['icloud library', 'icloudlibrary']);
 const DEDUPE_COLLECTION = new Set(['icloud library', 'icloudlibrary']);
@@ -20,6 +21,25 @@ export function fileAlreadyInCollection(
     return false;
     return false;
 }
 }
 
 
+export function findSameFileInOtherCollection(
+    existingFiles: EnteFile[],
+    newFileMetadata: Metadata
+) {
+    if (!hasFileHash(newFileMetadata)) {
+        return null;
+    }
+
+    for (const existingFile of existingFiles) {
+        if (
+            hasFileHash(existingFile.metadata) &&
+            areFilesWithFileHashSame(existingFile.metadata, newFileMetadata)
+        ) {
+            return existingFile;
+        }
+    }
+    return null;
+}
+
 export function shouldDedupeAcrossCollection(collectionName: string): boolean {
 export function shouldDedupeAcrossCollection(collectionName: string): boolean {
     // using set to avoid unnecessary regex for removing spaces for each upload
     // using set to avoid unnecessary regex for removing spaces for each upload
     return DEDUPE_COLLECTION.has(collectionName.toLocaleLowerCase());
     return DEDUPE_COLLECTION.has(collectionName.toLocaleLowerCase());
@@ -29,24 +49,52 @@ export function areFilesSame(
     existingFile: Metadata,
     existingFile: Metadata,
     newFile: Metadata
     newFile: Metadata
 ): boolean {
 ): boolean {
-    /*
-     * The maximum difference in the creation/modification times of two similar files is set to 1 second.
-     * This is because while uploading files in the web - browsers and users could have set reduced
-     * precision of file times to prevent timing attacks and fingerprinting.
-     * Context: https://developer.mozilla.org/en-US/docs/Web/API/File/lastModified#reduced_time_precision
-     */
+    if (hasFileHash(existingFile) && hasFileHash(newFile)) {
+        return areFilesWithFileHashSame(existingFile, newFile);
+    } else {
+        /*
+         * The maximum difference in the creation/modification times of two similar files is set to 1 second.
+         * This is because while uploading files in the web - browsers and users could have set reduced
+         * precision of file times to prevent timing attacks and fingerprinting.
+         * Context: https://developer.mozilla.org/en-US/docs/Web/API/File/lastModified#reduced_time_precision
+         */
+        if (
+            existingFile.fileType === newFile.fileType &&
+            Math.abs(existingFile.creationTime - newFile.creationTime) <
+                A_SEC_IN_MICROSECONDS &&
+            Math.abs(existingFile.modificationTime - newFile.modificationTime) <
+                A_SEC_IN_MICROSECONDS &&
+            existingFile.title === newFile.title
+        ) {
+            return true;
+        } else {
+            return false;
+        }
+    }
+}
+
+export function hasFileHash(file: Metadata) {
+    return file.hash || (file.imageHash && file.videoHash);
+}
+
+export function areFilesWithFileHashSame(
+    existingFile: Metadata,
+    newFile: Metadata
+): boolean {
     if (
     if (
-        existingFile.fileType === newFile.fileType &&
-        Math.abs(existingFile.creationTime - newFile.creationTime) <
-            A_SEC_IN_MICROSECONDS &&
-        Math.abs(existingFile.modificationTime - newFile.modificationTime) <
-            A_SEC_IN_MICROSECONDS &&
-        existingFile.title === newFile.title
+        existingFile.fileType !== newFile.fileType ||
+        existingFile.title !== newFile.title
     ) {
     ) {
-        return true;
-    } else {
         return false;
         return false;
     }
     }
+    if (existingFile.fileType === FILE_TYPE.LIVE_PHOTO) {
+        return (
+            existingFile.imageHash === newFile.imageHash &&
+            existingFile.videoHash === newFile.videoHash
+        );
+    } else {
+        return existingFile.hash === newFile.hash;
+    }
 }
 }
 
 
 export function segregateMetadataAndMediaFiles(
 export function segregateMetadataAndMediaFiles(

+ 53 - 0
src/utils/upload/isCanvasBlocked.ts

@@ -0,0 +1,53 @@
+//
+// Canvas Blocker &
+// Firefox privacy.resistFingerprinting Detector.
+// (c) 2018 // JOHN OZBAY // CRYPT.EE
+// MIT License
+
+// Credits: https://github.com/johnozbay/canvas-block-detector/blob/master/isCanvasBlocked.js
+
+//
+export function isCanvasBlocked() {
+    // create a 1px image data
+    let blocked = false;
+    const canvas = document.createElement('canvas');
+    const ctx = canvas.getContext('2d');
+
+    // some blockers just return an undefined ctx. So let's check that first.
+    if (ctx) {
+        const imageData = ctx.createImageData(1, 1);
+        const originalImageData = imageData.data;
+
+        // set pixels to RGB 128
+        originalImageData[0] = 128;
+        originalImageData[1] = 128;
+        originalImageData[2] = 128;
+        originalImageData[3] = 255;
+
+        // set this to canvas
+        ctx.putImageData(imageData, 1, 1);
+
+        try {
+            // now get the data back from canvas.
+            const checkData = ctx.getImageData(1, 1, 1, 1).data;
+
+            // If this is firefox, and privacy.resistFingerprinting is enabled,
+            // OR a browser extension blocking the canvas,
+            // This will return RGB all white (255,255,255) instead of the (128,128,128) we put.
+
+            // so let's check the R and G to see if they're 255 or 128 (matching what we've initially set)
+            if (
+                originalImageData[0] !== checkData[0] &&
+                originalImageData[1] !== checkData[1]
+            ) {
+                blocked = true;
+            }
+        } catch (error) {
+            // some extensions will return getImageData null. this is to account for that.
+            blocked = true;
+        }
+    } else {
+        blocked = true;
+    }
+    return blocked;
+}