ente/src/components/PhotoFrame.tsx

589 lines
21 KiB
TypeScript

import { GalleryContext } from 'pages/gallery';
import PreviewCard from './pages/gallery/PreviewCard';
import React, { useContext, useEffect, useRef, useState } from 'react';
import { EnteFile } from 'types/file';
import styled from 'styled-components';
import DownloadManager from 'services/downloadManager';
import constants from 'utils/strings/constants';
import AutoSizer from 'react-virtualized-auto-sizer';
import PhotoSwipe from 'components/PhotoSwipe/PhotoSwipe';
import { isInsideBox, isSameDay as isSameDayAnyYear } from 'utils/search';
import { formatDateRelative } from 'utils/file';
import {
ALL_SECTION,
ARCHIVE_SECTION,
TRASH_SECTION,
} from 'constants/collection';
import { isSharedFile } from 'utils/file';
import { isPlaybackPossible } from 'utils/photoFrame';
import { PhotoList } from './PhotoList';
import { SetFiles, SelectedState, Search, setSearchStats } from 'types/gallery';
import { FILE_TYPE } from 'constants/file';
import PublicCollectionDownloadManager from 'services/publicCollectionDownloadManager';
import { PublicCollectionGalleryContext } from 'utils/publicCollectionGallery';
import { useRouter } from 'next/router';
import EmptyScreen from './EmptyScreen';
import { AppContext } from 'pages/_app';
import { DeduplicateContext } from 'pages/deduplicate';
import { IsArchived } from 'utils/magicMetadata';
const Container = styled.div`
display: block;
flex: 1;
width: 100%;
flex-wrap: wrap;
margin: 0 auto;
overflow-x: hidden;
.pswp-thumbnail {
display: inline-block;
cursor: pointer;
}
`;
const PHOTOSWIPE_HASH_SUFFIX = '&opened';
interface Props {
files: EnteFile[];
setFiles: SetFiles;
syncWithRemote: () => Promise<void>;
favItemIds?: Set<number>;
archivedCollections?: Set<number>;
setSelected: (
selected: SelectedState | ((selected: SelectedState) => SelectedState)
) => void;
selected: SelectedState;
isFirstLoad?;
openUploader?;
isInSearchMode?: boolean;
search?: Search;
setSearchStats?: setSearchStats;
deleted?: number[];
activeCollection: number;
isSharedCollection?: boolean;
enableDownload?: boolean;
}
type SourceURL = {
imageURL?: string;
videoURL?: string;
};
const PhotoFrame = ({
files,
setFiles,
syncWithRemote,
favItemIds,
archivedCollections,
setSelected,
selected,
isFirstLoad,
openUploader,
isInSearchMode,
search,
setSearchStats,
deleted,
activeCollection,
isSharedCollection,
enableDownload,
}: Props) => {
const [open, setOpen] = useState(false);
const [currentIndex, setCurrentIndex] = useState<number>(0);
const [fetching, setFetching] = useState<{ [k: number]: boolean }>({});
const startTime = Date.now();
const galleryContext = useContext(GalleryContext);
const appContext = useContext(AppContext);
const deduplicateContext = useContext(DeduplicateContext);
const publicCollectionGalleryContext = useContext(
PublicCollectionGalleryContext
);
const [rangeStart, setRangeStart] = useState(null);
const [currentHover, setCurrentHover] = useState(null);
const [isShiftKeyPressed, setIsShiftKeyPressed] = useState(false);
const filteredDataRef = useRef([]);
const filteredData = filteredDataRef?.current ?? [];
const router = useRouter();
const [isSourceLoaded, setIsSourceLoaded] = useState(false);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Shift') {
setIsShiftKeyPressed(true);
}
};
const handleKeyUp = (e: KeyboardEvent) => {
if (e.key === 'Shift') {
setIsShiftKeyPressed(false);
}
};
document.addEventListener('keydown', handleKeyDown, false);
document.addEventListener('keyup', handleKeyUp, false);
router.events.on('hashChangeComplete', (url: string) => {
const start = url.indexOf('#');
const hash = url.slice(start !== -1 ? start : url.length);
const shouldPhotoSwipeBeOpened = hash.endsWith(
PHOTOSWIPE_HASH_SUFFIX
);
if (shouldPhotoSwipeBeOpened) {
setOpen(true);
} else {
setOpen(false);
}
});
return () => {
document.addEventListener('keydown', handleKeyDown, false);
document.addEventListener('keyup', handleKeyUp, false);
};
}, []);
useEffect(() => {
if (isInSearchMode) {
setSearchStats({
resultCount: filteredData.length,
timeTaken: (Date.now() - startTime) / 1000,
});
}
if (search?.fileIndex || search?.fileIndex === 0) {
const filteredDataIdx = filteredData.findIndex(
(data) => data.dataIndex === search.fileIndex
);
if (filteredDataIdx || filteredDataIdx === 0) {
onThumbnailClick(filteredDataIdx)();
}
}
}, [search, filteredData]);
const resetFetching = () => {
setFetching({});
};
useEffect(() => {
if (selected.count === 0) {
setRangeStart(null);
}
}, [selected]);
useEffect(() => {
const idSet = new Set();
filteredDataRef.current = files
.map((item, index) => ({
...item,
dataIndex: index,
w: window.innerWidth,
h: window.innerHeight,
...(item.deleteBy && {
title: constants.AUTOMATIC_BIN_DELETE_MESSAGE(
formatDateRelative(item.deleteBy / 1000)
),
}),
}))
.filter((item) => {
if (deleted?.includes(item.id)) {
return false;
}
if (
search?.date &&
!isSameDayAnyYear(search.date)(
new Date(item.metadata.creationTime / 1000)
)
) {
return false;
}
if (
search?.location &&
!isInsideBox(
{
latitude: item.metadata.latitude,
longitude: item.metadata.longitude,
},
search.location
)
) {
return false;
}
if (
activeCollection === ALL_SECTION &&
(IsArchived(item) ||
archivedCollections?.has(item.collectionID))
) {
return false;
}
if (activeCollection === ARCHIVE_SECTION && !IsArchived(item)) {
return false;
}
if (isSharedFile(item) && !isSharedCollection) {
return false;
}
if (activeCollection === TRASH_SECTION && !item.isTrashed) {
return false;
}
if (activeCollection !== TRASH_SECTION && item.isTrashed) {
return false;
}
if (!idSet.has(item.id)) {
if (
activeCollection === ALL_SECTION ||
activeCollection === ARCHIVE_SECTION ||
activeCollection === TRASH_SECTION ||
activeCollection === item.collectionID
) {
idSet.add(item.id);
return true;
}
return false;
}
return false;
});
}, [files, deleted, search, activeCollection]);
useEffect(() => {
const currentURL = new URL(window.location.href);
const end = currentURL.hash.lastIndexOf('&');
const hash = currentURL.hash.slice(1, end !== -1 ? end : undefined);
if (open) {
router.push({
hash: hash + PHOTOSWIPE_HASH_SUFFIX,
});
} else {
router.push({
hash: hash,
});
}
}, [open]);
const updateURL = (index: number) => (url: string) => {
const updateFile = (file: EnteFile) => {
file = {
...file,
msrc: url,
w: window.innerWidth,
h: window.innerHeight,
};
if (file.metadata.fileType === FILE_TYPE.VIDEO && !file.html) {
file.html = `
<div class="pswp-item-container">
<img src="${url}" onContextMenu="return false;"/>
<div class="spinner-border text-light" role="status">
<span class="sr-only">Loading...</span>
</div>
</div>
`;
} else if (
file.metadata.fileType === FILE_TYPE.LIVE_PHOTO &&
!file.html
) {
file.html = `
<div class="pswp-item-container">
<img src="${url}" onContextMenu="return false;"/>
<div class="spinner-border text-light" role="status">
<span class="sr-only">Loading...</span>
</div>
</div>
`;
} else if (
file.metadata.fileType === FILE_TYPE.IMAGE &&
!file.src
) {
file.src = url;
}
return file;
};
setFiles((files) => {
files[index] = updateFile(files[index]);
return files;
});
return updateFile(files[index]);
};
const updateSrcURL = async (index: number, srcURL: SourceURL) => {
const { videoURL, imageURL } = srcURL;
const isPlayable = videoURL && (await isPlaybackPossible(videoURL));
const updateFile = (file: EnteFile) => {
file = {
...file,
w: window.innerWidth,
h: window.innerHeight,
};
if (file.metadata.fileType === FILE_TYPE.VIDEO) {
if (isPlayable) {
file.html = `
<video controls onContextMenu="return false;">
<source src="${videoURL}" />
Your browser does not support the video tag.
</video>
`;
} else {
file.html = `
<div class="pswp-item-container">
<img src="${file.msrc}" onContextMenu="return false;"/>
<div class="download-banner" >
${constants.VIDEO_PLAYBACK_FAILED_DOWNLOAD_INSTEAD}
<a class="btn btn-outline-success" href=${videoURL} download="${file.metadata.title}"">Download</a>
</div>
</div>
`;
}
} else if (file.metadata.fileType === FILE_TYPE.LIVE_PHOTO) {
if (isPlayable) {
file.html = `
<div class = 'pswp-item-container'>
<img id = "live-photo-image-${file.id}" src="${imageURL}" onContextMenu="return false;"/>
<video id = "live-photo-video-${file.id}" loop muted onContextMenu="return false;">
<source src="${videoURL}" />
Your browser does not support the video tag.
</video>
</div>
`;
} else {
file.html = `
<div class="pswp-item-container">
<img src="${file.msrc}" onContextMenu="return false;"/>
<div class="download-banner">
${constants.VIDEO_PLAYBACK_FAILED_DOWNLOAD_INSTEAD}
<button class = "btn btn-outline-success" id = "download-btn-${file.id}">Download</button>
</div>
</div>
`;
}
} else {
file.src = imageURL;
}
return file;
};
setFiles((files) => {
files[index] = updateFile(files[index]);
return files;
});
setIsSourceLoaded(true);
return updateFile(files[index]);
};
const handleClose = (needUpdate) => {
setOpen(false);
needUpdate && syncWithRemote();
};
const onThumbnailClick = (index: number) => () => {
setCurrentIndex(index);
setOpen(true);
};
const handleSelect = (id: number, index?: number) => (checked: boolean) => {
if (selected.collectionID !== activeCollection) {
setSelected({ count: 0, collectionID: 0 });
}
if (typeof index !== 'undefined') {
if (checked) {
setRangeStart(index);
} else {
setRangeStart(undefined);
}
}
setSelected((selected) => ({
...selected,
[id]: checked,
count:
selected[id] === checked
? selected.count
: checked
? selected.count + 1
: selected.count - 1,
collectionID: activeCollection,
}));
};
const onHoverOver = (index: number) => () => {
setCurrentHover(index);
};
const handleRangeSelect = (index: number) => () => {
if (typeof rangeStart !== 'undefined' && rangeStart !== index) {
const direction =
(index - rangeStart) / Math.abs(index - rangeStart);
let checked = true;
for (
let i = rangeStart;
(index - i) * direction >= 0;
i += direction
) {
checked = checked && !!selected[filteredData[i].id];
}
for (
let i = rangeStart;
(index - i) * direction > 0;
i += direction
) {
handleSelect(filteredData[i].id)(!checked);
}
handleSelect(filteredData[index].id, index)(!checked);
}
};
const getThumbnail = (files: EnteFile[], index: number) =>
files[index] ? (
<PreviewCard
key={`tile-${files[index].id}-selected-${
selected[files[index].id] ?? false
}`}
file={files[index]}
updateURL={updateURL(files[index].dataIndex)}
onClick={onThumbnailClick(index)}
selectable={!isSharedCollection}
onSelect={handleSelect(files[index].id, index)}
selected={
selected.collectionID === activeCollection &&
selected[files[index].id]
}
selectOnClick={selected.count > 0}
onHover={onHoverOver(index)}
onRangeSelect={handleRangeSelect(index)}
isRangeSelectActive={isShiftKeyPressed && selected.count > 0}
isInsSelectRange={
(index >= rangeStart && index <= currentHover) ||
(index >= currentHover && index <= rangeStart)
}
/>
) : (
<></>
);
const getSlideData = async (
instance: any,
index: number,
item: EnteFile
) => {
if (!item.msrc) {
try {
let url: string;
if (galleryContext.thumbs.has(item.id)) {
url = galleryContext.thumbs.get(item.id);
} else {
if (
publicCollectionGalleryContext.accessedThroughSharedURL
) {
url =
await PublicCollectionDownloadManager.getThumbnail(
item,
publicCollectionGalleryContext.token,
publicCollectionGalleryContext.passwordToken
);
} else {
url = await DownloadManager.getThumbnail(item);
}
galleryContext.thumbs.set(item.id, url);
}
const newFile = updateURL(item.dataIndex)(url);
item.msrc = newFile.msrc;
item.html = newFile.html;
item.src = newFile.src;
item.w = newFile.w;
item.h = newFile.h;
try {
instance.invalidateCurrItems();
instance.updateSize(true);
} catch (e) {
// ignore
}
} catch (e) {
// no-op
}
}
if (!fetching[item.dataIndex]) {
try {
fetching[item.dataIndex] = true;
let urls: string[];
if (galleryContext.files.has(item.id)) {
const mergedURL = galleryContext.files.get(item.id);
urls = mergedURL.split(',');
} else {
appContext.startLoading();
if (
publicCollectionGalleryContext.accessedThroughSharedURL
) {
urls = await PublicCollectionDownloadManager.getFile(
item,
publicCollectionGalleryContext.token,
publicCollectionGalleryContext.passwordToken,
true
);
} else {
urls = await DownloadManager.getFile(item, true);
}
appContext.finishLoading();
const mergedURL = urls.join(',');
galleryContext.files.set(item.id, mergedURL);
}
let imageURL;
let videoURL;
if (item.metadata.fileType === FILE_TYPE.LIVE_PHOTO) {
[imageURL, videoURL] = urls;
} else if (item.metadata.fileType === FILE_TYPE.VIDEO) {
[videoURL] = urls;
} else {
[imageURL] = urls;
}
setIsSourceLoaded(false);
const newFile = await updateSrcURL(item.dataIndex, {
imageURL,
videoURL,
});
item.msrc = newFile.msrc;
item.html = newFile.html;
item.src = newFile.src;
item.w = newFile.w;
item.h = newFile.h;
try {
instance.invalidateCurrItems();
instance.updateSize(true);
} catch (e) {
// ignore
}
} catch (e) {
// no-op
} finally {
fetching[item.dataIndex] = false;
}
}
};
return (
<>
{!isFirstLoad && files.length === 0 && !isInSearchMode ? (
<EmptyScreen openUploader={openUploader} />
) : (
<Container>
<AutoSizer>
{({ height, width }) => (
<PhotoList
width={width}
height={height}
getThumbnail={getThumbnail}
filteredData={filteredData}
activeCollection={activeCollection}
showAppDownloadBanner={
files.length < 30 &&
!isInSearchMode &&
!deduplicateContext.isOnDeduplicatePage
}
resetFetching={resetFetching}
/>
)}
</AutoSizer>
<PhotoSwipe
isOpen={open}
items={filteredData}
currentIndex={currentIndex}
onClose={handleClose}
gettingData={getSlideData}
favItemIds={favItemIds}
isSharedCollection={isSharedCollection}
isTrashCollection={activeCollection === TRASH_SECTION}
enableDownload={enableDownload}
isSourceLoaded={isSourceLoaded}
/>
</Container>
)}
</>
);
};
export default PhotoFrame;