Improved Lightbox experience

This commit is contained in:
Josh Perez 2021-08-23 19:14:53 -04:00 committed by GitHub
parent d80e738fb1
commit d5d808651a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
26 changed files with 1054 additions and 966 deletions

View File

@ -41,7 +41,6 @@ const {
const { Emojify } = require('../../ts/components/conversation/Emojify');
const { ErrorModal } = require('../../ts/components/ErrorModal');
const { Lightbox } = require('../../ts/components/Lightbox');
const { LightboxGallery } = require('../../ts/components/LightboxGallery');
const {
MediaGallery,
} = require('../../ts/components/conversation/media-gallery/MediaGallery');
@ -140,7 +139,6 @@ const VisualAttachment = require('./types/visual_attachment');
const EmbeddedContact = require('../../ts/types/EmbeddedContact');
const Conversation = require('./types/conversation');
const Errors = require('../../ts/types/errors');
const MediaGalleryMessage = require('../../ts/components/conversation/media-gallery/types/Message');
const MessageType = require('./types/message');
const MIME = require('../../ts/types/MIME');
const SettingsType = require('../../ts/types/Settings');
@ -349,7 +347,6 @@ exports.setup = (options = {}) => {
Emojify,
ErrorModal,
Lightbox,
LightboxGallery,
MediaGallery,
MessageDetail,
Quote,
@ -357,9 +354,6 @@ exports.setup = (options = {}) => {
StagedLinkPreview,
DisappearingTimeDialog,
SystemTraySettingsCheckboxes,
Types: {
Message: MediaGalleryMessage,
},
WhatsNew,
};

View File

@ -1,76 +0,0 @@
// Copyright 2016-2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
.lightbox-container {
display: none;
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 100;
}
.iconButton {
@include button-reset;
// NOTE: Cannot move these to inline styles as hover breaks due to precedence.
// We use vanilla CSS-in-JS which outputs inline styles. The `:hover`
// pseudo-class cannot be expressed using vanilla CSS-in-JS, so we define it
// here. If we move the other properties to JS, they have higher precedence
// as they are inline and the `:hover` `background` change wont override the
// base `background` definition. Revisit this as we adopt a more sophisticated
// style system in the future:
background: transparent;
width: 50px;
height: 50px;
display: inline-block;
border-radius: 50%;
padding: 3px;
&:before {
content: '';
display: block;
width: 100%;
height: 100%;
}
&:hover,
&:focus {
background: $color-gray-60;
}
&.save {
&:before {
@include color-svg(
'../images/icons/v2/save-outline-24.svg',
$color-white
);
}
}
&.close {
&:before {
@include color-svg('../images/icons/v2/x-24.svg', $color-white);
}
}
&.previous {
&:before {
@include color-svg(
'../images/icons/v2/chevron-left-24.svg',
$color-white
);
}
}
&.next {
&:before {
@include color-svg(
'../images/icons/v2/chevron-right-24.svg',
$color-white
);
}
}
}

View File

@ -0,0 +1,273 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
.Lightbox {
&__container {
background-color: $color-black-alpha-80;
bottom: 0;
display: flex;
flex-direction: column;
left: 0;
padding: 0 16px;
position: absolute;
right: 0;
top: 0;
z-index: 10;
}
&__main-container {
display: flex;
flex-direction: column;
flex-grow: 1;
// To ensure that a large image doesnt overflow the flex layout
min-height: 50px;
outline: none;
}
&__footer {
align-items: center;
display: flex;
flex-direction: column;
justify-content: center;
min-height: 56px;
}
&__thumbnails {
align-items: center;
display: flex;
justify-content: center;
left: 50%;
position: absolute;
&--container {
height: 64px;
margin-bottom: 16px;
margin-top: 10px;
position: relative;
}
}
&__thumbnail {
@include button-reset;
border-radius: 4px;
height: 64px;
margin-right: 8px;
overflow: hidden;
width: 64px;
img {
height: 100%;
object-fit: contain;
width: 100%;
}
&--selected {
box-shadow: 0px 0px 0px 2px $color-ultramarine;
}
&--unavailable {
@include color-svg('../images/image.svg', $color-gray-25);
height: 100%;
width: 100%;
}
}
&__object {
&--container {
display: inline-flex;
flex-grow: 1;
justify-content: center;
margin: 0 40px;
overflow: hidden;
position: relative;
&--zoomed {
margin: 0;
}
}
height: auto;
left: 50%;
max-height: 100%;
max-width: 100%;
object-fit: contain;
outline: none;
position: absolute;
top: 50%;
transform: translate(-50%, -50%);
width: auto;
}
&__unsupported {
@include button-reset;
flex-grow: 1;
height: 100%;
max-width: 200px;
width: 100%;
&--image {
@include color-svg('../images/image.svg', $color-gray-25);
}
&--video {
@include color-svg('../images/movie.svg', $color-gray-25);
}
&--file {
@include color-svg('../images/file.svg', $color-gray-25);
}
&--missing {
@include color-svg(
'../images/full-screen-flow/alert-outline.svg',
$color-gray-25
);
}
}
&__zoom-button {
@include button-reset;
cursor: zoom-in;
}
&__object--container--zoomed {
.Lightbox__zoom-button {
cursor: zoom-out;
}
}
&__caption {
@include font-body-2;
color: $color-white;
margin: 12px 0;
text-align: center;
}
&__countdown {
padding: 8px;
}
&__timestamp {
@include font-body-1;
background-color: $color-black;
border-radius: 15px;
color: #eeefef;
padding: 6px 18px;
text-align: center;
}
&__nav-next {
bottom: 50%;
position: absolute;
right: 21px;
}
&__nav-prev {
bottom: 50%;
left: 21px;
position: absolute;
}
&__header {
align-items: center;
display: flex;
height: 56px;
justify-content: space-between;
margin-top: 24px;
&--container {
display: flex;
}
&--avatar {
margin-right: 12px;
}
&--name {
@include font-body-2-bold;
color: $color-white;
}
&--timestamp {
@include font-caption;
color: $color-gray-25;
}
}
&__button {
@include button-reset;
border-radius: 4px;
display: inline-block;
margin-left: 24px;
height: 24px;
width: 24px;
&::before {
content: '';
display: block;
height: 100%;
width: 100%;
}
&:hover {
&::before {
background: $color-white;
}
}
&:focus {
outline: 4px solid $color-ultramarine;
}
&:disabled {
&::before {
background: $color-gray-65;
}
}
&--forward {
&::before {
@include color-svg(
'../images/icons/v2/reply-solid-24.svg',
$color-gray-15
);
}
}
&--save {
&::before {
@include color-svg(
'../images/icons/v2/save-solid-24.svg',
$color-gray-15
);
}
}
&--close {
&::before {
@include color-svg('../images/icons/v2/x-24.svg', $color-gray-15);
}
}
&--previous {
margin-left: 0;
&::before {
@include color-svg(
'../images/icons/v2/chevron-left-24.svg',
$color-gray-15
);
}
}
&--next {
margin-left: 0;
&::before {
@include color-svg(
'../images/icons/v2/chevron-right-24.svg',
$color-gray-15
);
}
}
}
}

View File

@ -2,7 +2,7 @@
// SPDX-License-Identifier: AGPL-3.0-only
@mixin preferences-icon($light_svg, $dark_svg) {
&:before {
&::before {
@include light-theme {
@include color-svg($light_svg, $color-gray-75);
}
@ -55,7 +55,7 @@
}
}
&:before {
&::before {
content: '';
display: block;
height: 22px;
@ -104,7 +104,7 @@
'../images/icons/v2/lock-outline-24.svg',
'../images/icons/v2/lock-solid-24.svg'
);
&:before {
&::before {
-webkit-mask-size: 75%;
}
}

View File

@ -11,7 +11,6 @@
@import 'progress';
@import 'modal';
@import 'debugLog';
@import 'lightbox';
@import 'recorder';
@import 'emoji';
@import 'settings';
@ -63,6 +62,7 @@
@import './components/IncomingCallBar.scss';
@import './components/Input.scss';
@import './components/LeftPaneDialog.scss';
@import './components/Lightbox.scss';
@import './components/MediaQualitySelector.scss';
@import './components/MessageAudio.scss';
@import './components/MessageDetail.scss';

View File

@ -26,13 +26,7 @@ export const AvatarLightbox = ({
onClose,
}: PropsType): JSX.Element => {
return (
<Lightbox
contentType={undefined}
close={onClose}
i18n={i18n}
isViewOnce={false}
objectURL=""
>
<Lightbox close={onClose} i18n={i18n} media={[]}>
<AvatarPreview
avatarColor={avatarColor}
avatarPath={avatarPath}

View File

@ -1,13 +1,16 @@
// Copyright 2020 Signal Messenger, LLC
// Copyright 2020-2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import * as React from 'react';
import { action } from '@storybook/addon-actions';
import { boolean, text } from '@storybook/addon-knobs';
import { storiesOf } from '@storybook/react';
import { action } from '@storybook/addon-actions';
import { number } from '@storybook/addon-knobs';
import { Lightbox, Props } from './Lightbox';
import enMessages from '../../_locales/en/messages.json';
import { Lightbox, PropsType } from './Lightbox';
import { MediaItemType } from '../types/MediaItem';
import { setup as setupI18n } from '../../js/modules/i18n';
import {
AUDIO_MP3,
IMAGE_JPEG,
@ -15,123 +18,237 @@ import {
VIDEO_QUICKTIME,
stringToMIMEType,
} from '../types/MIME';
import { setup as setupI18n } from '../../js/modules/i18n';
import enMessages from '../../_locales/en/messages.json';
const i18n = setupI18n('en', enMessages);
const story = storiesOf('Components/Lightbox', module);
const createProps = (overrideProps: Partial<Props> = {}): Props => ({
caption: text('caption', overrideProps.caption || ''),
type OverridePropsMediaItemType = Partial<MediaItemType> & { caption?: string };
function createMediaItem(
overrideProps: OverridePropsMediaItemType
): MediaItemType {
return {
attachment: {
caption: overrideProps.caption || '',
contentType: IMAGE_JPEG,
fileName: overrideProps.objectURL,
url: overrideProps.objectURL,
},
contentType: IMAGE_JPEG,
index: 0,
message: {
attachments: [],
conversationId: '1234',
id: 'image-msg',
received_at: 0,
received_at_ms: Date.now(),
},
objectURL: '',
...overrideProps,
};
}
const createProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
close: action('close'),
contentType: overrideProps.contentType || IMAGE_JPEG,
i18n,
isViewOnce: boolean('isViewOnce', overrideProps.isViewOnce || false),
objectURL: text('objectURL', overrideProps.objectURL || ''),
onNext: overrideProps.onNext,
onPrevious: overrideProps.onPrevious,
onSave: overrideProps.onSave,
media: overrideProps.media || [],
onSave: action('onSave'),
selectedIndex: number('selectedIndex', overrideProps.selectedIndex || 0),
});
story.add('Image', () => {
story.add('Multimedia', () => {
const props = createProps({
objectURL: '/fixtures/tina-rolf-269345-unsplash.jpg',
media: [
{
attachment: {
contentType: IMAGE_JPEG,
fileName: 'tina-rolf-269345-unsplash.jpg',
url: '/fixtures/tina-rolf-269345-unsplash.jpg',
caption:
'Still from The Lighthouse, starring Robert Pattinson and Willem Defoe.',
},
contentType: IMAGE_JPEG,
index: 0,
message: {
attachments: [],
conversationId: '1234',
id: 'image-msg',
received_at: 1,
received_at_ms: Date.now(),
},
objectURL: '/fixtures/tina-rolf-269345-unsplash.jpg',
},
{
attachment: {
contentType: VIDEO_MP4,
fileName: 'pixabay-Soap-Bubble-7141.mp4',
url: '/fixtures/pixabay-Soap-Bubble-7141.mp4',
},
contentType: VIDEO_MP4,
index: 1,
message: {
attachments: [],
conversationId: '1234',
id: 'video-msg',
received_at: 2,
received_at_ms: Date.now(),
},
objectURL: '/fixtures/pixabay-Soap-Bubble-7141.mp4',
},
createMediaItem({
contentType: IMAGE_JPEG,
index: 2,
thumbnailObjectUrl: '/fixtures/kitten-1-64-64.jpg',
objectURL: '/fixtures/kitten-1-64-64.jpg',
}),
createMediaItem({
contentType: IMAGE_JPEG,
index: 3,
thumbnailObjectUrl: '/fixtures/kitten-2-64-64.jpg',
objectURL: '/fixtures/kitten-2-64-64.jpg',
}),
],
});
return <Lightbox {...props} />;
});
story.add('Image with Caption (normal image)', () => {
story.add('Missing Media', () => {
const props = createProps({
caption:
'This is the user-provided caption. It can get long and wrap onto multiple lines.',
objectURL: '/fixtures/tina-rolf-269345-unsplash.jpg',
media: [
{
attachment: {
contentType: IMAGE_JPEG,
fileName: 'tina-rolf-269345-unsplash.jpg',
url: '/fixtures/tina-rolf-269345-unsplash.jpg',
},
contentType: IMAGE_JPEG,
index: 0,
message: {
attachments: [],
conversationId: '1234',
id: 'image-msg',
received_at: 3,
received_at_ms: Date.now(),
},
objectURL: undefined,
},
],
});
return <Lightbox {...props} />;
});
story.add('Image with Caption (all-white image)', () => {
const props = createProps({
caption:
'This is the user-provided caption. It should be visible on light backgrounds.',
objectURL: '/fixtures/2000x2000-white.png',
});
story.add('Single Image', () => (
<Lightbox
{...createProps({
media: [
createMediaItem({
objectURL: '/fixtures/tina-rolf-269345-unsplash.jpg',
}),
],
})}
/>
));
return <Lightbox {...props} />;
});
story.add('Image with Caption (normal image)', () => (
<Lightbox
{...createProps({
media: [
createMediaItem({
caption:
'This lighthouse is really cool because there are lots of rocks and there is a tower that has a light and the light is really bright because it shines so much. The day was super duper cloudy and stormy and you can see all the waves hitting against the rocks. Wait? What is that weird red hose line thingy running all the way to the tower? Those rocks look slippery! I bet that water is really cold. I am cold now, can I get a sweater? I wonder where this place is, probably somewhere cold like Coldsgar, Frozenville.',
objectURL: '/fixtures/tina-rolf-269345-unsplash.jpg',
}),
],
})}
/>
));
story.add('Video', () => {
const props = createProps({
contentType: VIDEO_MP4,
objectURL: '/fixtures/pixabay-Soap-Bubble-7141.mp4',
});
story.add('Image with Caption (all-white image)', () => (
<Lightbox
{...createProps({
media: [
createMediaItem({
caption:
'This is the user-provided caption. It should be visible on light backgrounds.',
objectURL: '/fixtures/2000x2000-white.png',
}),
],
})}
/>
));
return <Lightbox {...props} />;
});
story.add('Single Video', () => (
<Lightbox
{...createProps({
media: [
createMediaItem({
contentType: VIDEO_MP4,
objectURL: '/fixtures/pixabay-Soap-Bubble-7141.mp4',
}),
],
})}
/>
));
story.add('Video with Caption', () => {
const props = createProps({
caption:
'This is the user-provided caption. It can get long and wrap onto multiple lines.',
contentType: VIDEO_MP4,
objectURL: '/fixtures/pixabay-Soap-Bubble-7141.mp4',
});
story.add('Single Video w/caption', () => (
<Lightbox
{...createProps({
media: [
createMediaItem({
caption:
'This is the user-provided caption. It can get long and wrap onto multiple lines.',
contentType: VIDEO_MP4,
objectURL: '/fixtures/pixabay-Soap-Bubble-7141.mp4',
}),
],
})}
/>
));
return <Lightbox {...props} />;
});
story.add('Unsupported Image Type', () => (
<Lightbox
{...createProps({
media: [
createMediaItem({
contentType: stringToMIMEType('image/tiff'),
objectURL: 'unsupported-image.tiff',
}),
],
})}
/>
));
story.add('Video (View Once)', () => {
const props = createProps({
contentType: VIDEO_MP4,
isViewOnce: true,
objectURL: '/fixtures/pixabay-Soap-Bubble-7141.mp4',
});
story.add('Unsupported Video Type', () => (
<Lightbox
{...createProps({
media: [
createMediaItem({
contentType: VIDEO_QUICKTIME,
objectURL: 'unsupported-video.mov',
}),
],
})}
/>
));
return <Lightbox {...props} />;
});
story.add('Unsupported Image Type', () => {
const props = createProps({
contentType: stringToMIMEType('image/tiff'),
objectURL: 'unsupported-image.tiff',
});
return <Lightbox {...props} />;
});
story.add('Unsupported Video Type', () => {
const props = createProps({
contentType: VIDEO_QUICKTIME,
objectURL: 'unsupported-video.mov',
});
return <Lightbox {...props} />;
});
story.add('Unsupported ContentType', () => {
const props = createProps({
contentType: AUDIO_MP3,
objectURL: '/fixtures/incompetech-com-Agnus-Dei-X.mp3',
});
return <Lightbox {...props} />;
});
story.add('Including Next/Previous/Save Callbacks', () => {
const props = createProps({
objectURL: '/fixtures/tina-rolf-269345-unsplash.jpg',
onNext: action('onNext'),
onPrevious: action('onPrevious'),
onSave: action('onSave'),
});
return <Lightbox {...props} />;
});
story.add('Unsupported Content', () => (
<Lightbox
{...createProps({
media: [
createMediaItem({
contentType: AUDIO_MP3,
objectURL: '/fixtures/incompetech-com-Agnus-Dei-X.mp3',
}),
],
})}
/>
));
story.add('Custom children', () => (
<Lightbox {...createProps({})} contentType={undefined}>
<Lightbox {...createProps({})} media={[]}>
<div
style={{
color: 'white',
@ -144,3 +261,30 @@ story.add('Custom children', () => (
</div>
</Lightbox>
));
story.add('Forwarding', () => (
<Lightbox {...createProps({})} onForward={action('onForward')} />
));
story.add('Conversation Header', () => (
<Lightbox
{...createProps({})}
getConversation={() => ({
acceptedMessageRequest: true,
avatarPath: '/fixtures/kitten-1-64-64.jpg',
id: '1234',
isMe: false,
name: 'Test',
profileName: 'Test',
sharedGroupNames: [],
title: 'Test',
type: 'direct',
})}
media={[
createMediaItem({
contentType: VIDEO_MP4,
objectURL: '/fixtures/pixabay-Soap-Bubble-7141.mp4',
}),
]}
/>
));

View File

@ -1,291 +1,137 @@
// Copyright 2018-2020 Signal Messenger, LLC
// Copyright 2018-2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { ReactNode } from 'react';
import React, {
MouseEvent,
ReactNode,
useCallback,
useEffect,
useRef,
useState,
} from 'react';
import moment from 'moment';
import classNames from 'classnames';
import is from '@sindresorhus/is';
import { createPortal } from 'react-dom';
import * as GoogleChrome from '../util/GoogleChrome';
import * as MIME from '../types/MIME';
import { formatDuration } from '../util/formatDuration';
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';
import { LocalizerType } from '../types/Util';
import { MediaItemType, MessageAttributesType } from '../types/MediaItem';
const Colors = {
ICON_SECONDARY: '#b9b9b9',
};
const colorSVG = (url: string, color: string) => {
return {
WebkitMask: `url(${url}) no-repeat center`,
WebkitMaskSize: '100%',
backgroundColor: color,
};
};
export type Props = {
export type PropsType = {
children?: ReactNode;
close: () => void;
contentType: MIME.MIMEType | undefined;
getConversation?: (id: string) => ConversationType;
i18n: LocalizerType;
objectURL: string;
caption?: string;
isViewOnce: boolean;
loop?: boolean;
onNext?: () => void;
onPrevious?: () => void;
onSave?: () => void;
};
type State = {
videoTime?: number;
media: Array<MediaItemType>;
onForward?: (messageId: string) => void;
onSave?: (options: {
attachment: AttachmentType;
message: MessageAttributesType;
index: number;
}) => void;
selectedIndex?: number;
};
const CONTROLS_WIDTH = 50;
const CONTROLS_SPACING = 10;
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
);
const styles = {
container: {
display: 'flex',
flexDirection: 'column',
position: 'absolute',
left: 0,
right: 0,
top: 0,
bottom: 0,
backgroundColor: 'rgba(0, 0, 0, 0.9)',
zIndex: 10,
} as React.CSSProperties,
buttonContainer: {
backgroundColor: 'transparent',
border: 'none',
display: 'flex',
flexDirection: 'column',
outline: 'none',
width: '100%',
padding: 0,
} as React.CSSProperties,
mainContainer: {
display: 'flex',
flexDirection: 'row',
flexGrow: 1,
paddingTop: 40,
paddingLeft: 40,
paddingRight: 40,
paddingBottom: 0,
// To ensure that a large image doesn't overflow the flex layout
minHeight: '50px',
outline: 'none',
} as React.CSSProperties,
objectContainer: {
position: 'relative',
flexGrow: 1,
display: 'inline-flex',
justifyContent: 'center',
} as React.CSSProperties,
object: {
flexGrow: 1,
flexShrink: 1,
maxWidth: '100%',
maxHeight: '100%',
objectFit: 'contain',
outline: 'none',
} as React.CSSProperties,
img: {
position: 'absolute',
left: '50%',
top: '50%',
transform: 'translate(-50%, -50%)',
width: 'auto',
height: 'auto',
maxWidth: '100%',
maxHeight: '100%',
objectFit: 'contain',
outline: 'none',
} as React.CSSProperties,
caption: {
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
textAlign: 'center',
color: 'white',
fontWeight: 'bold',
textShadow: '0 0 1px black, 0 0 2px black, 0 0 3px black, 0 0 4px black',
padding: '1em',
paddingLeft: '3em',
paddingRight: '3em',
backgroundColor: 'rgba(192, 192, 192, .20)',
} as React.CSSProperties,
controlsOffsetPlaceholder: {
width: CONTROLS_WIDTH,
marginRight: CONTROLS_SPACING,
flexShrink: 0,
},
controls: {
width: CONTROLS_WIDTH,
flexShrink: 0,
display: 'flex',
flexDirection: 'column',
marginLeft: CONTROLS_SPACING,
} as React.CSSProperties,
navigationContainer: {
flexShrink: 0,
display: 'flex',
flexDirection: 'row',
justifyContent: 'center',
padding: 10,
} as React.CSSProperties,
saveButton: {
marginTop: 10,
},
countdownContainer: {
padding: 8,
},
iconButtonPlaceholder: {
// Dimensions match `.iconButton`:
display: 'inline-block',
width: 50,
height: 50,
},
timestampPill: {
borderRadius: '15px',
backgroundColor: '#000000',
color: '#eeefef',
fontSize: '16px',
letterSpacing: '0px',
lineHeight: '18px',
// This cast is necessary or typescript chokes
textAlign: 'center' as const,
padding: '6px',
paddingLeft: '18px',
paddingRight: '18px',
},
};
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);
type IconButtonProps = {
i18n: LocalizerType;
onClick?: () => void;
style?: React.CSSProperties;
type: 'save' | 'close' | 'previous' | 'next';
};
const IconButton = ({ i18n, onClick, style, type }: IconButtonProps) => {
const clickHandler = (event: React.MouseEvent<HTMLButtonElement>): void => {
event.preventDefault();
if (!onClick) {
return;
const restorePreviousFocus = useCallback(() => {
if (previousFocus && previousFocus.focus) {
previousFocus.focus();
}
}, [previousFocus]);
onClick();
const onPrevious = useCallback(() => {
setSelectedIndex(prevSelectedIndex => Math.max(prevSelectedIndex - 1, 0));
}, []);
const onNext = useCallback(() => {
setSelectedIndex(prevSelectedIndex =>
Math.min(prevSelectedIndex + 1, media.length - 1)
);
}, [media]);
const handleSave = () => {
const mediaItem = media[selectedIndex];
const { attachment, message, index } = mediaItem;
onSave?.({ attachment, message, index });
};
return (
<button
onClick={clickHandler}
className={classNames('iconButton', type)}
style={style}
aria-label={i18n(type)}
type="button"
/>
);
};
const handleForward = () => {
close();
const mediaItem = media[selectedIndex];
onForward?.(mediaItem.message.id);
};
const IconButtonPlaceholder = () => (
<div style={styles.iconButtonPlaceholder} />
);
const onKeyDown = useCallback(
(event: KeyboardEvent) => {
switch (event.key) {
case 'Escape':
if (zoomed) {
setZoomed(false);
} else {
close();
}
const Icon = ({
i18n,
onClick,
url,
}: {
i18n: LocalizerType;
onClick: (event: React.MouseEvent<HTMLButtonElement>) => void;
url: string;
}) => (
<button
style={{
...styles.object,
...colorSVG(url, Colors.ICON_SECONDARY),
maxWidth: 200,
}}
onClick={onClick}
aria-label={i18n('unsupportedAttachment')}
type="button"
/>
);
event.preventDefault();
event.stopPropagation();
export class Lightbox extends React.Component<Props, State> {
public readonly containerRef = React.createRef<HTMLDivElement>();
break;
public readonly videoRef = React.createRef<HTMLVideoElement>();
case 'ArrowLeft':
if (onPrevious) {
onPrevious();
public readonly focusRef = React.createRef<HTMLDivElement>();
event.preventDefault();
event.stopPropagation();
}
break;
public previousFocus: HTMLElement | null = null;
case 'ArrowRight':
if (onNext) {
onNext();
public constructor(props: Props) {
super(props);
event.preventDefault();
event.stopPropagation();
}
break;
this.state = {};
}
public componentDidMount(): void {
this.previousFocus = document.activeElement as HTMLElement;
const { isViewOnce } = this.props;
const useCapture = true;
document.addEventListener('keydown', this.onKeyDown, useCapture);
const video = this.getVideo();
if (video && isViewOnce) {
video.addEventListener('timeupdate', this.onTimeUpdate);
}
// Wait until we're added to the DOM. ConversationView first creates this view, then
// appends its elements into the DOM.
setTimeout(() => {
this.playVideo();
if (this.focusRef && this.focusRef.current) {
this.focusRef.current.focus();
default:
}
});
}
},
[close, onNext, onPrevious, zoomed]
);
public componentWillUnmount(): void {
if (this.previousFocus && this.previousFocus.focus) {
this.previousFocus.focus();
}
const stopPropagationAndClose = (event: MouseEvent<HTMLElement>) => {
event.stopPropagation();
close();
};
const { isViewOnce } = this.props;
const useCapture = true;
document.removeEventListener('keydown', this.onKeyDown, useCapture);
const video = this.getVideo();
if (video && isViewOnce) {
video.removeEventListener('timeupdate', this.onTimeUpdate);
}
}
public getVideo(): HTMLVideoElement | null {
if (!this.videoRef) {
return null;
}
const { current } = this.videoRef;
if (!current) {
return null;
}
return current;
}
public playVideo(): void {
const video = this.getVideo();
const playVideo = () => {
const video = videoRef.current;
if (!video) {
return;
}
@ -295,238 +141,333 @@ export class Lightbox extends React.Component<Props, State> {
} else {
video.pause();
}
}
};
public render(): JSX.Element {
const {
caption,
children,
contentType,
i18n,
isViewOnce,
loop = false,
objectURL,
onNext,
onPrevious,
onSave,
} = this.props;
const { videoTime } = this.state;
useEffect(() => {
const div = document.createElement('div');
document.body.appendChild(div);
setRoot(div);
return (
<div
className="module-lightbox"
style={styles.container}
onClick={this.onContainerClick}
onKeyUp={this.onContainerKeyUp}
ref={this.containerRef}
role="presentation"
>
<div style={styles.mainContainer} tabIndex={-1} ref={this.focusRef}>
<div style={styles.controlsOffsetPlaceholder} />
<div style={styles.objectContainer}>
{!is.undefined(contentType)
? this.renderObject({
objectURL,
contentType,
i18n,
isViewOnce,
loop,
})
: children}
{caption ? <div style={styles.caption}>{caption}</div> : null}
</div>
<div style={styles.controls}>
<IconButton i18n={i18n} type="close" onClick={this.onClose} />
{onSave ? (
<IconButton
i18n={i18n}
type="save"
onClick={onSave}
style={styles.saveButton}
/>
) : null}
</div>
</div>
{isViewOnce && videoTime && is.number(videoTime) ? (
<div style={styles.navigationContainer}>
<div style={styles.timestampPill}>{formatDuration(videoTime)}</div>
</div>
) : (
<div style={styles.navigationContainer}>
{onPrevious ? (
<IconButton i18n={i18n} type="previous" onClick={onPrevious} />
) : (
<IconButtonPlaceholder />
)}
{onNext ? (
<IconButton i18n={i18n} type="next" onClick={onNext} />
) : (
<IconButtonPlaceholder />
)}
</div>
)}
</div>
);
}
return () => {
document.body.removeChild(div);
setRoot(undefined);
};
}, []);
private readonly renderObject = ({
objectURL,
contentType,
i18n,
isViewOnce,
loop,
}: {
objectURL: string;
contentType: MIME.MIMEType;
i18n: LocalizerType;
isViewOnce: boolean;
loop: boolean;
}) => {
const isImageTypeSupported = GoogleChrome.isImageTypeSupported(contentType);
if (isImageTypeSupported) {
return (
<button
type="button"
style={styles.buttonContainer}
onClick={this.onObjectClick}
>
<img
alt={i18n('lightboxImageAlt')}
style={styles.img}
src={objectURL}
onContextMenu={this.onContextMenu}
/>
</button>
);
useEffect(() => {
if (!previousFocus) {
setPreviousFocus(document.activeElement as HTMLElement);
}
}, [previousFocus]);
useEffect(() => {
return () => {
restorePreviousFocus();
};
}, [restorePreviousFocus]);
useEffect(() => {
const useCapture = true;
document.addEventListener('keydown', onKeyDown, useCapture);
return () => {
document.removeEventListener('keydown', onKeyDown, useCapture);
};
}, [onKeyDown]);
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();
if (focusRef && focusRef.current) {
focusRef.current.focus();
}
});
return () => {
if (timeout) {
window.clearTimeout(timeout);
}
};
}, [selectedIndex]);
const { attachment, contentType, loop = false, objectURL, message } =
media[selectedIndex] || {};
const caption = attachment?.caption;
let content: JSX.Element;
if (!contentType) {
content = <>{children}</>;
} else {
const isImageTypeSupported = GoogleChrome.isImageTypeSupported(contentType);
const isVideoTypeSupported = GoogleChrome.isVideoTypeSupported(contentType);
if (isVideoTypeSupported) {
return (
const isUnsupportedImageType =
!isImageTypeSupported && isImage(contentType);
const isUnsupportedVideoType =
!isVideoTypeSupported && isVideo(contentType);
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
ref={this.videoRef}
loop={loop || isViewOnce}
controls={!loop && !isViewOnce}
style={styles.object}
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 });
content = (
<button
aria-label={i18n('unsupportedAttachment')}
className="Lightbox__object Lightbox__unsupported Lightbox__unsupported--file"
onClick={stopPropagationAndClose}
type="button"
/>
);
}
}
const isUnsupportedImageType =
!isImageTypeSupported && MIME.isImage(contentType);
const isUnsupportedVideoType =
!isVideoTypeSupported && MIME.isVideo(contentType);
if (isUnsupportedImageType || isUnsupportedVideoType) {
const iconUrl = isUnsupportedVideoType
? 'images/movie.svg'
: 'images/image.svg';
const hasNext = selectedIndex < media.length - 1;
const hasPrevious = selectedIndex > 0;
return <Icon i18n={i18n} url={iconUrl} onClick={this.onObjectClick} />;
}
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;
}
window.log.info('Lightbox: Unexpected content type', { contentType });
return (
<Icon i18n={i18n} onClick={this.onObjectClick} url="images/file.svg" />
);
};
private readonly onContextMenu = (
event: React.MouseEvent<HTMLImageElement>
) => {
const { contentType = '' } = this.props;
// These are the only image types supported by Electron's NativeImage
if (
event &&
contentType !== 'image/png' &&
!/image\/jpe?g/g.test(contentType)
) {
event.preventDefault();
}
};
private readonly onClose = () => {
const { close } = this.props;
if (!close) {
return;
}
close();
};
private readonly onTimeUpdate = () => {
const video = this.getVideo();
if (!video) {
return;
}
this.setState({
videoTime: video.currentTime,
});
};
private readonly onKeyDown = (event: KeyboardEvent) => {
const { onNext, onPrevious } = this.props;
switch (event.key) {
case 'Escape':
this.onClose();
event.preventDefault();
event.stopPropagation();
break;
case 'ArrowLeft':
if (onPrevious) {
onPrevious();
event.preventDefault();
event.stopPropagation();
}
break;
case 'ArrowRight':
if (onNext) {
onNext();
event.preventDefault();
event.stopPropagation();
}
break;
default:
}
};
private readonly onContainerClick = (
event: React.MouseEvent<HTMLDivElement>
) => {
if (this.containerRef && event.target !== this.containerRef.current) {
return;
}
this.onClose();
};
private readonly onContainerKeyUp = (
event: React.KeyboardEvent<HTMLDivElement>
) => {
if (
(this.containerRef && event.target !== this.containerRef.current) ||
event.keyCode !== 27
) {
return;
}
this.onClose();
};
private readonly onObjectClick = (
event: React.MouseEvent<HTMLAnchorElement | HTMLButtonElement>
) => {
event.stopPropagation();
this.onClose();
};
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;
}
function LightboxHeader({
getConversation,
i18n,
message,
}: {
getConversation: (id: string) => ConversationType;
i18n: LocalizerType;
message: MessageAttributesType;
}): JSX.Element {
const conversation = getConversation(message.conversationId);
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>
);
}

View File

@ -1,93 +0,0 @@
// Copyright 2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import * as React from 'react';
import { storiesOf } from '@storybook/react';
import { action } from '@storybook/addon-actions';
import { number } from '@storybook/addon-knobs';
import { LightboxGallery, Props } from './LightboxGallery';
import { setup as setupI18n } from '../../js/modules/i18n';
import enMessages from '../../_locales/en/messages.json';
import { IMAGE_JPEG, VIDEO_MP4 } from '../types/MIME';
const i18n = setupI18n('en', enMessages);
const story = storiesOf('Components/LightboxGallery', module);
const createProps = (overrideProps: Partial<Props> = {}): Props => ({
close: action('close'),
i18n,
media: overrideProps.media || [],
onSave: action('onSave'),
selectedIndex: number('selectedIndex', overrideProps.selectedIndex || 0),
});
story.add('Image and Video', () => {
const props = createProps({
media: [
{
attachment: {
contentType: IMAGE_JPEG,
fileName: 'tina-rolf-269345-unsplash.jpg',
url: '/fixtures/tina-rolf-269345-unsplash.jpg',
caption:
'Still from The Lighthouse, starring Robert Pattinson and Willem Defoe.',
},
contentType: IMAGE_JPEG,
index: 0,
message: {
attachments: [],
id: 'image-msg',
received_at: 1,
received_at_ms: Date.now(),
},
objectURL: '/fixtures/tina-rolf-269345-unsplash.jpg',
},
{
attachment: {
contentType: VIDEO_MP4,
fileName: 'pixabay-Soap-Bubble-7141.mp4',
url: '/fixtures/pixabay-Soap-Bubble-7141.mp4',
},
contentType: VIDEO_MP4,
index: 1,
message: {
attachments: [],
id: 'video-msg',
received_at: 2,
received_at_ms: Date.now(),
},
objectURL: '/fixtures/pixabay-Soap-Bubble-7141.mp4',
},
],
});
return <LightboxGallery {...props} />;
});
story.add('Missing Media', () => {
const props = createProps({
media: [
{
attachment: {
contentType: IMAGE_JPEG,
fileName: 'tina-rolf-269345-unsplash.jpg',
url: '/fixtures/tina-rolf-269345-unsplash.jpg',
},
contentType: IMAGE_JPEG,
index: 0,
message: {
attachments: [],
id: 'image-msg',
received_at: 3,
received_at_ms: Date.now(),
},
objectURL: undefined,
},
],
});
return <LightboxGallery {...props} />;
});

View File

@ -1,112 +0,0 @@
// Copyright 2018-2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import * as MIME from '../types/MIME';
import { Lightbox } from './Lightbox';
import { Message } from './conversation/media-gallery/types/Message';
import { AttachmentType } from '../types/Attachment';
import { LocalizerType } from '../types/Util';
export type MediaItemType = {
objectURL?: string;
thumbnailObjectUrl?: string;
contentType?: MIME.MIMEType;
index: number;
attachment: AttachmentType;
message: Message;
};
export type Props = {
close: () => void;
i18n: LocalizerType;
media: Array<MediaItemType>;
onSave?: (options: {
attachment: AttachmentType;
message: Message;
index: number;
}) => void;
selectedIndex: number;
};
type State = {
selectedIndex: number;
};
export class LightboxGallery extends React.Component<Props, State> {
public static defaultProps: Partial<Props> = {
selectedIndex: 0,
};
constructor(props: Props) {
super(props);
this.state = {
selectedIndex: props.selectedIndex,
};
}
public render(): JSX.Element {
const { close, media, onSave, i18n } = this.props;
const { selectedIndex } = this.state;
const selectedMedia = media[selectedIndex];
const firstIndex = 0;
const lastIndex = media.length - 1;
const onPrevious =
selectedIndex > firstIndex ? this.handlePrevious : undefined;
const onNext = selectedIndex < lastIndex ? this.handleNext : undefined;
const objectURL =
selectedMedia.objectURL || 'images/full-screen-flow/alert-outline.svg';
const { attachment } = selectedMedia;
const saveCallback = onSave ? this.handleSave : undefined;
const captionCallback = attachment ? attachment.caption : undefined;
return (
<Lightbox
caption={captionCallback}
close={close}
contentType={selectedMedia.contentType}
i18n={i18n}
isViewOnce={false}
objectURL={objectURL}
onNext={onNext}
onPrevious={onPrevious}
onSave={saveCallback}
/>
);
}
private readonly handlePrevious = () => {
this.setState(prevState => ({
selectedIndex: Math.max(prevState.selectedIndex - 1, 0),
}));
};
private readonly handleNext = () => {
this.setState((prevState, props) => ({
selectedIndex: Math.min(
prevState.selectedIndex + 1,
props.media.length - 1
),
}));
};
private readonly handleSave = () => {
const { media, onSave } = this.props;
if (!onSave) {
return;
}
const { selectedIndex } = this.state;
const mediaItem = media[selectedIndex];
const { attachment, message, index } = mediaItem;
onSave({ attachment, message, index });
};
}

View File

@ -8,7 +8,7 @@ import { assert } from '../../../util/assert';
import { getMutedUntilText } from '../../../util/getMutedUntilText';
import { LocalizerType } from '../../../types/Util';
import { MediaItemType } from '../../LightboxGallery';
import { MediaItemType } from '../../../types/MediaItem';
import { missingCaseError } from '../../../util/missingCaseError';
import { DisappearingTimerSelect } from '../../DisappearingTimerSelect';

View File

@ -17,7 +17,7 @@ import {
createPreparedMediaItems,
createRandomMedia,
} from '../media-gallery/AttachmentSection.stories';
import { MediaItemType } from '../../LightboxGallery';
import { MediaItemType } from '../../../types/MediaItem';
import { getDefaultConversation } from '../../../test-both/helpers/getDefaultConversation';
const i18n = setupI18n('en', enMessages);

View File

@ -5,7 +5,7 @@ import React from 'react';
import { LocalizerType } from '../../../types/Util';
import { MediaItemType } from '../../LightboxGallery';
import { MediaItemType } from '../../../types/MediaItem';
import { ConversationType } from '../../../state/ducks/conversations';
import { PanelSection } from './PanelSection';

View File

@ -11,7 +11,7 @@ import { random, range, sample, sortBy } from 'lodash';
import { setup as setupI18n } from '../../../../js/modules/i18n';
import enMessages from '../../../../_locales/en/messages.json';
import { MIMEType } from '../../../types/MIME';
import { MediaItemType } from '../../LightboxGallery';
import { MediaItemType } from '../../../types/MediaItem';
import { AttachmentSection, Props } from './AttachmentSection';
@ -51,6 +51,7 @@ const createRandomFile = (
return {
contentType,
message: {
conversationId: '123',
id: random(now).toString(),
received_at: Math.floor(Math.random() * 10),
received_at_ms: random(startTime, startTime + timeWindow),

View File

@ -6,7 +6,7 @@ import React from 'react';
import { DocumentListItem } from './DocumentListItem';
import { ItemClickEvent } from './types/ItemClickEvent';
import { MediaGridItem } from './MediaGridItem';
import { MediaItemType } from '../../LightboxGallery';
import { MediaItemType } from '../../../types/MediaItem';
import { missingCaseError } from '../../../util/missingCaseError';
import { LocalizerType } from '../../../types/Util';
import { getMessageTimestamp } from '../../../util/getMessageTimestamp';

View File

@ -14,7 +14,7 @@ import { missingCaseError } from '../../../util/missingCaseError';
import { LocalizerType } from '../../../types/Util';
import { getMessageTimestamp } from '../../../util/getMessageTimestamp';
import { MediaItemType } from '../../LightboxGallery';
import { MediaItemType } from '../../../types/MediaItem';
export type Props = {
documents: Array<MediaItemType>;

View File

@ -8,12 +8,11 @@ import { action } from '@storybook/addon-actions';
import { setup as setupI18n } from '../../../../js/modules/i18n';
import enMessages from '../../../../_locales/en/messages.json';
import { MediaItemType } from '../../LightboxGallery';
import { MediaItemType } from '../../../types/MediaItem';
import { AttachmentType } from '../../../types/Attachment';
import { stringToMIMEType } from '../../../types/MIME';
import { MediaGridItem, Props } from './MediaGridItem';
import { Message } from './types/Message';
const i18n = setupI18n('en', enMessages);
@ -45,7 +44,13 @@ const createMediaItem = (
),
index: 0,
attachment: {} as AttachmentType, // attachment not useful in the component
message: {} as Message, // message not used in the component
message: {
attachments: [],
conversationId: '1234',
id: 'id',
received_at: Date.now(),
received_at_ms: Date.now(),
},
});
story.add('Image', () => {

View File

@ -9,7 +9,7 @@ import {
isVideoTypeSupported,
} from '../../../util/GoogleChrome';
import { LocalizerType } from '../../../types/Util';
import { MediaItemType } from '../../LightboxGallery';
import { MediaItemType } from '../../../types/MediaItem';
export type Props = {
mediaItem: MediaItemType;

View File

@ -4,7 +4,7 @@
import moment from 'moment';
import { compact, groupBy, sortBy } from 'lodash';
import { MediaItemType } from '../../LightboxGallery';
import { MediaItemType } from '../../../types/MediaItem';
import { getMessageTimestamp } from '../../../util/getMessageTimestamp';
// import { missingCaseError } from '../../../util/missingCaseError';

View File

@ -42,7 +42,7 @@ import {
} from '../../model-types.d';
import { BodyRangeType } from '../../types/Util';
import { CallMode } from '../../types/Calling';
import { MediaItemType } from '../../components/LightboxGallery';
import { MediaItemType } from '../../types/MediaItem';
import {
getGroupSizeRecommendedLimit,
getGroupSizeHardLimit,

View File

@ -15,7 +15,7 @@ import {
} from '../selectors/conversations';
import { getGroupMemberships } from '../../util/getGroupMemberships';
import { getIntl } from '../selectors/user';
import { MediaItemType } from '../../components/LightboxGallery';
import { MediaItemType } from '../../types/MediaItem';
import { assert } from '../../util/assert';
import { SignalService as Proto } from '../../protobuf';

View File

@ -9,12 +9,13 @@ import {
groupMediaItemsByDate,
Section,
} from '../../../components/conversation/media-gallery/groupMediaItemsByDate';
import { MediaItemType } from '../../../components/LightboxGallery';
import { MediaItemType } from '../../../types/MediaItem';
const toMediaItem = (date: Date): MediaItemType => ({
objectURL: date.toUTCString(),
index: 0,
message: {
conversationId: '1234',
id: 'id',
received_at: date.getTime(),
received_at_ms: date.getTime(),
@ -56,6 +57,7 @@ describe('groupMediaItemsByDate', () => {
objectURL: 'Thu, 12 Apr 2018 12:00:00 GMT',
index: 0,
message: {
conversationId: '1234',
id: 'id',
received_at: 1523534400000,
received_at_ms: 1523534400000,
@ -71,6 +73,7 @@ describe('groupMediaItemsByDate', () => {
objectURL: 'Thu, 12 Apr 2018 00:01:00 GMT',
index: 0,
message: {
conversationId: '1234',
id: 'id',
received_at: 1523491260000,
received_at_ms: 1523491260000,
@ -91,6 +94,7 @@ describe('groupMediaItemsByDate', () => {
objectURL: 'Wed, 11 Apr 2018 23:59:00 GMT',
index: 0,
message: {
conversationId: '1234',
id: 'id',
received_at: 1523491140000,
received_at_ms: 1523491140000,
@ -111,6 +115,7 @@ describe('groupMediaItemsByDate', () => {
objectURL: 'Mon, 09 Apr 2018 00:01:00 GMT',
index: 0,
message: {
conversationId: '1234',
id: 'id',
received_at: 1523232060000,
received_at_ms: 1523232060000,
@ -131,6 +136,7 @@ describe('groupMediaItemsByDate', () => {
objectURL: 'Sun, 08 Apr 2018 23:59:00 GMT',
index: 0,
message: {
conversationId: '1234',
id: 'id',
received_at: 1523231940000,
received_at_ms: 1523231940000,
@ -146,6 +152,7 @@ describe('groupMediaItemsByDate', () => {
objectURL: 'Sun, 01 Apr 2018 00:01:00 GMT',
index: 0,
message: {
conversationId: '1234',
id: 'id',
received_at: 1522540860000,
received_at_ms: 1522540860000,
@ -168,6 +175,7 @@ describe('groupMediaItemsByDate', () => {
objectURL: 'Sat, 31 Mar 2018 23:59:00 GMT',
index: 0,
message: {
conversationId: '1234',
id: 'id',
received_at: 1522540740000,
received_at_ms: 1522540740000,
@ -183,6 +191,7 @@ describe('groupMediaItemsByDate', () => {
objectURL: 'Thu, 01 Mar 2018 14:00:00 GMT',
index: 0,
message: {
conversationId: '1234',
id: 'id',
received_at: 1519912800000,
received_at_ms: 1519912800000,
@ -205,6 +214,7 @@ describe('groupMediaItemsByDate', () => {
objectURL: 'Mon, 28 Feb 2011 23:59:00 GMT',
index: 0,
message: {
conversationId: '1234',
id: 'id',
received_at: 1298937540000,
received_at_ms: 1298937540000,
@ -220,6 +230,7 @@ describe('groupMediaItemsByDate', () => {
objectURL: 'Tue, 01 Feb 2011 10:00:00 GMT',
index: 0,
message: {
conversationId: '1234',
id: 'id',
received_at: 1296554400000,
received_at_ms: 1296554400000,

25
ts/types/MediaItem.ts Normal file
View File

@ -0,0 +1,25 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { AttachmentType } from './Attachment';
import { MIMEType } from './MIME';
export type MessageAttributesType = {
attachments: Array<AttachmentType>;
conversationId: string;
id: string;
// eslint-disable-next-line camelcase
received_at: number;
// eslint-disable-next-line camelcase
received_at_ms: number;
};
export type MediaItemType = {
attachment: AttachmentType;
contentType?: MIMEType;
index: number;
loop?: boolean;
message: MessageAttributesType;
objectURL?: string;
thumbnailObjectUrl?: string;
};

View File

@ -13538,27 +13538,46 @@
"updated": "2020-07-21T18:34:59.251Z"
},
{
"rule": "React-createRef",
"rule": "React-useRef",
"path": "ts/components/Lightbox.js",
"line": " this.containerRef = react_1.default.createRef();",
"line": " const containerRef = react_1.useRef(null);",
"reasonCategory": "usageTrusted",
"updated": "2019-11-06T19:56:38.557Z",
"reasonDetail": "Used to double-check outside clicks"
"updated": "2021-08-23T18:39:37.081Z"
},
{
"rule": "React-createRef",
"rule": "React-useRef",
"path": "ts/components/Lightbox.js",
"line": " this.focusRef = react_1.default.createRef();",
"line": " const focusRef = react_1.useRef(null);",
"reasonCategory": "usageTrusted",
"updated": "2019-11-06T19:56:38.557Z",
"reasonDetail": "Used to manage focus"
"updated": "2021-08-23T18:39:37.081Z"
},
{
"rule": "React-createRef",
"rule": "React-useRef",
"path": "ts/components/Lightbox.js",
"line": " this.videoRef = react_1.default.createRef();",
"line": " const videoRef = react_1.useRef(null);",
"reasonCategory": "usageTrusted",
"updated": "2020-09-14T23:03:44.863Z"
"updated": "2021-08-23T18:39:37.081Z"
},
{
"rule": "React-useRef",
"path": "ts/components/Lightbox.tsx",
"line": " const containerRef = useRef<HTMLDivElement | null>(null);",
"reasonCategory": "usageTrusted",
"updated": "2021-08-23T18:39:37.081Z"
},
{
"rule": "React-useRef",
"path": "ts/components/Lightbox.tsx",
"line": " const focusRef = useRef<HTMLDivElement | null>(null);",
"reasonCategory": "usageTrusted",
"updated": "2021-08-23T18:39:37.081Z"
},
{
"rule": "React-useRef",
"path": "ts/components/Lightbox.tsx",
"line": " const videoRef = useRef<HTMLVideoElement | null>(null);",
"reasonCategory": "usageTrusted",
"updated": "2021-08-23T18:39:37.081Z"
},
{
"rule": "React-createRef",

View File

@ -26,7 +26,7 @@ import {
MessageAttributesType,
} from '../model-types.d';
import { LinkPreviewType } from '../types/message/LinkPreviews';
import { MediaItemType } from '../components/LightboxGallery';
import { MediaItemType } from '../types/MediaItem';
import { MessageModel } from '../models/messages';
import { assert } from '../util/assert';
import { maybeParseUrl } from '../util/url';
@ -47,7 +47,10 @@ import {
isTapToView,
} from '../state/selectors/message';
import { isMessageUnread } from '../util/isMessageUnread';
import { getMessagesByConversation } from '../state/selectors/conversations';
import {
getConversationSelector,
getMessagesByConversation,
} from '../state/selectors/conversations';
import { ConversationDetailsMembershipList } from '../components/conversation/conversation-details/ConversationDetailsMembershipList';
import { showSafetyNumberChangeDialog } from '../shims/showSafetyNumberChangeDialog';
import {
@ -2654,7 +2657,7 @@ Whisper.ConversationView = Whisper.View.extend({
);
this.lightboxGalleryView = new Whisper.ReactWrapperView({
className: 'lightbox-wrapper',
Component: window.Signal.Components.LightboxGallery,
Component: window.Signal.Components.Lightbox,
props: {
media,
onSave: saveAttachment,
@ -3039,10 +3042,10 @@ Whisper.ConversationView = Whisper.View.extend({
});
},
// TODO: DESKTOP-1133 (DRY up these lightboxes)
showLightboxForMedia(
selectedMediaItem: WhatIsThis,
media: Array<WhatIsThis> = []
selectedMediaItem: MediaItemType,
media: Array<MediaItemType> = [],
loop = false
) {
const onSave = async (options: WhatIsThis = {}) => {
const fullPath = await window.Signal.Types.Attachment.save({
@ -3065,11 +3068,14 @@ Whisper.ConversationView = Whisper.View.extend({
this.lightboxGalleryView = new Whisper.ReactWrapperView({
className: 'lightbox-wrapper',
Component: window.Signal.Components.LightboxGallery,
Component: window.Signal.Components.Lightbox,
props: {
getConversation: getConversationSelector(window.reduxStore.getState()),
loop,
media,
onForward: this.showForwardMessageModal.bind(this),
onSave,
selectedIndex,
selectedIndex: selectedIndex >= 0 ? selectedIndex : 0,
},
onClose: () => window.Signal.Backbone.Views.Lightbox.hide(),
});
@ -3096,7 +3102,7 @@ Whisper.ConversationView = Whisper.View.extend({
return;
}
const { contentType, path } = attachment;
const { contentType } = attachment;
if (
!window.Signal.Util.GoogleChrome.isImageTypeSupported(contentType) &&
@ -3118,71 +3124,23 @@ Whisper.ConversationView = Whisper.View.extend({
contentType: item.contentType,
loop,
index,
message,
message: {
attachments: message.get('attachments'),
id: message.get('id'),
conversationId: message.get('conversationId'),
received_at: message.get('received_at'),
received_at_ms: message.get('received_at_ms'),
},
attachment: item,
thumbnailObjectUrl:
item.thumbnail?.objectUrl ||
getAbsoluteAttachmentPath(item.thumbnail?.path ?? ''),
}));
if (media.length === 1) {
const props = {
objectURL: getAbsoluteAttachmentPath(path ?? ''),
contentType,
caption: attachment.caption,
loop,
onSave: () => {
const timestamp = message.get('sent_at');
this.downloadAttachment({ attachment, timestamp, message });
},
};
this.lightboxView = new Whisper.ReactWrapperView({
className: 'lightbox-wrapper',
Component: window.Signal.Components.Lightbox,
props,
onClose: () => {
window.Signal.Backbone.Views.Lightbox.hide();
this.stopListening(message);
},
});
this.listenTo(message, 'expired', () => this.lightboxView.remove());
window.Signal.Backbone.Views.Lightbox.show(this.lightboxView.el);
return;
}
const selectedMedia =
media.find(item => attachment.path === item.path) || media[0];
const selectedIndex = window._.findIndex(
media,
item => attachment.path === item.path
);
const onSave = async (options: WhatIsThis = {}) => {
const fullPath = await window.Signal.Types.Attachment.save({
attachment: options.attachment,
index: options.index + 1,
readAttachmentData,
saveAttachmentToDisk,
timestamp: options.message.get('sent_at'),
});
if (fullPath) {
this.showToast(Whisper.FileSavedToast, { fullPath });
}
};
const props = {
media,
loop,
selectedIndex: selectedIndex >= 0 ? selectedIndex : 0,
onSave,
};
this.lightboxGalleryView = new Whisper.ReactWrapperView({
className: 'lightbox-wrapper',
Component: window.Signal.Components.LightboxGallery,
props,
onClose: () => {
window.Signal.Backbone.Views.Lightbox.hide();
this.stopListening(message);
},
});
this.listenTo(message, 'expired', () => this.lightboxGalleryView.remove());
window.Signal.Backbone.Views.Lightbox.show(this.lightboxGalleryView.el);
this.showLightboxForMedia(selectedMedia, media, loop);
},
showContactModal(contactId: string) {
@ -3608,9 +3566,15 @@ Whisper.ConversationView = Whisper.View.extend({
contentType: attachment.contentType,
index,
attachment,
// this message is a valid structure, but doesn't work with ts
// eslint-disable-next-line @typescript-eslint/no-explicit-any
message: message as any,
message: {
attachments: message.attachments || [],
conversationId:
window.ConversationController.get(message.sourceUuid)?.id ||
message.conversationId,
id: message.id,
received_at: message.received_at,
received_at_ms: Number(message.received_at_ms),
},
};
}
),

2
ts/window.d.ts vendored
View File

@ -96,7 +96,6 @@ import { ContactDetail } from './components/conversation/ContactDetail';
import { ContactModal } from './components/conversation/ContactModal';
import { ErrorModal } from './components/ErrorModal';
import { Lightbox } from './components/Lightbox';
import { LightboxGallery } from './components/LightboxGallery';
import { MediaGallery } from './components/conversation/media-gallery/MediaGallery';
import { MessageDetail } from './components/conversation/MessageDetail';
import { ProgressModal } from './components/ProgressModal';
@ -421,7 +420,6 @@ declare global {
DisappearingTimeDialog: typeof DisappearingTimeDialog;
ErrorModal: typeof ErrorModal;
Lightbox: typeof Lightbox;
LightboxGallery: typeof LightboxGallery;
MediaGallery: typeof MediaGallery;
MessageDetail: typeof MessageDetail;
ProgressModal: typeof ProgressModal;