Improved Lightbox experience
This commit is contained in:
parent
d80e738fb1
commit
d5d808651a
|
@ -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,
|
||||
};
|
||||
|
||||
|
|
|
@ -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 won’t 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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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%;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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', () => {
|
||||
const props = createProps({
|
||||
objectURL: '/fixtures/tina-rolf-269345-unsplash.jpg',
|
||||
});
|
||||
|
||||
return <Lightbox {...props} />;
|
||||
});
|
||||
|
||||
story.add('Image with Caption (normal image)', () => {
|
||||
story.add('Multimedia', () => {
|
||||
const props = createProps({
|
||||
media: [
|
||||
{
|
||||
attachment: {
|
||||
contentType: IMAGE_JPEG,
|
||||
fileName: 'tina-rolf-269345-unsplash.jpg',
|
||||
url: '/fixtures/tina-rolf-269345-unsplash.jpg',
|
||||
caption:
|
||||
'This is the user-provided caption. It can get long and wrap onto multiple lines.',
|
||||
'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 (all-white image)', () => {
|
||||
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: [],
|
||||
conversationId: '1234',
|
||||
id: 'image-msg',
|
||||
received_at: 3,
|
||||
received_at_ms: Date.now(),
|
||||
},
|
||||
objectURL: undefined,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
return <Lightbox {...props} />;
|
||||
});
|
||||
|
||||
story.add('Single Image', () => (
|
||||
<Lightbox
|
||||
{...createProps({
|
||||
media: [
|
||||
createMediaItem({
|
||||
objectURL: '/fixtures/tina-rolf-269345-unsplash.jpg',
|
||||
}),
|
||||
],
|
||||
})}
|
||||
/>
|
||||
));
|
||||
|
||||
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('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('Video', () => {
|
||||
const props = createProps({
|
||||
story.add('Single Video', () => (
|
||||
<Lightbox
|
||||
{...createProps({
|
||||
media: [
|
||||
createMediaItem({
|
||||
contentType: VIDEO_MP4,
|
||||
objectURL: '/fixtures/pixabay-Soap-Bubble-7141.mp4',
|
||||
});
|
||||
}),
|
||||
],
|
||||
})}
|
||||
/>
|
||||
));
|
||||
|
||||
return <Lightbox {...props} />;
|
||||
});
|
||||
|
||||
story.add('Video with Caption', () => {
|
||||
const props = createProps({
|
||||
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('Video (View Once)', () => {
|
||||
const props = createProps({
|
||||
contentType: VIDEO_MP4,
|
||||
isViewOnce: true,
|
||||
objectURL: '/fixtures/pixabay-Soap-Bubble-7141.mp4',
|
||||
});
|
||||
|
||||
return <Lightbox {...props} />;
|
||||
});
|
||||
|
||||
story.add('Unsupported Image Type', () => {
|
||||
const props = createProps({
|
||||
story.add('Unsupported Image Type', () => (
|
||||
<Lightbox
|
||||
{...createProps({
|
||||
media: [
|
||||
createMediaItem({
|
||||
contentType: stringToMIMEType('image/tiff'),
|
||||
objectURL: 'unsupported-image.tiff',
|
||||
});
|
||||
}),
|
||||
],
|
||||
})}
|
||||
/>
|
||||
));
|
||||
|
||||
return <Lightbox {...props} />;
|
||||
});
|
||||
|
||||
story.add('Unsupported Video Type', () => {
|
||||
const props = createProps({
|
||||
story.add('Unsupported Video Type', () => (
|
||||
<Lightbox
|
||||
{...createProps({
|
||||
media: [
|
||||
createMediaItem({
|
||||
contentType: VIDEO_QUICKTIME,
|
||||
objectURL: 'unsupported-video.mov',
|
||||
});
|
||||
}),
|
||||
],
|
||||
})}
|
||||
/>
|
||||
));
|
||||
|
||||
return <Lightbox {...props} />;
|
||||
});
|
||||
|
||||
story.add('Unsupported ContentType', () => {
|
||||
const props = createProps({
|
||||
story.add('Unsupported Content', () => (
|
||||
<Lightbox
|
||||
{...createProps({
|
||||
media: [
|
||||
createMediaItem({
|
||||
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', () => (
|
||||
<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',
|
||||
}),
|
||||
]}
|
||||
/>
|
||||
));
|
||||
|
|
|
@ -1,478 +1,100 @@
|
|||
// 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;
|
||||
|
||||
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',
|
||||
},
|
||||
};
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
onClick();
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={clickHandler}
|
||||
className={classNames('iconButton', type)}
|
||||
style={style}
|
||||
aria-label={i18n(type)}
|
||||
type="button"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const IconButtonPlaceholder = () => (
|
||||
<div style={styles.iconButtonPlaceholder} />
|
||||
);
|
||||
|
||||
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"
|
||||
/>
|
||||
);
|
||||
|
||||
export class Lightbox extends React.Component<Props, State> {
|
||||
public readonly containerRef = React.createRef<HTMLDivElement>();
|
||||
|
||||
public readonly videoRef = React.createRef<HTMLVideoElement>();
|
||||
|
||||
public readonly focusRef = React.createRef<HTMLDivElement>();
|
||||
|
||||
public previousFocus: HTMLElement | null = null;
|
||||
|
||||
public constructor(props: Props) {
|
||||
super(props);
|
||||
|
||||
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();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public componentWillUnmount(): void {
|
||||
if (this.previousFocus && this.previousFocus.focus) {
|
||||
this.previousFocus.focus();
|
||||
}
|
||||
|
||||
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();
|
||||
if (!video) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (video.paused) {
|
||||
video.play();
|
||||
} else {
|
||||
video.pause();
|
||||
}
|
||||
}
|
||||
|
||||
public render(): JSX.Element {
|
||||
const {
|
||||
caption,
|
||||
export function Lightbox({
|
||||
children,
|
||||
contentType,
|
||||
close,
|
||||
getConversation,
|
||||
media,
|
||||
i18n,
|
||||
isViewOnce,
|
||||
loop = false,
|
||||
objectURL,
|
||||
onNext,
|
||||
onPrevious,
|
||||
onForward,
|
||||
onSave,
|
||||
} = this.props;
|
||||
const { videoTime } = this.state;
|
||||
|
||||
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>
|
||||
selectedIndex: initialSelectedIndex,
|
||||
}: PropsType): JSX.Element | null {
|
||||
const [root, setRoot] = React.useState<HTMLElement | undefined>();
|
||||
const [selectedIndex, setSelectedIndex] = useState<number>(
|
||||
initialSelectedIndex || 0
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
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);
|
||||
|
||||
const restorePreviousFocus = useCallback(() => {
|
||||
if (previousFocus && previousFocus.focus) {
|
||||
previousFocus.focus();
|
||||
}
|
||||
}, [previousFocus]);
|
||||
|
||||
const onPrevious = useCallback(() => {
|
||||
setSelectedIndex(prevSelectedIndex => Math.max(prevSelectedIndex - 1, 0));
|
||||
}, []);
|
||||
|
||||
const onNext = useCallback(() => {
|
||||
setSelectedIndex(prevSelectedIndex =>
|
||||
Math.min(prevSelectedIndex + 1, media.length - 1)
|
||||
);
|
||||
}
|
||||
}, [media]);
|
||||
|
||||
const isVideoTypeSupported = GoogleChrome.isVideoTypeSupported(contentType);
|
||||
if (isVideoTypeSupported) {
|
||||
return (
|
||||
<video
|
||||
ref={this.videoRef}
|
||||
loop={loop || isViewOnce}
|
||||
controls={!loop && !isViewOnce}
|
||||
style={styles.object}
|
||||
key={objectURL}
|
||||
>
|
||||
<source src={objectURL} />
|
||||
</video>
|
||||
);
|
||||
}
|
||||
const handleSave = () => {
|
||||
const mediaItem = media[selectedIndex];
|
||||
const { attachment, message, index } = mediaItem;
|
||||
|
||||
const isUnsupportedImageType =
|
||||
!isImageTypeSupported && MIME.isImage(contentType);
|
||||
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} />;
|
||||
}
|
||||
|
||||
window.log.info('Lightbox: Unexpected content type', { contentType });
|
||||
|
||||
return (
|
||||
<Icon i18n={i18n} onClick={this.onObjectClick} url="images/file.svg" />
|
||||
);
|
||||
onSave?.({ attachment, message, index });
|
||||
};
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
const handleForward = () => {
|
||||
close();
|
||||
const mediaItem = media[selectedIndex];
|
||||
onForward?.(mediaItem.message.id);
|
||||
};
|
||||
|
||||
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;
|
||||
const onKeyDown = useCallback(
|
||||
(event: KeyboardEvent) => {
|
||||
switch (event.key) {
|
||||
case 'Escape':
|
||||
this.onClose();
|
||||
if (zoomed) {
|
||||
setZoomed(false);
|
||||
} else {
|
||||
close();
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
@ -499,34 +121,353 @@ export class Lightbox extends React.Component<Props, State> {
|
|||
|
||||
default:
|
||||
}
|
||||
},
|
||||
[close, onNext, onPrevious, zoomed]
|
||||
);
|
||||
|
||||
const stopPropagationAndClose = (event: MouseEvent<HTMLElement>) => {
|
||||
event.stopPropagation();
|
||||
close();
|
||||
};
|
||||
|
||||
private readonly onContainerClick = (
|
||||
event: React.MouseEvent<HTMLDivElement>
|
||||
) => {
|
||||
if (this.containerRef && event.target !== this.containerRef.current) {
|
||||
const playVideo = () => {
|
||||
const video = videoRef.current;
|
||||
if (!video) {
|
||||
return;
|
||||
}
|
||||
this.onClose();
|
||||
|
||||
if (video.paused) {
|
||||
video.play();
|
||||
} else {
|
||||
video.pause();
|
||||
}
|
||||
};
|
||||
|
||||
private readonly onContainerKeyUp = (
|
||||
event: React.KeyboardEvent<HTMLDivElement>
|
||||
) => {
|
||||
useEffect(() => {
|
||||
const div = document.createElement('div');
|
||||
document.body.appendChild(div);
|
||||
setRoot(div);
|
||||
|
||||
return () => {
|
||||
document.body.removeChild(div);
|
||||
setRoot(undefined);
|
||||
};
|
||||
}, []);
|
||||
|
||||
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);
|
||||
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 (
|
||||
(this.containerRef && event.target !== this.containerRef.current) ||
|
||||
event &&
|
||||
contentType !== IMAGE_PNG &&
|
||||
!/image\/jpe?g/g.test(contentType)
|
||||
) {
|
||||
event.preventDefault();
|
||||
}
|
||||
}}
|
||||
src={objectURL}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
} else {
|
||||
content = (
|
||||
<button
|
||||
aria-label={i18n('lightboxImageAlt')}
|
||||
className={classNames({
|
||||
Lightbox__object: true,
|
||||
Lightbox__unsupported: true,
|
||||
'Lightbox__unsupported--missing': true,
|
||||
})}
|
||||
onClick={stopPropagationAndClose}
|
||||
type="button"
|
||||
/>
|
||||
);
|
||||
}
|
||||
} else if (isVideoTypeSupported) {
|
||||
const shouldLoop = loop || isGIF([attachment]);
|
||||
content = (
|
||||
<video
|
||||
className="Lightbox__object"
|
||||
controls={!shouldLoop}
|
||||
key={objectURL}
|
||||
loop={shouldLoop}
|
||||
ref={videoRef}
|
||||
>
|
||||
<source src={objectURL} />
|
||||
</video>
|
||||
);
|
||||
} else if (isUnsupportedImageType || isUnsupportedVideoType) {
|
||||
content = (
|
||||
<button
|
||||
aria-label={i18n('unsupportedAttachment')}
|
||||
className={classNames({
|
||||
Lightbox__object: true,
|
||||
Lightbox__unsupported: true,
|
||||
'Lightbox__unsupported--image': isUnsupportedImageType,
|
||||
'Lightbox__unsupported--video': isUnsupportedVideoType,
|
||||
})}
|
||||
onClick={stopPropagationAndClose}
|
||||
type="button"
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
window.log.info('Lightbox: Unexpected content type', { contentType });
|
||||
|
||||
content = (
|
||||
<button
|
||||
aria-label={i18n('unsupportedAttachment')}
|
||||
className="Lightbox__object Lightbox__unsupported Lightbox__unsupported--file"
|
||||
onClick={stopPropagationAndClose}
|
||||
type="button"
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const hasNext = selectedIndex < media.length - 1;
|
||||
const hasPrevious = selectedIndex > 0;
|
||||
|
||||
return root
|
||||
? createPortal(
|
||||
<div
|
||||
className="Lightbox Lightbox__container"
|
||||
onClick={(event: MouseEvent<HTMLDivElement>) => {
|
||||
if (containerRef && event.target !== containerRef.current) {
|
||||
return;
|
||||
}
|
||||
close();
|
||||
}}
|
||||
onKeyUp={(event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
if (
|
||||
(containerRef && event.target !== containerRef.current) ||
|
||||
event.keyCode !== 27
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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} />;
|
||||
});
|
|
@ -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 });
|
||||
};
|
||||
}
|
|
@ -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';
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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>;
|
||||
|
|
|
@ -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', () => {
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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';
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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;
|
||||
};
|
|
@ -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",
|
||||
|
|
|
@ -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),
|
||||
},
|
||||
};
|
||||
}
|
||||
),
|
||||
|
|
|
@ -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;
|
||||
|
|
Loading…
Reference in New Issue