diff --git a/web/apps/photos/src/components/FileListWithViewer.tsx b/web/apps/photos/src/components/FileListWithViewer.tsx index 73064593de..d482074f95 100644 --- a/web/apps/photos/src/components/FileListWithViewer.tsx +++ b/web/apps/photos/src/components/FileListWithViewer.tsx @@ -68,6 +68,7 @@ export type FileListWithViewerProps = { | "collectionNameByID" | "pendingFavoriteUpdates" | "pendingVisibilityUpdates" + | "onFileAndCollectionSyncWithRemote" | "onVisualFeedback" | "onToggleFavorite" | "onFileVisibilityUpdate" @@ -102,6 +103,7 @@ export const FileListWithViewer: React.FC = ({ setFilesDownloadProgressAttributesCreator, onSetOpenFileViewer, onSyncWithRemote, + onFileAndCollectionSyncWithRemote, onVisualFeedback, onToggleFavorite, onFileVisibilityUpdate, @@ -201,6 +203,7 @@ export const FileListWithViewer: React.FC = ({ collectionNameByID, pendingFavoriteUpdates, pendingVisibilityUpdates, + onFileAndCollectionSyncWithRemote, onVisualFeedback, onToggleFavorite, onFileVisibilityUpdate, diff --git a/web/apps/photos/src/components/FixCreationTime.tsx b/web/apps/photos/src/components/FixCreationTime.tsx index 73a7053a2f..986f0a106b 100644 --- a/web/apps/photos/src/components/FixCreationTime.tsx +++ b/web/apps/photos/src/components/FixCreationTime.tsx @@ -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, diff --git a/web/apps/photos/src/pages/gallery.tsx b/web/apps/photos/src/pages/gallery.tsx index 6576f1ff6a..e0e25e98c6 100644 --- a/web/apps/photos/src/pages/gallery.tsx +++ b/web/apps/photos/src/pages/gallery.tsx @@ -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} diff --git a/web/packages/gallery/components/FileInfo.tsx b/web/packages/gallery/components/FileInfo.tsx index aee6191788..1d891e0b81 100644 --- a/web/packages/gallery/components/FileInfo.tsx +++ b/web/packages/gallery/components/FileInfo.tsx @@ -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; /** - * 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; /** * 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 = ({ showCollections, fileCollectionIDs, collectionNameByID, - onNeedsRemoteSync, + onFileMetadataUpdate, onUpdateCaption, onSelectCollection, onSelectPerson, @@ -273,13 +274,19 @@ export const FileInfo: React.FC = ({ {...{ file, allowEdits, - onNeedsRemoteSync, + onFileMetadataUpdate, onUpdateCaption, + onClose, }} /> - + {annotatedExif?.takenOnDevice && ( @@ -559,14 +566,19 @@ const EditButton: React.FC = ({ onClick, loading }) => ( type CaptionProps = Pick< FileInfoProps, - "file" | "allowEdits" | "onNeedsRemoteSync" | "onUpdateCaption" + | "file" + | "allowEdits" + | "onFileMetadataUpdate" + | "onUpdateCaption" + | "onClose" >; const Caption: React.FC = ({ file, allowEdits, - onNeedsRemoteSync, + onFileMetadataUpdate, onUpdateCaption, + onClose, }) => { const [isSaving, setIsSaving] = useState(false); @@ -582,15 +594,16 @@ const Caption: React.FC = ({ 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 = ({ file, allowEdits, - onNeedsRemoteSync, + onFileMetadataUpdate, }) => { const { onGenericError } = useBaseContext(); @@ -689,14 +702,11 @@ const CreationTime: React.FC = ({ // 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 = ({ ); }; -type FileNameProps = Pick & { - file: EnteFile; - annotatedExif: AnnotatedExif | undefined; -}; +type FileNameProps = Pick< + FileInfoProps, + "allowEdits" | "onFileMetadataUpdate" +> & { file: EnteFile; annotatedExif: AnnotatedExif | undefined }; const FileName: React.FC = ({ file, annotatedExif, allowEdits, - onNeedsRemoteSync, + onFileMetadataUpdate, }) => { const { show: showRename, props: renameVisibilityProps } = useModalVisibility(); @@ -743,9 +753,8 @@ const FileName: React.FC = ({ 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 = diff --git a/web/packages/gallery/components/viewer/FileViewer.tsx b/web/packages/gallery/components/viewer/FileViewer.tsx index 12f4d6e590..baa10f24d5 100644 --- a/web/packages/gallery/components/viewer/FileViewer.tsx +++ b/web/packages/gallery/components/viewer/FileViewer.tsx @@ -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; /** * 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 = ({ fileNormalCollectionIDs, collectionNameByID, onTriggerSyncWithRemote, + onFileAndCollectionSyncWithRemote, onVisualFeedback, onToggleFavorite, onFileVisibilityUpdate, @@ -620,9 +632,6 @@ export const FileViewer: React.FC = ({ 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 = ({ 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 = ({ 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 = ({ allowMap={haveUser} showCollections={haveUser && !isInHiddenSection} fileCollectionIDs={fileNormalCollectionIDs} - collectionNameByID={collectionNameByID} - onNeedsRemoteSync={handleNeedsRemoteSync} + onFileMetadataUpdate={handleFileMetadataUpdate} onUpdateCaption={handleUpdateCaption} onSelectCollection={handleSelectCollection} onSelectPerson={handleSelectPerson} + {...{ collectionNameByID, onFileAndCollectionSyncWithRemote }} /> { * 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; } }; diff --git a/web/packages/gallery/components/viewer/photoswipe.ts b/web/packages/gallery/components/viewer/photoswipe.ts index 7aecab6c89..b6aea80d0a 100644 --- a/web/packages/gallery/components/viewer/photoswipe.ts +++ b/web/packages/gallery/components/viewer/photoswipe.ts @@ -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 diff --git a/web/packages/media/file-metadata.ts b/web/packages/media/file-metadata.ts index 542303d7ec..e75e6be8e2 100644 --- a/web/packages/media/file-metadata.ts +++ b/web/packages/media/file-metadata.ts @@ -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. */ diff --git a/web/packages/new/photos/components/FileDateTimePicker.tsx b/web/packages/new/photos/components/FileDateTimePicker.tsx index 4a33a396f6..ab503fdef6 100644 --- a/web/packages/new/photos/components/FileDateTimePicker.tsx +++ b/web/packages/new/photos/components/FileDateTimePicker.tsx @@ -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 = ({ const [open, setOpen] = useState(true); const [value, setValue] = useState(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 = ({ 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={{ diff --git a/web/packages/new/photos/services/file.ts b/web/packages/new/photos/services/file.ts index e9c3b2cbbb..4716054e5c 100644 --- a/web/packages/new/photos/services/file.ts +++ b/web/packages/new/photos/services/file.ts @@ -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 = ( * * 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), + }), + );