Merge branch 'refactor-cache-storage' into better-comlink-types

This commit is contained in:
Abhinav 2023-02-09 23:33:23 +05:30
commit a96a611225
60 changed files with 1369 additions and 840 deletions

38
.github/workflows/cla.yaml vendored Normal file
View file

@ -0,0 +1,38 @@
name: "CLA Assistant"
on:
issue_comment:
types: [created]
pull_request_target:
types: [opened, closed, synchronize]
jobs:
CLAAssistant:
# This job only runs for pull request comments
if: ${{ github.event.issue.pull_request }}
runs-on: ubuntu-latest
steps:
- name: "CLA Assistant"
if: (github.event.comment.body == 'recheck' || github.event.comment.body == 'I have read the CLA Document and I hereby sign the CLA') || github.event_name == 'pull_request_target'
# Beta Release
uses: contributor-assistant/github-action@v2.2.1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# the below token should have repo scope and must be manually added by you in the repository's secret
PERSONAL_ACCESS_TOKEN: ${{ secrets.PERSONAL_ACCESS_TOKEN }}
with:
path-to-signatures: "signatures/version1/cla.json"
path-to-document: "https://github.com/ente-io/cla/blob/main/CLA.md" # e.g. a CLA or a DCO document
# branch should not be protected
branch: "main"
allowlist: enteio
# the followings are the optional inputs - If the optional inputs are not given, then default values will be taken
#remote-organization-name: enter the remote organization name where the signatures should be stored (Default is storing the signatures in the same repository)
remote-repository-name: cla
#create-file-commit-message: 'For example: Creating file for storing CLA Signatures'
#signed-commit-message: 'For example: $contributorName has signed the CLA in #$pullRequestNo'
#custom-notsigned-prcomment: 'pull request comment with Introductory message to ask new contributors to sign'
#custom-pr-sign-comment: 'The signature to be committed in order to sign the CLA'
#custom-allsigned-prcomment: 'pull request comment when all contributors has signed, defaults to **CLA Assistant Lite bot** All Contributors have signed the CLA.'
#lock-pullrequest-aftermerge: false - if you don't want this bot to automatically lock the pull request after merging (default - true)
#use-dco-flag: true - If you are using DCO instead of CLA

98
CLA.md Normal file
View file

@ -0,0 +1,98 @@
## Contributor License Agreement
Thank you for your contribution to ente projects.
This contributor license agreement documents the rights granted by contributors
to Ente Technologies, Inc ("ente"). This license is for your protection as a
Contributor as well as the protection of ente, its users, and its licensees; you
may still license your own Contributions under other terms.
In exchange for the ability to participate in the ente community and for other
good consideration, the receipt of which is hereby acknowledged, you accept and
agree to the following terms and conditions for Your present and future
Contributions submitted to ente. Except for the license granted herein to ente
and recipients of software distributed by ente, You reserve all right, title,
and interest in and to Your Contributions.
1. Definitions.
"You" (or "Your") shall mean the copyright owner or legal entity authorized
by the copyright owner that is making this Agreement with ente. For legal
entities, the entity making a Contribution and all other entities that
control, are controlled by, or are under common control with that entity are
considered to be a single Contributor. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the direction or
management of such entity, whether by contract or otherwise, or (ii)
ownership of fifty percent (50%) or more of the outstanding shares, or (iii)
beneficial ownership of such entity.
"Contribution" shall mean any original work of authorship or invention,
including any modifications or additions to an existing work, that is
intentionally submitted by You to ente for inclusion in, or documentation of,
any of the products owned or managed by ente (the "Work"). For the purposes
of this definition, "submitted" means any form of electronic, verbal, or
written communication sent to ente or its representatives, including but not
limited to communication on electronic mailing lists, source code control
systems, and issue tracking systems that are managed by, or on behalf of,
ente for the purpose of discussing and improving the Work, but excluding
communication that is conspicuously marked or otherwise designated in writing
by You as "Not a Contribution."
2. Grant of Copyright License. Subject to the terms and conditions of this
Agreement, You hereby grant to ente and to recipients of software distributed
by ente a perpetual, worldwide, non-exclusive, no-charge, royalty-free,
irrevocable copyright license to reproduce, prepare derivative works of,
publicly display, publicly perform, and distribute Your Contributions and
such derivative works.
3. Grant of Patent License. Subject to the terms and conditions of this
Agreement, You hereby grant to ente and to recipients of software distributed
by ente a perpetual, worldwide, non-exclusive, no-charge, royalty-free,
irrevocable (except as stated in this section) patent license to make, have
made, use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable by You that
are necessarily infringed by Your Contribution(s) alone or by combination of
Your Contribution(s) with the Work to which such Contribution(s) was
submitted. If any entity institutes patent litigation against You or any
other entity (including a cross-claim or counterclaim in a lawsuit) alleging
that your Contribution, or the Work to which you have contributed,
constitutes direct or contributory patent infringement, then any patent
licenses granted to that entity under this Agreement for that Contribution or
Work shall terminate as of the date such litigation is filed.
4. You represent that you are legally entitled to grant the above license. If
your employer(s) has rights to intellectual property that you create that
includes your Contributions, you represent that you have received permission
to make Contributions on behalf of that employer, that your employer has
waived such rights for your contributions to ente, or that your employer has
executed with ente a separate contributor license agreement substantially
similar to this Agreement. If You are a current employee or contractor of
ente, then the terms of your existing Employment Agreement or Consulting
Services Agreement shall supersede this CLA, and remain in full effect.
5. You represent that each of Your Contributions is Your original creation (see
section 7 for submissions on behalf of others). You represent that Your
Contribution submissions include complete details of any third-party license
or other restriction (including, but not limited to, related patents and
trademarks) of which you are personally aware and which are associated with
any part of Your Contributions.
6. You are not expected to provide support for Your Contributions, except to the
extent You desire to provide support. You may provide support for free, for
a fee, or not at all. Unless required by applicable law or agreed to in
writing, You provide Your Contributions on an "AS IS" BASIS, WITHOUT
WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including,
without limitation, any warranties or conditions of title, non-infringement,
merchantability, or fitness for a particular purpose.
7. Should You wish to submit work that is not Your original creation, You may
submit it to ente separately from any Contribution, identifying the complete
details of its source and of any license or other restriction (including, but
not limited to, related patents, trademarks, and license agreements) of which
you are personally aware, and conspicuously marking the work as "Not a
Contribution". Third-party materials licensed pursuant to: [license name(s)
here]" (substituting the bracketed text with the appropriate license
name(s)).
8. You agree to notify ente of any facts or circumstances of which you become
aware that would make these representations inaccurate in any respect.

View file

