mirror of
https://github.com/signalapp/Signal-Desktop.git
synced 2025-12-24 20:26:24 +00:00
Rename files
This commit is contained in:
911
ts/components/Lightbox.dom.tsx
Normal file
911
ts/components/Lightbox.dom.tsx
Normal file
@@ -0,0 +1,911 @@
|
||||
// Copyright 2018 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import type { ReactNode } from 'react';
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import classNames from 'classnames';
|
||||
import { createPortal } from 'react-dom';
|
||||
import lodash from 'lodash';
|
||||
import { useSpring, animated, to } from '@react-spring/web';
|
||||
|
||||
import type { ReadonlyDeep } from 'type-fest';
|
||||
import type {
|
||||
ConversationType,
|
||||
SaveAttachmentActionCreatorType,
|
||||
} from '../state/ducks/conversations.preload.js';
|
||||
import type { LocalizerType } from '../types/Util.std.js';
|
||||
import type { MediaItemType } from '../types/MediaItem.std.js';
|
||||
import * as GoogleChrome from '../util/GoogleChrome.std.js';
|
||||
import { createLogger } from '../logging/log.std.js';
|
||||
import * as Errors from '../types/errors.std.js';
|
||||
import { Avatar, AvatarSize } from './Avatar.dom.js';
|
||||
import { IMAGE_PNG, isImage, isVideo } from '../types/MIME.std.js';
|
||||
import { formatDateTimeForAttachment } from '../util/formatTimestamp.dom.js';
|
||||
import { formatDuration } from '../util/formatDuration.std.js';
|
||||
import { isGIF, isIncremental } from '../util/Attachment.std.js';
|
||||
import { useRestoreFocus } from '../hooks/useRestoreFocus.dom.js';
|
||||
import { usePrevious } from '../hooks/usePrevious.std.js';
|
||||
import { arrow } from '../util/keyboard.dom.js';
|
||||
import { drop } from '../util/drop.std.js';
|
||||
import { isCmdOrCtrl } from '../hooks/useKeyboardShortcuts.dom.js';
|
||||
import type { ForwardMessagesPayload } from '../state/ducks/globalModals.preload.js';
|
||||
import { ForwardMessagesModalType } from './ForwardMessagesModal.dom.js';
|
||||
import { useReducedMotion } from '../hooks/useReducedMotion.dom.js';
|
||||
import { formatFileSize } from '../util/formatFileSize.std.js';
|
||||
import { SECOND } from '../util/durations/index.std.js';
|
||||
import { Toast } from './Toast.dom.js';
|
||||
import { isAbortError } from '../util/isAbortError.std.js';
|
||||
|
||||
const { noop } = lodash;
|
||||
|
||||
const log = createLogger('Lightbox');
|
||||
|
||||
export type PropsType = {
|
||||
children?: ReactNode;
|
||||
closeLightbox: () => unknown;
|
||||
getConversation?: (id: string) => ConversationType;
|
||||
i18n: LocalizerType;
|
||||
isViewOnce?: boolean;
|
||||
media: ReadonlyArray<ReadonlyDeep<MediaItemType>>;
|
||||
playbackDisabled: boolean;
|
||||
saveAttachment: SaveAttachmentActionCreatorType;
|
||||
selectedIndex: number;
|
||||
toggleForwardMessagesModal: (payload: ForwardMessagesPayload) => unknown;
|
||||
onMediaPlaybackStart: () => void;
|
||||
onNextAttachment: () => void;
|
||||
onPrevAttachment: () => void;
|
||||
onSelectAttachment: (index: number) => void;
|
||||
hasPrevMessage?: boolean;
|
||||
hasNextMessage?: boolean;
|
||||
};
|
||||
|
||||
const ZOOM_SCALE = 3;
|
||||
|
||||
const TWO_SECONDS = 2.5 * SECOND;
|
||||
|
||||
const INITIAL_IMAGE_TRANSFORM = {
|
||||
scale: 1,
|
||||
translateX: 0,
|
||||
translateY: 0,
|
||||
config: {
|
||||
clamp: true,
|
||||
friction: 20,
|
||||
mass: 0.5,
|
||||
tension: 350,
|
||||
},
|
||||
};
|
||||
|
||||
const THUMBNAIL_SPRING_CONFIG = {
|
||||
mass: 1,
|
||||
tension: 986,
|
||||
friction: 64,
|
||||
velocity: 0,
|
||||
};
|
||||
|
||||
const THUMBNAIL_WIDTH = 44;
|
||||
const THUMBNAIL_PADDING = 8;
|
||||
const THUMBNAIL_FULL_WIDTH = THUMBNAIL_WIDTH + THUMBNAIL_PADDING;
|
||||
|
||||
export function Lightbox({
|
||||
children,
|
||||
closeLightbox,
|
||||
getConversation,
|
||||
media,
|
||||
i18n,
|
||||
isViewOnce = false,
|
||||
saveAttachment,
|
||||
selectedIndex,
|
||||
toggleForwardMessagesModal,
|
||||
playbackDisabled,
|
||||
onMediaPlaybackStart,
|
||||
onNextAttachment,
|
||||
onPrevAttachment,
|
||||
onSelectAttachment,
|
||||
hasNextMessage,
|
||||
hasPrevMessage,
|
||||
}: PropsType): JSX.Element | null {
|
||||
const hasThumbnails = media.length > 1;
|
||||
const messageId = media.at(0)?.message.id;
|
||||
const prevMessageId = usePrevious(messageId, messageId);
|
||||
const needsAnimation = messageId !== prevMessageId;
|
||||
const [root, setRoot] = React.useState<HTMLElement | undefined>();
|
||||
|
||||
const [videoElement, setVideoElement] = useState<HTMLVideoElement | null>(
|
||||
null
|
||||
);
|
||||
const [shouldShowDownloadToast, setShouldShowDownloadToast] = useState(false);
|
||||
const downloadToastTimeout = useRef<NodeJS.Timeout | number | undefined>();
|
||||
|
||||
const [videoTime, setVideoTime] = useState<number | undefined>();
|
||||
const [isZoomed, setIsZoomed] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const [focusRef] = useRestoreFocus();
|
||||
const animateRef = useRef<HTMLDivElement | null>(null);
|
||||
const dragCacheRef = useRef<
|
||||
| {
|
||||
startX: number;
|
||||
startY: number;
|
||||
translateX: number;
|
||||
translateY: number;
|
||||
}
|
||||
| undefined
|
||||
>();
|
||||
const imageRef = useRef<HTMLImageElement | null>(null);
|
||||
const zoomCacheRef = useRef<
|
||||
| {
|
||||
maxX: number;
|
||||
maxY: number;
|
||||
screenWidth: number;
|
||||
screenHeight: number;
|
||||
}
|
||||
| undefined
|
||||
>();
|
||||
|
||||
const currentItem = media[selectedIndex];
|
||||
const attachment = currentItem?.attachment;
|
||||
const url = attachment?.url;
|
||||
const incrementalUrl = attachment?.incrementalUrl;
|
||||
const contentType = attachment?.contentType;
|
||||
|
||||
const isAttachmentGIF = isGIF(attachment ? [attachment] : undefined);
|
||||
const isDownloading =
|
||||
attachment &&
|
||||
isIncremental(attachment) &&
|
||||
attachment.pending &&
|
||||
!attachment.path;
|
||||
|
||||
const onMouseLeaveVideo = useCallback(() => {
|
||||
if (downloadToastTimeout.current) {
|
||||
clearTimeout(downloadToastTimeout.current);
|
||||
downloadToastTimeout.current = undefined;
|
||||
}
|
||||
if (!isDownloading) {
|
||||
return;
|
||||
}
|
||||
|
||||
setShouldShowDownloadToast(false);
|
||||
}, [isDownloading, setShouldShowDownloadToast]);
|
||||
const onUserInteractionOnVideo = useCallback(
|
||||
(event: React.MouseEvent<HTMLVideoElement, MouseEvent>) => {
|
||||
if (downloadToastTimeout.current) {
|
||||
clearTimeout(downloadToastTimeout.current);
|
||||
downloadToastTimeout.current = undefined;
|
||||
}
|
||||
if (!isDownloading) {
|
||||
return;
|
||||
}
|
||||
const elementRect = event.currentTarget.getBoundingClientRect();
|
||||
const bottomThreshold = elementRect.bottom - 75;
|
||||
|
||||
setShouldShowDownloadToast(true);
|
||||
|
||||
if (event.clientY >= bottomThreshold) {
|
||||
return;
|
||||
}
|
||||
downloadToastTimeout.current = setTimeout(onMouseLeaveVideo, TWO_SECONDS);
|
||||
},
|
||||
[isDownloading, onMouseLeaveVideo, setShouldShowDownloadToast]
|
||||
);
|
||||
|
||||
const onPrevious = useCallback(
|
||||
(
|
||||
event: KeyboardEvent | React.MouseEvent<HTMLButtonElement, MouseEvent>
|
||||
) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
if (isZoomed) {
|
||||
return;
|
||||
}
|
||||
|
||||
onPrevAttachment();
|
||||
},
|
||||
[isZoomed, onPrevAttachment]
|
||||
);
|
||||
|
||||
const onNext = useCallback(
|
||||
(
|
||||
event: KeyboardEvent | React.MouseEvent<HTMLButtonElement, MouseEvent>
|
||||
) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
if (isZoomed) {
|
||||
return;
|
||||
}
|
||||
|
||||
onNextAttachment();
|
||||
},
|
||||
[isZoomed, onNextAttachment]
|
||||
);
|
||||
|
||||
const onTimeUpdate = useCallback(() => {
|
||||
if (!videoElement) {
|
||||
return;
|
||||
}
|
||||
setVideoTime(videoElement.currentTime);
|
||||
}, [setVideoTime, videoElement]);
|
||||
|
||||
const handleSave = useCallback(
|
||||
(
|
||||
event: KeyboardEvent | React.MouseEvent<HTMLButtonElement, MouseEvent>
|
||||
) => {
|
||||
if (isViewOnce) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
|
||||
const mediaItem = media[selectedIndex];
|
||||
const { attachment: attachmentToSave, message, index } = mediaItem;
|
||||
|
||||
saveAttachment(attachmentToSave, message.sentAt, index + 1);
|
||||
},
|
||||
[isViewOnce, media, saveAttachment, selectedIndex]
|
||||
);
|
||||
|
||||
const handleForward = (
|
||||
event: React.MouseEvent<HTMLButtonElement, MouseEvent>
|
||||
) => {
|
||||
if (isViewOnce) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
closeLightbox();
|
||||
const mediaItem = media[selectedIndex];
|
||||
toggleForwardMessagesModal({
|
||||
type: ForwardMessagesModalType.Forward,
|
||||
messageIds: [mediaItem.message.id],
|
||||
});
|
||||
};
|
||||
|
||||
const onKeyDown = useCallback(
|
||||
(event: KeyboardEvent) => {
|
||||
switch (event.key) {
|
||||
case 'Escape': {
|
||||
closeLightbox();
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
case arrow('start'):
|
||||
onPrevious(event);
|
||||
break;
|
||||
|
||||
case arrow('end'):
|
||||
onNext(event);
|
||||
break;
|
||||
|
||||
case 's':
|
||||
if (isCmdOrCtrl(event)) {
|
||||
handleSave(event);
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
}
|
||||
},
|
||||
[closeLightbox, onNext, onPrevious, handleSave]
|
||||
);
|
||||
|
||||
const onClose = (event: React.MouseEvent<HTMLElement>) => {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
|
||||
closeLightbox();
|
||||
};
|
||||
|
||||
const playVideo = useCallback(() => {
|
||||
if (!videoElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (videoElement.paused) {
|
||||
onMediaPlaybackStart();
|
||||
void videoElement.play().catch(error => {
|
||||
if (!isAbortError(error)) {
|
||||
log.error('Failed to play video', Errors.toLogFormat(error));
|
||||
}
|
||||
});
|
||||
} else {
|
||||
videoElement.pause();
|
||||
}
|
||||
}, [videoElement, onMediaPlaybackStart]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!videoElement || videoElement.paused) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (playbackDisabled) {
|
||||
videoElement.pause();
|
||||
}
|
||||
}, [playbackDisabled, videoElement]);
|
||||
|
||||
useEffect(() => {
|
||||
const div = document.createElement('div');
|
||||
document.body.appendChild(div);
|
||||
setRoot(div);
|
||||
|
||||
return () => {
|
||||
document.body.removeChild(div);
|
||||
setRoot(undefined);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const useCapture = true;
|
||||
document.addEventListener('keydown', onKeyDown, useCapture);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', onKeyDown, useCapture);
|
||||
};
|
||||
}, [onKeyDown]);
|
||||
|
||||
useEffect(() => {
|
||||
playVideo();
|
||||
|
||||
if (!videoElement || !isViewOnce) {
|
||||
return noop;
|
||||
}
|
||||
|
||||
if (isAttachmentGIF) {
|
||||
return noop;
|
||||
}
|
||||
|
||||
videoElement.addEventListener('timeupdate', onTimeUpdate);
|
||||
|
||||
return () => {
|
||||
videoElement.removeEventListener('timeupdate', onTimeUpdate);
|
||||
};
|
||||
}, [isViewOnce, isAttachmentGIF, onTimeUpdate, playVideo, videoElement]);
|
||||
|
||||
const [{ scale, translateX, translateY }, springApi] = useSpring(
|
||||
() => INITIAL_IMAGE_TRANSFORM
|
||||
);
|
||||
|
||||
const thumbnailsMarginInlineStart =
|
||||
0 - (selectedIndex * THUMBNAIL_FULL_WIDTH + THUMBNAIL_WIDTH / 2);
|
||||
|
||||
const reducedMotion = useReducedMotion();
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- FIXME
|
||||
const [thumbnailsStyle, thumbnailsAnimation] = useSpring(
|
||||
{
|
||||
immediate: reducedMotion,
|
||||
config: THUMBNAIL_SPRING_CONFIG,
|
||||
to: {
|
||||
marginInlineStart: thumbnailsMarginInlineStart,
|
||||
opacity: hasThumbnails ? 1 : 0,
|
||||
},
|
||||
},
|
||||
[selectedIndex, hasThumbnails]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!needsAnimation) {
|
||||
return;
|
||||
}
|
||||
|
||||
thumbnailsAnimation.stop();
|
||||
thumbnailsAnimation.set({
|
||||
marginInlineStart:
|
||||
thumbnailsMarginInlineStart +
|
||||
(selectedIndex === 0 ? 1 : -1) * THUMBNAIL_FULL_WIDTH,
|
||||
opacity: 0,
|
||||
});
|
||||
drop(
|
||||
Promise.all(
|
||||
thumbnailsAnimation.start({
|
||||
marginInlineStart: thumbnailsMarginInlineStart,
|
||||
opacity: 1,
|
||||
})
|
||||
)
|
||||
);
|
||||
}, [
|
||||
needsAnimation,
|
||||
selectedIndex,
|
||||
thumbnailsMarginInlineStart,
|
||||
thumbnailsAnimation,
|
||||
]);
|
||||
|
||||
const maxBoundsLimiter = useCallback(
|
||||
(x: number, y: number): [number, number] => {
|
||||
const zoomCache = zoomCacheRef.current;
|
||||
|
||||
if (!zoomCache) {
|
||||
return [0, 0];
|
||||
}
|
||||
|
||||
const { maxX, maxY } = zoomCache;
|
||||
|
||||
const posX = Math.min(maxX, Math.max(-maxX, x));
|
||||
const posY = Math.min(maxY, Math.max(-maxY, y));
|
||||
|
||||
return [posX, posY];
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const positionImage = useCallback(
|
||||
(ev: MouseEvent) => {
|
||||
const zoomCache = zoomCacheRef.current;
|
||||
|
||||
if (!zoomCache) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { maxX, maxY, screenWidth, screenHeight } = zoomCache;
|
||||
|
||||
const shouldTranslateX = maxX * ZOOM_SCALE > screenWidth;
|
||||
const shouldTranslateY = maxY * ZOOM_SCALE > screenHeight;
|
||||
|
||||
const offsetX = screenWidth / 2 - ev.clientX;
|
||||
const offsetY = screenHeight / 2 - ev.clientY;
|
||||
const posX = offsetX * ZOOM_SCALE;
|
||||
const posY = offsetY * ZOOM_SCALE;
|
||||
const [x, y] = maxBoundsLimiter(posX, posY);
|
||||
|
||||
drop(
|
||||
Promise.all(
|
||||
springApi.start({
|
||||
scale: ZOOM_SCALE,
|
||||
translateX: shouldTranslateX ? x : undefined,
|
||||
translateY: shouldTranslateY ? y : undefined,
|
||||
})
|
||||
)
|
||||
);
|
||||
},
|
||||
[maxBoundsLimiter, springApi]
|
||||
);
|
||||
|
||||
const handleTouchStart = useCallback(
|
||||
(ev: TouchEvent) => {
|
||||
const [touch] = ev.touches;
|
||||
|
||||
dragCacheRef.current = {
|
||||
startX: touch.clientX,
|
||||
startY: touch.clientY,
|
||||
translateX: translateX.get(),
|
||||
translateY: translateY.get(),
|
||||
};
|
||||
},
|
||||
[translateY, translateX]
|
||||
);
|
||||
|
||||
const handleTouchMove = useCallback(
|
||||
(ev: TouchEvent) => {
|
||||
const dragCache = dragCacheRef.current;
|
||||
|
||||
if (!dragCache) {
|
||||
return;
|
||||
}
|
||||
|
||||
const [touch] = ev.touches;
|
||||
|
||||
const deltaX = touch.clientX - dragCache.startX;
|
||||
const deltaY = touch.clientY - dragCache.startY;
|
||||
|
||||
const x = dragCache.translateX + deltaX;
|
||||
const y = dragCache.translateY + deltaY;
|
||||
|
||||
drop(
|
||||
Promise.all(
|
||||
springApi.start({
|
||||
scale: ZOOM_SCALE,
|
||||
translateX: x,
|
||||
translateY: y,
|
||||
})
|
||||
)
|
||||
);
|
||||
},
|
||||
[springApi]
|
||||
);
|
||||
|
||||
const zoomButtonHandler = useCallback(
|
||||
(ev: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
|
||||
const imageNode = imageRef.current;
|
||||
const animateNode = animateRef.current;
|
||||
if (!imageNode || !animateNode) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isZoomed) {
|
||||
const maxX = imageNode.offsetWidth;
|
||||
const maxY = imageNode.offsetHeight;
|
||||
const screenHeight = window.innerHeight;
|
||||
const screenWidth = window.innerWidth;
|
||||
|
||||
zoomCacheRef.current = {
|
||||
maxX,
|
||||
maxY,
|
||||
screenHeight,
|
||||
screenWidth,
|
||||
};
|
||||
|
||||
const shouldTranslateX = maxX * ZOOM_SCALE > screenWidth;
|
||||
const shouldTranslateY = maxY * ZOOM_SCALE > screenHeight;
|
||||
|
||||
const { height, left, top, width } =
|
||||
animateNode.getBoundingClientRect();
|
||||
|
||||
const offsetX = ev.clientX - left - width / 2;
|
||||
const offsetY = ev.clientY - top - height / 2;
|
||||
const posX = -offsetX * ZOOM_SCALE + translateX.get();
|
||||
const posY = -offsetY * ZOOM_SCALE + translateY.get();
|
||||
const [x, y] = maxBoundsLimiter(posX, posY);
|
||||
|
||||
drop(
|
||||
Promise.all(
|
||||
springApi.start({
|
||||
scale: ZOOM_SCALE,
|
||||
translateX: shouldTranslateX ? x : undefined,
|
||||
translateY: shouldTranslateY ? y : undefined,
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
setIsZoomed(true);
|
||||
} else {
|
||||
drop(Promise.all(springApi.start(INITIAL_IMAGE_TRANSFORM)));
|
||||
setIsZoomed(false);
|
||||
}
|
||||
},
|
||||
[isZoomed, maxBoundsLimiter, translateX, translateY, springApi]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const animateNode = animateRef.current;
|
||||
let hasListener = false;
|
||||
|
||||
if (animateNode && isZoomed) {
|
||||
hasListener = true;
|
||||
document.addEventListener('mousemove', positionImage);
|
||||
document.addEventListener('touchmove', handleTouchMove);
|
||||
document.addEventListener('touchstart', handleTouchStart);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (hasListener) {
|
||||
document.removeEventListener('mousemove', positionImage);
|
||||
document.removeEventListener('touchmove', handleTouchMove);
|
||||
document.removeEventListener('touchstart', handleTouchStart);
|
||||
}
|
||||
};
|
||||
}, [handleTouchMove, handleTouchStart, isZoomed, positionImage]);
|
||||
|
||||
const caption = attachment?.caption;
|
||||
|
||||
let content: JSX.Element;
|
||||
if (!contentType) {
|
||||
content = <>{children}</>;
|
||||
} else {
|
||||
const isImageTypeSupported = GoogleChrome.isImageTypeSupported(contentType);
|
||||
const isVideoTypeSupported = GoogleChrome.isVideoTypeSupported(contentType);
|
||||
const isUnsupportedImageType =
|
||||
!isImageTypeSupported && isImage(contentType);
|
||||
const isUnsupportedVideoType =
|
||||
!isVideoTypeSupported && isVideo(contentType);
|
||||
|
||||
if (isImageTypeSupported) {
|
||||
if (url) {
|
||||
content = (
|
||||
<div className="Lightbox__zoomable-container">
|
||||
<button
|
||||
className="Lightbox__zoom-button"
|
||||
onClick={zoomButtonHandler}
|
||||
type="button"
|
||||
>
|
||||
<img
|
||||
alt={i18n('icu:lightboxImageAlt')}
|
||||
className="Lightbox__object"
|
||||
data-testid={attachment.cdnKey}
|
||||
onContextMenu={(ev: React.MouseEvent<HTMLImageElement>) => {
|
||||
// These are the only image types supported by Electron's NativeImage
|
||||
if (
|
||||
ev &&
|
||||
contentType !== IMAGE_PNG &&
|
||||
!/image\/jpe?g/g.test(contentType)
|
||||
) {
|
||||
ev.preventDefault();
|
||||
}
|
||||
}}
|
||||
src={url}
|
||||
ref={imageRef}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
content = (
|
||||
<button
|
||||
aria-label={i18n('icu:lightboxImageAlt')}
|
||||
className={classNames({
|
||||
Lightbox__object: true,
|
||||
Lightbox__unsupported: true,
|
||||
'Lightbox__unsupported--missing': true,
|
||||
})}
|
||||
onClick={onClose}
|
||||
type="button"
|
||||
/>
|
||||
);
|
||||
}
|
||||
} else if (isVideoTypeSupported) {
|
||||
const shouldLoop = isAttachmentGIF || isViewOnce;
|
||||
|
||||
content = (
|
||||
<video
|
||||
className="Lightbox__object Lightbox__object--video"
|
||||
controls={!shouldLoop}
|
||||
key={url || incrementalUrl}
|
||||
loop={shouldLoop}
|
||||
ref={setVideoElement}
|
||||
onMouseMove={onUserInteractionOnVideo}
|
||||
onMouseLeave={onMouseLeaveVideo}
|
||||
>
|
||||
<source src={url || incrementalUrl} />
|
||||
</video>
|
||||
);
|
||||
} else if (isUnsupportedImageType || isUnsupportedVideoType) {
|
||||
content = (
|
||||
<button
|
||||
aria-label={i18n('icu:unsupportedAttachment')}
|
||||
className={classNames({
|
||||
Lightbox__object: true,
|
||||
Lightbox__unsupported: true,
|
||||
'Lightbox__unsupported--image': isUnsupportedImageType,
|
||||
'Lightbox__unsupported--video': isUnsupportedVideoType,
|
||||
})}
|
||||
onClick={onClose}
|
||||
type="button"
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
log.info('Unexpected content type', { contentType });
|
||||
|
||||
content = (
|
||||
<button
|
||||
aria-label={i18n('icu:unsupportedAttachment')}
|
||||
className="Lightbox__object Lightbox__unsupported Lightbox__unsupported--file"
|
||||
onClick={onClose}
|
||||
type="button"
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const hasNext =
|
||||
!isZoomed && (selectedIndex < media.length - 1 || hasNextMessage);
|
||||
const hasPrevious = !isZoomed && (selectedIndex > 0 || hasPrevMessage);
|
||||
|
||||
return root
|
||||
? createPortal(
|
||||
<div
|
||||
className={classNames('Lightbox Lightbox__container', {
|
||||
'Lightbox__container--zoom': isZoomed,
|
||||
})}
|
||||
onClick={(event: React.MouseEvent<HTMLDivElement>) => {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
|
||||
closeLightbox();
|
||||
}}
|
||||
onKeyUp={(event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
if (
|
||||
(containerRef && event.target !== containerRef.current) ||
|
||||
event.keyCode !== 27
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
closeLightbox();
|
||||
}}
|
||||
ref={containerRef}
|
||||
role="presentation"
|
||||
>
|
||||
<div className="Lightbox__animated">
|
||||
<div
|
||||
className="Lightbox__main-container"
|
||||
tabIndex={-1}
|
||||
ref={focusRef}
|
||||
>
|
||||
<div className="Lightbox__header">
|
||||
{getConversation ? (
|
||||
<LightboxHeader
|
||||
getConversation={getConversation}
|
||||
i18n={i18n}
|
||||
item={currentItem}
|
||||
/>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
<div className="Lightbox__controls">
|
||||
{!isViewOnce ? (
|
||||
<button
|
||||
aria-label={i18n('icu:forwardMessage')}
|
||||
className="Lightbox__button Lightbox__button--forward"
|
||||
onClick={handleForward}
|
||||
type="button"
|
||||
/>
|
||||
) : null}
|
||||
{!isViewOnce ? (
|
||||
<button
|
||||
aria-label={i18n('icu:save')}
|
||||
className="Lightbox__button Lightbox__button--save"
|
||||
onClick={handleSave}
|
||||
type="button"
|
||||
/>
|
||||
) : null}
|
||||
<button
|
||||
aria-label={i18n('icu:close')}
|
||||
className="Lightbox__button Lightbox__button--close"
|
||||
onClick={closeLightbox}
|
||||
type="button"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<animated.div
|
||||
className={classNames('Lightbox__object--container', {
|
||||
'Lightbox__object--container--zoom': isZoomed,
|
||||
})}
|
||||
ref={animateRef}
|
||||
style={{
|
||||
transform: to(
|
||||
[scale, translateX, translateY],
|
||||
(s, x, y) => `translate(${x}px, ${y}px) scale(${s})`
|
||||
),
|
||||
}}
|
||||
>
|
||||
{isDownloading ? (
|
||||
<div
|
||||
className={classNames(
|
||||
'Lightbox__toast-container',
|
||||
shouldShowDownloadToast
|
||||
? 'Lightbox__toast-container--visible'
|
||||
: null
|
||||
)}
|
||||
>
|
||||
<Toast onClose={noop}>
|
||||
{attachment.totalDownloaded && attachment.size
|
||||
? i18n('icu:lightBoxDownloading', {
|
||||
downloaded: formatFileSize(
|
||||
attachment.totalDownloaded
|
||||
),
|
||||
total: formatFileSize(attachment.size),
|
||||
})
|
||||
: undefined}
|
||||
</Toast>
|
||||
</div>
|
||||
) : null}
|
||||
{content}
|
||||
|
||||
{hasPrevious && (
|
||||
<div className="Lightbox__nav-prev">
|
||||
<button
|
||||
aria-label={i18n('icu:previous')}
|
||||
className="Lightbox__button Lightbox__button--previous"
|
||||
onClick={onPrevious}
|
||||
type="button"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{hasNext && (
|
||||
<div className="Lightbox__nav-next">
|
||||
<button
|
||||
aria-label={i18n('icu:next')}
|
||||
className="Lightbox__button Lightbox__button--next"
|
||||
onClick={onNext}
|
||||
type="button"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</animated.div>
|
||||
</div>
|
||||
<div className="Lightbox__footer">
|
||||
{isViewOnce && videoTime ? (
|
||||
<div className="Lightbox__timestamp">
|
||||
{formatDuration(videoTime)}
|
||||
</div>
|
||||
) : null}
|
||||
{caption ? (
|
||||
<div className="Lightbox__caption">{caption}</div>
|
||||
) : null}
|
||||
<div className="Lightbox__thumbnails--container">
|
||||
<animated.div
|
||||
className="Lightbox__thumbnails"
|
||||
style={thumbnailsStyle}
|
||||
>
|
||||
{hasThumbnails
|
||||
? media.map((item, index) => (
|
||||
<button
|
||||
className={classNames({
|
||||
Lightbox__thumbnail: true,
|
||||
'Lightbox__thumbnail--selected':
|
||||
index === selectedIndex,
|
||||
})}
|
||||
key={item.attachment.thumbnail?.url}
|
||||
type="button"
|
||||
onClick={(
|
||||
event: React.MouseEvent<
|
||||
HTMLButtonElement,
|
||||
MouseEvent
|
||||
>
|
||||
) => {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
|
||||
onSelectAttachment(index);
|
||||
}}
|
||||
>
|
||||
{item.attachment.thumbnail?.url ? (
|
||||
<img
|
||||
alt={i18n('icu:lightboxImageAlt')}
|
||||
src={item.attachment.thumbnail.url}
|
||||
/>
|
||||
) : (
|
||||
<div className="Lightbox__thumbnail--unavailable" />
|
||||
)}
|
||||
</button>
|
||||
))
|
||||
: undefined}
|
||||
</animated.div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
root
|
||||
)
|
||||
: null;
|
||||
}
|
||||
|
||||
function LightboxHeader({
|
||||
getConversation,
|
||||
i18n,
|
||||
item,
|
||||
}: {
|
||||
getConversation: (id: string) => ConversationType;
|
||||
i18n: LocalizerType;
|
||||
item: ReadonlyDeep<MediaItemType>;
|
||||
}): JSX.Element {
|
||||
const { message } = item;
|
||||
const conversation = getConversation(message.conversationId);
|
||||
|
||||
const now = Date.now();
|
||||
|
||||
return (
|
||||
<div className="Lightbox__header--container">
|
||||
<div className="Lightbox__header--avatar">
|
||||
<Avatar
|
||||
avatarPlaceholderGradient={conversation.avatarPlaceholderGradient}
|
||||
avatarUrl={conversation.avatarUrl}
|
||||
badge={undefined}
|
||||
color={conversation.color}
|
||||
conversationType={conversation.type}
|
||||
hasAvatar={conversation.hasAvatar}
|
||||
i18n={i18n}
|
||||
phoneNumber={conversation.e164}
|
||||
profileName={conversation.profileName}
|
||||
sharedGroupNames={conversation.sharedGroupNames}
|
||||
size={AvatarSize.THIRTY_TWO}
|
||||
title={conversation.title}
|
||||
/>
|
||||
</div>
|
||||
<div className="Lightbox__header--content">
|
||||
<div className="Lightbox__header--name">{conversation.title}</div>
|
||||
<div className="Lightbox__header--timestamp">
|
||||
{formatDateTimeForAttachment(i18n, message.sentAt ?? now)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user