/* eslint-disable @typescript-eslint/prefer-nullish-coalescing */ /* eslint-disable @typescript-eslint/ban-ts-comment */ /* eslint-disable @typescript-eslint/no-unnecessary-condition */ /* eslint-disable @typescript-eslint/no-unused-expressions */ /* TODO: Audit this file */ /* @ts-nocheck */ 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 } from "@/base/components/utils/modal"; import type { BaseContextT } from "@/base/context"; import { haveWindow } from "@/base/env"; import { nameAndExtension } from "@/base/file-name"; import log from "@/base/log"; import type { Location } from "@/base/types"; import { changeCaption, changeFileName, updateExistingFilePubMetadata, } from "@/gallery/services/file"; import { type EnteFile } from "@/media/file"; import type { ParsedMetadata } from "@/media/file-metadata"; import { fileCreationPhotoDate, fileLocation, updateRemotePublicMagicMetadata, type ParsedMetadataDate, } from "@/media/file-metadata"; import { FileType } from "@/media/file-type"; import { CopyButton } from "@/new/photos/components/FileInfo"; import { ChipButton } from "@/new/photos/components/mui/ChipButton"; import { FilePeopleList } from "@/new/photos/components/PeopleList"; import { PhotoDateTimePicker } from "@/new/photos/components/PhotoDateTimePicker"; 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 { tagNumericValue, type RawExifTags } from "@/new/photos/services/exif"; 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 { getPublicMagicMetadataSync } from "@ente/shared/file-metadata"; import { formatDate, formatTime } from "@ente/shared/time/format"; 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"; // 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 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 interface FileInfoExif { tags: RawExifTags | undefined; parsed: ParsedMetadata | undefined; } export interface FileInfoProps { /** * The file whose information we are showing. */ file: EnteFile | undefined; showInfo: boolean; handleCloseInfo: () => void; exif: FileInfoExif | undefined; /** * This is the same as the {@link showMiniDialog} prop in the top level * {@link AppContext} of the app which we're currently being shown in. */ showMiniDialog: BaseContextT["showMiniDialog"]; /** * TODO: Rename and flip to allowEdits. */ shouldDisableEdits: boolean; /** * If `true`, an inline map will be shown (if the user has enabled it) using * the file's location. */ allowMap: boolean; scheduleUpdate: () => void; refreshPhotoswipe: () => void; fileToCollectionsMap?: Map; collectionNameMap?: Map; showCollectionChips: boolean; /** * 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) | undefined; } export const FileInfo: React.FC = ({ file, showMiniDialog, shouldDisableEdits, allowMap, showInfo, handleCloseInfo, exif, scheduleUpdate, refreshPhotoswipe, fileToCollectionsMap, collectionNameMap, showCollectionChips, onSelectCollection, onSelectPerson, }) => { const { mapEnabled } = useSettingsSnapshot(); const [exifInfo, setExifInfo] = useState(); const { show: showRawExif, props: rawExifVisibilityProps } = useModalVisibility(); const [annotatedFaces, setAnnotatedFaces] = useState([]); const location = useMemo(() => { if (file) { const location = fileLocation(file); if (location) return location; } return exif?.parsed?.location; }, [file, exif]); useEffect(() => { if (!file) return; let didCancel = false; void (async () => { const result = await getAnnotatedFacesForFile(file); !didCancel && setAnnotatedFaces(result); })(); return () => { didCancel = true; }; }, [file]); useEffect(() => { setExifInfo(parseExifInfo(exif)); }, [exif]); if (!file) { return <>; } const openEnableMapConfirmationDialog = () => showMiniDialog( confirmEnableMapsDialogAttributes(() => updateMapEnabled(true)), ); const openDisableMapConfirmationDialog = () => showMiniDialog( confirmDisableMapsDialogAttributes(() => updateMapEnabled(false)), ); const handleSelectFace = ({ personID }: AnnotatedFaceID) => onSelectPerson?.(personID); return ( {exifInfo?.takenOnDevice && ( } title={exifInfo?.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 && ( }> )} {showCollectionChips && collectionNameMap && ( }> {fileToCollectionsMap ?.get(file.id) ?.filter((collectionID) => collectionNameMap.has(collectionID), ) ?.map((collectionID) => ( onSelectCollection(collectionID) } > {collectionNameMap.get(collectionID)} ))} )} ); }; /** * Some immediate fields of interest, in the form that we want to display on the * info panel for a file. */ type ExifInfo = Required & { resolution?: string; megaPixels?: string; takenOnDevice?: string; fNumber?: string; exposureTime?: string; iso?: string; }; const parseExifInfo = ( fileInfoExif: FileInfoExif | undefined, ): ExifInfo | undefined => { if (!fileInfoExif || !fileInfoExif.tags || !fileInfoExif.parsed) return undefined; const info: ExifInfo = { ...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 RenderCaptionFormValues { caption: string; } function RenderCaption({ file, scheduleUpdate, refreshPhotoswipe, shouldDisableEdits, }: { shouldDisableEdits: boolean; /* TODO: This is DisplayFile, but that's meant to be deprecated */ file: EnteFile & { title?: string; }; scheduleUpdate: () => void; refreshPhotoswipe: () => void; }) { const [caption, setCaption] = useState( file?.pubMagicMetadata?.data.caption, ); const [loading, setLoading] = useState(false); const saveEdits = async (newCaption: string) => { try { if (file) { 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: RenderCaptionFormValues) => { try { setLoading(true); await saveEdits(values.caption); } finally { setLoading(false); } }; if (!caption?.length && shouldDisableEdits) { 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} > )} )}
); } interface CreationTimeProps { file: EnteFile; shouldDisableEdits: boolean; scheduleUpdate: () => void; } const CreationTime: React.FC = ({ file, shouldDisableEdits, scheduleUpdate, }) => { const [loading, setLoading] = useState(false); const [isInEditMode, setIsInEditMode] = useState(false); const openEditMode = () => setIsInEditMode(true); const closeEditMode = () => setIsInEditMode(false); const publicMagicMetadata = getPublicMagicMetadataSync(file); const originalDate = fileCreationPhotoDate(file, publicMagicMetadata); const saveEdits = async (pickedTime: ParsedMetadataDate) => { try { setLoading(true); if (isInEditMode && file) { // [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. const { dateTime, timestamp } = pickedTime; if (timestamp == originalDate.getTime()) { // Same as before. closeEditMode(); return; } await updateRemotePublicMagicMetadata(file, { dateTime, editedTime: timestamp, }); scheduleUpdate(); } } catch (e) { log.error("failed to update creationTime", e); } finally { closeEditMode(); setLoading(false); } }; return ( <> } title={formatDate(originalDate)} caption={formatTime(originalDate)} trailingButton={ shouldDisableEdits || ( ) } /> {isInEditMode && ( )} ); }; interface RenderFileNameProps { file: EnteFile; shouldDisableEdits: boolean; exifInfo: ExifInfo | undefined; scheduleUpdate: () => void; } const RenderFileName: React.FC = ({ file, shouldDisableEdits, exifInfo, 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 (!file) return; 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, exifInfo)} trailingButton={ shouldDisableEdits || } /> ); }; const getCaption = (file: EnteFile, exifInfo: ExifInfo | 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<{ parsedExif: ExifInfo }> = ({ parsedExif, }) => { return ( {parsedExif.fNumber} {parsedExif.exposureTime} {parsedExif.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); } } } }, [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; `;