/* 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 { LinkButtonUndecorated } from "@/base/components/LinkButton"; import { TitledMiniDialog } from "@/base/components/MiniDialog"; import { type ButtonishProps } from "@/base/components/mui"; import { ActivityIndicator } from "@/base/components/mui/ActivityIndicator"; import { SidebarDrawer } from "@/base/components/mui/SidebarDrawer"; 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 { type EnteFile } from "@/media/file"; import { fileCreationPhotoDate, fileLocation, filePublicMagicMetadata, updateRemotePublicMagicMetadata, type ParsedMetadataDate, } from "@/media/file-metadata"; import { FileType } from "@/media/file-type"; import { FileDateTimePicker } from "@/new/photos/components/FileDateTimePicker"; import { ChipButton } from "@/new/photos/components/mui/ChipButton"; 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 { formattedByteSize } from "@/new/photos/utils/units"; import { FlexWrapper } from "@ente/shared/components/Container"; import SingleInputForm, { type SingleInputFormProps, } from "@ente/shared/components/SingleInputForm"; 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, CircularProgress, IconButton, Link, Stack, styled, TextField, Typography, type DialogProps, } from "@mui/material"; import { Formik } from "formik"; import { t } from "i18next"; import React, { useEffect, useMemo, useRef, useState } from "react"; import * as Yup from "yup"; import type { FileInfoExif } from "./viewer/data-source"; // 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; 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; 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) { return <>; } return ( {annotatedExif?.takenOnDevice && ( } title={annotatedExif.takenOnDevice} caption={} /> )} {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")} ) } /> {isMLEnabled() && annotatedFaces.length > 0 && ( }> )} {showCollections && fileCollectionIDs && allCollectionsNameByID && onSelectCollection && ( }> {fileCollectionIDs .get(file.id) ?.filter((collectionID) => allCollectionsNameByID.has( collectionID, ), ) .map((collectionID) => ( onSelectCollection(collectionID) } > {allCollectionsNameByID.get( collectionID, )} ))} )} ); }; /** * 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 ? ( ) : ( )} ); interface CaptionFormValues { caption: string; } 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 [caption, setCaption] = useState(file.pubMagicMetadata?.data.caption); const [loading, setLoading] = useState(false); const saveEdits = async (newCaption: string) => { try { if (caption === newCaption) { return; } setCaption(newCaption); const updatedFile = await changeCaption(file, newCaption); updateExistingFilePubMetadata(file, updatedFile); // @ts-ignore file.title = file.pubMagicMetadata.data.caption; refreshPhotoswipe(); scheduleUpdate(); } catch (e) { log.error("failed to update caption", e); } }; const onSubmit = async (values: CaptionFormValues) => { try { setLoading(true); await saveEdits(values.caption); } finally { setLoading(false); } }; if (!caption?.length && !allowEdits) { return <>; } return ( // @ts-ignore initialValues={{ caption }} validationSchema={Yup.object().shape({ caption: Yup.string().max( 5000, t("caption_character_limit"), ), })} validateOnBlur={false} onSubmit={onSubmit} > {({ values, errors, handleChange, handleSubmit, resetForm, }) => (
{values.caption !== caption && ( {loading ? ( ) : ( )} resetForm({ values: { caption: caption ?? "" }, touched: { caption: false }, }) } disabled={loading} > )} )}
); }; 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); setIsSaving(true); const { dateTime, timestamp: editedTime } = pickedTime; if (editedTime != originalDate.getTime()) { // If not same as before. 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, }); scheduleUpdate(); } catch (e) { onGenericError(e); } } 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 [isInEditMode, setIsInEditMode] = useState(false); const openEditMode = () => setIsInEditMode(true); const closeEditMode = () => setIsInEditMode(false); const [fileName, setFileName] = useState(); const [extension, setExtension] = useState(); useEffect(() => { const [filename, extension] = nameAndExtension(file.metadata.title); setFileName(filename); setExtension(extension); }, [file]); const saveEdits = async (newFilename: string) => { if (fileName === newFilename) { closeEditMode(); return; } setFileName(newFilename); const newTitle = [newFilename, extension].join("."); const updatedFile = await changeFileName(file, newTitle); updateExistingFilePubMetadata(file, updatedFile); scheduleUpdate(); }; return ( <> ) : ( ) } title={[fileName, extension].join(".")} caption={getCaption(file, annotatedExif)} trailingButton={ allowEdits && } /> ); }; const getCaption = (file: EnteFile, exifInfo: AnnotatedExif | undefined) => { const megaPixels = exifInfo?.megaPixels; const resolution = exifInfo?.resolution; const fileSize = file.info?.fileSize; const captionParts = []; if (megaPixels) { captionParts.push(megaPixels); } if (resolution) { captionParts.push(resolution); } if (fileSize) { captionParts.push(formattedByteSize(fileSize)); } return ( {captionParts.map((caption) => ( {caption} ))} ); }; interface FileNameEditDialogProps { isInEditMode: boolean; closeEditMode: () => void; filename: string; extension: string | undefined; saveEdits: (name: string) => Promise; } const FileNameEditDialog: React.FC = ({ isInEditMode, closeEditMode, filename, extension, saveEdits, }) => { const onSubmit: SingleInputFormProps["callback"] = async ( filename, setFieldError, ) => { try { await saveEdits(filename); closeEditMode(); } catch (e) { log.error(e); setFieldError(t("generic_error_retry")); } }; return ( ); }; const BasicDeviceCamera: React.FC<{ annotatedExif: AnnotatedExif }> = ({ annotatedExif, }) => ( {annotatedExif.fNumber} {annotatedExif.exposureTime} {annotatedExif.iso} ); 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; `;