/* eslint-disable @typescript-eslint/ban-ts-comment */ /* TODO: Audit this file Plan of action: - Move common components into FileInfoComponents.tsx - Move the rest out to files in the apps themeselves: albums/SharedFileInfo and photos/FileInfo to deal with the @/new/photos imports here. */ import { assertionFailed } from "@/base/assert"; import { LinkButtonUndecorated } from "@/base/components/LinkButton"; import { type ButtonishProps } from "@/base/components/mui"; import { ActivityIndicator } from "@/base/components/mui/ActivityIndicator"; import { SidebarDrawer } from "@/base/components/mui/SidebarDrawer"; import { SingleInputForm } from "@/base/components/SingleInputForm"; import { Titlebar } from "@/base/components/Titlebar"; import { EllipsizedTypography } from "@/base/components/Typography"; import { useModalVisibility, type ModalVisibilityProps, } from "@/base/components/utils/modal"; import { useBaseContext } from "@/base/context"; import { haveWindow } from "@/base/env"; import { nameAndExtension } from "@/base/file-name"; import { formattedDate, formattedTime } from "@/base/i18n-date"; import log from "@/base/log"; import type { Location } from "@/base/types"; import { CopyButton } from "@/gallery/components/FileInfoComponents"; import { tagNumericValue, type RawExifTags } from "@/gallery/services/exif"; import { changeCaption, changeFileName, updateExistingFilePubMetadata, } from "@/gallery/services/file"; import { formattedByteSize } from "@/gallery/utils/units"; import { type EnteFile } from "@/media/file"; import { fileCreationPhotoDate, fileLocation, filePublicMagicMetadata, updateRemotePublicMagicMetadata, type ParsedMetadata, type ParsedMetadataDate, } from "@/media/file-metadata"; import { FileType } from "@/media/file-type"; import { FileDateTimePicker } from "@/new/photos/components/FileDateTimePicker"; import { FilePeopleList } from "@/new/photos/components/PeopleList"; import { confirmDisableMapsDialogAttributes, confirmEnableMapsDialogAttributes, } from "@/new/photos/components/utils/dialog"; import { useSettingsSnapshot } from "@/new/photos/components/utils/use-snapshot"; import { aboveFileViewerContentZ, fileInfoDrawerZ, } from "@/new/photos/components/utils/z-index"; import { getAnnotatedFacesForFile, isMLEnabled, type AnnotatedFaceID, } from "@/new/photos/services/ml"; import { updateMapEnabled } from "@/new/photos/services/settings"; import CalendarTodayIcon from "@mui/icons-material/CalendarToday"; import CameraOutlinedIcon from "@mui/icons-material/CameraOutlined"; import CloseIcon from "@mui/icons-material/Close"; import DoneIcon from "@mui/icons-material/Done"; import EditIcon from "@mui/icons-material/Edit"; import FaceRetouchingNaturalIcon from "@mui/icons-material/FaceRetouchingNatural"; import FolderOutlinedIcon from "@mui/icons-material/FolderOutlined"; import LocationOnOutlinedIcon from "@mui/icons-material/LocationOnOutlined"; import PhotoOutlinedIcon from "@mui/icons-material/PhotoOutlined"; import TextSnippetOutlinedIcon from "@mui/icons-material/TextSnippetOutlined"; import VideocamOutlinedIcon from "@mui/icons-material/VideocamOutlined"; import { Box, Button, CircularProgress, Dialog, DialogContent, DialogTitle, IconButton, InputAdornment, Link, Stack, styled, TextField, Typography, type ButtonProps, type DialogProps, } from "@mui/material"; import { useFormik } from "formik"; import { t } from "i18next"; import React, { useEffect, useMemo, useRef, useState } from "react"; // Re-uses images from ~leaflet package. import "leaflet-defaulticon-compatibility/dist/leaflet-defaulticon-compatibility.webpack.css"; import "leaflet/dist/leaflet.css"; // eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-unused-expressions haveWindow() && require("leaflet-defaulticon-compatibility"); const leaflet = haveWindow() ? // eslint-disable-next-line @typescript-eslint/no-require-imports (require("leaflet") as typeof import("leaflet")) : null; /** * Exif data for a file, in a form suitable for use by {@link FileInfo}. * * TODO: Indicate missing exif (e.g. videos) better, both in the data type, and * in the UI (e.g. by omitting the entire row). */ export interface FileInfoExif { tags: RawExifTags | undefined; parsed: ParsedMetadata | undefined; } export type FileInfoProps = ModalVisibilityProps & { /** * The file whose information we are showing. */ file: EnteFile | undefined; /** * Exif information for {@link file}. */ exif: FileInfoExif | undefined; /** * If set, then controls to edit the file's metadata (name, date, caption) * will be shown. */ allowEdits?: boolean; /** * If set, then an inline map will be shown (if the user has enabled it) * using the file's location. */ allowMap?: boolean; /** * If set, then a clickable chip will be shown for each collection that this * file is a part of. * * Uses {@link fileCollectionIDs}, {@link allCollectionsNameByID} and * {@link onSelectCollection}, so all of those props should also be set for * this to have an effect. */ showCollections?: boolean; /** * A map from file IDs to the IDs of the collections that they're a part of. * * Used when {@link showCollections} is set. */ fileCollectionIDs?: Map; /** * A map from collection IDs to their name. * * Used when {@link showCollections} is set. */ allCollectionsNameByID?: Map; scheduleUpdate: () => void; refreshPhotoswipe: () => void; /** * Called when the user selects a collection from among the collections that * the file belongs to. */ onSelectCollection?: (collectionID: number) => void; /** * Called when the user selects a person in the file info panel. */ onSelectPerson?: (personID: string) => void; }; export const FileInfo: React.FC = ({ open, onClose, file, exif, allowEdits, allowMap, showCollections, fileCollectionIDs, allCollectionsNameByID, scheduleUpdate, refreshPhotoswipe, onSelectCollection, onSelectPerson, }) => { const { showMiniDialog } = useBaseContext(); const { mapEnabled } = useSettingsSnapshot(); const [annotatedFaces, setAnnotatedFaces] = useState([]); const { show: showRawExif, props: rawExifVisibilityProps } = useModalVisibility(); const location = useMemo( // Prefer the location in the EnteFile, then fall back to Exif. () => (file ? fileLocation(file) : undefined) ?? exif?.parsed?.location, [file, exif], ); const annotatedExif = useMemo(() => annotateExif(exif), [exif]); useEffect(() => { if (!file) return; if (!isMLEnabled()) return; let didCancel = false; void getAnnotatedFacesForFile(file).then( (faces) => !didCancel && setAnnotatedFaces(faces), ); return () => { didCancel = true; }; }, [file]); const openEnableMapConfirmationDialog = () => showMiniDialog( confirmEnableMapsDialogAttributes(() => updateMapEnabled(true)), ); const openDisableMapConfirmationDialog = () => showMiniDialog( confirmDisableMapsDialogAttributes(() => updateMapEnabled(false)), ); const handleSelectFace = ({ personID }: AnnotatedFaceID) => onSelectPerson?.(personID); if (!file) { if (open) assertionFailed(); return <>; } return ( {annotatedExif?.takenOnDevice && ( } title={annotatedExif.takenOnDevice} caption={createMultipartCaption( annotatedExif.fNumber, annotatedExif.exposureTime, annotatedExif.iso, )} /> )} {location && ( <> } title={t("location")} caption={ !mapEnabled || !allowMap ? ( {t("view_on_map")} ) : ( {t("disable_map")} ) } trailingButton={ } /> {allowMap && ( )} )} } title={t("details")} caption={ !exif ? ( ) : !exif.tags ? ( t("no_exif") ) : ( {t("view_exif")} ) } /> {annotatedFaces.length > 0 && ( }> )} {showCollections && fileCollectionIDs && allCollectionsNameByID && onSelectCollection && ( )} ); }; /** * Some immediate fields of interest, in the form that we want to display on the * info panel for a file. */ type AnnotatedExif = Required & { resolution?: string; megaPixels?: string; takenOnDevice?: string; fNumber?: string; exposureTime?: string; iso?: string; }; const annotateExif = ( fileInfoExif: FileInfoExif | undefined, ): AnnotatedExif | undefined => { if (!fileInfoExif || !fileInfoExif.tags || !fileInfoExif.parsed) return undefined; const info: AnnotatedExif = { ...fileInfoExif }; const { width, height } = fileInfoExif.parsed; if (width && height) { info.resolution = `${width} x ${height}`; const mp = Math.round((width * height) / 1000000); if (mp) info.megaPixels = `${mp}MP`; } const { tags } = fileInfoExif; const { exif } = tags; if (exif) { if (exif.Make && exif.Model) info.takenOnDevice = `${exif.Make.description} ${exif.Model.description}`; if (exif.FNumber) info.fNumber = exif.FNumber.description; /* e.g. "f/16" */ if (exif.ExposureTime) info.exposureTime = exif.ExposureTime.description; /* "1/10" */ if (exif.ISOSpeedRatings) info.iso = `ISO${tagNumericValue(exif.ISOSpeedRatings)}`; } return info; }; const FileInfoSidebar = styled( (props: Pick) => ( ), )(({ theme }) => ({ zIndex: fileInfoDrawerZ, // [Note: Lighter backdrop for overlays on photo viewer] // // The default backdrop color we use for the drawer in light mode is too // "white" when used in the image gallery because unlike the rest of the app // the gallery retains a black background irrespective of the mode. So use a // lighter scrim when overlaying content directly atop the image gallery. // // We don't need to add this special casing for nested overlays (e.g. // dialogs initiated from the file info drawer itself) since now there is // enough "white" on the screen to warrant the stronger (default) backdrop. ...theme.applyStyles("light", { ".MuiBackdrop-root": { backgroundColor: theme.vars.palette.backdrop.faint, }, }), })); interface InfoItemProps { /** * The icon associated with the info entry. */ icon: React.ReactNode; /** * The primary content / title of the info entry. * * Only used if {@link children} are not specified. */ title?: string; /** * The secondary information / subtext associated with the info entry. * * Only used if {@link children} are not specified. */ caption?: React.ReactNode; /** * A component, usually a button (e.g. an "edit button"), shown at the * trailing edge of the info entry. */ trailingButton?: React.ReactNode; } /** * An entry in the file info panel listing. */ const InfoItem: React.FC> = ({ icon, title, caption, trailingButton, children, }) => ( {icon} {children ? ( {children} ) : ( {title} {caption} )} {trailingButton} ); const InfoItemIconContainer = styled("div")( ({ theme }) => ` width: 48px; aspect-ratio: 1; display: flex; justify-content: center; align-items: center; color: ${theme.vars.palette.stroke.muted} `, ); type EditButtonProps = ButtonishProps & { /** * If true, then an activity indicator is shown in place of the edit icon. */ loading?: boolean; }; const EditButton: React.FC = ({ onClick, loading }) => ( {!loading ? ( ) : ( )} ); type CaptionProps = Pick< FileInfoProps, "allowEdits" | "scheduleUpdate" | "refreshPhotoswipe" > & { /* TODO(PS): This is DisplayFile, but that's meant to be removed */ file: EnteFile & { title?: string; }; }; const Caption: React.FC = ({ file, allowEdits, scheduleUpdate, refreshPhotoswipe, }) => { const [isSaving, setIsSaving] = useState(false); const caption = file.pubMagicMetadata?.data.caption ?? ""; const formik = useFormik<{ caption: string }>({ initialValues: { caption }, validate: ({ caption }) => caption.length > 5000 ? { caption: t("caption_character_limit") } : {}, onSubmit: async ({ caption: newCaption }, { setFieldError }) => { if (newCaption == caption) return; setIsSaving(true); try { const updatedFile = await changeCaption(file, newCaption); updateExistingFilePubMetadata(file, updatedFile); // @ts-ignore file.title = file.pubMagicMetadata.data.caption; } catch (e) { log.error("Failed to update caption", e); setFieldError("caption", t("generic_error")); } refreshPhotoswipe(); scheduleUpdate(); setIsSaving(false); }, }); const { values, errors, handleChange, handleSubmit, resetForm } = formik; if (!caption.length && !allowEdits) { return <>; } return ( {values.caption != caption && ( {isSaving ? ( ) : ( )} resetForm()} disabled={isSaving}> )} ); }; const CaptionForm = styled("form")(({ theme }) => ({ padding: theme.spacing(1), })); type CreationTimeProps = Pick< FileInfoProps, "allowEdits" | "scheduleUpdate" > & { file: EnteFile; }; const CreationTime: React.FC = ({ file, allowEdits, scheduleUpdate, }) => { const { onGenericError } = useBaseContext(); const [isEditing, setIsEditing] = useState(false); const [isSaving, setIsSaving] = useState(false); const originalDate = fileCreationPhotoDate( file, filePublicMagicMetadata(file), ); const saveEdits = async (pickedTime: ParsedMetadataDate) => { setIsEditing(false); const { dateTime, timestamp: editedTime } = pickedTime; if (editedTime == originalDate.getTime()) { // Same as before. return; } setIsSaving(true); try { // [Note: Don't modify offsetTime when editing date via picker] // // Use the updated date time (both in its canonical dateTime form, // and also as in the epoch timestamp), but don't use the offset. // // The offset here will be the offset of the computer where this // user is making this edit, not the offset of the place where the // photo was taken. In a future iteration of the date time editor, // 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, }); } catch (e) { onGenericError(e); } scheduleUpdate(); setIsSaving(false); }; return ( <> } title={formattedDate(originalDate)} caption={formattedTime(originalDate)} trailingButton={ allowEdits && ( setIsEditing(true)} loading={isSaving} /> ) } /> {isEditing && ( setIsEditing(false)} /> )} ); }; type FileNameProps = Pick & { file: EnteFile; annotatedExif: AnnotatedExif | undefined; }; const FileName: React.FC = ({ file, annotatedExif, allowEdits, scheduleUpdate, }) => { const { show: showRename, props: renameVisibilityProps } = useModalVisibility(); const fileName = file.metadata.title; const handleRename = async (newFileName: string) => { const updatedFile = await changeFileName(file, newFileName); updateExistingFilePubMetadata(file, updatedFile); scheduleUpdate(); }; const icon = file.metadata.fileType === FileType.video ? ( ) : ( ); const fileSize = file.info?.fileSize; const caption = createMultipartCaption( annotatedExif?.megaPixels, annotatedExif?.resolution, fileSize ? formattedByteSize(fileSize) : undefined, ); return ( <> } /> ); }; const createMultipartCaption = ( p1: string | undefined, p2: string | undefined, p3: string | undefined, ) => ( {p1 &&
{p1}
} {p2 &&
{p2}
} {p3 &&
{p3}
}
); type RenameFileDialogProps = ModalVisibilityProps & { /** * The current name of the file. */ fileName: string; /** * Called when the user makes a change to the existing name and activates the * rename button on the dialog. * * @param newFileName The changed name. The extension currently cannot be * modified, but it is guaranteed the name component of {@link newFileName} * will be different from that of the {@link fileName} prop of the dialog. * * Until the promise settles, the dialog will show an activity indicator. If * the promise rejects, it will also show an error. If the promise is * fulfilled, then the dialog will also be closed. * * The dialog will also be closed if the user activates the rename button * without changing the name. */ onRename: (newFileName: string) => Promise; }; const RenameFileDialog: React.FC = ({ open, onClose, fileName, onRename, }) => { const [name, extension] = nameAndExtension(fileName); const handleSubmit = async (newName: string) => { const newFileName = [newName, extension].filter((x) => !!x).join("."); if (newFileName != fileName) { await onRename(newFileName); } onClose(); }; return ( {t("rename_file")} {`.${extension}`} ), }, }} /> ); }; const openStreetMapLink = ({ latitude, longitude }: Location) => `https://www.openstreetmap.org/?mlat=${latitude}&mlon=${longitude}#map=15/${latitude}/${longitude}`; interface MapBoxProps { location: Location; mapEnabled: boolean; openUpdateMapConfirmationDialog: () => void; } const MapBox: React.FC = ({ location, mapEnabled, openUpdateMapConfirmationDialog, }) => { const urlTemplate = "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"; const attribution = '© OpenStreetMap contributors'; const zoom = 16; const mapBoxContainerRef = useRef(null); useEffect(() => { const mapContainer = mapBoxContainerRef.current; if (mapEnabled) { const position: L.LatLngTuple = [ location.latitude, location.longitude, ]; if (mapContainer && !mapContainer.hasChildNodes()) { // @ts-ignore const map = leaflet.map(mapContainer).setView(position, zoom); // @ts-ignore leaflet .tileLayer(urlTemplate, { attribution, }) .addTo(map); // @ts-ignore leaflet.marker(position).addTo(map).openPopup(); } } else { if (mapContainer?.hasChildNodes()) { if (mapContainer.firstChild) { mapContainer.removeChild(mapContainer.firstChild); } } } // eslint-disable-next-line react-hooks/exhaustive-deps }, [mapEnabled]); return mapEnabled ? ( ) : ( {t("enable_map")} ); }; const MapBoxContainer = styled("div")` height: 200px; width: 100%; `; const MapBoxEnableContainer = styled(MapBoxContainer)( ({ theme }) => ` position: relative; display: flex; justify-content: center; align-items: center; background-color: ${theme.vars.palette.fill.fainter}; `, ); interface RawExifProps { open: boolean; onClose: () => void; onInfoClose: () => void; tags: RawExifTags | undefined; fileName: string; } const RawExif: React.FC = ({ open, onClose, onInfoClose, tags, fileName, }) => { if (!tags) { return <>; } const handleRootClose = () => { onClose(); onInfoClose(); }; const items: (readonly [string, string, string, string])[] = Object.entries( tags, ) .map(([namespace, namespaceTags]) => { return Object.entries(namespaceTags).map(([tagName, tag]) => { const key = `${namespace}:${tagName}`; let description = "<...>"; if (typeof tag == "string") { description = tag; } else if (typeof tag == "number") { description = `${tag}`; } else if ( tag && typeof tag == "object" && "description" in tag && // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access typeof tag.description == "string" ) { // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access description = tag.description; } return [key, namespace, tagName, description] as const; }); }) .flat() .filter(([, , , description]) => description); return ( } /> {items.map(([key, namespace, tagName, description]) => ( {tagName} {namespace} {description} ))} ); }; const ExifItem = styled("div")` padding-left: 8px; padding-right: 8px; display: flex; flex-direction: column; gap: 4px; `; type AlbumsProps = Required< Pick< FileInfoProps, "fileCollectionIDs" | "allCollectionsNameByID" | "onSelectCollection" > > & { file: EnteFile; }; const Albums: React.FC = ({ file, fileCollectionIDs, allCollectionsNameByID, onSelectCollection, }) => ( }> {fileCollectionIDs .get(file.id) ?.filter((collectionID) => allCollectionsNameByID.has(collectionID), ) .map((collectionID) => ( onSelectCollection(collectionID)} > {allCollectionsNameByID.get(collectionID)} ))} ); const ChipButton = styled((props: ButtonProps) => (