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 { Emojify } = require('../../ts/components/conversation/Emojify');
const { ErrorModal } = require('../../ts/components/ErrorModal'); const { ErrorModal } = require('../../ts/components/ErrorModal');
const { Lightbox } = require('../../ts/components/Lightbox'); const { Lightbox } = require('../../ts/components/Lightbox');
const { LightboxGallery } = require('../../ts/components/LightboxGallery');
const { const {
MediaGallery, MediaGallery,
} = require('../../ts/components/conversation/media-gallery/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 EmbeddedContact = require('../../ts/types/EmbeddedContact');
const Conversation = require('./types/conversation'); const Conversation = require('./types/conversation');
const Errors = require('../../ts/types/errors'); const Errors = require('../../ts/types/errors');
const MediaGalleryMessage = require('../../ts/components/conversation/media-gallery/types/Message');
const MessageType = require('./types/message'); const MessageType = require('./types/message');
const MIME = require('../../ts/types/MIME'); const MIME = require('../../ts/types/MIME');
const SettingsType = require('../../ts/types/Settings'); const SettingsType = require('../../ts/types/Settings');
@ -349,7 +347,6 @@ exports.setup = (options = {}) => {
Emojify, Emojify,
ErrorModal, ErrorModal,
Lightbox, Lightbox,
LightboxGallery,
MediaGallery, MediaGallery,
MessageDetail, MessageDetail,
Quote, Quote,
@ -357,9 +354,6 @@ exports.setup = (options = {}) => {
StagedLinkPreview, StagedLinkPreview,
DisappearingTimeDialog, DisappearingTimeDialog,
SystemTraySettingsCheckboxes, SystemTraySettingsCheckboxes,
Types: {
Message: MediaGalleryMessage,
},
WhatsNew, 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 // SPDX-License-Identifier: AGPL-3.0-only
@mixin preferences-icon($light_svg, $dark_svg) { @mixin preferences-icon($light_svg, $dark_svg) {
&:before { &::before {
@include light-theme { @include light-theme {
@include color-svg($light_svg, $color-gray-75); @include color-svg($light_svg, $color-gray-75);
} }
@ -55,7 +55,7 @@
} }
} }
&:before { &::before {
content: ''; content: '';
display: block; display: block;
height: 22px; height: 22px;
@ -104,7 +104,7 @@
'../images/icons/v2/lock-outline-24.svg', '../images/icons/v2/lock-outline-24.svg',
'../images/icons/v2/lock-solid-24.svg' '../images/icons/v2/lock-solid-24.svg'
); );
&:before { &::before {
-webkit-mask-size: 75%; -webkit-mask-size: 75%;
} }
} }

View File

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

View File

@ -26,13 +26,7 @@ export const AvatarLightbox = ({
onClose, onClose,
}: PropsType): JSX.Element => { }: PropsType): JSX.Element => {
return ( return (
<Lightbox <Lightbox close={onClose} i18n={i18n} media={[]}>
contentType={undefined}
close={onClose}
i18n={i18n}
isViewOnce={false}
objectURL=""
>
<AvatarPreview <AvatarPreview
avatarColor={avatarColor} avatarColor={avatarColor}
avatarPath={avatarPath} 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 // SPDX-License-Identifier: AGPL-3.0-only
import * as React from 'react'; import * as React from 'react';
import { action } from '@storybook/addon-actions';
import { boolean, text } from '@storybook/addon-knobs';
import { storiesOf } from '@storybook/react'; 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 { import {
AUDIO_MP3, AUDIO_MP3,
IMAGE_JPEG, IMAGE_JPEG,
@ -15,123 +18,237 @@ import {
VIDEO_QUICKTIME, VIDEO_QUICKTIME,
stringToMIMEType, stringToMIMEType,
} from '../types/MIME'; } from '../types/MIME';
import { setup as setupI18n } from '../../js/modules/i18n';
import enMessages from '../../_locales/en/messages.json';
const i18n = setupI18n('en', enMessages); const i18n = setupI18n('en', enMessages);
const story = storiesOf('Components/Lightbox', module); const story = storiesOf('Components/Lightbox', module);
const createProps = (overrideProps: Partial<Props> = {}): Props => ({ type OverridePropsMediaItemType = Partial<MediaItemType> & { caption?: string };
caption: text('caption', overrideProps.caption || ''),
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'), close: action('close'),
contentType: overrideProps.contentType || IMAGE_JPEG,
i18n, i18n,
isViewOnce: boolean('isViewOnce', overrideProps.isViewOnce || false), media: overrideProps.media || [],
objectURL: text('objectURL', overrideProps.objectURL || ''), onSave: action('onSave'),
onNext: overrideProps.onNext, selectedIndex: number('selectedIndex', overrideProps.selectedIndex || 0),
onPrevious: overrideProps.onPrevious,
onSave: overrideProps.onSave,
}); });
story.add('Image', () => { story.add('Multimedia', () => {
const props = createProps({ 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} />; return <Lightbox {...props} />;
}); });
story.add('Image with Caption (normal image)', () => { story.add('Missing Media', () => {
const props = createProps({ const props = createProps({
caption: media: [
'This is the user-provided caption. It can get long and wrap onto multiple lines.', {
objectURL: '/fixtures/tina-rolf-269345-unsplash.jpg', 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} />; return <Lightbox {...props} />;
}); });
story.add('Image with Caption (all-white image)', () => { story.add('Single Image', () => (
const props = createProps({ <Lightbox
caption: {...createProps({
'This is the user-provided caption. It should be visible on light backgrounds.', media: [
objectURL: '/fixtures/2000x2000-white.png', 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', () => { story.add('Image with Caption (all-white image)', () => (
const props = createProps({ <Lightbox
contentType: VIDEO_MP4, {...createProps({
objectURL: '/fixtures/pixabay-Soap-Bubble-7141.mp4', 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', () => { story.add('Single Video w/caption', () => (
const props = createProps({ <Lightbox
caption: {...createProps({
'This is the user-provided caption. It can get long and wrap onto multiple lines.', media: [
contentType: VIDEO_MP4, createMediaItem({
objectURL: '/fixtures/pixabay-Soap-Bubble-7141.mp4', 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)', () => { story.add('Unsupported Video Type', () => (
const props = createProps({ <Lightbox
contentType: VIDEO_MP4, {...createProps({
isViewOnce: true, media: [
objectURL: '/fixtures/pixabay-Soap-Bubble-7141.mp4', createMediaItem({
}); contentType: VIDEO_QUICKTIME,
objectURL: 'unsupported-video.mov',
}),
],
})}
/>
));
return <Lightbox {...props} />; story.add('Unsupported Content', () => (
}); <Lightbox
{...createProps({
story.add('Unsupported Image Type', () => { media: [
const props = createProps({ createMediaItem({
contentType: stringToMIMEType('image/tiff'), contentType: AUDIO_MP3,
objectURL: 'unsupported-image.tiff', objectURL: '/fixtures/incompetech-com-Agnus-Dei-X.mp3',
}); }),
],
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('Custom children', () => ( story.add('Custom children', () => (
<Lightbox {...createProps({})} contentType={undefined}> <Lightbox {...createProps({})} media={[]}>
<div <div
style={{ style={{
color: 'white', color: 'white',
@ -144,3 +261,30 @@ story.add('Custom children', () => (
</div> </div>
</Lightbox> </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 // 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 classNames from 'classnames';
import is from '@sindresorhus/is'; import { createPortal } from 'react-dom';
import * as GoogleChrome from '../util/GoogleChrome'; import * as GoogleChrome from '../util/GoogleChrome';
import * as MIME from '../types/MIME'; import { AttachmentType, isGIF } from '../types/Attachment';
import { Avatar, AvatarSize } from './Avatar';
import { formatDuration } from '../util/formatDuration'; import { ConversationType } from '../state/ducks/conversations';
import { IMAGE_PNG, isImage, isVideo } from '../types/MIME';
import { LocalizerType } from '../types/Util'; import { LocalizerType } from '../types/Util';
import { MediaItemType, MessageAttributesType } from '../types/MediaItem';
const Colors = { export type PropsType = {
ICON_SECONDARY: '#b9b9b9',
};
const colorSVG = (url: string, color: string) => {
return {
WebkitMask: `url(${url}) no-repeat center`,
WebkitMaskSize: '100%',
backgroundColor: color,
};
};
export type Props = {
children?: ReactNode; children?: ReactNode;
close: () => void; close: () => void;
contentType: MIME.MIMEType | undefined; getConversation?: (id: string) => ConversationType;
i18n: LocalizerType; i18n: LocalizerType;
objectURL: string; media: Array<MediaItemType>;
caption?: string; onForward?: (messageId: string) => void;
isViewOnce: boolean; onSave?: (options: {
loop?: boolean; attachment: AttachmentType;
onNext?: () => void; message: MessageAttributesType;
onPrevious?: () => void; index: number;
onSave?: () => void; }) => void;
}; selectedIndex?: number;
type State = {
videoTime?: number;
}; };
const CONTROLS_WIDTH = 50; export function Lightbox({
const CONTROLS_SPACING = 10; 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 = { const [previousFocus, setPreviousFocus] = useState<HTMLElement | undefined>();
container: { const [zoomed, setZoomed] = useState(false);
display: 'flex', const containerRef = useRef<HTMLDivElement | null>(null);
flexDirection: 'column', const focusRef = useRef<HTMLDivElement | null>(null);
position: 'absolute', const videoRef = useRef<HTMLVideoElement | null>(null);
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',
},
};
type IconButtonProps = { const restorePreviousFocus = useCallback(() => {
i18n: LocalizerType; if (previousFocus && previousFocus.focus) {
onClick?: () => void; previousFocus.focus();
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;
} }
}, [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 ( const handleForward = () => {
<button close();
onClick={clickHandler} const mediaItem = media[selectedIndex];
className={classNames('iconButton', type)} onForward?.(mediaItem.message.id);
style={style} };
aria-label={i18n(type)}
type="button"
/>
);
};
const IconButtonPlaceholder = () => ( const onKeyDown = useCallback(
<div style={styles.iconButtonPlaceholder} /> (event: KeyboardEvent) => {
); switch (event.key) {
case 'Escape':
if (zoomed) {
setZoomed(false);
} else {
close();
}
const Icon = ({ event.preventDefault();
i18n, event.stopPropagation();
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"
/>
);
export class Lightbox extends React.Component<Props, State> { break;
public readonly containerRef = React.createRef<HTMLDivElement>();
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) { event.preventDefault();
super(props); event.stopPropagation();
}
break;
this.state = {}; default:
}
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();
} }
}); },
} [close, onNext, onPrevious, zoomed]
);
public componentWillUnmount(): void { const stopPropagationAndClose = (event: MouseEvent<HTMLElement>) => {
if (this.previousFocus && this.previousFocus.focus) { event.stopPropagation();
this.previousFocus.focus(); close();
} };
const { isViewOnce } = this.props; const playVideo = () => {
const video = videoRef.current;
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();
if (!video) { if (!video) {
return; return;
} }
@ -295,238 +141,333 @@ export class Lightbox extends React.Component<Props, State> {
} else { } else {
video.pause(); video.pause();
} }
} };
public render(): JSX.Element { useEffect(() => {
const { const div = document.createElement('div');
caption, document.body.appendChild(div);
children, setRoot(div);
contentType,
i18n,
isViewOnce,
loop = false,
objectURL,
onNext,
onPrevious,
onSave,
} = this.props;
const { videoTime } = this.state;
return ( return () => {
<div document.body.removeChild(div);
className="module-lightbox" setRoot(undefined);
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>
);
}
private readonly renderObject = ({ useEffect(() => {
objectURL, if (!previousFocus) {
contentType, setPreviousFocus(document.activeElement as HTMLElement);
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>
);
} }
}, [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); const isVideoTypeSupported = GoogleChrome.isVideoTypeSupported(contentType);
if (isVideoTypeSupported) { const isUnsupportedImageType =
return ( !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 <video
ref={this.videoRef} className="Lightbox__object"
loop={loop || isViewOnce} controls={!shouldLoop}
controls={!loop && !isViewOnce}
style={styles.object}
key={objectURL} key={objectURL}
loop={shouldLoop}
ref={videoRef}
> >
<source src={objectURL} /> <source src={objectURL} />
</video> </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 = const hasNext = selectedIndex < media.length - 1;
!isImageTypeSupported && MIME.isImage(contentType); const hasPrevious = selectedIndex > 0;
const isUnsupportedVideoType =
!isVideoTypeSupported && MIME.isVideo(contentType);
if (isUnsupportedImageType || isUnsupportedVideoType) {
const iconUrl = isUnsupportedVideoType
? 'images/movie.svg'
: 'images/image.svg';
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 }); close();
}}
return ( ref={containerRef}
<Icon i18n={i18n} onClick={this.onObjectClick} url="images/file.svg" /> role="presentation"
); >
}; <div
className="Lightbox__main-container"
private readonly onContextMenu = ( tabIndex={-1}
event: React.MouseEvent<HTMLImageElement> ref={focusRef}
) => { >
const { contentType = '' } = this.props; {!zoomed && (
<div className="Lightbox__header">
// These are the only image types supported by Electron's NativeImage {getConversation ? (
if ( <LightboxHeader
event && getConversation={getConversation}
contentType !== 'image/png' && i18n={i18n}
!/image\/jpe?g/g.test(contentType) message={message}
) { />
event.preventDefault(); ) : (
} <div />
}; )}
<div className="Lightbox__controls">
private readonly onClose = () => { {onForward ? (
const { close } = this.props; <button
if (!close) { aria-label={i18n('forwardMessage')}
return; className="Lightbox__button Lightbox__button--forward"
} onClick={handleForward}
type="button"
close(); />
}; ) : null}
{onSave ? (
private readonly onTimeUpdate = () => { <button
const video = this.getVideo(); aria-label={i18n('save')}
if (!video) { className="Lightbox__button Lightbox__button--save"
return; onClick={handleSave}
} type="button"
this.setState({ />
videoTime: video.currentTime, ) : null}
}); <button
}; aria-label={i18n('close')}
className="Lightbox__button Lightbox__button--close"
private readonly onKeyDown = (event: KeyboardEvent) => { onClick={close}
const { onNext, onPrevious } = this.props; type="button"
switch (event.key) { />
case 'Escape': </div>
this.onClose(); </div>
)}
event.preventDefault(); <div
event.stopPropagation(); className={classNames('Lightbox__object--container', {
'Lightbox__object--container--zoomed': zoomed,
break; })}
>
case 'ArrowLeft': {content}
if (onPrevious) { </div>
onPrevious(); {hasPrevious && (
<div className="Lightbox__nav-prev">
event.preventDefault(); <button
event.stopPropagation(); aria-label={i18n('previous')}
} className="Lightbox__button Lightbox__button--previous"
break; disabled={zoomed}
onClick={onPrevious}
case 'ArrowRight': type="button"
if (onNext) { />
onNext(); </div>
)}
event.preventDefault(); {hasNext && (
event.stopPropagation(); <div className="Lightbox__nav-next">
} <button
break; aria-label={i18n('next')}
className="Lightbox__button Lightbox__button--next"
default: disabled={zoomed}
} onClick={onNext}
}; type="button"
/>
private readonly onContainerClick = ( </div>
event: React.MouseEvent<HTMLDivElement> )}
) => { </div>
if (this.containerRef && event.target !== this.containerRef.current) { {!zoomed && (
return; <div className="Lightbox__footer">
} {caption ? (
this.onClose(); <div className="Lightbox__caption">{caption}</div>
}; ) : null}
{media.length > 1 && (
private readonly onContainerKeyUp = ( <div className="Lightbox__thumbnails--container">
event: React.KeyboardEvent<HTMLDivElement> <div
) => { className="Lightbox__thumbnails"
if ( style={{
(this.containerRef && event.target !== this.containerRef.current) || marginLeft:
event.keyCode !== 27 0 - (selectedIndex * 64 + selectedIndex * 8 + 32),
) { }}
return; >
} {media.map((item, index) => (
<button
this.onClose(); className={classNames({
}; Lightbox__thumbnail: true,
'Lightbox__thumbnail--selected':
private readonly onObjectClick = ( index === selectedIndex,
event: React.MouseEvent<HTMLAnchorElement | HTMLButtonElement> })}
) => { key={item.thumbnailObjectUrl}
event.stopPropagation(); type="button"
this.onClose(); 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 { getMutedUntilText } from '../../../util/getMutedUntilText';
import { LocalizerType } from '../../../types/Util'; import { LocalizerType } from '../../../types/Util';
import { MediaItemType } from '../../LightboxGallery'; import { MediaItemType } from '../../../types/MediaItem';
import { missingCaseError } from '../../../util/missingCaseError'; import { missingCaseError } from '../../../util/missingCaseError';
import { DisappearingTimerSelect } from '../../DisappearingTimerSelect'; import { DisappearingTimerSelect } from '../../DisappearingTimerSelect';

View File

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

View File

@ -5,7 +5,7 @@ import React from 'react';
import { LocalizerType } from '../../../types/Util'; import { LocalizerType } from '../../../types/Util';
import { MediaItemType } from '../../LightboxGallery'; import { MediaItemType } from '../../../types/MediaItem';
import { ConversationType } from '../../../state/ducks/conversations'; import { ConversationType } from '../../../state/ducks/conversations';
import { PanelSection } from './PanelSection'; 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 { setup as setupI18n } from '../../../../js/modules/i18n';
import enMessages from '../../../../_locales/en/messages.json'; import enMessages from '../../../../_locales/en/messages.json';
import { MIMEType } from '../../../types/MIME'; import { MIMEType } from '../../../types/MIME';
import { MediaItemType } from '../../LightboxGallery'; import { MediaItemType } from '../../../types/MediaItem';
import { AttachmentSection, Props } from './AttachmentSection'; import { AttachmentSection, Props } from './AttachmentSection';
@ -51,6 +51,7 @@ const createRandomFile = (
return { return {
contentType, contentType,
message: { message: {
conversationId: '123',
id: random(now).toString(), id: random(now).toString(),
received_at: Math.floor(Math.random() * 10), received_at: Math.floor(Math.random() * 10),
received_at_ms: random(startTime, startTime + timeWindow), received_at_ms: random(startTime, startTime + timeWindow),

View File

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

View File

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

View File

@ -8,12 +8,11 @@ import { action } from '@storybook/addon-actions';
import { setup as setupI18n } from '../../../../js/modules/i18n'; import { setup as setupI18n } from '../../../../js/modules/i18n';
import enMessages from '../../../../_locales/en/messages.json'; import enMessages from '../../../../_locales/en/messages.json';
import { MediaItemType } from '../../LightboxGallery'; import { MediaItemType } from '../../../types/MediaItem';
import { AttachmentType } from '../../../types/Attachment'; import { AttachmentType } from '../../../types/Attachment';
import { stringToMIMEType } from '../../../types/MIME'; import { stringToMIMEType } from '../../../types/MIME';
import { MediaGridItem, Props } from './MediaGridItem'; import { MediaGridItem, Props } from './MediaGridItem';
import { Message } from './types/Message';
const i18n = setupI18n('en', enMessages); const i18n = setupI18n('en', enMessages);
@ -45,7 +44,13 @@ const createMediaItem = (
), ),
index: 0, index: 0,
attachment: {} as AttachmentType, // attachment not useful in the component 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', () => { story.add('Image', () => {

View File

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

View File

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

View File

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

View File

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

View File

@ -9,12 +9,13 @@ import {
groupMediaItemsByDate, groupMediaItemsByDate,
Section, Section,
} from '../../../components/conversation/media-gallery/groupMediaItemsByDate'; } from '../../../components/conversation/media-gallery/groupMediaItemsByDate';
import { MediaItemType } from '../../../components/LightboxGallery'; import { MediaItemType } from '../../../types/MediaItem';
const toMediaItem = (date: Date): MediaItemType => ({ const toMediaItem = (date: Date): MediaItemType => ({
objectURL: date.toUTCString(), objectURL: date.toUTCString(),
index: 0, index: 0,
message: { message: {
conversationId: '1234',
id: 'id', id: 'id',
received_at: date.getTime(), received_at: date.getTime(),
received_at_ms: date.getTime(), received_at_ms: date.getTime(),
@ -56,6 +57,7 @@ describe('groupMediaItemsByDate', () => {
objectURL: 'Thu, 12 Apr 2018 12:00:00 GMT', objectURL: 'Thu, 12 Apr 2018 12:00:00 GMT',
index: 0, index: 0,
message: { message: {
conversationId: '1234',
id: 'id', id: 'id',
received_at: 1523534400000, received_at: 1523534400000,
received_at_ms: 1523534400000, received_at_ms: 1523534400000,
@ -71,6 +73,7 @@ describe('groupMediaItemsByDate', () => {
objectURL: 'Thu, 12 Apr 2018 00:01:00 GMT', objectURL: 'Thu, 12 Apr 2018 00:01:00 GMT',
index: 0, index: 0,
message: { message: {
conversationId: '1234',
id: 'id', id: 'id',
received_at: 1523491260000, received_at: 1523491260000,
received_at_ms: 1523491260000, received_at_ms: 1523491260000,
@ -91,6 +94,7 @@ describe('groupMediaItemsByDate', () => {
objectURL: 'Wed, 11 Apr 2018 23:59:00 GMT', objectURL: 'Wed, 11 Apr 2018 23:59:00 GMT',
index: 0, index: 0,
message: { message: {
conversationId: '1234',
id: 'id', id: 'id',
received_at: 1523491140000, received_at: 1523491140000,
received_at_ms: 1523491140000, received_at_ms: 1523491140000,
@ -111,6 +115,7 @@ describe('groupMediaItemsByDate', () => {
objectURL: 'Mon, 09 Apr 2018 00:01:00 GMT', objectURL: 'Mon, 09 Apr 2018 00:01:00 GMT',
index: 0, index: 0,
message: { message: {
conversationId: '1234',
id: 'id', id: 'id',
received_at: 1523232060000, received_at: 1523232060000,
received_at_ms: 1523232060000, received_at_ms: 1523232060000,
@ -131,6 +136,7 @@ describe('groupMediaItemsByDate', () => {
objectURL: 'Sun, 08 Apr 2018 23:59:00 GMT', objectURL: 'Sun, 08 Apr 2018 23:59:00 GMT',
index: 0, index: 0,
message: { message: {
conversationId: '1234',
id: 'id', id: 'id',
received_at: 1523231940000, received_at: 1523231940000,
received_at_ms: 1523231940000, received_at_ms: 1523231940000,
@ -146,6 +152,7 @@ describe('groupMediaItemsByDate', () => {
objectURL: 'Sun, 01 Apr 2018 00:01:00 GMT', objectURL: 'Sun, 01 Apr 2018 00:01:00 GMT',
index: 0, index: 0,
message: { message: {
conversationId: '1234',
id: 'id', id: 'id',
received_at: 1522540860000, received_at: 1522540860000,
received_at_ms: 1522540860000, received_at_ms: 1522540860000,
@ -168,6 +175,7 @@ describe('groupMediaItemsByDate', () => {
objectURL: 'Sat, 31 Mar 2018 23:59:00 GMT', objectURL: 'Sat, 31 Mar 2018 23:59:00 GMT',
index: 0, index: 0,
message: { message: {
conversationId: '1234',
id: 'id', id: 'id',
received_at: 1522540740000, received_at: 1522540740000,
received_at_ms: 1522540740000, received_at_ms: 1522540740000,
@ -183,6 +191,7 @@ describe('groupMediaItemsByDate', () => {
objectURL: 'Thu, 01 Mar 2018 14:00:00 GMT', objectURL: 'Thu, 01 Mar 2018 14:00:00 GMT',
index: 0, index: 0,
message: { message: {
conversationId: '1234',
id: 'id', id: 'id',
received_at: 1519912800000, received_at: 1519912800000,
received_at_ms: 1519912800000, received_at_ms: 1519912800000,
@ -205,6 +214,7 @@ describe('groupMediaItemsByDate', () => {
objectURL: 'Mon, 28 Feb 2011 23:59:00 GMT', objectURL: 'Mon, 28 Feb 2011 23:59:00 GMT',
index: 0, index: 0,
message: { message: {
conversationId: '1234',
id: 'id', id: 'id',
received_at: 1298937540000, received_at: 1298937540000,
received_at_ms: 1298937540000, received_at_ms: 1298937540000,
@ -220,6 +230,7 @@ describe('groupMediaItemsByDate', () => {
objectURL: 'Tue, 01 Feb 2011 10:00:00 GMT', objectURL: 'Tue, 01 Feb 2011 10:00:00 GMT',
index: 0, index: 0,
message: { message: {
conversationId: '1234',
id: 'id', id: 'id',
received_at: 1296554400000, received_at: 1296554400000,
received_at_ms: 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" "updated": "2020-07-21T18:34:59.251Z"
}, },
{ {
"rule": "React-createRef", "rule": "React-useRef",
"path": "ts/components/Lightbox.js", "path": "ts/components/Lightbox.js",
"line": " this.containerRef = react_1.default.createRef();", "line": " const containerRef = react_1.useRef(null);",
"reasonCategory": "usageTrusted", "reasonCategory": "usageTrusted",
"updated": "2019-11-06T19:56:38.557Z", "updated": "2021-08-23T18:39:37.081Z"
"reasonDetail": "Used to double-check outside clicks"
}, },
{ {
"rule": "React-createRef", "rule": "React-useRef",
"path": "ts/components/Lightbox.js", "path": "ts/components/Lightbox.js",
"line": " this.focusRef = react_1.default.createRef();", "line": " const focusRef = react_1.useRef(null);",
"reasonCategory": "usageTrusted", "reasonCategory": "usageTrusted",
"updated": "2019-11-06T19:56:38.557Z", "updated": "2021-08-23T18:39:37.081Z"
"reasonDetail": "Used to manage focus"
}, },
{ {
"rule": "React-createRef", "rule": "React-useRef",
"path": "ts/components/Lightbox.js", "path": "ts/components/Lightbox.js",
"line": " this.videoRef = react_1.default.createRef();", "line": " const videoRef = react_1.useRef(null);",
"reasonCategory": "usageTrusted", "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", "rule": "React-createRef",

View File

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