@ -62,8 +62,15 @@ We maintain a public roadmap, that's driven by our community @ [roadmap.ente.io]
If you like this project, please consider upgrading to a paid subscription.
If you would like to motivate us to keep building, you can do so by
[starring](https://github.com/ente-io/photos-web/stargazers) this project.
And [star this repo](https://github.com/ente-io/photos-web/stargazers)!
<br/>
## 🌍 Translate
Create a copy of
[src/utils/strings/englishConstants.tsx](src/utils/strings/englishConstants.tsx)
and open a PR.
<br/>

View file

@ -34,7 +34,7 @@
"formik": "^2.1.5",
"heic-convert": "^1.2.4",
"is-electron": "^2.2.0",
"jszip": "3.7.1",
"jszip": "3.8.0",
"libsodium-wrappers": "^0.7.8",
"localforage": "^1.9.0",
"next": "^13.1.2",

View file

@ -10,9 +10,13 @@ interface Iprops {
action: CollectionActions,
loader?: boolean
) => (...args: any[]) => Promise<void>;
downloadOptionText?: string;
}
export function FavoritiesCollectionOption({ handleCollectionAction }: Iprops) {
export function OnlyDownloadCollectionOption({
handleCollectionAction,
downloadOptionText = constants.DOWNLOAD,
}: Iprops) {
return (
<OverflowMenuOption
startIcon={<FileDownloadOutlinedIcon />}
@ -20,7 +24,7 @@ export function FavoritiesCollectionOption({ handleCollectionAction }: Iprops) {
CollectionActions.CONFIRM_DOWNLOAD,
false
)}>
{constants.DOWNLOAD_COLLECTION}
{downloadOptionText}
</OverflowMenuOption>
);
}

View file

@ -25,6 +25,7 @@ export function QuickOptions({
{!(
collectionSummaryType === CollectionSummaryType.trash ||
collectionSummaryType === CollectionSummaryType.favorites ||
collectionSummaryType === CollectionSummaryType.uncategorized ||
collectionSummaryType === CollectionSummaryType.incomingShare
) && (
<Tooltip
@ -51,7 +52,10 @@ export function QuickOptions({
title={
collectionSummaryType ===
CollectionSummaryType.favorites
? constants.DOWNLOAD_FAVOURITES
? constants.DOWNLOAD_FAVORITES
: collectionSummaryType ===
CollectionSummaryType.uncategorized
? constants.DOWNLOAD_UNCATEGORIZED
: constants.DOWNLOAD_COLLECTION
}>
<IconButton

View file

@ -18,7 +18,7 @@ import OverflowMenu from 'components/OverflowMenu/menu';
import { CollectionSummaryType } from 'constants/collection';
import { TrashCollectionOption } from './TrashCollectionOption';
import { SharedCollectionOption } from './SharedCollectionOption';
import { FavoritiesCollectionOption } from './FavoritiesCollectionOption';
import { OnlyDownloadCollectionOption } from './OnlyDownloadCollectionOption';
import { QuickOptions } from './QuickOptions';
import MoreHoriz from '@mui/icons-material/MoreHoriz';
import { HorizontalFlex } from 'components/Container';
@ -39,7 +39,8 @@ export enum CollectionActions {
ARCHIVE,
UNARCHIVE,
CONFIRM_DELETE,
DELETE,
DELETE_WITH_FILES,
DELETE_BUT_KEEP_FILES,
SHOW_SHARE_DIALOG,
CONFIRM_EMPTY_TRASH,
EMPTY_TRASH,
@ -87,8 +88,11 @@ const CollectionOptions = (props: CollectionOptionsProps) => {
case CollectionActions.CONFIRM_DELETE:
callback = confirmDeleteCollection;
break;
case CollectionActions.DELETE:
callback = deleteCollection;
case CollectionActions.DELETE_WITH_FILES:
callback = deleteCollectionAlongWithFiles;
break;
case CollectionActions.DELETE_BUT_KEEP_FILES:
callback = deleteCollectionButKeepFiles;
break;
case CollectionActions.SHOW_SHARE_DIALOG:
callback = showCollectionShareModal;
@ -137,8 +141,13 @@ const CollectionOptions = (props: CollectionOptionsProps) => {
}
};
const deleteCollection = async () => {
await CollectionAPI.deleteCollection(activeCollection.id);
const deleteCollectionAlongWithFiles = async () => {
await CollectionAPI.deleteCollection(activeCollection.id, false);
redirectToAll();
};
const deleteCollectionButKeepFiles = async () => {
await CollectionAPI.deleteCollection(activeCollection.id, true);
redirectToAll();
};
@ -177,12 +186,21 @@ const CollectionOptions = (props: CollectionOptionsProps) => {
const confirmDeleteCollection = () => {
setDialogMessage({
title: constants.DELETE_COLLECTION_TITLE,
content: constants.DELETE_COLLECTION_MESSAGE,
content: constants.DELETE_COLLECTION_MESSAGE(),
proceed: {
text: constants.DELETE_COLLECTION,
action: handleCollectionAction(CollectionActions.DELETE),
text: constants.DELETE_PHOTOS,
action: handleCollectionAction(
CollectionActions.DELETE_WITH_FILES
),
variant: 'danger',
},
secondary: {
text: constants.KEEP_PHOTOS,
action: handleCollectionAction(
CollectionActions.DELETE_BUT_KEEP_FILES
),
variant: 'primary',
},
close: {
text: constants.CANCEL,
},
@ -250,8 +268,15 @@ const CollectionOptions = (props: CollectionOptionsProps) => {
/>
) : collectionSummaryType ===
CollectionSummaryType.favorites ? (
<FavoritiesCollectionOption
<OnlyDownloadCollectionOption
handleCollectionAction={handleCollectionAction}
downloadOptionText={constants.DOWNLOAD_FAVORITES}
/>
) : collectionSummaryType ===
CollectionSummaryType.uncategorized ? (
<OnlyDownloadCollectionOption
handleCollectionAction={handleCollectionAction}
downloadOptionText={constants.DOWNLOAD_UNCATEGORIZED}
/>
) : collectionSummaryType ===
CollectionSummaryType.incomingShare ? (

View file

@ -15,7 +15,8 @@ export default function EmailShare({ collection }) {
const collectionShare: SingleInputFormProps['callback'] = async (
email,
setFieldError
setFieldError,
resetForm
) => {
try {
const user: User = getData(LS_KEYS.USER);
@ -28,6 +29,7 @@ export default function EmailShare({ collection }) {
} else {
await shareCollection(collection, email);
await galleryContext.syncWithRemote(false, true);
resetForm();
}
} catch (e) {
const errorMessage = handleSharingErrors(e);

View file

@ -96,6 +96,18 @@ export default function DialogBox({
{attributes.proceed.text}
</Button>
)}
{attributes.secondary && (
<Button
size="large"
color={attributes.secondary?.variant}
onClick={() => {
attributes.secondary.action();
onClose();
}}
disabled={attributes.secondary.disabled}>
{attributes.secondary.text}
</Button>
)}
</>
</DialogActions>
)}

View file

@ -3,7 +3,7 @@ import VerticallyCentered, { FlexWrapper } from 'components/Container';
import { AppContext } from 'pages/_app';
import React, { useContext, useEffect } from 'react';
import billingService from 'services/billingService';
import { getFamilyPlanAdmin } from 'utils/billing';
import { getFamilyPlanAdmin } from 'utils/user/family';
import { preloadImage } from 'utils/common';
import constants from 'utils/strings/constants';
import DialogTitleWithCloseButton from './DialogBox/TitleWithCloseButton';

View file

@ -31,7 +31,7 @@ export function OverflowMenuOption({
<MenuItem
onClick={handleClick}
sx={{
width: 220,
minWidth: 220,
color: (theme) => theme.palette[color].main,
padding: 1.5,
'& .MuiSvgIcon-root': {

View file

@ -4,7 +4,6 @@ import React, { useContext, useEffect, useRef, useState } from 'react';
import { EnteFile } from 'types/file';
import { styled } from '@mui/material';
import DownloadManager from 'services/downloadManager';
import constants from 'utils/strings/constants';
import AutoSizer from 'react-virtualized-auto-sizer';
import PhotoViewer from 'components/PhotoViewer';
import {
@ -13,10 +12,9 @@ import {
TRASH_SECTION,
} from 'constants/collection';
import { isSharedFile } from 'utils/file';
import { isPlaybackPossible } from 'utils/photoFrame';
import { updateFileMsrcProps, updateFileSrcProps } from 'utils/photoFrame';
import { PhotoList } from './PhotoList';
import { SelectedState } from 'types/gallery';
import { FILE_TYPE } from 'constants/file';
import { MergedSourceURL, SelectedState } from 'types/gallery';
import PublicCollectionDownloadManager from 'services/publicCollectionDownloadManager';
import { PublicCollectionGalleryContext } from 'utils/publicCollectionGallery';
import { useRouter } from 'next/router';
@ -27,12 +25,12 @@ import { IsArchived } from 'utils/magicMetadata';
import { isSameDayAnyYear, isInsideBox } from 'utils/search';
import { Search } from 'types/search';
import { logError } from 'utils/sentry';
import { CustomError } from 'utils/error';
import { User } from 'types/user';
import { getData, LS_KEYS } from 'utils/storage/localStorage';
import { useMemo } from 'react';
import { Collection } from 'types/collection';
import { addLogLine } from 'utils/logging';
import PhotoSwipe from 'photoswipe';
const Container = styled('div')`
display: block;
@ -66,19 +64,12 @@ interface Props {
deletedFileIds?: Set<number>;
setDeletedFileIds?: (value: Set<number>) => void;
activeCollection: number;
isSharedCollection?: boolean;
isIncomingSharedCollection?: boolean;
enableDownload?: boolean;
isDeduplicating?: boolean;
resetSearch?: () => void;
}
type SourceURL = {
originalImageURL?: string;
originalVideoURL?: string;
convertedImageURL?: string;
convertedVideoURL?: string;
};
const PhotoFrame = ({
files,
collections,
@ -95,10 +86,11 @@ const PhotoFrame = ({
deletedFileIds,
setDeletedFileIds,
activeCollection,
isSharedCollection,
isIncomingSharedCollection,
enableDownload,
isDeduplicating,
}: Props) => {
const [user, setUser] = useState<User>(null);
const [open, setOpen] = useState(false);
const [currentIndex, setCurrentIndex] = useState<number>(0);
const [fetching, setFetching] = useState<{ [k: number]: boolean }>({});
@ -119,6 +111,11 @@ const PhotoFrame = ({
const [filteredData, setFilteredData] = useState<EnteFile[]>([]);
useEffect(() => {
const user: User = getData(LS_KEYS.USER);
setUser(user);
}, []);
useEffect(() => {
const main = () => {
if (updateInProgress.current) {
@ -130,13 +127,6 @@ const PhotoFrame = ({
const user: User = getData(LS_KEYS.USER);
const filteredData = files
.map((item, index) => ({
...item,
dataIndex: index,
w: window.innerWidth,
h: window.innerHeight,
title: item.pubMagicMetadata?.data.caption,
}))
.filter((item) => {
if (
deletedFileIds?.has(item.id) &&
@ -179,7 +169,10 @@ const PhotoFrame = ({
return false;
}
if (isSharedFile(user, item) && !isSharedCollection) {
if (
isSharedFile(user, item) &&
activeCollection !== item.collectionID
) {
return false;
}
if (activeCollection === TRASH_SECTION && !item.isTrashed) {
@ -202,6 +195,31 @@ const PhotoFrame = ({
return false;
}
return false;
})
.map((item) => {
const filteredItem = {
...item,
w: window.innerWidth,
h: window.innerHeight,
title: item.pubMagicMetadata?.data.caption,
};
try {
if (galleryContext.thumbs.has(item.id)) {
updateFileMsrcProps(
filteredItem,
galleryContext.thumbs.get(item.id)
);
}
if (galleryContext.files.has(item.id)) {
updateFileSrcProps(
filteredItem,
galleryContext.files.get(item.id)
);
}
} catch (e) {
logError(e, 'PhotoFrame url prefill failed');
}
return filteredItem;
});
setFilteredData(filteredData);
updateInProgress.current = false;
@ -221,6 +239,10 @@ const PhotoFrame = ({
activeCollection,
]);
useEffect(() => {
setFetching({});
}, [filteredData]);
const fileToCollectionsMap = useMemo(() => {
const fileToCollectionsMap = new Map<number, number[]>();
files.forEach((file) => {
@ -273,6 +295,7 @@ const PhotoFrame = ({
};
document.addEventListener('keydown', handleKeyDown, false);
document.addEventListener('keyup', handleKeyUp, false);
router.events.on('hashChangeComplete', (url: string) => {
const start = url.indexOf('#');
const hash = url.slice(start !== -1 ? start : url.length);
@ -285,9 +308,10 @@ const PhotoFrame = ({
setOpen(false);
}
});
return () => {
document.addEventListener('keydown', handleKeyDown, false);
document.addEventListener('keyup', handleKeyUp, false);
document.removeEventListener('keydown', handleKeyDown, false);
document.removeEventListener('keyup', handleKeyUp, false);
};
}, []);
@ -303,127 +327,61 @@ const PhotoFrame = ({
}
}, [search, filteredData]);
const resetFetching = () => {
setFetching({});
};
useEffect(() => {
if (selected.count === 0) {
setRangeStart(null);
}
}, [selected]);
const getFileIndexFromID = (files: EnteFile[], id: number) => {
const index = files.findIndex((file) => file.id === id);
if (index === -1) {
throw CustomError.FILE_ID_NOT_FOUND;
const updateURL = (index: number) => (id: number, url: string) => {
const file = filteredData[index];
// this is to prevent outdated updateURL call from updating the wrong file
if (file.id !== id) {
addLogLine(
`PhotoSwipe: updateURL: file id mismatch: ${file.id} !== ${id}`
);
return;
}
return index;
if (file.msrc) {
addLogLine(`PhotoSwipe: updateURL: msrc already set: ${file.msrc}`);
logError(
new Error(
`PhotoSwipe: updateURL: msrc already set: ${file.msrc}`
),
'PhotoSwipe: updateURL called with msrc already set'
);
return;
}
updateFileMsrcProps(file, url);
};
const updateURL = (id: number) => (url: string) => {
const updateFile = (file: EnteFile) => {
file.msrc = url;
file.w = window.innerWidth;
file.h = window.innerHeight;
if (file.metadata.fileType === FILE_TYPE.VIDEO && !file.html) {
file.html = `
<div class="pswp-item-container">
<img src="${url}" onContextMenu="return false;"/>
<div class="spinner-border text-light" role="status">
<span class="sr-only">Loading...</span>
</div>
</div>
`;
} else if (
file.metadata.fileType === FILE_TYPE.LIVE_PHOTO &&
!file.html
) {
file.html = `
<div class="pswp-item-container">
<img src="${url}" onContextMenu="return false;"/>
<div class="spinner-border text-light" role="status">
<span class="sr-only">Loading...</span>
</div>
</div>
`;
} else if (
file.metadata.fileType === FILE_TYPE.IMAGE &&
!file.src
) {
file.src = url;
}
return file;
};
const index = getFileIndexFromID(files, id);
return updateFile(files[index]);
};
const updateSrcURL = async (id: number, srcURL: SourceURL) => {
const {
originalImageURL,
convertedImageURL,
originalVideoURL,
convertedVideoURL,
} = srcURL;
const isPlayable =
convertedVideoURL && (await isPlaybackPossible(convertedVideoURL));
const updateFile = (file: EnteFile) => {
file.w = window.innerWidth;
file.h = window.innerHeight;
file.isSourceLoaded = true;
file.originalImageURL = originalImageURL;
file.originalVideoURL = originalVideoURL;
if (file.metadata.fileType === FILE_TYPE.VIDEO) {
if (isPlayable) {
file.html = `
<video controls onContextMenu="return false;">
<source src="${convertedVideoURL}" />
Your browser does not support the video tag.
</video>
`;
} else {
file.html = `
<div class="pswp-item-container">
<img src="${file.msrc}" onContextMenu="return false;"/>
<div class="download-banner" >
${constants.VIDEO_PLAYBACK_FAILED_DOWNLOAD_INSTEAD}
<a class="btn btn-outline-success" href=${convertedVideoURL} download="${file.metadata.title}"">Download</a>
</div>
</div>
`;
}
} else if (file.metadata.fileType === FILE_TYPE.LIVE_PHOTO) {
if (isPlayable) {
file.html = `
<div class = 'pswp-item-container'>
<img id = "live-photo-image-${file.id}" src="${convertedImageURL}" onContextMenu="return false;"/>
<video id = "live-photo-video-${file.id}" loop muted onContextMenu="return false;">
<source src="${convertedVideoURL}" />
Your browser does not support the video tag.
</video>
</div>
`;
} else {
file.html = `
<div class="pswp-item-container">
<img src="${file.msrc}" onContextMenu="return false;"/>
<div class="download-banner">
${constants.VIDEO_PLAYBACK_FAILED_DOWNLOAD_INSTEAD}
<button class = "btn btn-outline-success" id = "download-btn-${file.id}">Download</button>
</div>
</div>
`;
}
} else {
file.src = convertedImageURL;
}
return file;
};
const updateSrcURL = async (
index: number,
id: number,
mergedSrcURL: MergedSourceURL
) => {
const file = filteredData[index];
// this is to prevent outdate updateSrcURL call from updating the wrong file
if (file.id !== id) {
addLogLine(
`PhotoSwipe: updateSrcURL: file id mismatch: ${file.id} !== ${id}`
);
return;
}
if (file.isSourceLoaded) {
addLogLine(
`PhotoSwipe: updateSrcURL: source already loaded: ${file.id}`
);
logError(
new Error(
`PhotoSwipe: updateSrcURL: source already loaded: ${file.id}`
),
'PhotoSwipe updateSrcURL called when source already loaded'
);
return;
}
await updateFileSrcProps(file, mergedSrcURL);
setIsSourceLoaded(true);
const index = getFileIndexFromID(files, id);
return updateFile(files[index]);
};
const handleClose = (needUpdate) => {
@ -436,30 +394,52 @@ const PhotoFrame = ({
setOpen(true);
};
const handleSelect = (id: number, index?: number) => (checked: boolean) => {
if (selected.collectionID !== activeCollection) {
setSelected({ count: 0, collectionID: 0 });
}
if (typeof index !== 'undefined') {
if (checked) {
setRangeStart(index);
} else {
setRangeStart(undefined);
const handleSelect =
(id: number, isOwnFile: boolean, index?: number) =>
(checked: boolean) => {
if (typeof index !== 'undefined') {
if (checked) {
setRangeStart(index);
} else {
setRangeStart(undefined);
}
}
}
setSelected((selected) => {
if (selected.collectionID !== activeCollection) {
selected = { ownCount: 0, count: 0, collectionID: 0 };
}
setSelected((selected) => ({
...selected,
[id]: checked,
count:
selected[id] === checked
? selected.count
: checked
? selected.count + 1
: selected.count - 1,
collectionID: activeCollection,
}));
};
const handleCounterChange = (count: number) => {
if (selected[id] === checked) {
return count;
}
if (checked) {
return count + 1;
} else {
return count - 1;
}
};
const handleAllCounterChange = () => {
if (isOwnFile) {
return {
ownCount: handleCounterChange(selected.ownCount),
count: handleCounterChange(selected.count),
};
} else {
return {
count: handleCounterChange(selected.count),
};
}
};
return {
...selected,
[id]: checked,
collectionID: activeCollection,
...handleAllCounterChange(),
};
});
};
const onHoverOver = (index: number) => () => {
setCurrentHover(index);
};
@ -481,56 +461,59 @@ const PhotoFrame = ({
(index - i) * direction > 0;
i += direction
) {
handleSelect(filteredData[i].id)(!checked);
handleSelect(
filteredData[i].id,
filteredData[i].ownerID === user?.id
)(!checked);
}
handleSelect(filteredData[index].id, index)(!checked);
handleSelect(
filteredData[index].id,
filteredData[index].ownerID === user?.id,
index
)(!checked);
}
};
const getThumbnail = (
files: EnteFile[],
item: EnteFile,
index: number,
isScrolling: boolean
) =>
files[index] ? (
<PreviewCard
key={`tile-${files[index].id}-selected-${
selected[files[index].id] ?? false
}`}
file={files[index]}
updateURL={updateURL(files[index].id)}
onClick={onThumbnailClick(index)}
selectable={!isSharedCollection}
onSelect={handleSelect(files[index].id, index)}
selected={
selected.collectionID === activeCollection &&
selected[files[index].id]
}
selectOnClick={selected.count > 0}
onHover={onHoverOver(index)}
onRangeSelect={handleRangeSelect(index)}
isRangeSelectActive={isShiftKeyPressed && selected.count > 0}
isInsSelectRange={
(index >= rangeStart && index <= currentHover) ||
(index >= currentHover && index <= rangeStart)
}
activeCollection={activeCollection}
showPlaceholder={isScrolling}
/>
) : (
<></>
);
) => (
<PreviewCard
key={`tile-${item.id}-selected-${selected[item.id] ?? false}`}
file={item}
updateURL={updateURL(index)}
onClick={onThumbnailClick(index)}
selectable={
!publicCollectionGalleryContext?.accessedThroughSharedURL
}
onSelect={handleSelect(item.id, item.ownerID === user?.id, index)}
selected={
selected.collectionID === activeCollection && selected[item.id]
}
selectOnClick={selected.count > 0}
onHover={onHoverOver(index)}
onRangeSelect={handleRangeSelect(index)}
isRangeSelectActive={isShiftKeyPressed && selected.count > 0}
isInsSelectRange={
(index >= rangeStart && index <= currentHover) ||
(index >= currentHover && index <= rangeStart)
}
activeCollection={activeCollection}
showPlaceholder={isScrolling}
/>
);
const getSlideData = async (
instance: any,
instance: PhotoSwipe<PhotoSwipe.Options>,
index: number,
item: EnteFile
) => {
addLogLine(
`[${
item.id
}] getSlideData called for thumbnail:${!!item.msrc} original:${
!!item.msrc && item.src !== item.msrc
} inProgress:${fetching[item.id]}`
}] getSlideData called for thumbnail:${!!item.msrc} sourceLoaded:${isSourceLoaded} fetching:${
fetching[item.id]
}`
);
if (!item.msrc) {
addLogLine(`[${item.id}] doesn't have thumbnail`);
@ -559,22 +542,15 @@ const PhotoFrame = ({
}
galleryContext.thumbs.set(item.id, url);
}
const newFile = updateURL(item.id)(url);
item.msrc = newFile.msrc;
item.html = newFile.html;
item.src = newFile.src;
item.isSourceLoaded = newFile.isSourceLoaded;
item.originalImageURL = newFile.originalImageURL;
item.originalVideoURL = newFile.originalVideoURL;
item.w = newFile.w;
item.h = newFile.h;
addLogLine(
`[${item.id}] calling invalidateCurrItems for thumbnail`
);
updateURL(index)(item.id, url);
try {
addLogLine(
`[${
item.id
}] calling invalidateCurrItems for thumbnail msrc :${!!item.msrc}`
);
instance.invalidateCurrItems();
if (instance.isOpen()) {
if ((instance as any).isOpen()) {
instance.updateSize(true);
}
} catch (e) {
@ -588,100 +564,76 @@ const PhotoFrame = ({
logError(e, 'getSlideData failed get msrc url failed');
}
}
if (!fetching[item.id]) {
addLogLine(`[${item.id}] new file download fetch original request`);
try {
fetching[item.id] = true;
let urls: { original: string[]; converted: string[] };
if (galleryContext.files.has(item.id)) {
addLogLine(
`[${item.id}] gallery context cache hit, using cached file`
);
const mergedURL = galleryContext.files.get(item.id);
urls = {
original: mergedURL.original.split(','),
converted: mergedURL.converted.split(','),
};
} else {
addLogLine(
`[${item.id}] gallery context cache miss, calling downloadManager to get file`
);
appContext.startLoading();
if (
publicCollectionGalleryContext.accessedThroughSharedURL
) {
urls = await PublicCollectionDownloadManager.getFile(
if (item.isSourceLoaded) {
addLogLine(`[${item.id}] source already loaded`);
return;
}
if (fetching[item.id]) {
addLogLine(`[${item.id}] file download already in progress`);
return;
}
try {
addLogLine(`[${item.id}] new file src request`);
fetching[item.id] = true;
let srcURL: MergedSourceURL;
if (galleryContext.files.has(item.id)) {
addLogLine(
`[${item.id}] gallery context cache hit, using cached file`
);
srcURL = galleryContext.files.get(item.id);
} else {
addLogLine(
`[${item.id}] gallery context cache miss, calling downloadManager to get file`
);
appContext.startLoading();
let downloadedURL;
if (publicCollectionGalleryContext.accessedThroughSharedURL) {
downloadedURL =
await PublicCollectionDownloadManager.getFile(
item,
publicCollectionGalleryContext.token,
publicCollectionGalleryContext.passwordToken,
true
);
} else {
urls = await DownloadManager.getFile(item, true);
}
appContext.finishLoading();
const mergedURL = {
original: urls.original.join(','),
converted: urls.converted.join(','),
};
galleryContext.files.set(item.id, mergedURL);
}
let originalImageURL;
let originalVideoURL;
let convertedImageURL;
let convertedVideoURL;
if (item.metadata.fileType === FILE_TYPE.LIVE_PHOTO) {
[originalImageURL, originalVideoURL] = urls.original;
[convertedImageURL, convertedVideoURL] = urls.converted;
} else if (item.metadata.fileType === FILE_TYPE.VIDEO) {
[originalVideoURL] = urls.original;
[convertedVideoURL] = urls.converted;
} else {
[originalImageURL] = urls.original;
[convertedImageURL] = urls.converted;
downloadedURL = await DownloadManager.getFile(item, true);
}
setIsSourceLoaded(false);
const newFile = await updateSrcURL(item.id, {
originalImageURL,
originalVideoURL,
convertedImageURL,
convertedVideoURL,
});
item.msrc = newFile.msrc;
item.html = newFile.html;
item.src = newFile.src;
item.isSourceLoaded = newFile.isSourceLoaded;
item.originalImageURL = newFile.originalImageURL;
item.originalVideoURL = newFile.originalVideoURL;
item.w = newFile.w;
item.h = newFile.h;
try {
addLogLine(
`[${item.id}] calling invalidateCurrItems for src`
);
instance.invalidateCurrItems();
if (instance.isOpen()) {
instance.updateSize(true);
}
} catch (e) {
logError(
e,
'updating photoswipe after src url update failed'
);
throw e;
appContext.finishLoading();
const mergedURL: MergedSourceURL = {
original: downloadedURL.original.join(','),
converted: downloadedURL.converted.join(','),
};
galleryContext.files.set(item.id, mergedURL);
srcURL = mergedURL;
}
setIsSourceLoaded(false);
await updateSrcURL(index, item.id, srcURL);
try {
addLogLine(
`[${item.id}] calling invalidateCurrItems for src, source loaded :${item.isSourceLoaded}`
);
instance.invalidateCurrItems();
if ((instance as any).isOpen()) {
instance.updateSize(true);
}
} catch (e) {
logError(e, 'getSlideData failed get src url failed');
fetching[item.id] = false;
// no-op
logError(e, 'updating photoswipe after src url update failed');
throw e;
}
} catch (e) {
logError(e, 'getSlideData failed get src url failed');
fetching[item.id] = false;
// no-op
}
};
return (
<>
{!isFirstLoad && files.length === 0 && !isInSearchMode ? (
{!isFirstLoad &&
files.length === 0 &&
!isInSearchMode &&
activeCollection === ALL_SECTION ? (
<EmptyScreen openUploader={openUploader} />
) : (
<Container>
@ -698,7 +650,6 @@ const PhotoFrame = ({
!isInSearchMode &&
!deduplicateContext.isOnDeduplicatePage
}
resetFetching={resetFetching}
/>
)}
</AutoSizer>
@ -711,7 +662,7 @@ const PhotoFrame = ({
favItemIds={favItemIds}
deletedFileIds={deletedFileIds}
setDeletedFileIds={setDeletedFileIds}
isSharedCollection={isSharedCollection}
isIncomingSharedCollection={isIncomingSharedCollection}
isTrashCollection={activeCollection === TRASH_SECTION}
enableDownload={enableDownload}
isSourceLoaded={isSourceLoaded}

View file

@ -163,12 +163,11 @@ interface Props {
filteredData: EnteFile[];
showAppDownloadBanner: boolean;
getThumbnail: (
files: EnteFile[],
file: EnteFile,
index: number,
isScrolling?: boolean
) => JSX.Element;
activeCollection: number;
resetFetching: () => void;
}
export function PhotoList({
@ -178,7 +177,6 @@ export function PhotoList({
showAppDownloadBanner,
getThumbnail,
activeCollection,
resetFetching,
}: Props) {
const galleryContext = useContext(GalleryContext);
const publicCollectionGalleryContext = useContext(
@ -205,7 +203,6 @@ export function PhotoList({
const refreshList = () => {
listRef.current?.resetAfterIndex(0);
resetFetching();
};
useEffect(() => {
@ -657,7 +654,7 @@ export function PhotoList({
case ITEM_TYPE.FILE: {
const ret = listItem.items.map((item, idx) =>
getThumbnail(
filteredData,
item,
listItem.itemStartIndex + idx,
isScrolling
)
@ -666,7 +663,11 @@ export function PhotoList({
let sum = 0;
for (let i = 0; i < listItem.groups.length - 1; i++) {
sum = sum + listItem.groups[i];
ret.splice(sum, 0, <div />);
ret.splice(
sum,
0,
<div key={`${listItem.items[0].id}-gap-${i}`} />
);
sum += 1;
}
}
@ -690,7 +691,7 @@ export function PhotoList({
width={width}
itemCount={timeStampList.length}
itemKey={generateKey}
overscanCount={0}
overscanCount={3}
useIsScrolling>
{({ index, style, isScrolling }) => (
<ListItem style={style}>

View file

@ -68,7 +68,7 @@ interface Iprops {
favItemIds: Set<number>;
deletedFileIds: Set<number>;
setDeletedFileIds?: (value: Set<number>) => void;
isSharedCollection: boolean;
isIncomingSharedCollection: boolean;
isTrashCollection: boolean;
enableDownload: boolean;
isSourceLoaded: boolean;
@ -163,10 +163,6 @@ function PhotoViewer(props: Iprops) {
};
}, [isOpen, photoSwipe, showInfo]);
useEffect(() => {
updateItems(items);
}, [items]);
useEffect(() => {
if (photoSwipe) {
photoSwipe.options.arrowKeys = !showInfo;
@ -386,42 +382,64 @@ function PhotoViewer(props: Iprops) {
const trashFile = async (file: EnteFile) => {
const { deletedFileIds, setDeletedFileIds } = props;
deletedFileIds.add(file.id);
setDeletedFileIds(new Set(deletedFileIds));
await trashFiles([file]);
needUpdate.current = true;
try {
appContext.startLoading();
await trashFiles([file]);
appContext.finishLoading();
deletedFileIds.add(file.id);
setDeletedFileIds(new Set(deletedFileIds));
updateItems(props.items.filter((item) => item.id !== file.id));
needUpdate.current = true;
} catch (e) {
logError(e, 'trashFile failed');
}
};
const confirmTrashFile = (file: EnteFile) => {
if (!file || props.isSharedCollection || props.isTrashCollection) {
if (
!file ||
props.isIncomingSharedCollection ||
props.isTrashCollection
) {
return;
}
appContext.setDialogMessage(getTrashFileMessage(() => trashFile(file)));
};
const updateItems = (items = []) => {
if (photoSwipe) {
if (items.length === 0) {
photoSwipe.close();
}
photoSwipe.items.length = 0;
items.forEach((item) => {
photoSwipe.items.push(item);
});
photoSwipe.invalidateCurrItems();
if (isOpen) {
photoSwipe.updateSize(true);
if (photoSwipe.getCurrentIndex() >= photoSwipe.items.length) {
photoSwipe.goTo(0);
const updateItems = (items: EnteFile[]) => {
try {
if (photoSwipe) {
if (items.length === 0) {
photoSwipe.close();
}
photoSwipe.items.length = 0;
items.forEach((item) => {
photoSwipe.items.push(item);
});
photoSwipe.invalidateCurrItems();
if (isOpen) {
photoSwipe.updateSize(true);
if (
photoSwipe.getCurrentIndex() >= photoSwipe.items.length
) {
photoSwipe.goTo(0);
}
}
}
} catch (e) {
logError(e, 'updateItems failed');
}
};
const refreshPhotoswipe = () => {
photoSwipe.invalidateCurrItems();
if (isOpen) {
photoSwipe.updateSize(true);
try {
photoSwipe.invalidateCurrItems();
if (isOpen) {
photoSwipe.updateSize(true);
}
} catch (e) {
logError(e, 'refreshPhotoswipe failed');
}
};
@ -560,7 +578,7 @@ function PhotoViewer(props: Iprops) {
<ContentCopy fontSize="small" />
</button>
)}
{!props.isSharedCollection &&
{!props.isIncomingSharedCollection &&
!props.isTrashCollection && (
<button
className="pswp__button pswp__button--custom"
@ -582,7 +600,7 @@ function PhotoViewer(props: Iprops) {
title={constants.TOGGLE_FULLSCREEN}
/>
{!props.isSharedCollection && (
{!props.isIncomingSharedCollection && (
<button
className="pswp__button pswp__button--custom"
title={constants.INFO_OPTION}
@ -590,7 +608,7 @@ function PhotoViewer(props: Iprops) {
<InfoIcon fontSize="small" />
</button>
)}
{!props.isSharedCollection &&
{!props.isIncomingSharedCollection &&
!props.isTrashCollection && (
<button
title={
@ -641,7 +659,7 @@ function PhotoViewer(props: Iprops) {
</div>
<FileInfo
isTrashCollection={props.isTrashCollection}
shouldDisableEdits={props.isSharedCollection}
shouldDisableEdits={props.isIncomingSharedCollection}
showInfo={showInfo}
handleCloseInfo={handleCloseInfo}
file={photoSwipe?.currItem as EnteFile}

View file

@ -20,12 +20,11 @@ const ShortcutButton: FC<ButtonProps<'button', Iprops>> = ({
sx={{ fontWeight: 'normal' }}
{...props}>
{label}
{count > 0 && (
<Box sx={{ color: 'text.secondary' }}>
<DotSeparator />
{count}
</Box>
)}
<Box sx={{ color: 'text.secondary' }}>
<DotSeparator />
{count}
</Box>
</SidebarButton>
);
};

View file

@ -1,11 +1,17 @@
import React, { useContext } from 'react';
import React, { useContext, useState, useEffect } from 'react';
import constants from 'utils/strings/constants';
import { GalleryContext } from 'pages/gallery';
import { ARCHIVE_SECTION, TRASH_SECTION } from 'constants/collection';
import {
ARCHIVE_SECTION,
DUMMY_UNCATEGORIZED_SECTION,
TRASH_SECTION,
} from 'constants/collection';
import { CollectionSummaries } from 'types/collection';
import ShortcutButton from './ShortcutButton';
import DeleteOutline from '@mui/icons-material/DeleteOutline';
import ArchiveOutlined from '@mui/icons-material/ArchiveOutlined';
import CategoryIcon from '@mui/icons-material/Category';
import { getUncategorizedCollection } from 'services/collectionService';
interface Iprops {
closeSidebar: () => void;
collectionSummaries: CollectionSummaries;
@ -16,6 +22,24 @@ export default function ShortcutSection({
collectionSummaries,
}: Iprops) {
const galleryContext = useContext(GalleryContext);
const [uncategorizedCollectionId, setUncategorizedCollectionID] =
useState<number>();
useEffect(() => {
const main = async () => {
const unCategorizedCollection = await getUncategorizedCollection();
if (unCategorizedCollection) {
setUncategorizedCollectionID(unCategorizedCollection.id);
} else {
setUncategorizedCollectionID(DUMMY_UNCATEGORIZED_SECTION);
}
};
main();
}, []);
const openUncategorizedSection = () => {
galleryContext.setActiveCollection(uncategorizedCollectionId);
closeSidebar();
};
const openTrashSection = () => {
galleryContext.setActiveCollection(TRASH_SECTION);
@ -26,9 +50,17 @@ export default function ShortcutSection({
galleryContext.setActiveCollection(ARCHIVE_SECTION);
closeSidebar();
};
return (
<>
<ShortcutButton
startIcon={<CategoryIcon />}
label={constants.UNCATEGORIZED}
onClick={openUncategorizedSection}
count={
collectionSummaries.get(uncategorizedCollectionId)
?.fileCount
}
/>
<ShortcutButton
startIcon={<DeleteOutline />}
label={constants.TRASH}

View file

@ -1,6 +1,6 @@
import React, { useMemo } from 'react';
import { UserDetails } from 'types/user';
import { isPartOfFamily } from 'utils/billing';
import { isPartOfFamily } from 'utils/user/family';
import StorageSection from '../storageSection';
import { FamilyUsageSection } from './usageSection';

View file

@ -1,7 +1,7 @@
import { IndividualSubscriptionCardContent } from './individual';
import { FamilySubscriptionCardContent } from './family';
import React from 'react';
import { hasNonAdminFamilyMembers } from 'utils/billing';
import { hasNonAdminFamilyMembers } from 'utils/user/family';
import { Overlay, SpaceBetweenFlex } from 'components/Container';
import { UserDetails } from 'types/user';

View file

@ -2,9 +2,7 @@ import { GalleryContext } from 'pages/gallery';
import React, { MouseEventHandler, useContext, useMemo } from 'react';
import {
hasPaidSubscription,
isFamilyAdmin,
isOnFreePlan,
isPartOfFamily,
hasExceededStorageQuota,
isSubscriptionActive,
isSubscriptionCancelled,
@ -15,6 +13,7 @@ import { UserDetails } from 'types/user';
import constants from 'utils/strings/constants';
import { Typography } from '@mui/material';
import billingService from 'services/billingService';
import { isPartOfFamily, isFamilyAdmin } from 'utils/user/family';
export default function SubscriptionStatus({
userDetails,

View file

@ -9,7 +9,7 @@ import SubscriptionStatus from './SubscriptionStatus';
import { Box, Skeleton } from '@mui/material';
import { MemberSubscriptionManage } from '../MemberSubscriptionManage';
import { GalleryContext } from 'pages/gallery';
import { isPartOfFamily, isFamilyAdmin } from 'utils/billing';
import { isFamilyAdmin, isPartOfFamily } from 'utils/user/family';
export default function UserDetailsSection({ sidebarView }) {
const galleryContext = useContext(GalleryContext);

View file

@ -1,6 +1,6 @@
import React, { useMemo, useState } from 'react';
import constants from 'utils/strings/constants';
import { Formik, FormikHelpers } from 'formik';
import { Formik, FormikHelpers, FormikState } from 'formik';
import * as Yup from 'yup';
import SubmitButton from './SubmitButton';
import TextField from '@mui/material/TextField';
@ -14,7 +14,8 @@ interface formValues {
export interface SingleInputFormProps {
callback: (
inputValue: string,
setFieldError: (errorMessage: string) => void
setFieldError: (errorMessage: string) => void,
resetForm: (nextState?: Partial<FormikState<formValues>>) => void
) => Promise<void>;
fieldType: 'text' | 'email' | 'password';
placeholder: string;
@ -43,10 +44,11 @@ export default function SingleInputForm(props: SingleInputFormProps) {
{ setFieldError, resetForm }: FormikHelpers<formValues>
) => {
SetLoading(true);
await props.callback(values.inputValue, (message) =>
setFieldError('inputValue', message)
await props.callback(
values.inputValue,
(message) => setFieldError('inputValue', message),
resetForm
);
resetForm();
SetLoading(false);
};

View file

@ -533,7 +533,7 @@ export default function Uploader(props: Props) {
}
};
function showUserFacingError(err: CustomError) {
function showUserFacingError(err: string) {
let notification: NotificationAttributes;
switch (err) {
case CustomError.SESSION_EXPIRED:

View file

@ -11,8 +11,6 @@ import {
hasMobileSubscription,
getLocalUserSubscription,
hasPaidSubscription,
getTotalFamilyUsage,
isPartOfFamily,
isSubscriptionActive,
} from 'utils/billing';
import { reverseString } from 'utils/common';
@ -28,6 +26,7 @@ import { getLocalUserDetails } from 'utils/user';
import { PLAN_PERIOD } from 'constants/gallery';
import FreeSubscriptionPlanSelectorCard from './free';
import PaidSubscriptionPlanSelectorCard from './paid';
import { isPartOfFamily, getTotalFamilyUsage } from 'utils/user/family';
interface Props {
closeModal: any;

View file

@ -1,4 +1,4 @@
import React, { useContext, useLayoutEffect, useState } from 'react';
import React, { useContext, useEffect, useRef, useState } from 'react';
import { EnteFile } from 'types/file';
import { styled } from '@mui/material';
import PlayCircleOutlineOutlinedIcon from '@mui/icons-material/PlayCircleOutlineOutlined';
@ -18,7 +18,7 @@ import { formatDateRelative } from 'utils/time/format';
interface IProps {
file: EnteFile;
updateURL: (url: string) => EnteFile;
updateURL: (id: number, url: string) => void;
onClick: () => void;
selectable: boolean;
selected: boolean;
@ -197,8 +197,8 @@ const Cont = styled('div')<{ disabled: boolean }>`
`;
export default function PreviewCard(props: IProps) {
const [imgSrc, setImgSrc] = useState<string>();
const { thumbs } = useContext(GalleryContext);
const {
file,
onClick,
@ -212,56 +212,57 @@ export default function PreviewCard(props: IProps) {
isRangeSelectActive,
isInsSelectRange,
} = props;
const [imgSrc, setImgSrc] = useState<string>(file.msrc);
const publicCollectionGalleryContext = useContext(
PublicCollectionGalleryContext
);
const deduplicateContext = useContext(DeduplicateContext);
useLayoutEffect(() => {
if (file && !file.msrc && !props.showPlaceholder) {
const isMounted = useRef(true);
useEffect(() => {
return () => {
isMounted.current = false;
};
}, []);
useEffect(() => {
if (!file.msrc && !props.showPlaceholder) {
const main = async () => {
try {
let url;
if (
publicCollectionGalleryContext.accessedThroughSharedURL
) {
url =
await PublicCollectionDownloadManager.getThumbnail(
file,
publicCollectionGalleryContext.token,
publicCollectionGalleryContext.passwordToken
);
if (thumbs.has(file.id)) {
url = thumbs.get(file.id);
} else {
url = await DownloadManager.getThumbnail(file);
if (
publicCollectionGalleryContext.accessedThroughSharedURL
) {
url =
await PublicCollectionDownloadManager.getThumbnail(
file,
publicCollectionGalleryContext.token,
publicCollectionGalleryContext.passwordToken
);
} else {
url = await DownloadManager.getThumbnail(file);
}
thumbs.set(file.id, url);
}
if (!isMounted.current) {
return;
}
setImgSrc(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;
updateURL(file.id, url);
} catch (e) {
logError(e, 'preview card useEffect failed');
// no-op
}
};
if (thumbs.has(file.id)) {
const thumbImgSrc = thumbs.get(file.id);
setImgSrc(thumbImgSrc);
const newFile = updateURL(thumbImgSrc);
file.msrc = newFile.msrc;
file.html = newFile.html;
file.src = newFile.src;
file.w = newFile.w;
file.h = newFile.h;
} else {
main();
}
main();
}
}, [file, props.showPlaceholder]);
}, [props.showPlaceholder]);
const handleClick = () => {
if (selectOnClick) {
@ -294,7 +295,7 @@ export default function PreviewCard(props: IProps) {
return (
<Cont
id={`thumb-${file?.id}-${props.showPlaceholder}`}
key={`thumb-${file.id}-${props.showPlaceholder}`}
onClick={handleClick}
onMouseEnter={handleHover}
disabled={!file?.msrc && !imgSrc}

View file

@ -35,11 +35,14 @@ interface Props {
fixTimeHelper: () => void;
downloadHelper: () => void;
count: number;
ownCount: number;
clearSelection: () => void;
archiveFilesHelper: () => void;
unArchiveFilesHelper: () => void;
activeCollection: number;
isFavoriteCollection: boolean;
isUncategorizedCollection: boolean;
isIncomingSharedCollection: boolean;
}
const SelectedFileOptions = ({
@ -53,11 +56,14 @@ const SelectedFileOptions = ({
deleteFileHelper,
downloadHelper,
count,
ownCount,
clearSelection,
archiveFilesHelper,
unArchiveFilesHelper,
activeCollection,
isFavoriteCollection,
isUncategorizedCollection,
isIncomingSharedCollection,
}: Props) => {
const { setDialogMessage } = useContext(AppContext);
const addToCollection = () =>
@ -92,18 +98,33 @@ const SelectedFileOptions = ({
title: constants.RESTORE_TO_COLLECTION,
});
const removeFromCollectionHandler = () =>
setDialogMessage({
title: constants.CONFIRM_REMOVE,
content: constants.CONFIRM_REMOVE_MESSAGE(),
const removeFromCollectionHandler = () => {
if (ownCount === count) {
setDialogMessage({
title: constants.REMOVE_FROM_COLLECTION,
content: constants.CONFIRM_SELF_REMOVE_MESSAGE(),
proceed: {
action: removeFromCollectionHelper,
text: constants.REMOVE,
variant: 'danger',
},
close: { text: constants.CANCEL },
});
proceed: {
action: removeFromCollectionHelper,
text: constants.YES_REMOVE,
variant: 'primary',
},
close: { text: constants.CANCEL },
});
} else {
setDialogMessage({
title: constants.REMOVE_FROM_COLLECTION,
content: constants.CONFIRM_SELF_AND_OTHER_REMOVE_MESSAGE(),
proceed: {
action: removeFromCollectionHelper,
text: constants.YES_REMOVE,
variant: 'danger',
},
close: { text: constants.CANCEL },
});
}
};
const moveToCollection = () => {
setCollectionSelectorAttributes({
@ -121,7 +142,8 @@ const SelectedFileOptions = ({
<CloseIcon />
</IconButton>
<Box ml={1.5}>
{count} {constants.SELECTED}
{count} {constants.SELECTED}{' '}
{ownCount !== count && `(${ownCount} ${constants.YOURS})`}
</Box>
</FluidContainer>
<Stack spacing={2} direction="row" mr={2}>
@ -138,6 +160,30 @@ const SelectedFileOptions = ({
</IconButton>
</Tooltip>
</>
) : isUncategorizedCollection ? (
<>
<Tooltip title={constants.DOWNLOAD}>
<IconButton onClick={downloadHelper}>
<DownloadIcon />
</IconButton>
</Tooltip>
<Tooltip title={constants.MOVE}>
<IconButton onClick={moveToCollection}>
<MoveIcon />
</IconButton>
</Tooltip>
<Tooltip title={constants.DELETE}>
<IconButton onClick={trashHandler}>
<DeleteIcon />
</IconButton>
</Tooltip>
</>
) : isIncomingSharedCollection ? (
<Tooltip title={constants.DOWNLOAD}>
<IconButton onClick={downloadHelper}>
<DownloadIcon />
</IconButton>
</Tooltip>
) : (
<>
<Tooltip title={constants.FIX_CREATION_TIME}>

3
src/constants/cache/index.ts vendored Normal file
View file

@ -0,0 +1,3 @@
export enum CACHES {
THUMBS = 'thumbs',
}

View file

@ -1,11 +1,12 @@
export const ARCHIVE_SECTION = -1;
export const TRASH_SECTION = -2;
export const DUMMY_UNCATEGORIZED_SECTION = -3;
export const ALL_SECTION = 0;
export enum CollectionType {
folder = 'folder',
favorites = 'favorites',
album = 'album',
uncategorized = 'uncategorized',
}
export enum CollectionSummaryType {
@ -14,6 +15,7 @@ export enum CollectionSummaryType {
album = 'album',
archive = 'archive',
trash = 'trash',
uncategorized = 'uncategorized',
all = 'all',
outgoingShare = 'outgoingShare',
incomingShare = 'incomingShare',
@ -26,6 +28,9 @@ export enum COLLECTION_SORT_BY {
UPDATION_TIME_DESCENDING,
}
export const UNCATEGORIZED_COLLECTION_NAME = 'Uncategorized';
export const FAVORITE_COLLECTION_NAME = 'Favorites';
export const COLLECTION_SHARE_DEFAULT_VALID_DURATION =
10 * 24 * 60 * 60 * 1000 * 1000;
export const COLLECTION_SHARE_DEFAULT_DEVICE_LIMIT = 4;
@ -41,21 +46,22 @@ export const COLLECTION_SORT_ORDER = new Map([
[CollectionSummaryType.archived, 2],
[CollectionSummaryType.archive, 3],
[CollectionSummaryType.trash, 4],
[CollectionSummaryType.uncategorized, 4],
]);
export const SYSTEM_COLLECTION_TYPES = new Set([
CollectionSummaryType.all,
CollectionSummaryType.archive,
CollectionSummaryType.trash,
CollectionSummaryType.uncategorized,
]);
export const UPLOAD_NOT_ALLOWED_COLLECTION_TYPES = new Set([
CollectionSummaryType.all,
CollectionSummaryType.archive,
CollectionSummaryType.incomingShare,
CollectionSummaryType.outgoingShare,
CollectionSummaryType.sharedOnlyViaLink,
CollectionSummaryType.trash,
CollectionSummaryType.uncategorized,
]);
export const OPTIONS_NOT_HAVING_COLLECTION_TYPES = new Set([
@ -66,4 +72,5 @@ export const OPTIONS_NOT_HAVING_COLLECTION_TYPES = new Set([
export const HIDE_FROM_COLLECTION_BAR_TYPES = new Set([
CollectionSummaryType.trash,
CollectionSummaryType.archive,
CollectionSummaryType.uncategorized,
]);

View file

@ -15,6 +15,8 @@ export const FILE_TYPE_LIB_MISSED_FORMATS = [
{ fileType: FILE_TYPE.VIDEO, exactType: 'mp4', mimeType: 'video/mp4' },
];
export const KNOWN_NON_MEDIA_FORMATS = ['xmp'];
export const EXIFLESS_FORMATS = ['image/gif'];
export const EXIF_LIBRARY_UNSUPPORTED_FORMATS = ['image/webp'];

View file

@ -257,8 +257,10 @@ export default function App({ Component, err }) {
isLoadingBarRunning.current = true;
};
const finishLoading = () => {
isLoadingBarRunning.current && loadingBar.current?.complete();
isLoadingBarRunning.current = false;
setTimeout(() => {
isLoadingBarRunning.current && loadingBar.current?.complete();
isLoadingBarRunning.current = false;
}, 100);
};
const closeMessageDialog = () => setMessageDialogView(false);

View file

@ -54,6 +54,7 @@ export default function Deduplicate() {
const [selected, setSelected] = useState<SelectedState>({
count: 0,
collectionID: 0,
ownCount: 0,
});
const closeDeduplication = function () {
Router.push(PAGES.GALLERY);
@ -107,6 +108,7 @@ export default function Deduplicate() {
setFileSizeMap(currFileSizeMap);
const selectedFiles = {
count: count,
ownCount: count,
collectionID: ALL_SECTION,
};
for (const fileID of toSelectFileIDs) {
@ -144,7 +146,7 @@ export default function Deduplicate() {
};
const clearSelection = function () {
setSelected({ count: 0, collectionID: 0 });
setSelected({ count: 0, collectionID: 0, ownCount: 0 });
};
if (!duplicateFiles) {

View file

@ -60,18 +60,19 @@ import Uploader from 'components/Upload/Uploader';
import {
ALL_SECTION,
ARCHIVE_SECTION,
CollectionSummaryType,
CollectionType,
DUMMY_UNCATEGORIZED_SECTION,
TRASH_SECTION,
UNCATEGORIZED_COLLECTION_NAME,
} from 'constants/collection';
import { AppContext } from 'pages/_app';
import { CustomError, ServerErrorCodes } from 'utils/error';
import { PAGES } from 'constants/pages';
import {
COLLECTION_OPS_TYPE,
isSharedCollection,
handleCollectionOps,
getSelectedCollection,
isFavoriteCollection,
getArchivedCollections,
hasNonSystemCollections,
} from 'utils/collection';
@ -87,7 +88,7 @@ import FixCreationTime, {
} from 'components/FixCreationTime';
import { Collection, CollectionSummaries } from 'types/collection';
import { EnteFile } from 'types/file';
import { GalleryContextType, SelectedState, SetFiles } from 'types/gallery';
import { GalleryContextType, SelectedState } from 'types/gallery';
import { VISIBILITY_STATE } from 'types/magicMetadata';
import Collections from 'components/Collections';
import { GalleryNavbar } from 'components/pages/gallery/Navbar';
@ -124,60 +125,18 @@ export const GalleryContext = createContext<GalleryContextType>(
defaultGalleryContext
);
type FilesFn = EnteFile[] | ((files: EnteFile[]) => EnteFile[]);
export default function Gallery() {
const router = useRouter();
const [user, setUser] = useState(null);
const [collections, setCollections] = useState<Collection[]>(null);
const [files, setFilesOriginal] = useState<EnteFile[]>(null);
const filesUpdateInProgress = useRef(false);
const filesCount = useRef(0);
const newerFilesFN = useRef<FilesFn>(null);
const setFilesOriginalWithReSyncIfRequired: SetFiles = (filesFn) => {
setFilesOriginal((currentFiles) => {
let newFiles: EnteFile[];
if (typeof filesFn === 'function') {
newFiles = filesFn(currentFiles);
} else {
newFiles = filesFn;
}
filesCount.current = newFiles?.length;
return newFiles;
});
filesUpdateInProgress.current = false;
if (newerFilesFN.current) {
const newerFiles = newerFilesFN.current;
setTimeout(() => setFiles(newerFiles), 0);
newerFilesFN.current = null;
}
};
const setFiles: SetFiles = async (filesFn) => {
if (filesUpdateInProgress.current) {
newerFilesFN.current = filesFn;
return;
}
filesUpdateInProgress.current = true;
if (!filesCount.current || filesCount.current < 5000) {
setFilesOriginalWithReSyncIfRequired(filesFn);
} else {
const waitTime = getData(LS_KEYS.WAIT_TIME) ?? 5000;
setTimeout(
() => setFilesOriginalWithReSyncIfRequired(filesFn),
waitTime
);
}
};
const [files, setFiles] = useState<EnteFile[]>(null);
const [favItemIds, setFavItemIds] = useState<Set<number>>();
const [isFirstLoad, setIsFirstLoad] = useState(false);
const [isFirstFetch, setIsFirstFetch] = useState(false);
const [selected, setSelected] = useState<SelectedState>({
ownCount: 0,
count: 0,
collectionID: 0,
});
@ -326,6 +285,8 @@ export default function Gallery() {
collectionURL += constants.ARCHIVE;
} else if (activeCollection === TRASH_SECTION) {
collectionURL += constants.TRASH;
} else if (activeCollection === DUMMY_UNCATEGORIZED_SECTION) {
collectionURL += UNCATEGORIZED_COLLECTION_NAME;
} else {
collectionURL += activeCollection;
}
@ -373,10 +334,8 @@ export default function Gallery() {
!silent && startLoading();
const collections = await syncCollections();
setCollections(collections);
let files = await syncFiles(collections, setFiles);
const trash = await syncTrash(collections, setFiles, files);
files = [...files, ...getTrashedFiles(trash)];
setFiles(sortFiles(files));
await syncFiles(collections, setFiles);
await syncTrash(collections, setFiles);
} catch (e) {
logError(e, 'syncWithRemote failed');
switch (e.message) {
@ -389,6 +348,7 @@ export default function Gallery() {
break;
}
} finally {
setDeletedFileIds(new Set());
!silent && finishLoading();
}
syncInProgress.current = false;
@ -408,7 +368,7 @@ export default function Gallery() {
const archivedCollections = getArchivedCollections(collections);
setArchivedCollections(archivedCollections);
const collectionSummaries = getCollectionSummaries(
const collectionSummaries = await getCollectionSummaries(
user,
collections,
files,
@ -418,7 +378,7 @@ export default function Gallery() {
};
const clearSelection = function () {
setSelected({ count: 0, collectionID: 0 });
setSelected({ ownCount: 0, count: 0, collectionID: 0 });
};
if (!files || !collectionSummaries) {
@ -430,10 +390,19 @@ export default function Gallery() {
try {
setCollectionSelectorView(false);
const selectedFiles = getSelectedFiles(selected, files);
const toProcessFiles =
ops === COLLECTION_OPS_TYPE.REMOVE
? selectedFiles
: selectedFiles.filter(
(file) => file.ownerID === user.id
);
if (toProcessFiles.length === 0) {
return;
}
await handleCollectionOps(
ops,
collection,
selectedFiles,
toProcessFiles,
selected.collectionID
);
clearSelection();
@ -551,7 +520,6 @@ export default function Gallery() {
});
} finally {
await syncWithRemote(false, true);
setDeletedFileIds(new Set());
finishLoading();
}
};
@ -724,10 +692,10 @@ export default function Gallery() {
deletedFileIds={deletedFileIds}
setDeletedFileIds={setDeletedFileIds}
activeCollection={activeCollection}
isSharedCollection={isSharedCollection(
activeCollection,
collections
)}
isIncomingSharedCollection={
collectionSummaries.get(activeCollection)?.type ===
CollectionSummaryType.incomingShare
}
enableDownload={true}
resetSearch={resetSearch}
/>
@ -771,12 +739,23 @@ export default function Gallery() {
fixTimeHelper={fixTimeHelper}
downloadHelper={downloadHelper}
count={selected.count}
ownCount={selected.ownCount}
clearSelection={clearSelection}
activeCollection={activeCollection}
isFavoriteCollection={isFavoriteCollection(
activeCollection,
collections
)}
isFavoriteCollection={
collectionSummaries.get(activeCollection)
?.type === CollectionSummaryType.favorites
}
isUncategorizedCollection={
collectionSummaries.get(activeCollection)
?.type ===
CollectionSummaryType.uncategorized
}
isIncomingSharedCollection={
collectionSummaries.get(activeCollection)
?.type ===
CollectionSummaryType.incomingShare
}
/>
)}
</FullScreenDropZone>

View file

@ -407,10 +407,10 @@ export default function PublicCollectionGallery() {
files={publicFiles}
syncWithRemote={syncWithRemote}
setSelected={() => null}
selected={{ count: 0, collectionID: null }}
selected={{ count: 0, collectionID: null, ownCount: 0 }}
isFirstLoad={true}
activeCollection={ALL_SECTION}
isSharedCollection
isIncomingSharedCollection
enableDownload={
publicCollection?.publicURLs?.[0]?.enableDownload ??
true

View file

@ -0,0 +1,27 @@
import { LimitedCacheStorage } from 'types/cache/index';
import { ElectronCacheStorage } from 'services/electron/cache';
import isElectron from 'is-electron';
export function getCacheStorage(): LimitedCacheStorage {
if (isElectron()) {
return ElectronCacheStorage;
} else {
return transformBrowserCacheStorageToLimitedCacheStorage(caches);
}
}
function transformBrowserCacheStorageToLimitedCacheStorage(
caches: CacheStorage
): LimitedCacheStorage {
return {
async open(cacheName) {
const cache = await caches.open(cacheName);
return {
match: cache.match.bind(cache),
put: cache.put.bind(cache),
delete: cache.delete.bind(cache),
};
},
delete: caches.delete.bind(caches),
};
}

View file

@ -0,0 +1,21 @@
import { logError } from 'utils/sentry';
import { getCacheStorage } from './cacheStorageFactory';
async function openCache(cacheName: string) {
try {
return await getCacheStorage().open(cacheName);
} catch (e) {
// log and ignore, we don't want to crash the app if cache is not available
logError(e, 'openCache failed');
}
}
async function deleteCache(cacheName: string) {
try {
return await getCacheStorage().delete(cacheName);
} catch (e) {
// log and ignore, we don't want to crash the app if cache is not available
logError(e, 'deleteCache failed');
}
}
export const CacheStorageService = { open: openCache, delete: deleteCache };

View file

@ -1,31 +0,0 @@
import electronService from './electron/common';
import electronCacheService from './electron/cache';
import { logError } from 'utils/sentry';
const THUMB_CACHE = 'thumbs';
export function getCacheProvider() {
if (electronService.checkIsBundledApp()) {
return electronCacheService;
} else {
return caches;
}
}
export async function openThumbnailCache() {
try {
return await getCacheProvider().open(THUMB_CACHE);
} catch (e) {
logError(e, 'openThumbnailCache failed');
// log and ignore
}
}
export async function deleteThumbnailCache() {
try {
return await getCacheProvider().delete(THUMB_CACHE);
} catch (e) {
logError(e, 'deleteThumbnailCache failed');
// dont throw
}
}

View file

@ -19,7 +19,6 @@ import {
AddToCollectionRequest,
MoveToCollectionRequest,
EncryptedFileKey,
RemoveFromCollectionRequest,
CreatePublicAccessTokenRequest,
PublicURL,
UpdatePublicURL,
@ -29,6 +28,7 @@ import {
EncryptedCollection,
CollectionMagicMetadata,
CollectionMagicMetadataProps,
RemoveFromCollectionRequest,
} from 'types/collection';
import {
COLLECTION_SORT_BY,
@ -38,6 +38,9 @@ import {
COLLECTION_SORT_ORDER,
ALL_SECTION,
CollectionSummaryType,
UNCATEGORIZED_COLLECTION_NAME,
FAVORITE_COLLECTION_NAME,
DUMMY_UNCATEGORIZED_SECTION,
} from 'constants/collection';
import {
NEW_COLLECTION_MAGIC_METADATA,
@ -53,8 +56,10 @@ import {
isOutgoingShare,
isIncomingShare,
isSharedOnlyViaLink,
isValidMoveTarget,
} from 'utils/collection';
import ComlinkCryptoWorker from 'utils/comlink/ComlinkCryptoWorker';
import { getLocalFiles } from './fileService';
const ENDPOINT = getEndpoint();
const COLLECTION_TABLE = 'collections';
@ -238,7 +243,8 @@ export const getCollection = async (
);
return collectionWithSecrets;
} catch (e) {
logError(e, 'failed to get collection', { collectionID });
logError(e, 'failed to get collection');
throw e;
}
};
@ -358,7 +364,7 @@ export const addToFavorites = async (file: EnteFile) => {
let favCollection = await getFavCollection();
if (!favCollection) {
favCollection = await createCollection(
'Favorites',
FAVORITE_COLLECTION_NAME,
CollectionType.favorites
);
const localCollections = await getLocalCollections();
@ -491,35 +497,142 @@ const encryptWithNewCollectionKey = async (
};
export const removeFromCollection = async (
collectionID: number,
files: EnteFile[]
toRemoveFiles: EnteFile[],
allFiles?: EnteFile[]
) => {
try {
const token = getToken();
const request: RemoveFromCollectionRequest = {
collectionID: collectionID,
fileIDs: files.map((file) => file.id),
};
const user: User = getData(LS_KEYS.USER);
const nonUserFiles = [];
const userFiles = [];
for (const file of toRemoveFiles) {
if (file.ownerID === user.id) {
userFiles.push(file);
} else {
nonUserFiles.push(file);
}
}
await HTTPService.post(
`${ENDPOINT}/collections/v2/remove-files`,
request,
null,
{ 'X-Auth-Token': token }
);
if (nonUserFiles.length > 0) {
await removeNonUserFiles(collectionID, nonUserFiles);
}
if (userFiles.length > 0) {
await removeUserFiles(collectionID, userFiles, allFiles);
}
} catch (e) {
logError(e, 'remove from collection failed ');
throw e;
}
};
export const deleteCollection = async (collectionID: number) => {
export const removeUserFiles = async (
sourceCollectionID: number,
toRemoveFiles: EnteFile[],
allFiles?: EnteFile[]
) => {
try {
if (!allFiles) {
allFiles = await getLocalFiles();
}
const toRemoveFilesIds = new Set(toRemoveFiles.map((f) => f.id));
const toRemoveFilesCopiesInOtherCollections = allFiles.filter((f) => {
return toRemoveFilesIds.has(f.id);
});
const groupiedFiles = groupFilesBasedOnCollectionID(
toRemoveFilesCopiesInOtherCollections
);
const collections = await getLocalCollections();
const collectionsMap = new Map(collections.map((c) => [c.id, c]));
const user: User = getData(LS_KEYS.USER);
for (const [targetCollectionID, files] of groupiedFiles.entries()) {
const targetCollection = collectionsMap.get(targetCollectionID);
if (
!isValidMoveTarget(sourceCollectionID, targetCollection, user)
) {
continue;
}
const toMoveFiles = files.filter((f) => {
if (toRemoveFilesIds.has(f.id)) {
toRemoveFilesIds.delete(f.id);
return true;
}
return false;
});
if (toMoveFiles.length === 0) {
continue;
}
await moveToCollection(
targetCollection,
sourceCollectionID,
toMoveFiles
);
}
const leftFiles = toRemoveFiles.filter((f) =>
toRemoveFilesIds.has(f.id)
);
if (leftFiles.length === 0) {
return;
}
let uncategorizedCollection = await getUncategorizedCollection();
if (!uncategorizedCollection) {
uncategorizedCollection = await createUnCategorizedCollection();
}
await moveToCollection(
uncategorizedCollection,
sourceCollectionID,
leftFiles
);
} catch (e) {
logError(e, 'remove user files failed ');
throw e;
}
};
export const removeNonUserFiles = async (
collectionID: number,
nonUserFiles: EnteFile[]
) => {
try {
const fileIDs = nonUserFiles.map((f) => f.id);
const token = getToken();
const request: RemoveFromCollectionRequest = {
collectionID,
fileIDs,
};
await HTTPService.post(
`${ENDPOINT}/collections/v3/remove-files`,
request,
null,
{ 'X-Auth-Token': token }
);
} catch (e) {
logError(e, 'remove non user files failed ');
throw e;
}
};
export const deleteCollection = async (
collectionID: number,
keepFiles: boolean
) => {
try {
if (keepFiles) {
const allFiles = await getLocalFiles();
const collectionFiles = allFiles.filter((file) => {
return file.collectionID === collectionID;
});
await removeFromCollection(collectionID, collectionFiles, allFiles);
}
const token = getToken();
await HTTPService.delete(
`${ENDPOINT}/collections/v2/${collectionID}`,
null,
`${ENDPOINT}/collections/v3/${collectionID}`,
null,
{ collectionID, keepFiles },
{ 'X-Auth-Token': token }
);
} catch (e) {
@ -816,12 +929,12 @@ function compareCollectionsLatestFile(first: EnteFile, second: EnteFile) {
}
}
export function getCollectionSummaries(
export async function getCollectionSummaries(
user: User,
collections: Collection[],
files: EnteFile[],
archivedCollections: Set<number>
): CollectionSummaries {
): Promise<CollectionSummaries> {
const collectionSummaries: CollectionSummaries = new Map();
const collectionLatestFiles = getCollectionLatestFiles(
files,
@ -833,12 +946,15 @@ export function getCollectionSummaries(
);
for (const collection of collections) {
if (collectionFilesCount.get(collection.id)) {
if (
collectionFilesCount.get(collection.id) ||
collection.type === CollectionType.uncategorized
) {
collectionSummaries.set(collection.id, {
id: collection.id,
name: collection.name,
latestFile: collectionLatestFiles.get(collection.id),
fileCount: collectionFilesCount.get(collection.id),
fileCount: collectionFilesCount.get(collection.id) ?? 0,
updationTime: collection.updationTime,
type: isIncomingShare(collection, user)
? CollectionSummaryType.incomingShare
@ -852,6 +968,16 @@ export function getCollectionSummaries(
});
}
}
const uncategorizedCollection = await getUncategorizedCollection(
collections
);
if (!uncategorizedCollection) {
collectionSummaries.set(
DUMMY_UNCATEGORIZED_SECTION,
getDummyUncategorizedCollectionSummaries()
);
}
collectionSummaries.set(
ALL_SECTION,
getAllCollectionSummaries(collectionFilesCount, collectionLatestFiles)
@ -919,6 +1045,17 @@ function getAllCollectionSummaries(
};
}
function getDummyUncategorizedCollectionSummaries(): CollectionSummary {
return {
id: ALL_SECTION,
name: UNCATEGORIZED_COLLECTION_NAME,
type: CollectionSummaryType.uncategorized,
latestFile: null,
fileCount: 0,
updationTime: 0,
};
}
function getArchivedCollectionSummaries(
collectionFilesCount: CollectionFilesCount,
collectionsLatestFile: CollectionLatestFiles
@ -946,3 +1083,23 @@ function getTrashedCollectionSummaries(
updationTime: collectionsLatestFile.get(TRASH_SECTION)?.updationTime,
};
}
export async function getUncategorizedCollection(
collections?: Collection[]
): Promise<Collection> {
if (!collections) {
collections = await getLocalCollections();
}
const uncategorizedCollection = collections.find(
(collection) => collection.type === CollectionType.uncategorized
);
return uncategorizedCollection;
}
export async function createUnCategorizedCollection() {
return createCollection(
UNCATEGORIZED_COLLECTION_NAME,
CollectionType.uncategorized
);
}

View file

@ -11,10 +11,11 @@ import { EnteFile } from 'types/file';
import { logError } from 'utils/sentry';
import { FILE_TYPE } from 'constants/file';
import { CustomError } from 'utils/error';
import { openThumbnailCache } from './cacheService';
import QueueProcessor, { PROCESSING_STRATEGY } from './queueProcessor';
import ComlinkCryptoWorker from 'utils/comlink/ComlinkCryptoWorker';
import { addLogLine } from 'utils/logging';
import { CacheStorageService } from './cache/cacheStorageService';
import { CACHES } from 'constants/cache';
const MAX_PARALLEL_DOWNLOADS = 10;
@ -44,7 +45,9 @@ class DownloadManager {
}
if (!this.thumbnailObjectURLPromise.has(file.id)) {
const downloadPromise = async () => {
const thumbnailCache = await openThumbnailCache();
const thumbnailCache = await CacheStorageService.open(
CACHES.THUMBS
);
const cacheResp: Response = await thumbnailCache?.match(
file.id.toString()

View file

@ -1,15 +1,16 @@
import { LimitedCache, LimitedCacheStorage } from 'types/cache';
import { ElectronAPIs } from 'types/electron';
import { runningInBrowser } from 'utils/common';
class ElectronCacheService {
class ElectronCacheStorageService implements LimitedCacheStorage {
private electronAPIs: ElectronAPIs;
private allElectronAPIsExist: boolean = false;
constructor() {
this.electronAPIs = runningInBrowser() && window['ElectronAPIs'];
this.electronAPIs = globalThis['ElectronAPIs'];
this.allElectronAPIsExist = !!this.electronAPIs?.openDiskCache;
}
async open(cacheName: string): Promise<Cache> {
async open(cacheName: string): Promise<LimitedCache> {
if (this.allElectronAPIsExist) {
return await this.electronAPIs.openDiskCache(cacheName);
}
@ -22,4 +23,4 @@ class ElectronCacheService {
}
}
export default new ElectronCacheService();
export const ElectronCacheStorage = new ElectronCacheStorageService();

View file

@ -1,5 +1,6 @@
import { ElectronAPIs } from 'types/electron';
import { ElectronFile } from 'types/upload';
import { CustomError } from 'utils/error';
import { convertBytesToHumanReadable } from 'utils/file/size';
import { addLogLine } from 'utils/logging';
import { logError } from 'utils/sentry';
@ -37,7 +38,12 @@ class ElectronImageProcessorService {
);
return new Blob([convertedFileData]);
} catch (e) {
logError(e, 'failed to convert heic natively');
if (
e.message !==
CustomError.WINDOWS_NATIVE_IMAGE_PROCESSING_NOT_SUPPORTED
) {
logError(e, 'failed to convert heic natively');
}
throw e;
}
}
@ -68,7 +74,12 @@ class ElectronImageProcessorService {
);
return thumb;
} catch (e) {
logError(e, 'failed to generate image thumbnail natively');
if (
e.message !==
CustomError.WINDOWS_NATIVE_IMAGE_PROCESSING_NOT_SUPPORTED
) {
logError(e, 'failed to generate image thumbnail natively');
}
throw e;
}
}

View file

@ -7,8 +7,8 @@ import HTTPService from './HTTPService';
import { logError } from 'utils/sentry';
import {
decryptFile,
getLatestVersionFiles,
mergeMetadata,
preservePhotoswipeProps,
sortFiles,
} from 'utils/file';
import { EnteFile, EncryptedEnteFile, TrashRequest } from 'types/file';
@ -54,12 +54,12 @@ const setLocalFiles = async (files: EnteFile[]) => {
export const syncFiles = async (
collections: Collection[],
setFiles: SetFiles
) => {
): Promise<EnteFile[]> => {
const localFiles = await getLocalFiles();
let files = await removeDeletedCollectionFiles(collections, localFiles);
if (files.length !== localFiles.length) {
await setLocalFiles(files);
setFiles(preservePhotoswipeProps([...sortFiles(mergeMetadata(files))]));
setFiles(sortFiles(mergeMetadata(files)));
}
for (const collection of collections) {
if (!getToken()) {
@ -72,38 +72,17 @@ export const syncFiles = async (
if (collection.updationTime === lastSyncTime) {
continue;
}
const fetchedFiles =
(await getFiles(collection, lastSyncTime, files, setFiles)) ?? [];
files = [...files, ...fetchedFiles];
const latestVersionFiles = new Map<string, EnteFile>();
files.forEach((file) => {
const uid = `${file.collectionID}-${file.id}`;
if (
!latestVersionFiles.has(uid) ||
latestVersionFiles.get(uid).updationTime < file.updationTime
) {
latestVersionFiles.set(uid, file);
}
});
files = [];
// eslint-disable-next-line @typescript-eslint/no-unused-vars
for (const [_, file] of latestVersionFiles) {
if (file.isDeleted) {
continue;
}
files.push(file);
}
const newFiles = await getFiles(collection, lastSyncTime, setFiles);
files = getLatestVersionFiles([...files, ...newFiles]);
await setLocalFiles(files);
setCollectionLastSyncTime(collection, collection.updationTime);
setFiles(preservePhotoswipeProps([...sortFiles(mergeMetadata(files))]));
}
return sortFiles(mergeMetadata(files));
return files;
};
export const getFiles = async (
collection: Collection,
sinceTime: number,
files: EnteFile[],
setFiles: SetFiles
): Promise<EnteFile[]> => {
try {
@ -126,37 +105,35 @@ export const getFiles = async (
}
);
decryptedFiles = [
...decryptedFiles,
...(await Promise.all(
resp.data.diff.map(async (file: EncryptedEnteFile) => {
if (!file.isDeleted) {
return await decryptFile(file, collection.key);
} else {
return file;
}
}) as Promise<EnteFile>[]
)),
];
const newDecryptedFilesBatch = await Promise.all(
resp.data.diff.map(async (file: EncryptedEnteFile) => {
if (!file.isDeleted) {
return await decryptFile(file, collection.key);
} else {
return file;
}
}) as Promise<EnteFile>[]
);
decryptedFiles = [...decryptedFiles, ...newDecryptedFilesBatch];
if (resp.data.diff.length) {
time = resp.data.diff.slice(-1)[0].updationTime;
}
setFiles(
preservePhotoswipeProps(
sortFiles(
mergeMetadata(
[...(files || []), ...decryptedFiles].filter(
(item) => !item.isDeleted
)
)
setFiles((files) =>
sortFiles(
mergeMetadata(
getLatestVersionFiles([
...(files || []),
...decryptedFiles,
])
)
)
);
if (resp.data.diff.length) {
time = resp.data.diff.slice(-1)[0].updationTime;
}
} while (resp.data.hasMore);
return decryptedFiles;
} catch (e) {
logError(e, 'Get files failed');
throw e;
}
};

View file

@ -2,16 +2,10 @@ import { SetFiles } from 'types/gallery';
import { Collection } from 'types/collection';
import { getEndpoint } from 'utils/common/apiUtil';
import { getToken } from 'utils/common/key';
import {
decryptFile,
mergeMetadata,
preservePhotoswipeProps,
sortFiles,
} from 'utils/file';
import { decryptFile, mergeMetadata, sortFiles } from 'utils/file';
import { logError } from 'utils/sentry';
import localForage from 'utils/storage/localForage';
import { getCollection } from './collectionService';
import { EnteFile } from 'types/file';
import HTTPService from './HTTPService';
import { EncryptedTrashItem, Trash } from 'types/trash';
@ -30,7 +24,13 @@ export async function getLocalTrash() {
export async function getLocalDeletedCollections() {
const trashedCollections: Array<Collection> =
(await localForage.getItem<Collection[]>(DELETED_COLLECTION)) || [];
return trashedCollections;
const nonUndefinedCollections = trashedCollections.filter(
(collection) => !!collection
);
if (nonUndefinedCollections.length !== trashedCollections.length) {
await localForage.setItem(DELETED_COLLECTION, nonUndefinedCollections);
}
return nonUndefinedCollections;
}
export async function cleanTrashCollections(fileTrash: Trash) {
@ -49,16 +49,15 @@ async function getLastSyncTime() {
}
export async function syncTrash(
collections: Collection[],
setFiles: SetFiles,
files: EnteFile[]
): Promise<Trash> {
setFiles: SetFiles
): Promise<void> {
const trash = await getLocalTrash();
collections = [...collections, ...(await getLocalDeletedCollections())];
const collectionMap = new Map<number, Collection>(
collections.map((collection) => [collection.id, collection])
);
if (!getToken()) {
return trash;
return;
}
const lastSyncTime = await getLastSyncTime();
@ -66,18 +65,15 @@ export async function syncTrash(
collectionMap,
lastSyncTime,
setFiles,
files,
trash
);
cleanTrashCollections(updatedTrash);
return updatedTrash;
}
export const updateTrash = async (
collections: Map<number, Collection>,
sinceTime: number,
setFiles: SetFiles,
files: EnteFile[],
currentTrash: Trash
): Promise<Trash> => {
try {
@ -127,16 +123,8 @@ export const updateTrash = async (
time = resp.data.diff.slice(-1)[0].updatedAt;
}
setFiles(
preservePhotoswipeProps(
sortFiles([
...(files ?? []).map((file) => ({
...file,
isTrashed: false,
})),
...getTrashedFiles(updatedTrash),
])
)
setFiles((files) =>
sortFiles([...(files ?? []), ...getTrashedFiles(updatedTrash)])
);
await localForage.setItem(TRASH, updatedTrash);
await localForage.setItem(TRASH_TIME, time);

View file

@ -1,6 +1,9 @@
import { FILE_TYPE } from 'constants/file';
import { ElectronFile, FileTypeInfo } from 'types/upload';
import { FILE_TYPE_LIB_MISSED_FORMATS } from 'constants/upload';
import {
FILE_TYPE_LIB_MISSED_FORMATS,
KNOWN_NON_MEDIA_FORMATS,
} from 'constants/upload';
import { CustomError } from 'utils/error';
import { getFileExtension } from 'utils/file';
import { logError } from 'utils/sentry';
@ -27,7 +30,7 @@ export async function getFileType(
const mimTypeParts: string[] = typeResult.mime?.split('/');
if (mimTypeParts?.length !== 2) {
throw Error(CustomError.TYPE_DETECTION_FAILED);
throw Error(CustomError.INVALID_MIME_TYPE(typeResult.mime));
}
switch (mimTypeParts[0]) {
case TYPE_IMAGE:
@ -37,7 +40,7 @@ export async function getFileType(
fileType = FILE_TYPE.VIDEO;
break;
default:
fileType = FILE_TYPE.OTHERS;
throw Error(CustomError.UNSUPPORTED_FILE_FORMAT);
}
return {
fileType,
@ -45,6 +48,9 @@ export async function getFileType(
mimeType: typeResult.mime,
};
} catch (e) {
if (e.message === CustomError.UNSUPPORTED_FILE_FORMAT) {
throw e;
}
const fileFormat = getFileExtension(receivedFile.name);
const formatMissedByTypeDetection = FILE_TYPE_LIB_MISSED_FORMATS.find(
(a) => a.exactType === fileFormat
@ -52,14 +58,13 @@ export async function getFileType(
if (formatMissedByTypeDetection) {
return formatMissedByTypeDetection;
}
logError(e, CustomError.TYPE_DETECTION_FAILED, {
if (KNOWN_NON_MEDIA_FORMATS.includes(fileFormat)) {
throw Error(CustomError.UNSUPPORTED_FILE_FORMAT);
}
logError(e, 'type detection failed', {
fileFormat,
});
return {
fileType: FILE_TYPE.OTHERS,
exactType: fileFormat,
mimeType: receivedFile instanceof File ? receivedFile.type : null,
};
throw Error(CustomError.TYPE_DETECTION_FAILED(fileFormat));
}
}
@ -78,18 +83,15 @@ async function extractElectronFileType(file: ElectronFile) {
}
async function getFileTypeFromBuffer(buffer: Uint8Array) {
try {
const result = await FileType.fromBuffer(buffer);
if (!result.mime) {
logError(
Error('mimetype missing from file type result'),
CustomError.TYPE_DETECTION_FAILED,
{ result }
);
throw Error(CustomError.TYPE_DETECTION_FAILED);
const result = await FileType.fromBuffer(buffer);
if (!result?.mime) {
let logableInfo = '';
try {
logableInfo = `result: ${JSON.stringify(result)}`;
} catch (e) {
logableInfo = 'failed to stringify result';
}
return result;
} catch (e) {
throw Error(CustomError.TYPE_DETECTION_FAILED);
throw Error(`mimetype missing from file type result - ${logableInfo}`);
}
return result;
}

View file

@ -88,13 +88,6 @@ async function generateImageThumbnail(
MAX_THUMBNAIL_SIZE
);
} catch (e) {
logError(
e,
'Error generating thumbnail using electron image processor',
{
fileFormat: fileTypeInfo.exactType,
}
);
return await generateImageThumbnailUsingCanvas(file, fileTypeInfo);
}
} else {

View file

@ -2,7 +2,6 @@ import { getLocalFiles } from '../fileService';
import { SetFiles } from 'types/gallery';
import {
sortFiles,
preservePhotoswipeProps,
decryptFile,
getUserOwnedNonTrashedFiles,
} from 'utils/file';
@ -414,7 +413,7 @@ class UploadManager {
private updateUIFiles(decryptedFile: EnteFile) {
this.existingFiles.push(decryptedFile);
this.existingFiles = sortFiles(this.existingFiles);
this.setFiles(preservePhotoswipeProps(this.existingFiles));
this.setFiles((files) => sortFiles([...files, decryptedFile]));
}
private updateElectronRemainingFiles(

View file

@ -51,10 +51,9 @@ export default async function uploader(
}
addLogLine(`getting filetype for ${fileNameSize}`);
fileTypeInfo = await UploadService.getAssetFileType(uploadAsset);
addLogLine(`got filetype for ${fileNameSize}`);
if (fileTypeInfo.fileType === FILE_TYPE.OTHERS) {
throw Error(CustomError.UNSUPPORTED_FILE_FORMAT);
}
addLogLine(
`got filetype for ${fileNameSize} - ${JSON.stringify(fileTypeInfo)}`
);
if (skipVideos && fileTypeInfo.fileType === FILE_TYPE.VIDEO) {
addLogLine(
`skipped video upload for public upload ${fileNameSize}`
@ -176,7 +175,10 @@ export default async function uploader(
};
} catch (e) {
addLogLine(`upload failed for ${fileNameSize} ,error: ${e.message}`);
if (e.message !== CustomError.UPLOAD_CANCELLED) {
if (
e.message !== CustomError.UPLOAD_CANCELLED &&
e.message !== CustomError.UNSUPPORTED_FILE_FORMAT
) {
logError(e, 'file upload failed', {
fileFormat: fileTypeInfo?.exactType,
});

View file

@ -18,12 +18,12 @@ import {
UserDetails,
DeleteChallengeResponse,
} from 'types/user';
import { getLocalFamilyData, isPartOfFamily } from 'utils/billing';
import { ServerErrorCodes } from 'utils/error';
import isElectron from 'is-electron';
import safeStorageService from './electron/safeStorage';
import { deleteThumbnailCache } from './cacheService';
import { deleteThumbnailCache } from 'utils/storage/cache';
import { B64EncryptionResult } from 'types/crypto';
import { isPartOfFamily, getLocalFamilyData } from 'utils/user/family';
const ENDPOINT = getEndpoint();

10
src/types/cache/index.ts vendored Normal file
View file

@ -0,0 +1,10 @@
export interface LimitedCacheStorage {
open: (cacheName: string) => Promise<LimitedCache>;
delete: (cacheName: string) => Promise<boolean>;
}
export interface LimitedCache {
match: (key: string) => Promise<Response>;
put: (key: string, data: Response) => Promise<void>;
delete: (key: string) => Promise<boolean>;
}

View file

@ -37,7 +37,6 @@ export interface Collection
> {
key: string;
name: string;
isSharedCollection?: boolean;
magicMetadata: CollectionMagicMetadata;
}

View file

@ -17,6 +17,12 @@ export interface DialogBoxAttributes {
variant: ButtonProps['color'];
disabled?: boolean;
};
secondary?: {
text: string;
action: () => void;
variant: ButtonProps['color'];
disabled?: boolean;
};
}
export type SetDialogBoxAttributes = React.Dispatch<

View file

@ -5,6 +5,7 @@ import { EnteFile } from 'types/file';
export type SelectedState = {
[k: number]: boolean;
ownCount: number;
count: number;
collectionID: number;
};
@ -15,9 +16,14 @@ export type SetCollectionSelectorAttributes = React.Dispatch<
React.SetStateAction<CollectionSelectorAttributes>
>;
export type MergedSourceURL = {
original: string;
converted: string;
};
export type GalleryContextType = {
thumbs: Map<number, string>;
files: Map<number, { original: string; converted: string }>;
files: Map<number, MergedSourceURL>;
showPlanSelectorModal: () => void;
setActiveCollection: (collection: number) => void;
syncWithRemote: (force?: boolean, silent?: boolean) => Promise<void>;

View file

@ -8,8 +8,9 @@ import { CustomError } from '../error';
import { logError } from '../sentry';
import { SetDialogBoxAttributes } from 'types/dialogBox';
import { getFamilyPortalRedirectURL } from 'services/userService';
import { FamilyData, FamilyMember, User, UserDetails } from 'types/user';
import { openLink } from 'utils/common';
import { isPartOfFamily, getTotalFamilyUsage } from 'utils/user/family';
import { UserDetails } from 'types/user';
const PAYMENT_PROVIDER_STRIPE = 'stripe';
const PAYMENT_PROVIDER_APPSTORE = 'appstore';
@ -97,52 +98,10 @@ export function isSubscriptionCancelled(subscription: Subscription) {
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 getTotalFamilyUsage(familyData: FamilyData): number {
return familyData.members.reduce(
(sum, currentMember) => sum + currentMember.usage,
0
);
}
export function getLocalUserSubscription(): Subscription {
return getData(LS_KEYS.SUBSCRIPTION);
}
export function getLocalFamilyData(): FamilyData {
return getData(LS_KEYS.FAMILY_DATA);
}
export function isUserSubscribedPlan(plan: Plan, subscription: Subscription) {
return (
isSubscriptionActive(subscription) &&

View file

@ -20,7 +20,6 @@ import {
} from 'types/collection';
import {
CollectionSummaryType,
CollectionType,
HIDE_FROM_COLLECTION_BAR_TYPES,
OPTIONS_NOT_HAVING_COLLECTION_TYPES,
SYSTEM_COLLECTION_TYPES,
@ -77,31 +76,6 @@ export function getSelectedCollection(
return collections.find((collection) => collection.id === collectionID);
}
export function isSharedCollection(
collectionID: number,
collections: Collection[]
) {
const user: User = getData(LS_KEYS.USER);
const collection = getSelectedCollection(collectionID, collections);
if (!collection) {
return false;
}
return collection?.owner.id !== user.id;
}
export function isFavoriteCollection(
collectionID: number,
collections: Collection[]
) {
const collection = getSelectedCollection(collectionID, collections);
if (!collection) {
return false;
} else {
return collection.type === CollectionType.favorites;
}
}
export async function downloadAllCollectionFiles(collectionID: number) {
try {
const allFiles = await getLocalFiles();
@ -204,7 +178,10 @@ export const getArchivedCollections = (collections: Collection[]) => {
export const hasNonSystemCollections = (
collectionSummaries: CollectionSummaries
) => {
return collectionSummaries?.size > 3;
for (const collectionSummary of collectionSummaries.values()) {
if (!isSystemCollection(collectionSummary.type)) return true;
}
return false;
};
export const isUploadAllowedCollection = (type: CollectionSummaryType) => {
@ -251,3 +228,16 @@ export function isIncomingShare(collection: Collection, user: User) {
export function isSharedOnlyViaLink(collection: Collection) {
return collection.publicURLs?.length && !collection.sharees?.length;
}
export function isValidMoveTarget(
sourceCollectionID: number,
targetCollection: Collection,
user: User
) {
return (
sourceCollectionID !== targetCollection.id &&
!isCollectionHidden(targetCollection) &&
!isQuickLinkCollection(targetCollection) &&
!isIncomingShare(targetCollection, user)
);
}

View file

@ -11,44 +11,49 @@ export const ServerErrorCodes = {
NOT_FOUND: '404',
};
export enum CustomError {
SUBSCRIPTION_VERIFICATION_ERROR = 'Subscription verification failed',
THUMBNAIL_GENERATION_FAILED = 'thumbnail generation failed',
VIDEO_PLAYBACK_FAILED = 'video playback failed',
ETAG_MISSING = 'no header/etag present in response body',
KEY_MISSING = 'encrypted key missing from localStorage',
FAILED_TO_LOAD_WEB_WORKER = 'failed to load web worker',
CHUNK_MORE_THAN_EXPECTED = 'chunks more than expected',
CHUNK_LESS_THAN_EXPECTED = 'chunks less than expected',
UNSUPPORTED_FILE_FORMAT = 'unsupported file formats',
FILE_TOO_LARGE = 'file too large',
SUBSCRIPTION_EXPIRED = 'subscription expired',
STORAGE_QUOTA_EXCEEDED = 'storage quota exceeded',
SESSION_EXPIRED = 'session expired',
TYPE_DETECTION_FAILED = 'type detection failed',
SIGNUP_FAILED = 'signup failed',
FAV_COLLECTION_MISSING = 'favorite collection missing',
INVALID_COLLECTION_OPERATION = 'invalid collection operation',
WAIT_TIME_EXCEEDED = 'thumbnail generation wait time exceeded',
REQUEST_CANCELLED = 'request canceled',
REQUEST_FAILED = 'request failed',
TOKEN_EXPIRED = 'token expired',
TOKEN_MISSING = 'token missing',
TOO_MANY_REQUESTS = 'too many requests',
BAD_REQUEST = 'bad request',
SUBSCRIPTION_NEEDED = 'subscription not present',
NOT_FOUND = 'not found ',
NO_METADATA = 'no metadata',
TOO_LARGE_LIVE_PHOTO_ASSETS = 'too large live photo assets',
NOT_A_DATE = 'not a date',
FILE_ID_NOT_FOUND = 'file with id not found',
WEAK_DEVICE = 'password decryption failed on the device',
INCORRECT_PASSWORD = 'incorrect password',
UPLOAD_CANCELLED = 'upload cancelled',
REQUEST_TIMEOUT = 'request taking too long',
HIDDEN_COLLECTION_SYNC_FILE_ATTEMPTED = 'hidden collection sync file attempted',
UNKNOWN_ERROR = 'Something went wrong, please try again',
}
export const CustomError = {
SUBSCRIPTION_VERIFICATION_ERROR: 'Subscription verification failed',
THUMBNAIL_GENERATION_FAILED: 'thumbnail generation failed',
VIDEO_PLAYBACK_FAILED: 'video playback failed',
ETAG_MISSING: 'no header/etag present in response body',
KEY_MISSING: 'encrypted key missing from localStorage',
FAILED_TO_LOAD_WEB_WORKER: 'failed to load web worker',
CHUNK_MORE_THAN_EXPECTED: 'chunks more than expected',
CHUNK_LESS_THAN_EXPECTED: 'chunks less than expected',
UNSUPPORTED_FILE_FORMAT: 'unsupported file format',
FILE_TOO_LARGE: 'file too large',
SUBSCRIPTION_EXPIRED: 'subscription expired',
STORAGE_QUOTA_EXCEEDED: 'storage quota exceeded',
SESSION_EXPIRED: 'session expired',
INVALID_MIME_TYPE: (type: string) => `invalid mime type -${type}`,
SIGNUP_FAILED: 'signup failed',
FAV_COLLECTION_MISSING: 'favorite collection missing',
INVALID_COLLECTION_OPERATION: 'invalid collection operation',
WAIT_TIME_EXCEEDED: 'thumbnail generation wait time exceeded',
REQUEST_CANCELLED: 'request canceled',
REQUEST_FAILED: 'request failed',
TOKEN_EXPIRED: 'token expired',
TOKEN_MISSING: 'token missing',
TOO_MANY_REQUESTS: 'too many requests',
BAD_REQUEST: 'bad request',
SUBSCRIPTION_NEEDED: 'subscription not present',
NOT_FOUND: 'not found ',
NO_METADATA: 'no metadata',
TOO_LARGE_LIVE_PHOTO_ASSETS: 'too large live photo assets',
NOT_A_DATE: 'not a date',
FILE_ID_NOT_FOUND: 'file with id not found',
WEAK_DEVICE: 'password decryption failed on the device',
INCORRECT_PASSWORD: 'incorrect password',
UPLOAD_CANCELLED: 'upload cancelled',
REQUEST_TIMEOUT: 'request taking too long',
HIDDEN_COLLECTION_SYNC_FILE_ATTEMPTED:
'hidden collection sync file attempted',
UNKNOWN_ERROR: 'Something went wrong, please try again',
TYPE_DETECTION_FAILED: (fileFormat: string) =>
`type detection failed ${fileFormat}`,
WINDOWS_NATIVE_IMAGE_PROCESSING_NOT_SUPPORTED:
'Windows native image processing is not supported',
};
function parseUploadErrorCodes(error) {
let parsedMessage = null;

View file

@ -166,31 +166,14 @@ export function getSelectedFiles(
}
export function sortFiles(files: EnteFile[]) {
// sort according to modification time first
files = files.sort((a, b) => {
if (!b.metadata?.modificationTime) {
return -1;
}
if (!a.metadata?.modificationTime) {
return 1;
} else {
// sort based on the time of creation time of the file,
// for files with same creation time, sort based on the time of last modification
return files.sort((a, b) => {
if (a.metadata.creationTime === b.metadata.creationTime) {
return b.metadata.modificationTime - a.metadata.modificationTime;
}
return b.metadata.creationTime - a.metadata.creationTime;
});
// then sort according to creation time, maintaining ordering according to modification time for files with creation time
files = files
.map((file, index) => ({ index, file }))
.sort((a, b) => {
let diff =
b.file.metadata.creationTime - a.file.metadata.creationTime;
if (diff === 0) {
diff = a.index - b.index;
}
return diff;
})
.map((file) => file.file);
return files;
}
export async function decryptFile(
@ -252,19 +235,6 @@ export async function decryptFile(
}
}
export const preservePhotoswipeProps =
(newFiles: EnteFile[]) =>
(currentFiles: EnteFile[]): EnteFile[] => {
const currentFilesMap = Object.fromEntries(
currentFiles.map((file) => [file.id, file])
);
const fileWithPreservedProperty = newFiles.map((file) => {
const currentFile = currentFilesMap[file.id];
return { ...currentFile, ...file };
});
return fileWithPreservedProperty;
};
export function fileNameWithoutExtension(filename: string) {
const lastDotPosition = filename.lastIndexOf('.');
if (lastDotPosition === -1) return filename;
@ -456,23 +426,16 @@ export function isSharedFile(user: User, file: EnteFile) {
}
export function mergeMetadata(files: EnteFile[]): EnteFile[] {
return files.map((file) => ({
...file,
metadata: {
...file.metadata,
...(file.pubMagicMetadata?.data
? {
...(file.pubMagicMetadata?.data.editedTime && {
creationTime: file.pubMagicMetadata.data.editedTime,
}),
...(file.pubMagicMetadata?.data.editedName && {
title: file.pubMagicMetadata.data.editedName,
}),
}
: {}),
...(file.magicMetadata?.data ? file.magicMetadata.data : {}),
},
}));
return files.map((file) => {
if (file.pubMagicMetadata?.data.editedTime) {
file.metadata.creationTime = file.pubMagicMetadata.data.editedTime;
}
if (file.pubMagicMetadata?.data.editedName) {
file.metadata.title = file.pubMagicMetadata.data.editedName;
}
return file;
});
}
export function updateExistingFilePubMetadata(
@ -592,3 +555,19 @@ export const copyFileToClipboard = async (fileUrl: string) => {
.write([new ClipboardItem({ 'image/png': blobPromise })])
.catch((e) => logError(e, 'failed to copy to clipboard'));
};
export function getLatestVersionFiles(files: EnteFile[]) {
const latestVersionFiles = new Map<string, EnteFile>();
files.forEach((file) => {
const uid = `${file.collectionID}-${file.id}`;
if (
!latestVersionFiles.has(uid) ||
latestVersionFiles.get(uid).updationTime < file.updationTime
) {
latestVersionFiles.set(uid, file);
}
});
return Array.from(latestVersionFiles.values()).filter(
(file) => !file.isDeleted
);
}

View file

@ -1,3 +1,9 @@
import { FILE_TYPE } from 'constants/file';
import { EnteFile } from 'types/file';
import { MergedSourceURL } from 'types/gallery';
import { logError } from 'utils/sentry';
import constants from 'utils/strings/constants';
const WAIT_FOR_VIDEO_PLAYBACK = 1 * 1000;
export async function isPlaybackPossible(url: string): Promise<boolean> {
@ -32,3 +38,121 @@ export async function pauseVideo(livePhotoVideo, livePhotoImage) {
livePhotoVideo.style.opacity = 0;
livePhotoImage.style.opacity = 1;
}
export function updateFileMsrcProps(file: EnteFile, url: string) {
file.msrc = url;
if (file.metadata.fileType === FILE_TYPE.VIDEO) {
file.html = `
<div class="pswp-item-container">
<img src="${url}" onContextMenu="return false;"/>
<div class="spinner-border text-light" role="status">
<span class="sr-only">Loading...</span>
</div>
</div>
`;
} else if (file.metadata.fileType === FILE_TYPE.LIVE_PHOTO) {
file.html = `
<div class="pswp-item-container">
<img src="${url}" onContextMenu="return false;"/>
<div class="spinner-border text-light" role="status">
<span class="sr-only">Loading...</span>
</div>
</div>
`;
} else if (file.metadata.fileType === FILE_TYPE.IMAGE) {
file.src = url;
} else {
logError(
Error(`unknown file type - ${file.metadata.fileType}`),
'Unknown file type'
);
file.src = url;
}
}
export async function updateFileSrcProps(
file: EnteFile,
mergedURL: MergedSourceURL
) {
const urls = {
original: mergedURL.original.split(','),
converted: mergedURL.converted.split(','),
};
let originalImageURL;
let originalVideoURL;
let convertedImageURL;
let convertedVideoURL;
let originalURL;
if (file.metadata.fileType === FILE_TYPE.LIVE_PHOTO) {
[originalImageURL, originalVideoURL] = urls.original;
[convertedImageURL, convertedVideoURL] = urls.converted;
} else if (file.metadata.fileType === FILE_TYPE.VIDEO) {
[originalVideoURL] = urls.original;
[convertedVideoURL] = urls.converted;
} else if (file.metadata.fileType === FILE_TYPE.IMAGE) {
[originalImageURL] = urls.original;
[convertedImageURL] = urls.converted;
} else {
[originalURL] = urls.original;
}
const isPlayable =
convertedVideoURL && (await isPlaybackPossible(convertedVideoURL));
file.w = window.innerWidth;
file.h = window.innerHeight;
file.isSourceLoaded = true;
file.originalImageURL = originalImageURL;
file.originalVideoURL = originalVideoURL;
if (file.metadata.fileType === FILE_TYPE.VIDEO) {
if (isPlayable) {
file.html = `
<video controls onContextMenu="return false;">
<source src="${convertedVideoURL}" />
Your browser does not support the video tag.
</video>
`;
} else {
file.html = `
<div class="pswp-item-container">
<img src="${file.msrc}" onContextMenu="return false;"/>
<div class="download-banner" >
${constants.VIDEO_PLAYBACK_FAILED_DOWNLOAD_INSTEAD}
<a class="btn btn-outline-success" href=${convertedVideoURL} download="${file.metadata.title}"">Download</a>
</div>
</div>
`;
}
} else if (file.metadata.fileType === FILE_TYPE.LIVE_PHOTO) {
if (isPlayable) {
file.html = `
<div class = 'pswp-item-container'>
<img id = "live-photo-image-${file.id}" src="${convertedImageURL}" onContextMenu="return false;"/>
<video id = "live-photo-video-${file.id}" loop muted onContextMenu="return false;">
<source src="${convertedVideoURL}" />
Your browser does not support the video tag.
</video>
</div>
`;
} else {
file.html = `
<div class="pswp-item-container">
<img src="${file.msrc}" onContextMenu="return false;"/>
<div class="download-banner">
${constants.VIDEO_PLAYBACK_FAILED_DOWNLOAD_INSTEAD}
<button class = "btn btn-outline-success" id = "download-btn-${file.id}">Download</button>
</div>
</div>
`;
}
} else if (file.metadata.fileType === FILE_TYPE.IMAGE) {
file.src = convertedImageURL;
} else {
logError(
Error(`unknown file type - ${file.metadata.fileType}`),
'Unknown file type'
);
file.src = originalURL;
}
}

View file

@ -1,5 +1,4 @@
import * as Sentry from '@sentry/nextjs';
import { isDEVSentryENV } from 'constants/sentry';
import { addLogLine } from 'utils/logging';
import { getSentryUserID } from 'utils/user';
@ -17,12 +16,9 @@ export const logError = async (
addLogLine(
`error: ${error?.name} ${error?.message} ${
error?.stack
} msg: ${msg} info: ${JSON.stringify(info)}`
} msg: ${msg} ${info ? `info: ${JSON.stringify(info)}` : ''}`
);
}
if (isDEVSentryENV()) {
console.log(error, { msg, info });
}
Sentry.captureException(err, {
level: Sentry.Severity.Info,
user: { id: await getSentryUserID() },

View file

@ -0,0 +1,6 @@
import { CACHES } from 'constants/cache';
import { CacheStorageService } from 'services/cache/cacheStorageService';
export async function deleteThumbnailCache() {
await CacheStorageService.delete(CACHES.THUMBS);
}

View file

@ -140,7 +140,8 @@ const englishConstants = {
CREATE: 'Create',
DOWNLOAD: 'Download',
DOWNLOAD_OPTION: 'Download (D)',
DOWNLOAD_FAVOURITES: 'Download favourites',
DOWNLOAD_FAVORITES: 'Download favorites',
DOWNLOAD_UNCATEGORIZED: 'Download uncategorized',
COPY_OPTION: 'Copy as PNG (Ctrl/Cmd - C)',
TOGGLE_FULLSCREEN: 'Toggle fullscreen (F)',
ZOOM_IN_OUT: 'Zoom in/out',
@ -368,8 +369,15 @@ const englishConstants = {
DELETE_COLLECTION_TITLE: 'Delete album?',
DELETE_COLLECTION: 'Delete album',
DELETE_COLLECTION_FAILED: 'Album deletion failed, please try again',
DELETE_COLLECTION_MESSAGE:
'Files that are unique to this album will be moved to trash, and this album would be deleted.',
DELETE_COLLECTION_MESSAGE: () => (
<p>
Also delete the photos (and videos) present in this album from
<span style={{ color: '#fff' }}> all </span> other albums they are
part of?
</p>
),
DELETE_PHOTOS: 'Delete photos',
KEEP_PHOTOS: 'Keep photos',
SHARE: 'Share',
SHARE_COLLECTION: 'Share album',
SHARE_WITH_PEOPLE: 'Share with your loved ones',
@ -441,7 +449,7 @@ const englishConstants = {
),
NOT_FILE_OWNER: 'You cannot delete files in a shared album',
ADD_TO_COLLECTION: 'Add to album',
SELECTED: 'Selected',
SELECTED: 'selected',
VIDEO_PLAYBACK_FAILED: 'Video format not supported',
VIDEO_PLAYBACK_FAILED_DOWNLOAD_INSTEAD:
'This video cannot be played on your browser',
@ -587,6 +595,8 @@ const englishConstants = {
THUMBNAIL_GENERATION_FAILED_INFO:
'These files were uploaded, but unfortunately we could not generate the thumbnails for them.',
UPLOAD_TO_COLLECTION: 'Upload to album',
UNCATEGORIZED: 'Uncategorized',
MOVE_TO_UNCATEGORIZED: 'Move to uncategorized',
ARCHIVE: 'Archive',
ARCHIVE_COLLECTION: 'Archive album',
ARCHIVE_SECTION_NAME: 'Archive',
@ -598,7 +608,9 @@ const englishConstants = {
ADD: 'Add',
SORT: 'Sort',
REMOVE: 'Remove',
YES_REMOVE: 'Yes, remove',
CONFIRM_REMOVE: 'Confirm removal',
REMOVE_FROM_COLLECTION: 'Remove from album',
TRASH: 'Trash',
MOVE_TO_TRASH: 'Move to trash',
TRASH_FILES_MESSAGE:
@ -620,11 +632,19 @@ const englishConstants = {
LEAVE_SHARED_ALBUM_FAILED: 'failed to leave the album, please try again',
LEAVE_SHARED_ALBUM_MESSAGE:
'You will leave the album, and it will stop being visible to you.',
CONFIRM_REMOVE_MESSAGE: () => (
CONFIRM_SELF_REMOVE_MESSAGE: () => (
<>
<p>Are you sure you want to remove these files from the album?</p>
<p>
All files that are unique to this album will be moved to trash
Selected items will be removed from this album. Items which are
only in this album will be moved to Uncategorized.
</p>
</>
),
CONFIRM_SELF_AND_OTHER_REMOVE_MESSAGE: () => (
<>
<p>
Some of the items you are removing were added by other people,
and you will lose access to them.
</p>
</>
),
@ -690,7 +710,7 @@ const englishConstants = {
LINK_EXPIRED_MESSAGE: 'This link has either expired or been disabled!',
MANAGE_LINK: 'Manage link',
LINK_TOO_MANY_REQUESTS: 'This album is too popular for us to handle!',
DISABLE_PUBLIC_SHARING: "'Disable public sharing",
DISABLE_PUBLIC_SHARING: 'Disable public sharing',
DISABLE_PUBLIC_SHARING_MESSAGE:
'Are you sure you want to disable public sharing?',
FILE_DOWNLOAD: 'Allow downloads',
@ -894,6 +914,7 @@ const englishConstants = {
),
ADD_X_PHOTOS: (x: number) => `Add ${x} ${x > 1 ? 'photos' : 'photo'}`,
CHOSE_THEME: 'Choose theme',
YOURS: 'yours',
};
export default englishConstants;

45
src/utils/user/family.ts Normal file
View file

@ -0,0 +1,45 @@
import { FamilyData, FamilyMember, User } from 'types/user';
import { logError } from 'utils/sentry';
import { getData, LS_KEYS } from 'utils/storage/localStorage';
export function getLocalFamilyData(): FamilyData {
return getData(LS_KEYS.FAMILY_DATA);
}
// 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 getTotalFamilyUsage(familyData: FamilyData): number {
return familyData.members.reduce(
(sum, currentMember) => sum + currentMember.usage,
0
);
}

View file

@ -2901,10 +2901,10 @@ jsx-ast-utils@^3.3.2:
array-includes "^3.1.5"
object.assign "^4.1.3"
jszip@3.7.1:
version "3.7.1"
resolved "https://registry.npmjs.org/jszip/-/jszip-3.7.1.tgz"
integrity sha512-ghL0tz1XG9ZEmRMcEN2vt7xabrDdqHHeykgARpmZ0BiIctWxM47Vt63ZO2dnp4QYt/xJVLLy5Zv1l/xRdh2byg==
jszip@3.8.0:
version "3.8.0"
resolved "https://registry.yarnpkg.com/jszip/-/jszip-3.8.0.tgz#a2ac3c33fe96a76489765168213655850254d51b"
integrity sha512-cnpQrXvFSLdsR9KR5/x7zdf6c3m8IhZfZzSblFEHSqBaVwD2nvJ4CuCKLyvKvwBgZm08CgfSoiTBQLm5WW9hGw==
dependencies:
lie "~3.3.0"
pako "~1.0.2"