[web] File viewer tweaks (#6319)
This commit is contained in:
@@ -68,6 +68,7 @@ export type FileListWithViewerProps = {
|
||||
| "collectionNameByID"
|
||||
| "pendingFavoriteUpdates"
|
||||
| "pendingVisibilityUpdates"
|
||||
| "onFileAndCollectionSyncWithRemote"
|
||||
| "onVisualFeedback"
|
||||
| "onToggleFavorite"
|
||||
| "onFileVisibilityUpdate"
|
||||
@@ -102,6 +103,7 @@ export const FileListWithViewer: React.FC<FileListWithViewerProps> = ({
|
||||
setFilesDownloadProgressAttributesCreator,
|
||||
onSetOpenFileViewer,
|
||||
onSyncWithRemote,
|
||||
onFileAndCollectionSyncWithRemote,
|
||||
onVisualFeedback,
|
||||
onToggleFavorite,
|
||||
onFileVisibilityUpdate,
|
||||
@@ -201,6 +203,7 @@ export const FileListWithViewer: React.FC<FileListWithViewerProps> = ({
|
||||
collectionNameByID,
|
||||
pendingFavoriteUpdates,
|
||||
pendingVisibilityUpdates,
|
||||
onFileAndCollectionSyncWithRemote,
|
||||
onVisualFeedback,
|
||||
onToggleFavorite,
|
||||
onFileVisibilityUpdate,
|
||||
|
||||
@@ -21,11 +21,11 @@ import {
|
||||
decryptPublicMagicMetadata,
|
||||
fileCreationPhotoDate,
|
||||
fileFileName,
|
||||
updateRemotePublicMagicMetadata,
|
||||
type ParsedMetadataDate,
|
||||
} from "ente-media/file-metadata";
|
||||
import { FileType } from "ente-media/file-type";
|
||||
import { FileDateTimePicker } from "ente-new/photos/components/FileDateTimePicker";
|
||||
import { updateFilePublicMagicMetadata } from "ente-new/photos/services/file";
|
||||
import { useFormik } from "formik";
|
||||
import { t } from "i18next";
|
||||
import { GalleryContext } from "pages/gallery";
|
||||
@@ -279,7 +279,7 @@ const updateFiles = async (
|
||||
let hadErrors = false;
|
||||
for (const [i, file] of files.entries()) {
|
||||
try {
|
||||
await updateEnteFileDate(file, fixOption, customDate);
|
||||
await updateFileDate(file, fixOption, customDate);
|
||||
} catch (e) {
|
||||
log.error(`Failed to update date of ${fileLogID(file)}`, e);
|
||||
hadErrors = true;
|
||||
@@ -291,7 +291,7 @@ const updateFiles = async (
|
||||
};
|
||||
|
||||
/**
|
||||
* Update the date associated with a given {@link enteFile}.
|
||||
* Update the date associated with a given {@link EnteFile}.
|
||||
*
|
||||
* This is generally treated as the creation date of the underlying asset
|
||||
* (photo, video, live photo) that this file stores.
|
||||
@@ -305,11 +305,11 @@ const updateFiles = async (
|
||||
* If an Exif-involving {@link fixOption} is passed for an non-image file, then
|
||||
* that file is just skipped over. Similarly, if an Exif-involving
|
||||
* {@link fixOption} is provided, but the given underlying image for the given
|
||||
* {@link enteFile} does not have a corresponding Exif (or related) value, then
|
||||
* that file is skipped.
|
||||
* {@link file} does not have a corresponding Exif (or related) value, then that
|
||||
* file is skipped.
|
||||
*/
|
||||
const updateEnteFileDate = async (
|
||||
enteFile: EnteFile,
|
||||
const updateFileDate = async (
|
||||
file: EnteFile,
|
||||
fixOption: FixOption,
|
||||
customDate: ParsedMetadataDate | undefined,
|
||||
) => {
|
||||
@@ -323,11 +323,10 @@ const updateEnteFileDate = async (
|
||||
offset: undefined,
|
||||
timestamp: customDate!.timestamp,
|
||||
};
|
||||
} else if (enteFile.metadata.fileType == FileType.image) {
|
||||
const blob = await downloadManager.fileBlob(enteFile);
|
||||
const file = new File([blob], fileFileName(enteFile));
|
||||
} else if (file.metadata.fileType == FileType.image) {
|
||||
const blob = await downloadManager.fileBlob(file);
|
||||
const { DateTimeOriginal, DateTimeDigitized, MetadataDate, DateTime } =
|
||||
await extractExifDates(file);
|
||||
await extractExifDates(new File([blob], fileFileName(file)));
|
||||
|
||||
switch (fixOption) {
|
||||
case "date-time-original":
|
||||
@@ -345,12 +344,12 @@ const updateEnteFileDate = async (
|
||||
if (!newDate) return;
|
||||
|
||||
const existingDate = fileCreationPhotoDate(
|
||||
enteFile,
|
||||
await decryptPublicMagicMetadata(enteFile),
|
||||
file,
|
||||
await decryptPublicMagicMetadata(file),
|
||||
);
|
||||
if (newDate.timestamp == existingDate.getTime()) return;
|
||||
|
||||
await updateRemotePublicMagicMetadata(enteFile, {
|
||||
await updateFilePublicMagicMetadata(file, {
|
||||
dateTime: newDate.dateTime,
|
||||
offsetTime: newDate.offset,
|
||||
editedTime: newDate.timestamp,
|
||||
|
||||
@@ -520,6 +520,23 @@ const Page: React.FC = () => {
|
||||
setTimeout(hideLoadingBar, 0);
|
||||
}, [showLoadingBar, hideLoadingBar]);
|
||||
|
||||
/**
|
||||
* Sync the local files and collection with remote.
|
||||
*
|
||||
* [Note: Full sync vs file and collection sync]
|
||||
*
|
||||
* This is a subset of the sync which happens in {@link syncWithRemote}, but
|
||||
* in some cases where we know that the changes will not have transitive
|
||||
* effects outside of the locally stored files and collections this is a
|
||||
* better option for interactive operations because:
|
||||
*
|
||||
* 1. This involves a lesser number of API requests, so it reduces the time
|
||||
* the user has to wait for their interactive request to complete.
|
||||
*
|
||||
* 2. The current implementation {@link syncWithRemote} tries to run only
|
||||
* only one instance of it is in progress at a time, while each
|
||||
* invocation of {@link fileAndCollectionSyncWithRemote} is independent.
|
||||
*/
|
||||
const fileAndCollectionSyncWithRemote = useCallback(async () => {
|
||||
const didUpdateFiles = await syncCollectionAndFiles({
|
||||
onSetCollections: (
|
||||
@@ -1132,6 +1149,9 @@ const Page: React.FC = () => {
|
||||
onMarkTempDeleted={handleMarkTempDeleted}
|
||||
onSetOpenFileViewer={setIsFileViewerOpen}
|
||||
onSyncWithRemote={syncWithRemote}
|
||||
onFileAndCollectionSyncWithRemote={
|
||||
fileAndCollectionSyncWithRemote
|
||||
}
|
||||
onVisualFeedback={handleVisualFeedback}
|
||||
onSelectCollection={handleSelectCollection}
|
||||
onSelectPerson={handleSelectPerson}
|
||||
|
||||
@@ -55,11 +55,6 @@ import log from "ente-base/log";
|
||||
import type { Location } from "ente-base/types";
|
||||
import { CopyButton } from "ente-gallery/components/FileInfoComponents";
|
||||
import { tagNumericValue, type RawExifTags } from "ente-gallery/services/exif";
|
||||
import {
|
||||
changeCaption,
|
||||
changeFileName,
|
||||
updateExistingFilePubMetadata,
|
||||
} from "ente-gallery/services/file";
|
||||
import { formattedByteSize } from "ente-gallery/utils/units";
|
||||
import { type EnteFile } from "ente-media/file";
|
||||
import {
|
||||
@@ -68,7 +63,6 @@ import {
|
||||
fileFileName,
|
||||
fileLocation,
|
||||
filePublicMagicMetadata,
|
||||
updateRemotePublicMagicMetadata,
|
||||
type ParsedMetadata,
|
||||
type ParsedMetadataDate,
|
||||
} from "ente-media/file-metadata";
|
||||
@@ -80,6 +74,11 @@ import {
|
||||
confirmEnableMapsDialogAttributes,
|
||||
} from "ente-new/photos/components/utils/dialog";
|
||||
import { useSettingsSnapshot } from "ente-new/photos/components/utils/use-snapshot";
|
||||
import {
|
||||
updateFileCaption,
|
||||
updateFileFileName,
|
||||
updateFilePublicMagicMetadata,
|
||||
} from "ente-new/photos/services/file";
|
||||
import {
|
||||
getAnnotatedFacesForFile,
|
||||
isMLEnabled,
|
||||
@@ -152,26 +151,28 @@ export type FileInfoProps = ModalVisibilityProps & {
|
||||
*/
|
||||
collectionNameByID?: Map<number, string>;
|
||||
/**
|
||||
* Called when the action on the file info drawer has changed some the
|
||||
* metadata for some file, and we need to sync with remote to get our
|
||||
* locally persisted file objects up to date.
|
||||
* Called when the action on the file info drawer has changed some metadata
|
||||
* for a file.
|
||||
*
|
||||
* The sync is not performed immediately by the file info drawer to give
|
||||
* faster feedback to the user, and to allow changes to multiple files to be
|
||||
* batched together into a single sync when the file viewer is closed.
|
||||
* It should return a promise that settles when the changes have been
|
||||
* reflected locally. Until the promise settles the UI element that
|
||||
* triggered the change will show an activity indicator to the user.
|
||||
*/
|
||||
onNeedsRemoteSync: () => void;
|
||||
onFileMetadataUpdate?: () => Promise<void>;
|
||||
/**
|
||||
* Called when an action on the file info drawer change the caption of the
|
||||
* given {@link EnteFile}.
|
||||
*
|
||||
* This hook allows the file viewer to update the caption it is displaying
|
||||
* for the given file.
|
||||
* for the given file. It is called in addition to, and after the settlement
|
||||
* of, {@link onFileMetadataUpdate} since the caption update requires a
|
||||
* special case refresh of the PhotoSwipe dialog.
|
||||
*
|
||||
* @param updatedFile The updated file object, containing the updated
|
||||
* caption.
|
||||
* @param fileID The ID of the file whose caption was updated.
|
||||
*
|
||||
* @param newCaption The updated value of the file's caption.
|
||||
*/
|
||||
onUpdateCaption: (updatedFile: EnteFile) => void;
|
||||
onUpdateCaption: (fileID: number, newCaption: string) => void;
|
||||
/**
|
||||
* Called when the user selects a collection from among the collections that
|
||||
* the file belongs to.
|
||||
@@ -193,7 +194,7 @@ export const FileInfo: React.FC<FileInfoProps> = ({
|
||||
showCollections,
|
||||
fileCollectionIDs,
|
||||
collectionNameByID,
|
||||
onNeedsRemoteSync,
|
||||
onFileMetadataUpdate,
|
||||
onUpdateCaption,
|
||||
onSelectCollection,
|
||||
onSelectPerson,
|
||||
@@ -273,13 +274,19 @@ export const FileInfo: React.FC<FileInfoProps> = ({
|
||||
{...{
|
||||
file,
|
||||
allowEdits,
|
||||
onNeedsRemoteSync,
|
||||
onFileMetadataUpdate,
|
||||
onUpdateCaption,
|
||||
onClose,
|
||||
}}
|
||||
/>
|
||||
<CreationTime {...{ file, allowEdits, onNeedsRemoteSync }} />
|
||||
<CreationTime {...{ file, allowEdits, onFileMetadataUpdate }} />
|
||||
<FileName
|
||||
{...{ file, annotatedExif, allowEdits, onNeedsRemoteSync }}
|
||||
{...{
|
||||
file,
|
||||
annotatedExif,
|
||||
allowEdits,
|
||||
onFileMetadataUpdate,
|
||||
}}
|
||||
/>
|
||||
|
||||
{annotatedExif?.takenOnDevice && (
|
||||
@@ -559,14 +566,19 @@ const EditButton: React.FC<EditButtonProps> = ({ onClick, loading }) => (
|
||||
|
||||
type CaptionProps = Pick<
|
||||
FileInfoProps,
|
||||
"file" | "allowEdits" | "onNeedsRemoteSync" | "onUpdateCaption"
|
||||
| "file"
|
||||
| "allowEdits"
|
||||
| "onFileMetadataUpdate"
|
||||
| "onUpdateCaption"
|
||||
| "onClose"
|
||||
>;
|
||||
|
||||
const Caption: React.FC<CaptionProps> = ({
|
||||
file,
|
||||
allowEdits,
|
||||
onNeedsRemoteSync,
|
||||
onFileMetadataUpdate,
|
||||
onUpdateCaption,
|
||||
onClose,
|
||||
}) => {
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
@@ -582,15 +594,16 @@ const Caption: React.FC<CaptionProps> = ({
|
||||
if (newCaption == caption) return;
|
||||
setIsSaving(true);
|
||||
try {
|
||||
const updatedFile = await changeCaption(file, newCaption);
|
||||
updateExistingFilePubMetadata(file, updatedFile);
|
||||
onUpdateCaption(file);
|
||||
await updateFileCaption(file, newCaption);
|
||||
await onFileMetadataUpdate?.();
|
||||
onUpdateCaption(file.id, newCaption);
|
||||
setIsSaving(false);
|
||||
onClose();
|
||||
} catch (e) {
|
||||
log.error("Failed to update caption", e);
|
||||
setIsSaving(false);
|
||||
setFieldError("caption", t("generic_error"));
|
||||
}
|
||||
onNeedsRemoteSync();
|
||||
setIsSaving(false);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -649,13 +662,13 @@ const CaptionForm = styled("form")(({ theme }) => ({
|
||||
|
||||
type CreationTimeProps = Pick<
|
||||
FileInfoProps,
|
||||
"allowEdits" | "onNeedsRemoteSync"
|
||||
"allowEdits" | "onFileMetadataUpdate"
|
||||
> & { file: EnteFile };
|
||||
|
||||
const CreationTime: React.FC<CreationTimeProps> = ({
|
||||
file,
|
||||
allowEdits,
|
||||
onNeedsRemoteSync,
|
||||
onFileMetadataUpdate,
|
||||
}) => {
|
||||
const { onGenericError } = useBaseContext();
|
||||
|
||||
@@ -689,14 +702,11 @@ const CreationTime: React.FC<CreationTimeProps> = ({
|
||||
// we can provide functionality for the user to edit the associated
|
||||
// offset, but right now it is not even surfaced, so don't also
|
||||
// potentially overwrite it.
|
||||
await updateRemotePublicMagicMetadata(file, {
|
||||
dateTime,
|
||||
editedTime,
|
||||
});
|
||||
await updateFilePublicMagicMetadata(file, { dateTime, editedTime });
|
||||
await onFileMetadataUpdate?.();
|
||||
} catch (e) {
|
||||
onGenericError(e);
|
||||
}
|
||||
onNeedsRemoteSync();
|
||||
setIsSaving(false);
|
||||
};
|
||||
|
||||
@@ -726,16 +736,16 @@ const CreationTime: React.FC<CreationTimeProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
type FileNameProps = Pick<FileInfoProps, "allowEdits" | "onNeedsRemoteSync"> & {
|
||||
file: EnteFile;
|
||||
annotatedExif: AnnotatedExif | undefined;
|
||||
};
|
||||
type FileNameProps = Pick<
|
||||
FileInfoProps,
|
||||
"allowEdits" | "onFileMetadataUpdate"
|
||||
> & { file: EnteFile; annotatedExif: AnnotatedExif | undefined };
|
||||
|
||||
const FileName: React.FC<FileNameProps> = ({
|
||||
file,
|
||||
annotatedExif,
|
||||
allowEdits,
|
||||
onNeedsRemoteSync,
|
||||
onFileMetadataUpdate,
|
||||
}) => {
|
||||
const { show: showRename, props: renameVisibilityProps } =
|
||||
useModalVisibility();
|
||||
@@ -743,9 +753,8 @@ const FileName: React.FC<FileNameProps> = ({
|
||||
const fileName = fileFileName(file);
|
||||
|
||||
const handleRename = async (newFileName: string) => {
|
||||
const updatedFile = await changeFileName(file, newFileName);
|
||||
updateExistingFilePubMetadata(file, updatedFile);
|
||||
onNeedsRemoteSync();
|
||||
await updateFileFileName(file, newFileName);
|
||||
await onFileMetadataUpdate?.();
|
||||
};
|
||||
|
||||
const icon =
|
||||
|
||||
@@ -216,6 +216,17 @@ export type FileViewerProps = ModalVisibilityProps & {
|
||||
* not defined, then this prop is not used.
|
||||
*/
|
||||
onTriggerSyncWithRemote?: () => void;
|
||||
/**
|
||||
* Called when an action in the file viewer requires us to sync the local
|
||||
* files and collections with remote.
|
||||
*
|
||||
* Unlike {@link onTriggerSyncWithRemote}, which is a trigger, this function
|
||||
* returns a promise that will settle once the sync has completed, and thus
|
||||
* can be used in interactive operations that indicate activity to the user.
|
||||
*
|
||||
* See: [Note: Full sync vs file and collection sync]
|
||||
*/
|
||||
onFileAndCollectionSyncWithRemote?: () => Promise<void>;
|
||||
/**
|
||||
* Called when the user performs an action which does not otherwise have any
|
||||
* immediate visual impact, to acknowledge it.
|
||||
@@ -290,6 +301,7 @@ export const FileViewer: React.FC<FileViewerProps> = ({
|
||||
fileNormalCollectionIDs,
|
||||
collectionNameByID,
|
||||
onTriggerSyncWithRemote,
|
||||
onFileAndCollectionSyncWithRemote,
|
||||
onVisualFeedback,
|
||||
onToggleFavorite,
|
||||
onFileVisibilityUpdate,
|
||||
@@ -620,9 +632,6 @@ export const FileViewer: React.FC<FileViewerProps> = ({
|
||||
|
||||
const handleShortcutsClose = useCallback(() => setOpenShortcuts(false), []);
|
||||
|
||||
// TODO: Unused translation t("convert") - can be removed post the upcoming
|
||||
// streaming changes as they'll provides the equiv.
|
||||
|
||||
const shouldIgnoreKeyboardEvent = useCallback(() => {
|
||||
// Don't handle keydowns if any of the modals are open.
|
||||
return (
|
||||
@@ -769,56 +778,16 @@ export const FileViewer: React.FC<FileViewerProps> = ({
|
||||
performKeyAction,
|
||||
]);
|
||||
|
||||
// Update the active annotated file, if needed, on updates to files or
|
||||
// favoriteFileIDs.
|
||||
// Handle updates to files.
|
||||
//
|
||||
// If the active annotated file is no longer being shown, move to the next
|
||||
// slide if possible, or close the viewer otherwise.
|
||||
// See: [Note: Updates to the files prop for FileViewer]
|
||||
useEffect(() => {
|
||||
setActiveAnnotatedFile((af) => {
|
||||
// Do nothing if we're not showing a file (we might not be open).
|
||||
if (!af) return af;
|
||||
|
||||
if (files.length) {
|
||||
const updatedFile = files.find(({ id }) => id == af?.file.id);
|
||||
if (updatedFile) {
|
||||
// Modify the active annotated file if we found a file with
|
||||
// the same ID in the (possibly) updated files array.
|
||||
//
|
||||
// This is not correct in its full generality, but it works
|
||||
// fine in the specific cases we would need to handle (and
|
||||
// we want to avoid refreshing the entire UI unnecessarily
|
||||
// lest the user lose their zoom/pan etc):
|
||||
//
|
||||
// - In case of delete or toggling archived that caused the
|
||||
// file is no longer part of the list that is shown, we'll
|
||||
// not get to this code branch.
|
||||
//
|
||||
// - In case of toggling archive otherwise, just updating
|
||||
// the file attribute is enough, the UI state is derived
|
||||
// from it; none of the other attributes of the annotated
|
||||
// file currently depend on the archive status change.
|
||||
af = { ...af, file: updatedFile };
|
||||
} else {
|
||||
// The file we were displaying is no longer part of the list
|
||||
// of files that should be displayed. Refresh the slides,
|
||||
// adjusting the indexes as necessary.
|
||||
//
|
||||
// A special case is when we might've been the last slide,
|
||||
// in which case we need to go back one slide first. To
|
||||
// determine this, also pass the expected count of files to
|
||||
// our PhotoSwipe wrapper.
|
||||
psRef.current?.refreshCurrentSlideContentAfterRemove(
|
||||
files.length,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// If there are no more files left, close the viewer.
|
||||
handleClose();
|
||||
}
|
||||
|
||||
return af;
|
||||
});
|
||||
if (!files.length) {
|
||||
// If there are no more files left, close the viewer.
|
||||
handleClose();
|
||||
} else {
|
||||
psRef.current?.refreshSlideOnFilesUpdateIfNeeded();
|
||||
}
|
||||
}, [handleClose, files]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -881,10 +850,24 @@ export const FileViewer: React.FC<FileViewerProps> = ({
|
||||
handleMore,
|
||||
]);
|
||||
|
||||
const handleUpdateCaption = useCallback((updatedFile: EnteFile) => {
|
||||
updateItemDataAlt(updatedFile);
|
||||
psRef.current!.refreshCurrentSlideContent();
|
||||
}, []);
|
||||
const handleFileMetadataUpdate = useMemo(() => {
|
||||
return onFileAndCollectionSyncWithRemote
|
||||
? async () => {
|
||||
// Wait for the file and collection sync to complete.
|
||||
await onFileAndCollectionSyncWithRemote();
|
||||
// Set the flag to trigger the full sync to later.
|
||||
handleNeedsRemoteSync();
|
||||
}
|
||||
: undefined;
|
||||
}, [onFileAndCollectionSyncWithRemote, handleNeedsRemoteSync]);
|
||||
|
||||
const handleUpdateCaption = useCallback(
|
||||
(fileID: number, newCaption: string) => {
|
||||
updateItemDataAlt(fileID, newCaption);
|
||||
psRef.current!.refreshCurrentSlideContent();
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(updateFullscreenStatus, [updateFullscreenStatus]);
|
||||
|
||||
@@ -903,11 +886,11 @@ export const FileViewer: React.FC<FileViewerProps> = ({
|
||||
allowMap={haveUser}
|
||||
showCollections={haveUser && !isInHiddenSection}
|
||||
fileCollectionIDs={fileNormalCollectionIDs}
|
||||
collectionNameByID={collectionNameByID}
|
||||
onNeedsRemoteSync={handleNeedsRemoteSync}
|
||||
onFileMetadataUpdate={handleFileMetadataUpdate}
|
||||
onUpdateCaption={handleUpdateCaption}
|
||||
onSelectCollection={handleSelectCollection}
|
||||
onSelectPerson={handleSelectPerson}
|
||||
{...{ collectionNameByID, onFileAndCollectionSyncWithRemote }}
|
||||
/>
|
||||
<MoreMenu
|
||||
open={!!moreMenuAnchorEl}
|
||||
|
||||
@@ -404,12 +404,15 @@ export const forgetItemDataForFileIDIfNeeded = (fileID: number) => {
|
||||
* Update the alt attribute of the {@link ItemData}, if any, associated with the
|
||||
* given {@link EnteFile}.
|
||||
*
|
||||
* @param updatedFile The file whose caption was updated.
|
||||
* @param fileID The ID of the file whose {@link alt} attribute we want to
|
||||
* update.
|
||||
*
|
||||
* @param newAlt The new value of the {@link alt} attribute.
|
||||
*/
|
||||
export const updateItemDataAlt = (updatedFile: EnteFile) => {
|
||||
const itemData = _state.itemDataByFileID.get(updatedFile.id);
|
||||
export const updateItemDataAlt = (fileID: number, newAlt: string) => {
|
||||
const itemData = _state.itemDataByFileID.get(fileID);
|
||||
if (itemData) {
|
||||
itemData.alt = fileCaption(updatedFile);
|
||||
itemData.alt = newAlt;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -286,7 +286,11 @@ export class FileViewerPhotoSwipe {
|
||||
const currentAnnotatedFile = () => {
|
||||
const file = currentFile();
|
||||
let annotatedFile = _currentAnnotatedFile;
|
||||
if (!annotatedFile || annotatedFile.file.id != file.id) {
|
||||
if (
|
||||
!annotatedFile ||
|
||||
annotatedFile.file.id != file.id ||
|
||||
annotatedFile.file.updationTime != file.updationTime
|
||||
) {
|
||||
annotatedFile = onAnnotate(file, currSlideData());
|
||||
_currentAnnotatedFile = annotatedFile;
|
||||
}
|
||||
@@ -295,6 +299,92 @@ export class FileViewerPhotoSwipe {
|
||||
|
||||
const currentFileAnnotation = () => currentAnnotatedFile().annotation;
|
||||
|
||||
/**
|
||||
* [Note: Updates to the files prop for FileViewer]
|
||||
*
|
||||
* This function is called when the list of underlying files changes.
|
||||
*
|
||||
* These updates can be possibly spurious, since the list changes on
|
||||
* identity and not deep equality. Or they might be real but affect a
|
||||
* file that is not currently being shown.
|
||||
*
|
||||
* Apart from those cases, there are real updates that effect us:
|
||||
*
|
||||
* - If the file that was previously being shown is no longer being
|
||||
* shown (e.g. it was deleted, or if the marked it archived in a
|
||||
* context like "All" where archived files are not being shown), move
|
||||
* to the next slide if possible, or close the viewer otherwise (e.g.
|
||||
* user deleted the last one).
|
||||
*
|
||||
* - If the file that was previously being shown has moved to a new
|
||||
* index (e.g. user changed the date in the file info panel), go to
|
||||
* that new index.
|
||||
*
|
||||
* - If the file is still the same but the underlying file's updation
|
||||
* time has changed (e.g. the user changed the name of the file in the
|
||||
* file info panel), refresh the annotated file data cached by the
|
||||
* viewer.
|
||||
*
|
||||
* When handling these cases, the intent is to minimize the need to call
|
||||
* `refreshSlideContent` as much as possible since that would cause the
|
||||
* zoom and pan state to be reset too.
|
||||
*/
|
||||
this.refreshSlideOnFilesUpdateIfNeeded = () => {
|
||||
const prevFileID = _currentAnnotatedFile?.file.id;
|
||||
if (!prevFileID) return;
|
||||
|
||||
const files = delegate.getFiles();
|
||||
const newFileCount = files.length;
|
||||
|
||||
const newFile = files[pswp.currIndex];
|
||||
if (!newFile || newFile.id != prevFileID) {
|
||||
// File is either no longer there, or has moved.
|
||||
//
|
||||
// In either case, after repositioning ourselves to the new
|
||||
// index we also need to refresh the next (or both) neighbours.
|
||||
//
|
||||
// E.g. assume item at index 3 was removed. After refreshing,
|
||||
// the contents of the item previously at index 4, and now at
|
||||
// index 3, would be displayed. But the preloaded slide next to
|
||||
// us (showing item at index 4) would already be displaying the
|
||||
// same item, so that also needs to be refreshed to displaying
|
||||
// the item previously at index 5 (now at index 4).
|
||||
|
||||
const newIndex = files.findIndex(({ id }) => id == prevFileID);
|
||||
|
||||
if (newIndex == -1) {
|
||||
// File is no longer present.
|
||||
const i = pswp.currIndex;
|
||||
|
||||
if (i >= newFileCount) {
|
||||
// If the last slide was removed, take one step back
|
||||
// first (the code that calls us ensures that we don't
|
||||
// get called if there are no more slides left).
|
||||
this.pswp.prev();
|
||||
}
|
||||
|
||||
// Refresh the slide at current index (the erstwhile
|
||||
// neighbour), and refresh its subsequent neighbour (to get
|
||||
// the new neighbour).
|
||||
pswp.refreshSlideContent(i);
|
||||
pswp.refreshSlideContent(i + 1 == newFileCount ? 0 : i + 1);
|
||||
} else {
|
||||
// File has moved. Go to new index, and also refresh both
|
||||
// its neighbours.
|
||||
const i = newIndex;
|
||||
|
||||
pswp.goTo(i);
|
||||
pswp.refreshSlideContent(i == 0 ? newFileCount - 1 : i - 1);
|
||||
pswp.refreshSlideContent(i + 1 == newFileCount ? 0 : i + 1);
|
||||
}
|
||||
} else {
|
||||
// Calling `currentAnnotatedFile` has the side effect of
|
||||
// refreshing the cached annotated file if the `updationTime`
|
||||
// has changed.
|
||||
currentAnnotatedFile();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* File (ID)s for which we should render the original, non-streamable,
|
||||
* video even if a HLS playlist is available.
|
||||
@@ -1538,38 +1628,10 @@ export class FileViewerPhotoSwipe {
|
||||
}
|
||||
|
||||
/**
|
||||
* Reload the PhotoSwipe dialog (without recreating it) if the current slide
|
||||
* that was being viewed is no longer part of the list of files that should
|
||||
* be shown. This can happen when the user deleted the file, or if they
|
||||
* marked it archived in a context (like "All") where archived files are not
|
||||
* being shown.
|
||||
*
|
||||
* @param expectedFileCount The count of files that we expect to show after
|
||||
* the refresh.
|
||||
* Ask the delegate for the files information afresh, and perform the
|
||||
* sequence described in [Note: Updates to the files prop for FileViewer].
|
||||
*/
|
||||
refreshCurrentSlideContentAfterRemove(newFileCount: number) {
|
||||
// Refresh the slide, and its subsequent neighbour.
|
||||
//
|
||||
// To see why, consider item at index 3 was removed. After refreshing,
|
||||
// the contents of the item previously at index 4, and now at index 3,
|
||||
// would be displayed. But the preloaded slide next to us (showing item
|
||||
// at index 4) would already be displaying the same item, so that also
|
||||
// needs to be refreshed to displaying the item previously at index 5
|
||||
// (now at index 4).
|
||||
const refreshSlideAndNextNeighbour = (i: number) => {
|
||||
this.pswp.refreshSlideContent(i);
|
||||
this.pswp.refreshSlideContent(i + 1 == newFileCount ? 0 : i + 1);
|
||||
};
|
||||
|
||||
if (this.pswp.currIndex >= newFileCount) {
|
||||
// If the last slide was removed, take one step back first (the code
|
||||
// that calls us ensures that we don't get called if there are no
|
||||
// more slides left).
|
||||
this.pswp.prev();
|
||||
}
|
||||
|
||||
refreshSlideAndNextNeighbour(this.pswp.currIndex);
|
||||
}
|
||||
refreshSlideOnFilesUpdateIfNeeded: () => void;
|
||||
|
||||
/**
|
||||
* Refresh the favorite button (if indeed it is visible at all) on the
|
||||
|
||||
@@ -320,7 +320,7 @@ export interface PublicMagicMetadata {
|
||||
*/
|
||||
editedTime?: number;
|
||||
/**
|
||||
* Modified name of the {@link EnteFile}.
|
||||
* Modified file name of the {@link EnteFile}.
|
||||
*
|
||||
* This field stores edits to the {@link title} {@link FileMetadata} field.
|
||||
*/
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
} from "@mui/x-date-pickers";
|
||||
import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
|
||||
import dayjs, { Dayjs } from "dayjs";
|
||||
import { useIsSmallWidth } from "ente-base/components/utils/hooks";
|
||||
import type { ParsedMetadataDate } from "ente-media/file-metadata";
|
||||
import React, { useState } from "react";
|
||||
|
||||
@@ -50,6 +51,8 @@ export const FileDateTimePicker: React.FC<FileDateTimePickerProps> = ({
|
||||
const [open, setOpen] = useState(true);
|
||||
const [value, setValue] = useState<Dayjs | null>(dayjs(initialValue));
|
||||
|
||||
const isSmallWidth = useIsSmallWidth();
|
||||
|
||||
const handleAccept = (d: Dayjs | null) => {
|
||||
if (!dayjs.isDayjs(d))
|
||||
throw new Error(`Unexpected non-dayjs result ${typeof d}`);
|
||||
@@ -72,11 +75,8 @@ export const FileDateTimePicker: React.FC<FileDateTimePickerProps> = ({
|
||||
disableFuture={true}
|
||||
/* The dialog grows too big on the default portrait mode with
|
||||
our theme customizations. So we instead use the landscape
|
||||
layout. This works great on desktop since we have sufficient
|
||||
width. MUI omits the sidebar on mobile devices (using the
|
||||
pointer:fine media query), so it remains functional on mobile
|
||||
devices too. */
|
||||
orientation="landscape"
|
||||
layout if the screen is large enough. */
|
||||
orientation={isSmallWidth ? "portrait" : "landscape"}
|
||||
onAccept={handleAccept}
|
||||
slots={{ field: EmptyField }}
|
||||
slotProps={{
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { EnteFile, EnteFile2 } from "ente-media/file";
|
||||
import type {
|
||||
FilePrivateMagicMetadataData,
|
||||
ItemVisibility,
|
||||
PublicMagicMetadata,
|
||||
} from "ente-media/file-metadata";
|
||||
import {
|
||||
createMagicMetadata,
|
||||
@@ -51,6 +52,10 @@ export const performInBatches = <T, U>(
|
||||
*
|
||||
* Remote only, does not modify local state.
|
||||
*
|
||||
* The visibility of an {@link EnteFile} is stored in its private magic
|
||||
* metadata, so this function in effect updates the private magic metadata of
|
||||
* the given files on remote.
|
||||
*
|
||||
* @param files The list of files whose visibility we want to change. All the
|
||||
* files will get their visibility updated to the new, provided, value.
|
||||
*
|
||||
@@ -146,3 +151,96 @@ const putFilesMagicMetadata = async (
|
||||
body: JSON.stringify(updateRequest),
|
||||
}),
|
||||
);
|
||||
|
||||
/**
|
||||
* Update the file name of the provided file on remote.
|
||||
*
|
||||
* Remote only, does not modify local state.
|
||||
*
|
||||
* The file name of an {@link EnteFile} is stored in its public magic metadata,
|
||||
* so this function in effect updates the public magic metadata of the given
|
||||
* file on remote.
|
||||
*
|
||||
* @param file The file whose file name we want to change.
|
||||
*
|
||||
* @param newFileName The new file name of the file.
|
||||
*/
|
||||
export const updateFileFileName = (file: EnteFile2, newFileName: string) =>
|
||||
updateFilePublicMagicMetadata(file, { editedName: newFileName });
|
||||
|
||||
/**
|
||||
* Update the caption associated with the provided file on remote.
|
||||
*
|
||||
* Remote only, does not modify local state.
|
||||
*
|
||||
* The caption of an {@link EnteFile} is stored in its public magic metadata, so
|
||||
* this function in effect updates the public magic metadata of the given file
|
||||
* on remote.
|
||||
*
|
||||
* @param file The file whose file name we want to change.
|
||||
*
|
||||
* @param caption The caption associated with the file.
|
||||
*
|
||||
* Fields in magic metadata cannot be removed after being added, so to reset the
|
||||
* caption to the default (no value) state pass a blank string.
|
||||
*/
|
||||
export const updateFileCaption = (file: EnteFile2, caption: string) =>
|
||||
updateFilePublicMagicMetadata(file, { caption });
|
||||
|
||||
/**
|
||||
* Update the public magic metadata of a file on remote.
|
||||
*
|
||||
* Remote only, does not modify local state.
|
||||
*
|
||||
* @param file The list of files whose magic metadata we want to update.
|
||||
*
|
||||
* @param updates A non-empty but otherwise arbitrary subset of
|
||||
* {@link FilePrivateMagicMetadataData} entries.
|
||||
*
|
||||
* See: [Note: Magic metadata data cannot have nullish values]
|
||||
*/
|
||||
export const updateFilePublicMagicMetadata = async (
|
||||
file: EnteFile2,
|
||||
updates: PublicMagicMetadata,
|
||||
) => updateFilesPublicMagicMetadata([file], updates);
|
||||
|
||||
/**
|
||||
* Update the public magic metadata of a list of files on remote.
|
||||
*
|
||||
* Remote only, does not modify local state.
|
||||
*
|
||||
* This is a variant of {@link updateFilePrivateMagicMetadata} that works with
|
||||
* the {@link pubMagicMetadata} of the given files.
|
||||
*/
|
||||
const updateFilesPublicMagicMetadata = async (
|
||||
files: EnteFile2[],
|
||||
updates: PublicMagicMetadata,
|
||||
) =>
|
||||
putFilesPublicMagicMetadata({
|
||||
metadataList: await Promise.all(
|
||||
files.map(async ({ id, key, pubMagicMetadata }) => ({
|
||||
id,
|
||||
magicMetadata: await encryptMagicMetadata(
|
||||
createMagicMetadata(
|
||||
{ ...pubMagicMetadata?.data, ...updates },
|
||||
pubMagicMetadata?.version,
|
||||
),
|
||||
key,
|
||||
),
|
||||
})),
|
||||
),
|
||||
});
|
||||
|
||||
/**
|
||||
* Update the public magic metadata of a list of files on remote.
|
||||
*/
|
||||
const putFilesPublicMagicMetadata = async (
|
||||
updateRequest: UpdateMultipleMagicMetadataRequest,
|
||||
) =>
|
||||
ensureOk(
|
||||
await fetch(await apiURL("/files/public-magic-metadata"), {
|
||||
method: "PUT",
|
||||
headers: await authenticatedRequestHeaders(),
|
||||
body: JSON.stringify(updateRequest),
|
||||
}),
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user