Improved Lightbox experience
This commit is contained in:
parent
d80e738fb1
commit
d5d808651a
26 changed files with 1054 additions and 966 deletions
|
@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
273
stylesheets/components/Lightbox.scss
Normal file
273
stylesheets/components/Lightbox.scss
Normal 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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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', () => {
|
||||
story.add('Multimedia', () => {
|
||||
const props = createProps({
|
||||
objectURL: '/fixtures/tina-rolf-269345-unsplash.jpg',
|
||||
media: [
|
||||
{
|
||||
attachment: {
|
||||
contentType: IMAGE_JPEG,
|
||||
fileName: 'tina-rolf-269345-unsplash.jpg',
|
||||
url: '/fixtures/tina-rolf-269345-unsplash.jpg',
|
||||
caption:
|
||||
'Still from The Lighthouse, starring Robert Pattinson and Willem Defoe.',
|
||||
},
|
||||
contentType: IMAGE_JPEG,
|
||||
index: 0,
|
||||
message: {
|
||||
attachments: [],
|
||||
conversationId: '1234',
|
||||
id: 'image-msg',
|
||||
received_at: 1,
|
||||
received_at_ms: Date.now(),
|
||||
},
|
||||
objectURL: '/fixtures/tina-rolf-269345-unsplash.jpg',
|
||||
},
|
||||
{
|
||||
attachment: {
|
||||
contentType: VIDEO_MP4,
|
||||
fileName: 'pixabay-Soap-Bubble-7141.mp4',
|
||||
url: '/fixtures/pixabay-Soap-Bubble-7141.mp4',
|
||||
},
|
||||
contentType: VIDEO_MP4,
|
||||
index: 1,
|
||||
message: {
|
||||
attachments: [],
|
||||
conversationId: '1234',
|
||||
id: 'video-msg',
|
||||
received_at: 2,
|
||||
received_at_ms: Date.now(),
|
||||
},
|
||||
objectURL: '/fixtures/pixabay-Soap-Bubble-7141.mp4',
|
||||
},
|
||||
createMediaItem({
|
||||
contentType: IMAGE_JPEG,
|
||||
index: 2,
|
||||
thumbnailObjectUrl: '/fixtures/kitten-1-64-64.jpg',
|
||||
objectURL: '/fixtures/kitten-1-64-64.jpg',
|
||||
}),
|
||||
createMediaItem({
|
||||
contentType: IMAGE_JPEG,
|
||||
index: 3,
|
||||
thumbnailObjectUrl: '/fixtures/kitten-2-64-64.jpg',
|
||||
objectURL: '/fixtures/kitten-2-64-64.jpg',
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
return <Lightbox {...props} />;
|
||||
});
|
||||
|
||||
story.add('Image with Caption (normal image)', () => {
|
||||
story.add('Missing Media', () => {
|
||||
const props = createProps({
|
||||
caption:
|
||||
'This is the user-provided caption. It can get long and wrap onto multiple lines.',
|
||||
objectURL: '/fixtures/tina-rolf-269345-unsplash.jpg',
|
||||
media: [
|
||||
{
|
||||
attachment: {
|
||||
contentType: IMAGE_JPEG,
|
||||
fileName: 'tina-rolf-269345-unsplash.jpg',
|
||||
url: '/fixtures/tina-rolf-269345-unsplash.jpg',
|
||||
},
|
||||
contentType: IMAGE_JPEG,
|
||||
index: 0,
|
||||
message: {
|
||||
attachments: [],
|
||||
conversationId: '1234',
|
||||
id: 'image-msg',
|
||||
received_at: 3,
|
||||
received_at_ms: Date.now(),
|
||||
},
|
||||
objectURL: undefined,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
return <Lightbox {...props} />;
|
||||
});
|
||||
|
||||
story.add('Image with Caption (all-white image)', () => {
|
||||
const props = createProps({
|
||||
caption:
|
||||
'This is the user-provided caption. It should be visible on light backgrounds.',
|
||||
objectURL: '/fixtures/2000x2000-white.png',
|
||||
});
|
||||
story.add('Single Image', () => (
|
||||
<Lightbox
|
||||
{...createProps({
|
||||
media: [
|
||||
createMediaItem({
|
||||
objectURL: '/fixtures/tina-rolf-269345-unsplash.jpg',
|
||||
}),
|
||||
],
|
||||
})}
|
||||
/>
|
||||
));
|
||||
|
||||
return <Lightbox {...props} />;
|
||||
});
|
||||
story.add('Image with Caption (normal image)', () => (
|
||||
<Lightbox
|
||||
{...createProps({
|
||||
media: [
|
||||
createMediaItem({
|
||||
caption:
|
||||
'This lighthouse is really cool because there are lots of rocks and there is a tower that has a light and the light is really bright because it shines so much. The day was super duper cloudy and stormy and you can see all the waves hitting against the rocks. Wait? What is that weird red hose line thingy running all the way to the tower? Those rocks look slippery! I bet that water is really cold. I am cold now, can I get a sweater? I wonder where this place is, probably somewhere cold like Coldsgar, Frozenville.',
|
||||
objectURL: '/fixtures/tina-rolf-269345-unsplash.jpg',
|
||||
}),
|
||||
],
|
||||
})}
|
||||
/>
|
||||
));
|
||||
|
||||
story.add('Video', () => {
|
||||
const props = createProps({
|
||||
contentType: VIDEO_MP4,
|
||||
objectURL: '/fixtures/pixabay-Soap-Bubble-7141.mp4',
|
||||
});
|
||||
story.add('Image with Caption (all-white image)', () => (
|
||||
<Lightbox
|
||||
{...createProps({
|
||||
media: [
|
||||
createMediaItem({
|
||||
caption:
|
||||
'This is the user-provided caption. It should be visible on light backgrounds.',
|
||||
objectURL: '/fixtures/2000x2000-white.png',
|
||||
}),
|
||||
],
|
||||
})}
|
||||
/>
|
||||
));
|
||||
|
||||
return <Lightbox {...props} />;
|
||||
});
|
||||
story.add('Single Video', () => (
|
||||
<Lightbox
|
||||
{...createProps({
|
||||
media: [
|
||||
createMediaItem({
|
||||
contentType: VIDEO_MP4,
|
||||
objectURL: '/fixtures/pixabay-Soap-Bubble-7141.mp4',
|
||||
}),
|
||||
],
|
||||
})}
|
||||
/>
|
||||
));
|
||||
|
||||
story.add('Video with Caption', () => {
|
||||
const props = createProps({
|
||||
caption:
|
||||
'This is the user-provided caption. It can get long and wrap onto multiple lines.',
|
||||
contentType: VIDEO_MP4,
|
||||
objectURL: '/fixtures/pixabay-Soap-Bubble-7141.mp4',
|
||||
});
|
||||
story.add('Single Video w/caption', () => (
|
||||
<Lightbox
|
||||
{...createProps({
|
||||
media: [
|
||||
createMediaItem({
|
||||
caption:
|
||||
'This is the user-provided caption. It can get long and wrap onto multiple lines.',
|
||||
contentType: VIDEO_MP4,
|
||||
objectURL: '/fixtures/pixabay-Soap-Bubble-7141.mp4',
|
||||
}),
|
||||
],
|
||||
})}
|
||||
/>
|
||||
));
|
||||
|
||||
return <Lightbox {...props} />;
|
||||
});
|
||||
story.add('Unsupported Image Type', () => (
|
||||
<Lightbox
|
||||
{...createProps({
|
||||
media: [
|
||||
createMediaItem({
|
||||
contentType: stringToMIMEType('image/tiff'),
|
||||
objectURL: 'unsupported-image.tiff',
|
||||
}),
|
||||
],
|
||||
})}
|
||||
/>
|
||||
));
|
||||
|
||||
story.add('Video (View Once)', () => {
|
||||
const props = createProps({
|
||||
contentType: VIDEO_MP4,
|
||||
isViewOnce: true,
|
||||
objectURL: '/fixtures/pixabay-Soap-Bubble-7141.mp4',
|
||||
});
|
||||
story.add('Unsupported Video Type', () => (
|
||||
<Lightbox
|
||||
{...createProps({
|
||||
media: [
|
||||
createMediaItem({
|
||||
contentType: VIDEO_QUICKTIME,
|
||||
objectURL: 'unsupported-video.mov',
|
||||
}),
|
||||
],
|
||||
})}
|
||||
/>
|
||||
));
|
||||
|
||||
return <Lightbox {...props} />;
|
||||
});
|
||||
|
||||
story.add('Unsupported Image Type', () => {
|
||||
const props = createProps({
|
||||
contentType: stringToMIMEType('image/tiff'),
|
||||
objectURL: 'unsupported-image.tiff',
|
||||
});
|
||||
|
||||
return <Lightbox {...props} />;
|
||||
});
|
||||
|
||||
story.add('Unsupported Video Type', () => {
|
||||
const props = createProps({
|
||||
contentType: VIDEO_QUICKTIME,
|
||||
objectURL: 'unsupported-video.mov',
|
||||
});
|
||||
|
||||
return <Lightbox {...props} />;
|
||||
});
|
||||
|
||||
story.add('Unsupported ContentType', () => {
|
||||
const props = createProps({
|
||||
contentType: AUDIO_MP3,
|
||||
objectURL: '/fixtures/incompetech-com-Agnus-Dei-X.mp3',
|
||||
});
|
||||
|
||||
return <Lightbox {...props} />;
|
||||
});
|
||||
|
||||
story.add('Including Next/Previous/Save Callbacks', () => {
|
||||
const props = createProps({
|
||||
objectURL: '/fixtures/tina-rolf-269345-unsplash.jpg',
|
||||
onNext: action('onNext'),
|
||||
onPrevious: action('onPrevious'),
|
||||
onSave: action('onSave'),
|
||||
});
|
||||
|
||||
return <Lightbox {...props} />;
|
||||
});
|
||||
story.add('Unsupported Content', () => (
|
||||
<Lightbox
|
||||
{...createProps({
|
||||
media: [
|
||||
createMediaItem({
|
||||
contentType: AUDIO_MP3,
|
||||
objectURL: '/fixtures/incompetech-com-Agnus-Dei-X.mp3',
|
||||
}),
|
||||
],
|
||||
})}
|
||||
/>
|
||||
));
|
||||
|
||||
story.add('Custom children', () => (
|
||||
<Lightbox {...createProps({})} contentType={undefined}>
|
||||
<Lightbox {...createProps({})} media={[]}>
|
||||
<div
|
||||
style={{
|
||||
color: 'white',
|
||||
|
@ -144,3 +261,30 @@ story.add('Custom children', () => (
|
|||
</div>
|
||||
</Lightbox>
|
||||
));
|
||||
|
||||
story.add('Forwarding', () => (
|
||||
<Lightbox {...createProps({})} onForward={action('onForward')} />
|
||||
));
|
||||
|
||||
story.add('Conversation Header', () => (
|
||||
<Lightbox
|
||||
{...createProps({})}
|
||||
getConversation={() => ({
|
||||
acceptedMessageRequest: true,
|
||||
avatarPath: '/fixtures/kitten-1-64-64.jpg',
|
||||
id: '1234',
|
||||
isMe: false,
|
||||
name: 'Test',
|
||||
profileName: 'Test',
|
||||
sharedGroupNames: [],
|
||||
title: 'Test',
|
||||
type: 'direct',
|
||||
})}
|
||||
media={[
|
||||
createMediaItem({
|
||||
contentType: VIDEO_MP4,
|
||||
objectURL: '/fixtures/pixabay-Soap-Bubble-7141.mp4',
|
||||
}),
|
||||
]}
|
||||
/>
|
||||
));
|
||||
|
|
|
@ -1,291 +1,137 @@
|
|||
// Copyright 2018-2020 Signal Messenger, LLC
|
||||
// Copyright 2018-2021 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import React, { ReactNode } from 'react';
|
||||
|
||||
import React, {
|
||||
MouseEvent,
|
||||
ReactNode,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import moment from 'moment';
|
||||
import classNames from 'classnames';
|
||||
import is from '@sindresorhus/is';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
import * as GoogleChrome from '../util/GoogleChrome';
|
||||
import * as MIME from '../types/MIME';
|
||||
|
||||
import { formatDuration } from '../util/formatDuration';
|
||||
import { AttachmentType, isGIF } from '../types/Attachment';
|
||||
import { Avatar, AvatarSize } from './Avatar';
|
||||
import { ConversationType } from '../state/ducks/conversations';
|
||||
import { IMAGE_PNG, isImage, isVideo } from '../types/MIME';
|
||||
import { LocalizerType } from '../types/Util';
|
||||
import { MediaItemType, MessageAttributesType } from '../types/MediaItem';
|
||||
|
||||
const Colors = {
|
||||
ICON_SECONDARY: '#b9b9b9',
|
||||
};
|
||||
|
||||
const colorSVG = (url: string, color: string) => {
|
||||
return {
|
||||
WebkitMask: `url(${url}) no-repeat center`,
|
||||
WebkitMaskSize: '100%',
|
||||
backgroundColor: color,
|
||||
};
|
||||
};
|
||||
|
||||
export type Props = {
|
||||
export type PropsType = {
|
||||
children?: ReactNode;
|
||||
close: () => void;
|
||||
contentType: MIME.MIMEType | undefined;
|
||||
getConversation?: (id: string) => ConversationType;
|
||||
i18n: LocalizerType;
|
||||
objectURL: string;
|
||||
caption?: string;
|
||||
isViewOnce: boolean;
|
||||
loop?: boolean;
|
||||
onNext?: () => void;
|
||||
onPrevious?: () => void;
|
||||
onSave?: () => void;
|
||||
};
|
||||
type State = {
|
||||
videoTime?: number;
|
||||
media: Array<MediaItemType>;
|
||||
onForward?: (messageId: string) => void;
|
||||
onSave?: (options: {
|
||||
attachment: AttachmentType;
|
||||
message: MessageAttributesType;
|
||||
index: number;
|
||||
}) => void;
|
||||
selectedIndex?: number;
|
||||
};
|
||||
|
||||
const CONTROLS_WIDTH = 50;
|
||||
const CONTROLS_SPACING = 10;
|
||||
export function Lightbox({
|
||||
children,
|
||||
close,
|
||||
getConversation,
|
||||
media,
|
||||
i18n,
|
||||
onForward,
|
||||
onSave,
|
||||
selectedIndex: initialSelectedIndex,
|
||||
}: PropsType): JSX.Element | null {
|
||||
const [root, setRoot] = React.useState<HTMLElement | undefined>();
|
||||
const [selectedIndex, setSelectedIndex] = useState<number>(
|
||||
initialSelectedIndex || 0
|
||||
);
|
||||
|
||||
const styles = {
|
||||
container: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.9)',
|
||||
zIndex: 10,
|
||||
} as React.CSSProperties,
|
||||
buttonContainer: {
|
||||
backgroundColor: 'transparent',
|
||||
border: 'none',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
outline: 'none',
|
||||
width: '100%',
|
||||
padding: 0,
|
||||
} as React.CSSProperties,
|
||||
mainContainer: {
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
flexGrow: 1,
|
||||
paddingTop: 40,
|
||||
paddingLeft: 40,
|
||||
paddingRight: 40,
|
||||
paddingBottom: 0,
|
||||
// To ensure that a large image doesn't overflow the flex layout
|
||||
minHeight: '50px',
|
||||
outline: 'none',
|
||||
} as React.CSSProperties,
|
||||
objectContainer: {
|
||||
position: 'relative',
|
||||
flexGrow: 1,
|
||||
display: 'inline-flex',
|
||||
justifyContent: 'center',
|
||||
} as React.CSSProperties,
|
||||
object: {
|
||||
flexGrow: 1,
|
||||
flexShrink: 1,
|
||||
maxWidth: '100%',
|
||||
maxHeight: '100%',
|
||||
objectFit: 'contain',
|
||||
outline: 'none',
|
||||
} as React.CSSProperties,
|
||||
img: {
|
||||
position: 'absolute',
|
||||
left: '50%',
|
||||
top: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
width: 'auto',
|
||||
height: 'auto',
|
||||
maxWidth: '100%',
|
||||
maxHeight: '100%',
|
||||
objectFit: 'contain',
|
||||
outline: 'none',
|
||||
} as React.CSSProperties,
|
||||
caption: {
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
textAlign: 'center',
|
||||
color: 'white',
|
||||
fontWeight: 'bold',
|
||||
textShadow: '0 0 1px black, 0 0 2px black, 0 0 3px black, 0 0 4px black',
|
||||
padding: '1em',
|
||||
paddingLeft: '3em',
|
||||
paddingRight: '3em',
|
||||
backgroundColor: 'rgba(192, 192, 192, .20)',
|
||||
} as React.CSSProperties,
|
||||
controlsOffsetPlaceholder: {
|
||||
width: CONTROLS_WIDTH,
|
||||
marginRight: CONTROLS_SPACING,
|
||||
flexShrink: 0,
|
||||
},
|
||||
controls: {
|
||||
width: CONTROLS_WIDTH,
|
||||
flexShrink: 0,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
marginLeft: CONTROLS_SPACING,
|
||||
} as React.CSSProperties,
|
||||
navigationContainer: {
|
||||
flexShrink: 0,
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'center',
|
||||
padding: 10,
|
||||
} as React.CSSProperties,
|
||||
saveButton: {
|
||||
marginTop: 10,
|
||||
},
|
||||
countdownContainer: {
|
||||
padding: 8,
|
||||
},
|
||||
iconButtonPlaceholder: {
|
||||
// Dimensions match `.iconButton`:
|
||||
display: 'inline-block',
|
||||
width: 50,
|
||||
height: 50,
|
||||
},
|
||||
timestampPill: {
|
||||
borderRadius: '15px',
|
||||
backgroundColor: '#000000',
|
||||
color: '#eeefef',
|
||||
fontSize: '16px',
|
||||
letterSpacing: '0px',
|
||||
lineHeight: '18px',
|
||||
// This cast is necessary or typescript chokes
|
||||
textAlign: 'center' as const,
|
||||
padding: '6px',
|
||||
paddingLeft: '18px',
|
||||
paddingRight: '18px',
|
||||
},
|
||||
};
|
||||
const [previousFocus, setPreviousFocus] = useState<HTMLElement | undefined>();
|
||||
const [zoomed, setZoomed] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const focusRef = useRef<HTMLDivElement | null>(null);
|
||||
const videoRef = useRef<HTMLVideoElement | null>(null);
|
||||
|
||||
type IconButtonProps = {
|
||||
i18n: LocalizerType;
|
||||
onClick?: () => void;
|
||||
style?: React.CSSProperties;
|
||||
type: 'save' | 'close' | 'previous' | 'next';
|
||||
};
|
||||
|
||||
const IconButton = ({ i18n, onClick, style, type }: IconButtonProps) => {
|
||||
const clickHandler = (event: React.MouseEvent<HTMLButtonElement>): void => {
|
||||
event.preventDefault();
|
||||
if (!onClick) {
|
||||
return;
|
||||
const restorePreviousFocus = useCallback(() => {
|
||||
if (previousFocus && previousFocus.focus) {
|
||||
previousFocus.focus();
|
||||
}
|
||||
}, [previousFocus]);
|
||||
|
||||
onClick();
|
||||
const onPrevious = useCallback(() => {
|
||||
setSelectedIndex(prevSelectedIndex => Math.max(prevSelectedIndex - 1, 0));
|
||||
}, []);
|
||||
|
||||
const onNext = useCallback(() => {
|
||||
setSelectedIndex(prevSelectedIndex =>
|
||||
Math.min(prevSelectedIndex + 1, media.length - 1)
|
||||
);
|
||||
}, [media]);
|
||||
|
||||
const handleSave = () => {
|
||||
const mediaItem = media[selectedIndex];
|
||||
const { attachment, message, index } = mediaItem;
|
||||
|
||||
onSave?.({ attachment, message, index });
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={clickHandler}
|
||||
className={classNames('iconButton', type)}
|
||||
style={style}
|
||||
aria-label={i18n(type)}
|
||||
type="button"
|
||||
/>
|
||||
);
|
||||
};
|
||||
const handleForward = () => {
|
||||
close();
|
||||
const mediaItem = media[selectedIndex];
|
||||
onForward?.(mediaItem.message.id);
|
||||
};
|
||||
|
||||
const IconButtonPlaceholder = () => (
|
||||
<div style={styles.iconButtonPlaceholder} />
|
||||
);
|
||||
const onKeyDown = useCallback(
|
||||
(event: KeyboardEvent) => {
|
||||
switch (event.key) {
|
||||
case 'Escape':
|
||||
if (zoomed) {
|
||||
setZoomed(false);
|
||||
} else {
|
||||
close();
|
||||
}
|
||||
|
||||
const Icon = ({
|
||||
i18n,
|
||||
onClick,
|
||||
url,
|
||||
}: {
|
||||
i18n: LocalizerType;
|
||||
onClick: (event: React.MouseEvent<HTMLButtonElement>) => void;
|
||||
url: string;
|
||||
}) => (
|
||||
<button
|
||||
style={{
|
||||
...styles.object,
|
||||
...colorSVG(url, Colors.ICON_SECONDARY),
|
||||
maxWidth: 200,
|
||||
}}
|
||||
onClick={onClick}
|
||||
aria-label={i18n('unsupportedAttachment')}
|
||||
type="button"
|
||||
/>
|
||||
);
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
export class Lightbox extends React.Component<Props, State> {
|
||||
public readonly containerRef = React.createRef<HTMLDivElement>();
|
||||
break;
|
||||
|
||||
public readonly videoRef = React.createRef<HTMLVideoElement>();
|
||||
case 'ArrowLeft':
|
||||
if (onPrevious) {
|
||||
onPrevious();
|
||||
|
||||
public readonly focusRef = React.createRef<HTMLDivElement>();
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}
|
||||
break;
|
||||
|
||||
public previousFocus: HTMLElement | null = null;
|
||||
case 'ArrowRight':
|
||||
if (onNext) {
|
||||
onNext();
|
||||
|
||||
public constructor(props: Props) {
|
||||
super(props);
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}
|
||||
break;
|
||||
|
||||
this.state = {};
|
||||
}
|
||||
|
||||
public componentDidMount(): void {
|
||||
this.previousFocus = document.activeElement as HTMLElement;
|
||||
|
||||
const { isViewOnce } = this.props;
|
||||
|
||||
const useCapture = true;
|
||||
document.addEventListener('keydown', this.onKeyDown, useCapture);
|
||||
|
||||
const video = this.getVideo();
|
||||
if (video && isViewOnce) {
|
||||
video.addEventListener('timeupdate', this.onTimeUpdate);
|
||||
}
|
||||
|
||||
// Wait until we're added to the DOM. ConversationView first creates this view, then
|
||||
// appends its elements into the DOM.
|
||||
setTimeout(() => {
|
||||
this.playVideo();
|
||||
|
||||
if (this.focusRef && this.focusRef.current) {
|
||||
this.focusRef.current.focus();
|
||||
default:
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
[close, onNext, onPrevious, zoomed]
|
||||
);
|
||||
|
||||
public componentWillUnmount(): void {
|
||||
if (this.previousFocus && this.previousFocus.focus) {
|
||||
this.previousFocus.focus();
|
||||
}
|
||||
const stopPropagationAndClose = (event: MouseEvent<HTMLElement>) => {
|
||||
event.stopPropagation();
|
||||
close();
|
||||
};
|
||||
|
||||
const { isViewOnce } = this.props;
|
||||
|
||||
const useCapture = true;
|
||||
document.removeEventListener('keydown', this.onKeyDown, useCapture);
|
||||
|
||||
const video = this.getVideo();
|
||||
if (video && isViewOnce) {
|
||||
video.removeEventListener('timeupdate', this.onTimeUpdate);
|
||||
}
|
||||
}
|
||||
|
||||
public getVideo(): HTMLVideoElement | null {
|
||||
if (!this.videoRef) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { current } = this.videoRef;
|
||||
if (!current) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return current;
|
||||
}
|
||||
|
||||
public playVideo(): void {
|
||||
const video = this.getVideo();
|
||||
const playVideo = () => {
|
||||
const video = videoRef.current;
|
||||
if (!video) {
|
||||
return;
|
||||
}
|
||||
|
@ -295,238 +141,333 @@ export class Lightbox extends React.Component<Props, State> {
|
|||
} else {
|
||||
video.pause();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
public render(): JSX.Element {
|
||||
const {
|
||||
caption,
|
||||
children,
|
||||
contentType,
|
||||
i18n,
|
||||
isViewOnce,
|
||||
loop = false,
|
||||
objectURL,
|
||||
onNext,
|
||||
onPrevious,
|
||||
onSave,
|
||||
} = this.props;
|
||||
const { videoTime } = this.state;
|
||||
useEffect(() => {
|
||||
const div = document.createElement('div');
|
||||
document.body.appendChild(div);
|
||||
setRoot(div);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="module-lightbox"
|
||||
style={styles.container}
|
||||
onClick={this.onContainerClick}
|
||||
onKeyUp={this.onContainerKeyUp}
|
||||
ref={this.containerRef}
|
||||
role="presentation"
|
||||
>
|
||||
<div style={styles.mainContainer} tabIndex={-1} ref={this.focusRef}>
|
||||
<div style={styles.controlsOffsetPlaceholder} />
|
||||
<div style={styles.objectContainer}>
|
||||
{!is.undefined(contentType)
|
||||
? this.renderObject({
|
||||
objectURL,
|
||||
contentType,
|
||||
i18n,
|
||||
isViewOnce,
|
||||
loop,
|
||||
})
|
||||
: children}
|
||||
{caption ? <div style={styles.caption}>{caption}</div> : null}
|
||||
</div>
|
||||
<div style={styles.controls}>
|
||||
<IconButton i18n={i18n} type="close" onClick={this.onClose} />
|
||||
{onSave ? (
|
||||
<IconButton
|
||||
i18n={i18n}
|
||||
type="save"
|
||||
onClick={onSave}
|
||||
style={styles.saveButton}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
{isViewOnce && videoTime && is.number(videoTime) ? (
|
||||
<div style={styles.navigationContainer}>
|
||||
<div style={styles.timestampPill}>{formatDuration(videoTime)}</div>
|
||||
</div>
|
||||
) : (
|
||||
<div style={styles.navigationContainer}>
|
||||
{onPrevious ? (
|
||||
<IconButton i18n={i18n} type="previous" onClick={onPrevious} />
|
||||
) : (
|
||||
<IconButtonPlaceholder />
|
||||
)}
|
||||
{onNext ? (
|
||||
<IconButton i18n={i18n} type="next" onClick={onNext} />
|
||||
) : (
|
||||
<IconButtonPlaceholder />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return () => {
|
||||
document.body.removeChild(div);
|
||||
setRoot(undefined);
|
||||
};
|
||||
}, []);
|
||||
|
||||
private readonly renderObject = ({
|
||||
objectURL,
|
||||
contentType,
|
||||
i18n,
|
||||
isViewOnce,
|
||||
loop,
|
||||
}: {
|
||||
objectURL: string;
|
||||
contentType: MIME.MIMEType;
|
||||
i18n: LocalizerType;
|
||||
isViewOnce: boolean;
|
||||
loop: boolean;
|
||||
}) => {
|
||||
const isImageTypeSupported = GoogleChrome.isImageTypeSupported(contentType);
|
||||
if (isImageTypeSupported) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
style={styles.buttonContainer}
|
||||
onClick={this.onObjectClick}
|
||||
>
|
||||
<img
|
||||
alt={i18n('lightboxImageAlt')}
|
||||
style={styles.img}
|
||||
src={objectURL}
|
||||
onContextMenu={this.onContextMenu}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
useEffect(() => {
|
||||
if (!previousFocus) {
|
||||
setPreviousFocus(document.activeElement as HTMLElement);
|
||||
}
|
||||
}, [previousFocus]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
restorePreviousFocus();
|
||||
};
|
||||
}, [restorePreviousFocus]);
|
||||
|
||||
useEffect(() => {
|
||||
const useCapture = true;
|
||||
document.addEventListener('keydown', onKeyDown, useCapture);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', onKeyDown, useCapture);
|
||||
};
|
||||
}, [onKeyDown]);
|
||||
|
||||
useEffect(() => {
|
||||
// Wait until we're added to the DOM. ConversationView first creates this
|
||||
// view, then appends its elements into the DOM.
|
||||
const timeout = window.setTimeout(() => {
|
||||
playVideo();
|
||||
|
||||
if (focusRef && focusRef.current) {
|
||||
focusRef.current.focus();
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
if (timeout) {
|
||||
window.clearTimeout(timeout);
|
||||
}
|
||||
};
|
||||
}, [selectedIndex]);
|
||||
|
||||
const { attachment, contentType, loop = false, objectURL, message } =
|
||||
media[selectedIndex] || {};
|
||||
const caption = attachment?.caption;
|
||||
|
||||
let content: JSX.Element;
|
||||
if (!contentType) {
|
||||
content = <>{children}</>;
|
||||
} else {
|
||||
const isImageTypeSupported = GoogleChrome.isImageTypeSupported(contentType);
|
||||
const isVideoTypeSupported = GoogleChrome.isVideoTypeSupported(contentType);
|
||||
if (isVideoTypeSupported) {
|
||||
return (
|
||||
const isUnsupportedImageType =
|
||||
!isImageTypeSupported && isImage(contentType);
|
||||
const isUnsupportedVideoType =
|
||||
!isVideoTypeSupported && isVideo(contentType);
|
||||
|
||||
if (isImageTypeSupported) {
|
||||
if (objectURL) {
|
||||
content = (
|
||||
<button
|
||||
className="Lightbox__zoom-button"
|
||||
onClick={() => setZoomed(!zoomed)}
|
||||
type="button"
|
||||
>
|
||||
<img
|
||||
alt={i18n('lightboxImageAlt')}
|
||||
className="Lightbox__object"
|
||||
onContextMenu={(event: MouseEvent<HTMLImageElement>) => {
|
||||
// These are the only image types supported by Electron's NativeImage
|
||||
if (
|
||||
event &&
|
||||
contentType !== IMAGE_PNG &&
|
||||
!/image\/jpe?g/g.test(contentType)
|
||||
) {
|
||||
event.preventDefault();
|
||||
}
|
||||
}}
|
||||
src={objectURL}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
} else {
|
||||
content = (
|
||||
<button
|
||||
aria-label={i18n('lightboxImageAlt')}
|
||||
className={classNames({
|
||||
Lightbox__object: true,
|
||||
Lightbox__unsupported: true,
|
||||
'Lightbox__unsupported--missing': true,
|
||||
})}
|
||||
onClick={stopPropagationAndClose}
|
||||
type="button"
|
||||
/>
|
||||
);
|
||||
}
|
||||
} else if (isVideoTypeSupported) {
|
||||
const shouldLoop = loop || isGIF([attachment]);
|
||||
content = (
|
||||
<video
|
||||
ref={this.videoRef}
|
||||
loop={loop || isViewOnce}
|
||||
controls={!loop && !isViewOnce}
|
||||
style={styles.object}
|
||||
className="Lightbox__object"
|
||||
controls={!shouldLoop}
|
||||
key={objectURL}
|
||||
loop={shouldLoop}
|
||||
ref={videoRef}
|
||||
>
|
||||
<source src={objectURL} />
|
||||
</video>
|
||||
);
|
||||
} else if (isUnsupportedImageType || isUnsupportedVideoType) {
|
||||
content = (
|
||||
<button
|
||||
aria-label={i18n('unsupportedAttachment')}
|
||||
className={classNames({
|
||||
Lightbox__object: true,
|
||||
Lightbox__unsupported: true,
|
||||
'Lightbox__unsupported--image': isUnsupportedImageType,
|
||||
'Lightbox__unsupported--video': isUnsupportedVideoType,
|
||||
})}
|
||||
onClick={stopPropagationAndClose}
|
||||
type="button"
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
window.log.info('Lightbox: Unexpected content type', { contentType });
|
||||
|
||||
content = (
|
||||
<button
|
||||
aria-label={i18n('unsupportedAttachment')}
|
||||
className="Lightbox__object Lightbox__unsupported Lightbox__unsupported--file"
|
||||
onClick={stopPropagationAndClose}
|
||||
type="button"
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const isUnsupportedImageType =
|
||||
!isImageTypeSupported && MIME.isImage(contentType);
|
||||
const isUnsupportedVideoType =
|
||||
!isVideoTypeSupported && MIME.isVideo(contentType);
|
||||
if (isUnsupportedImageType || isUnsupportedVideoType) {
|
||||
const iconUrl = isUnsupportedVideoType
|
||||
? 'images/movie.svg'
|
||||
: 'images/image.svg';
|
||||
const hasNext = selectedIndex < media.length - 1;
|
||||
const hasPrevious = selectedIndex > 0;
|
||||
|
||||
return <Icon i18n={i18n} url={iconUrl} onClick={this.onObjectClick} />;
|
||||
}
|
||||
return root
|
||||
? createPortal(
|
||||
<div
|
||||
className="Lightbox Lightbox__container"
|
||||
onClick={(event: MouseEvent<HTMLDivElement>) => {
|
||||
if (containerRef && event.target !== containerRef.current) {
|
||||
return;
|
||||
}
|
||||
close();
|
||||
}}
|
||||
onKeyUp={(event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
if (
|
||||
(containerRef && event.target !== containerRef.current) ||
|
||||
event.keyCode !== 27
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.log.info('Lightbox: Unexpected content type', { contentType });
|
||||
|
||||
return (
|
||||
<Icon i18n={i18n} onClick={this.onObjectClick} url="images/file.svg" />
|
||||
);
|
||||
};
|
||||
|
||||
private readonly onContextMenu = (
|
||||
event: React.MouseEvent<HTMLImageElement>
|
||||
) => {
|
||||
const { contentType = '' } = this.props;
|
||||
|
||||
// These are the only image types supported by Electron's NativeImage
|
||||
if (
|
||||
event &&
|
||||
contentType !== 'image/png' &&
|
||||
!/image\/jpe?g/g.test(contentType)
|
||||
) {
|
||||
event.preventDefault();
|
||||
}
|
||||
};
|
||||
|
||||
private readonly onClose = () => {
|
||||
const { close } = this.props;
|
||||
if (!close) {
|
||||
return;
|
||||
}
|
||||
|
||||
close();
|
||||
};
|
||||
|
||||
private readonly onTimeUpdate = () => {
|
||||
const video = this.getVideo();
|
||||
if (!video) {
|
||||
return;
|
||||
}
|
||||
this.setState({
|
||||
videoTime: video.currentTime,
|
||||
});
|
||||
};
|
||||
|
||||
private readonly onKeyDown = (event: KeyboardEvent) => {
|
||||
const { onNext, onPrevious } = this.props;
|
||||
switch (event.key) {
|
||||
case 'Escape':
|
||||
this.onClose();
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
break;
|
||||
|
||||
case 'ArrowLeft':
|
||||
if (onPrevious) {
|
||||
onPrevious();
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}
|
||||
break;
|
||||
|
||||
case 'ArrowRight':
|
||||
if (onNext) {
|
||||
onNext();
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
}
|
||||
};
|
||||
|
||||
private readonly onContainerClick = (
|
||||
event: React.MouseEvent<HTMLDivElement>
|
||||
) => {
|
||||
if (this.containerRef && event.target !== this.containerRef.current) {
|
||||
return;
|
||||
}
|
||||
this.onClose();
|
||||
};
|
||||
|
||||
private readonly onContainerKeyUp = (
|
||||
event: React.KeyboardEvent<HTMLDivElement>
|
||||
) => {
|
||||
if (
|
||||
(this.containerRef && event.target !== this.containerRef.current) ||
|
||||
event.keyCode !== 27
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.onClose();
|
||||
};
|
||||
|
||||
private readonly onObjectClick = (
|
||||
event: React.MouseEvent<HTMLAnchorElement | HTMLButtonElement>
|
||||
) => {
|
||||
event.stopPropagation();
|
||||
this.onClose();
|
||||
};
|
||||
close();
|
||||
}}
|
||||
ref={containerRef}
|
||||
role="presentation"
|
||||
>
|
||||
<div
|
||||
className="Lightbox__main-container"
|
||||
tabIndex={-1}
|
||||
ref={focusRef}
|
||||
>
|
||||
{!zoomed && (
|
||||
<div className="Lightbox__header">
|
||||
{getConversation ? (
|
||||
<LightboxHeader
|
||||
getConversation={getConversation}
|
||||
i18n={i18n}
|
||||
message={message}
|
||||
/>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
<div className="Lightbox__controls">
|
||||
{onForward ? (
|
||||
<button
|
||||
aria-label={i18n('forwardMessage')}
|
||||
className="Lightbox__button Lightbox__button--forward"
|
||||
onClick={handleForward}
|
||||
type="button"
|
||||
/>
|
||||
) : null}
|
||||
{onSave ? (
|
||||
<button
|
||||
aria-label={i18n('save')}
|
||||
className="Lightbox__button Lightbox__button--save"
|
||||
onClick={handleSave}
|
||||
type="button"
|
||||
/>
|
||||
) : null}
|
||||
<button
|
||||
aria-label={i18n('close')}
|
||||
className="Lightbox__button Lightbox__button--close"
|
||||
onClick={close}
|
||||
type="button"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={classNames('Lightbox__object--container', {
|
||||
'Lightbox__object--container--zoomed': zoomed,
|
||||
})}
|
||||
>
|
||||
{content}
|
||||
</div>
|
||||
{hasPrevious && (
|
||||
<div className="Lightbox__nav-prev">
|
||||
<button
|
||||
aria-label={i18n('previous')}
|
||||
className="Lightbox__button Lightbox__button--previous"
|
||||
disabled={zoomed}
|
||||
onClick={onPrevious}
|
||||
type="button"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{hasNext && (
|
||||
<div className="Lightbox__nav-next">
|
||||
<button
|
||||
aria-label={i18n('next')}
|
||||
className="Lightbox__button Lightbox__button--next"
|
||||
disabled={zoomed}
|
||||
onClick={onNext}
|
||||
type="button"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{!zoomed && (
|
||||
<div className="Lightbox__footer">
|
||||
{caption ? (
|
||||
<div className="Lightbox__caption">{caption}</div>
|
||||
) : null}
|
||||
{media.length > 1 && (
|
||||
<div className="Lightbox__thumbnails--container">
|
||||
<div
|
||||
className="Lightbox__thumbnails"
|
||||
style={{
|
||||
marginLeft:
|
||||
0 - (selectedIndex * 64 + selectedIndex * 8 + 32),
|
||||
}}
|
||||
>
|
||||
{media.map((item, index) => (
|
||||
<button
|
||||
className={classNames({
|
||||
Lightbox__thumbnail: true,
|
||||
'Lightbox__thumbnail--selected':
|
||||
index === selectedIndex,
|
||||
})}
|
||||
key={item.thumbnailObjectUrl}
|
||||
type="button"
|
||||
onClick={() => setSelectedIndex(index)}
|
||||
>
|
||||
{item.thumbnailObjectUrl ? (
|
||||
<img
|
||||
alt={i18n('lightboxImageAlt')}
|
||||
src={item.thumbnailObjectUrl}
|
||||
/>
|
||||
) : (
|
||||
<div className="Lightbox__thumbnail--unavailable" />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>,
|
||||
root
|
||||
)
|
||||
: null;
|
||||
}
|
||||
|
||||
function LightboxHeader({
|
||||
getConversation,
|
||||
i18n,
|
||||
message,
|
||||
}: {
|
||||
getConversation: (id: string) => ConversationType;
|
||||
i18n: LocalizerType;
|
||||
message: MessageAttributesType;
|
||||
}): JSX.Element {
|
||||
const conversation = getConversation(message.conversationId);
|
||||
|
||||
return (
|
||||
<div className="Lightbox__header--container">
|
||||
<div className="Lightbox__header--avatar">
|
||||
<Avatar
|
||||
acceptedMessageRequest={conversation.acceptedMessageRequest}
|
||||
avatarPath={conversation.avatarPath}
|
||||
color={conversation.color}
|
||||
conversationType={conversation.type}
|
||||
i18n={i18n}
|
||||
isMe={conversation.isMe}
|
||||
name={conversation.name}
|
||||
phoneNumber={conversation.e164}
|
||||
profileName={conversation.profileName}
|
||||
sharedGroupNames={conversation.sharedGroupNames}
|
||||
size={AvatarSize.THIRTY_TWO}
|
||||
title={conversation.title}
|
||||
unblurredAvatarPath={conversation.unblurredAvatarPath}
|
||||
/>
|
||||
</div>
|
||||
<div className="Lightbox__header--content">
|
||||
<div className="Lightbox__header--name">{conversation.title}</div>
|
||||
<div className="Lightbox__header--timestamp">
|
||||
{moment(message.received_at_ms).format('L LT')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
25
ts/types/MediaItem.ts
Normal file
25
ts/types/MediaItem.ts
Normal 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;
|
||||
};
|
|
@ -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),
|
||||
},
|
||||
};
|
||||
}
|
||||
),
|
||||
|
|
2
ts/window.d.ts
vendored
2
ts/window.d.ts
vendored
|
@ -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 a new issue