2021-08-23 19:14:53 -04:00
|
|
|
// Copyright 2018-2021 Signal Messenger, LLC
|
2020-10-30 15:34:04 -05:00
|
|
|
// SPDX-License-Identifier: AGPL-3.0-only
|
|
|
|
|
2021-08-23 19:14:53 -04:00
|
|
|
import React, {
|
|
|
|
MouseEvent,
|
|
|
|
ReactNode,
|
|
|
|
useCallback,
|
|
|
|
useEffect,
|
|
|
|
useRef,
|
|
|
|
useState,
|
|
|
|
} from 'react';
|
|
|
|
import moment from 'moment';
|
2018-04-15 00:27:30 -04:00
|
|
|
import classNames from 'classnames';
|
2021-08-23 19:14:53 -04:00
|
|
|
import { createPortal } from 'react-dom';
|
2018-04-25 18:15:57 -04:00
|
|
|
|
|
|
|
import * as GoogleChrome from '../util/GoogleChrome';
|
2021-08-23 19:14:53 -04:00
|
|
|
import { AttachmentType, isGIF } from '../types/Attachment';
|
|
|
|
import { Avatar, AvatarSize } from './Avatar';
|
|
|
|
import { ConversationType } from '../state/ducks/conversations';
|
|
|
|
import { IMAGE_PNG, isImage, isVideo } from '../types/MIME';
|
2019-01-14 13:49:58 -08:00
|
|
|
import { LocalizerType } from '../types/Util';
|
2021-08-23 19:14:53 -04:00
|
|
|
import { MediaItemType, MessageAttributesType } from '../types/MediaItem';
|
2018-05-22 12:31:43 -07:00
|
|
|
|
2021-08-23 19:14:53 -04:00
|
|
|
export type PropsType = {
|
2021-08-05 20:17:05 -04:00
|
|
|
children?: ReactNode;
|
2018-04-14 23:27:03 -04:00
|
|
|
close: () => void;
|
2021-08-23 19:14:53 -04:00
|
|
|
getConversation?: (id: string) => ConversationType;
|
2019-01-14 13:49:58 -08:00
|
|
|
i18n: LocalizerType;
|
2021-08-23 19:14:53 -04:00
|
|
|
media: Array<MediaItemType>;
|
|
|
|
onForward?: (messageId: string) => void;
|
|
|
|
onSave?: (options: {
|
|
|
|
attachment: AttachmentType;
|
|
|
|
message: MessageAttributesType;
|
|
|
|
index: number;
|
|
|
|
}) => void;
|
|
|
|
selectedIndex?: number;
|
2021-01-14 12:07:05 -06:00
|
|
|
};
|
2018-04-14 23:27:03 -04:00
|
|
|
|
2021-08-23 19:14:53 -04:00
|
|
|
export function Lightbox({
|
|
|
|
children,
|
|
|
|
close,
|
|
|
|
getConversation,
|
|
|
|
media,
|
|
|
|
i18n,
|
|
|
|
onForward,
|
|
|
|
onSave,
|
|
|
|
selectedIndex: initialSelectedIndex,
|
|
|
|
}: PropsType): JSX.Element | null {
|
|
|
|
const [root, setRoot] = React.useState<HTMLElement | undefined>();
|
|
|
|
const [selectedIndex, setSelectedIndex] = useState<number>(
|
|
|
|
initialSelectedIndex || 0
|
|
|
|
);
|
2018-04-14 23:27:03 -04:00
|
|
|
|
2021-08-23 19:14:53 -04:00
|
|
|
const [previousFocus, setPreviousFocus] = useState<HTMLElement | undefined>();
|
|
|
|
const [zoomed, setZoomed] = useState(false);
|
|
|
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
|
|
|
const focusRef = useRef<HTMLDivElement | null>(null);
|
|
|
|
const videoRef = useRef<HTMLVideoElement | null>(null);
|
2018-04-26 11:18:24 -04:00
|
|
|
|
2021-08-23 19:14:53 -04:00
|
|
|
const restorePreviousFocus = useCallback(() => {
|
|
|
|
if (previousFocus && previousFocus.focus) {
|
|
|
|
previousFocus.focus();
|
2018-04-26 11:18:24 -04:00
|
|
|
}
|
2021-08-23 19:14:53 -04:00
|
|
|
}, [previousFocus]);
|
2018-04-26 11:18:24 -04:00
|
|
|
|
2021-08-23 19:14:53 -04:00
|
|
|
const onPrevious = useCallback(() => {
|
|
|
|
setSelectedIndex(prevSelectedIndex => Math.max(prevSelectedIndex - 1, 0));
|
|
|
|
}, []);
|
2018-04-15 00:27:30 -04:00
|
|
|
|
2021-08-23 19:14:53 -04:00
|
|
|
const onNext = useCallback(() => {
|
|
|
|
setSelectedIndex(prevSelectedIndex =>
|
|
|
|
Math.min(prevSelectedIndex + 1, media.length - 1)
|
|
|
|
);
|
|
|
|
}, [media]);
|
2019-11-07 13:36:16 -08:00
|
|
|
|
2021-08-23 19:14:53 -04:00
|
|
|
const handleSave = () => {
|
|
|
|
const mediaItem = media[selectedIndex];
|
|
|
|
const { attachment, message, index } = mediaItem;
|
2019-10-03 12:03:46 -07:00
|
|
|
|
2021-08-23 19:14:53 -04:00
|
|
|
onSave?.({ attachment, message, index });
|
|
|
|
};
|
2018-07-18 16:02:10 -07:00
|
|
|
|
2021-08-23 19:14:53 -04:00
|
|
|
const handleForward = () => {
|
|
|
|
close();
|
|
|
|
const mediaItem = media[selectedIndex];
|
|
|
|
onForward?.(mediaItem.message.id);
|
|
|
|
};
|
2019-10-03 12:03:46 -07:00
|
|
|
|
2021-08-23 19:14:53 -04:00
|
|
|
const onKeyDown = useCallback(
|
|
|
|
(event: KeyboardEvent) => {
|
|
|
|
switch (event.key) {
|
|
|
|
case 'Escape':
|
|
|
|
if (zoomed) {
|
|
|
|
setZoomed(false);
|
|
|
|
} else {
|
|
|
|
close();
|
|
|
|
}
|
2019-11-07 13:36:16 -08:00
|
|
|
|
2021-08-23 19:14:53 -04:00
|
|
|
event.preventDefault();
|
|
|
|
event.stopPropagation();
|
2018-04-15 00:50:18 -04:00
|
|
|
|
2021-08-23 19:14:53 -04:00
|
|
|
break;
|
2019-11-07 13:36:16 -08:00
|
|
|
|
2021-08-23 19:14:53 -04:00
|
|
|
case 'ArrowLeft':
|
|
|
|
if (onPrevious) {
|
|
|
|
onPrevious();
|
2019-10-03 12:03:46 -07:00
|
|
|
|
2021-08-23 19:14:53 -04:00
|
|
|
event.preventDefault();
|
|
|
|
event.stopPropagation();
|
|
|
|
}
|
|
|
|
break;
|
2019-10-03 12:03:46 -07:00
|
|
|
|
2021-08-23 19:14:53 -04:00
|
|
|
case 'ArrowRight':
|
|
|
|
if (onNext) {
|
|
|
|
onNext();
|
2018-04-15 00:50:18 -04:00
|
|
|
|
2021-08-23 19:14:53 -04:00
|
|
|
event.preventDefault();
|
|
|
|
event.stopPropagation();
|
|
|
|
}
|
|
|
|
break;
|
2018-07-18 16:02:10 -07:00
|
|
|
|
2021-08-23 19:14:53 -04:00
|
|
|
default:
|
|
|
|
}
|
|
|
|
},
|
|
|
|
[close, onNext, onPrevious, zoomed]
|
|
|
|
);
|
2019-01-14 13:49:58 -08:00
|
|
|
|
2021-08-23 19:14:53 -04:00
|
|
|
const stopPropagationAndClose = (event: MouseEvent<HTMLElement>) => {
|
|
|
|
event.stopPropagation();
|
|
|
|
close();
|
|
|
|
};
|
2019-10-03 12:03:46 -07:00
|
|
|
|
2021-08-23 19:14:53 -04:00
|
|
|
const playVideo = () => {
|
|
|
|
const video = videoRef.current;
|
2019-10-03 12:03:46 -07:00
|
|
|
if (!video) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
if (video.paused) {
|
|
|
|
video.play();
|
2018-07-18 16:02:10 -07:00
|
|
|
} else {
|
2019-10-03 12:03:46 -07:00
|
|
|
video.pause();
|
2018-07-18 16:02:10 -07:00
|
|
|
}
|
2021-08-23 19:14:53 -04:00
|
|
|
};
|
2018-04-25 18:15:57 -04:00
|
|
|
|
2021-08-23 19:14:53 -04:00
|
|
|
useEffect(() => {
|
|
|
|
const div = document.createElement('div');
|
|
|
|
document.body.appendChild(div);
|
|
|
|
setRoot(div);
|
2018-04-25 18:15:57 -04:00
|
|
|
|
2021-08-23 19:14:53 -04:00
|
|
|
return () => {
|
|
|
|
document.body.removeChild(div);
|
|
|
|
setRoot(undefined);
|
|
|
|
};
|
|
|
|
}, []);
|
2019-01-14 13:49:58 -08:00
|
|
|
|
2021-08-23 19:14:53 -04:00
|
|
|
useEffect(() => {
|
|
|
|
if (!previousFocus) {
|
|
|
|
setPreviousFocus(document.activeElement as HTMLElement);
|
2018-05-07 21:20:39 -04:00
|
|
|
}
|
2021-08-23 19:14:53 -04:00
|
|
|
}, [previousFocus]);
|
2018-05-07 21:20:39 -04:00
|
|
|
|
2021-08-23 19:14:53 -04:00
|
|
|
useEffect(() => {
|
|
|
|
return () => {
|
|
|
|
restorePreviousFocus();
|
|
|
|
};
|
|
|
|
}, [restorePreviousFocus]);
|
2018-05-22 12:31:43 -07:00
|
|
|
|
2021-08-23 19:14:53 -04:00
|
|
|
useEffect(() => {
|
|
|
|
const useCapture = true;
|
|
|
|
document.addEventListener('keydown', onKeyDown, useCapture);
|
2020-11-02 17:47:46 -07:00
|
|
|
|
2021-08-23 19:14:53 -04:00
|
|
|
return () => {
|
|
|
|
document.removeEventListener('keydown', onKeyDown, useCapture);
|
|
|
|
};
|
|
|
|
}, [onKeyDown]);
|
2018-04-24 16:12:11 -04:00
|
|
|
|
2021-08-23 19:14:53 -04:00
|
|
|
useEffect(() => {
|
|
|
|
// Wait until we're added to the DOM. ConversationView first creates this
|
|
|
|
// view, then appends its elements into the DOM.
|
|
|
|
const timeout = window.setTimeout(() => {
|
|
|
|
playVideo();
|
2018-04-15 01:48:21 -04:00
|
|
|
|
2021-08-23 19:14:53 -04:00
|
|
|
if (focusRef && focusRef.current) {
|
|
|
|
focusRef.current.focus();
|
|
|
|
}
|
2019-10-03 12:03:46 -07:00
|
|
|
});
|
2018-04-26 17:25:16 -04:00
|
|
|
|
2021-08-23 19:14:53 -04:00
|
|
|
return () => {
|
|
|
|
if (timeout) {
|
|
|
|
window.clearTimeout(timeout);
|
|
|
|
}
|
|
|
|
};
|
|
|
|
}, [selectedIndex]);
|
2018-04-26 17:25:16 -04:00
|
|
|
|
2021-08-23 19:14:53 -04:00
|
|
|
const { attachment, contentType, loop = false, objectURL, message } =
|
|
|
|
media[selectedIndex] || {};
|
|
|
|
const caption = attachment?.caption;
|
2019-11-07 13:36:16 -08:00
|
|
|
|
2021-08-23 19:14:53 -04:00
|
|
|
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);
|
2018-04-26 17:25:16 -04:00
|
|
|
|
2021-08-23 19:14:53 -04:00
|
|
|
if (isImageTypeSupported) {
|
|
|
|
if (objectURL) {
|
|
|
|
content = (
|
|
|
|
<button
|
|
|
|
className="Lightbox__zoom-button"
|
|
|
|
onClick={() => setZoomed(!zoomed)}
|
|
|
|
type="button"
|
|
|
|
>
|
|
|
|
<img
|
|
|
|
alt={i18n('lightboxImageAlt')}
|
|
|
|
className="Lightbox__object"
|
|
|
|
onContextMenu={(event: MouseEvent<HTMLImageElement>) => {
|
|
|
|
// These are the only image types supported by Electron's NativeImage
|
|
|
|
if (
|
|
|
|
event &&
|
|
|
|
contentType !== IMAGE_PNG &&
|
|
|
|
!/image\/jpe?g/g.test(contentType)
|
|
|
|
) {
|
|
|
|
event.preventDefault();
|
|
|
|
}
|
|
|
|
}}
|
|
|
|
src={objectURL}
|
|
|
|
/>
|
|
|
|
</button>
|
|
|
|
);
|
|
|
|
} else {
|
|
|
|
content = (
|
|
|
|
<button
|
|
|
|
aria-label={i18n('lightboxImageAlt')}
|
|
|
|
className={classNames({
|
|
|
|
Lightbox__object: true,
|
|
|
|
Lightbox__unsupported: true,
|
|
|
|
'Lightbox__unsupported--missing': true,
|
|
|
|
})}
|
|
|
|
onClick={stopPropagationAndClose}
|
|
|
|
type="button"
|
|
|
|
/>
|
|
|
|
);
|
|
|
|
}
|
|
|
|
} else if (isVideoTypeSupported) {
|
|
|
|
const shouldLoop = loop || isGIF([attachment]);
|
|
|
|
content = (
|
|
|
|
<video
|
|
|
|
className="Lightbox__object"
|
|
|
|
controls={!shouldLoop}
|
|
|
|
key={objectURL}
|
|
|
|
loop={shouldLoop}
|
|
|
|
ref={videoRef}
|
|
|
|
>
|
|
|
|
<source src={objectURL} />
|
|
|
|
</video>
|
|
|
|
);
|
|
|
|
} else if (isUnsupportedImageType || isUnsupportedVideoType) {
|
|
|
|
content = (
|
|
|
|
<button
|
|
|
|
aria-label={i18n('unsupportedAttachment')}
|
|
|
|
className={classNames({
|
|
|
|
Lightbox__object: true,
|
|
|
|
Lightbox__unsupported: true,
|
|
|
|
'Lightbox__unsupported--image': isUnsupportedImageType,
|
|
|
|
'Lightbox__unsupported--video': isUnsupportedVideoType,
|
|
|
|
})}
|
|
|
|
onClick={stopPropagationAndClose}
|
|
|
|
type="button"
|
|
|
|
/>
|
|
|
|
);
|
|
|
|
} else {
|
|
|
|
window.log.info('Lightbox: Unexpected content type', { contentType });
|
2018-04-15 01:48:21 -04:00
|
|
|
|
2021-08-23 19:14:53 -04:00
|
|
|
content = (
|
|
|
|
<button
|
|
|
|
aria-label={i18n('unsupportedAttachment')}
|
|
|
|
className="Lightbox__object Lightbox__unsupported Lightbox__unsupported--file"
|
|
|
|
onClick={stopPropagationAndClose}
|
|
|
|
type="button"
|
|
|
|
/>
|
|
|
|
);
|
2018-04-15 01:48:21 -04:00
|
|
|
}
|
2021-08-23 19:14:53 -04:00
|
|
|
}
|
2018-04-15 01:48:21 -04:00
|
|
|
|
2021-08-23 19:14:53 -04:00
|
|
|
const hasNext = selectedIndex < media.length - 1;
|
|
|
|
const hasPrevious = selectedIndex > 0;
|
|
|
|
|
|
|
|
return root
|
|
|
|
? createPortal(
|
|
|
|
<div
|
|
|
|
className="Lightbox Lightbox__container"
|
|
|
|
onClick={(event: MouseEvent<HTMLDivElement>) => {
|
|
|
|
if (containerRef && event.target !== containerRef.current) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
close();
|
|
|
|
}}
|
|
|
|
onKeyUp={(event: React.KeyboardEvent<HTMLDivElement>) => {
|
|
|
|
if (
|
|
|
|
(containerRef && event.target !== containerRef.current) ||
|
|
|
|
event.keyCode !== 27
|
|
|
|
) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
close();
|
|
|
|
}}
|
|
|
|
ref={containerRef}
|
|
|
|
role="presentation"
|
|
|
|
>
|
|
|
|
<div
|
|
|
|
className="Lightbox__main-container"
|
|
|
|
tabIndex={-1}
|
|
|
|
ref={focusRef}
|
|
|
|
>
|
|
|
|
{!zoomed && (
|
|
|
|
<div className="Lightbox__header">
|
|
|
|
{getConversation ? (
|
|
|
|
<LightboxHeader
|
|
|
|
getConversation={getConversation}
|
|
|
|
i18n={i18n}
|
|
|
|
message={message}
|
|
|
|
/>
|
|
|
|
) : (
|
|
|
|
<div />
|
|
|
|
)}
|
|
|
|
<div className="Lightbox__controls">
|
|
|
|
{onForward ? (
|
|
|
|
<button
|
|
|
|
aria-label={i18n('forwardMessage')}
|
|
|
|
className="Lightbox__button Lightbox__button--forward"
|
|
|
|
onClick={handleForward}
|
|
|
|
type="button"
|
|
|
|
/>
|
|
|
|
) : null}
|
|
|
|
{onSave ? (
|
|
|
|
<button
|
|
|
|
aria-label={i18n('save')}
|
|
|
|
className="Lightbox__button Lightbox__button--save"
|
|
|
|
onClick={handleSave}
|
|
|
|
type="button"
|
|
|
|
/>
|
|
|
|
) : null}
|
|
|
|
<button
|
|
|
|
aria-label={i18n('close')}
|
|
|
|
className="Lightbox__button Lightbox__button--close"
|
|
|
|
onClick={close}
|
|
|
|
type="button"
|
|
|
|
/>
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
)}
|
|
|
|
<div
|
|
|
|
className={classNames('Lightbox__object--container', {
|
|
|
|
'Lightbox__object--container--zoomed': zoomed,
|
|
|
|
})}
|
|
|
|
>
|
|
|
|
{content}
|
|
|
|
</div>
|
|
|
|
{hasPrevious && (
|
|
|
|
<div className="Lightbox__nav-prev">
|
|
|
|
<button
|
|
|
|
aria-label={i18n('previous')}
|
|
|
|
className="Lightbox__button Lightbox__button--previous"
|
|
|
|
disabled={zoomed}
|
|
|
|
onClick={onPrevious}
|
|
|
|
type="button"
|
|
|
|
/>
|
|
|
|
</div>
|
|
|
|
)}
|
|
|
|
{hasNext && (
|
|
|
|
<div className="Lightbox__nav-next">
|
|
|
|
<button
|
|
|
|
aria-label={i18n('next')}
|
|
|
|
className="Lightbox__button Lightbox__button--next"
|
|
|
|
disabled={zoomed}
|
|
|
|
onClick={onNext}
|
|
|
|
type="button"
|
|
|
|
/>
|
|
|
|
</div>
|
|
|
|
)}
|
|
|
|
</div>
|
|
|
|
{!zoomed && (
|
|
|
|
<div className="Lightbox__footer">
|
|
|
|
{caption ? (
|
|
|
|
<div className="Lightbox__caption">{caption}</div>
|
|
|
|
) : null}
|
|
|
|
{media.length > 1 && (
|
|
|
|
<div className="Lightbox__thumbnails--container">
|
|
|
|
<div
|
|
|
|
className="Lightbox__thumbnails"
|
|
|
|
style={{
|
|
|
|
marginLeft:
|
|
|
|
0 - (selectedIndex * 64 + selectedIndex * 8 + 32),
|
|
|
|
}}
|
|
|
|
>
|
|
|
|
{media.map((item, index) => (
|
|
|
|
<button
|
|
|
|
className={classNames({
|
|
|
|
Lightbox__thumbnail: true,
|
|
|
|
'Lightbox__thumbnail--selected':
|
|
|
|
index === selectedIndex,
|
|
|
|
})}
|
|
|
|
key={item.thumbnailObjectUrl}
|
|
|
|
type="button"
|
|
|
|
onClick={() => setSelectedIndex(index)}
|
|
|
|
>
|
|
|
|
{item.thumbnailObjectUrl ? (
|
|
|
|
<img
|
|
|
|
alt={i18n('lightboxImageAlt')}
|
|
|
|
src={item.thumbnailObjectUrl}
|
|
|
|
/>
|
|
|
|
) : (
|
|
|
|
<div className="Lightbox__thumbnail--unavailable" />
|
|
|
|
)}
|
|
|
|
</button>
|
|
|
|
))}
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
)}
|
|
|
|
</div>
|
|
|
|
)}
|
|
|
|
</div>,
|
|
|
|
root
|
|
|
|
)
|
|
|
|
: null;
|
|
|
|
}
|
2020-09-11 17:46:52 -07:00
|
|
|
|
2021-08-23 19:14:53 -04:00
|
|
|
function LightboxHeader({
|
|
|
|
getConversation,
|
|
|
|
i18n,
|
|
|
|
message,
|
|
|
|
}: {
|
|
|
|
getConversation: (id: string) => ConversationType;
|
|
|
|
i18n: LocalizerType;
|
|
|
|
message: MessageAttributesType;
|
|
|
|
}): JSX.Element {
|
|
|
|
const conversation = getConversation(message.conversationId);
|
2020-09-11 17:46:52 -07:00
|
|
|
|
2021-08-23 19:14:53 -04:00
|
|
|
return (
|
|
|
|
<div className="Lightbox__header--container">
|
|
|
|
<div className="Lightbox__header--avatar">
|
|
|
|
<Avatar
|
|
|
|
acceptedMessageRequest={conversation.acceptedMessageRequest}
|
|
|
|
avatarPath={conversation.avatarPath}
|
|
|
|
color={conversation.color}
|
|
|
|
conversationType={conversation.type}
|
|
|
|
i18n={i18n}
|
|
|
|
isMe={conversation.isMe}
|
|
|
|
name={conversation.name}
|
|
|
|
phoneNumber={conversation.e164}
|
|
|
|
profileName={conversation.profileName}
|
|
|
|
sharedGroupNames={conversation.sharedGroupNames}
|
|
|
|
size={AvatarSize.THIRTY_TWO}
|
|
|
|
title={conversation.title}
|
|
|
|
unblurredAvatarPath={conversation.unblurredAvatarPath}
|
|
|
|
/>
|
|
|
|
</div>
|
|
|
|
<div className="Lightbox__header--content">
|
|
|
|
<div className="Lightbox__header--name">{conversation.title}</div>
|
|
|
|
<div className="Lightbox__header--timestamp">
|
|
|
|
{moment(message.received_at_ms).format('L LT')}
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
);
|
2018-04-14 23:27:03 -04:00
|
|
|
}
|