GIF attachments
This commit is contained in:
parent
5f17d01f49
commit
caf1d4c4da
15 changed files with 526 additions and 93 deletions
253
ts/components/conversation/GIF.tsx
Normal file
253
ts/components/conversation/GIF.tsx
Normal file
|
@ -0,0 +1,253 @@
|
|||
// Copyright 2021 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import React, { useRef, useState, useEffect } from 'react';
|
||||
import classNames from 'classnames';
|
||||
import { Blurhash } from 'react-blurhash';
|
||||
import formatFileSize from 'filesize';
|
||||
|
||||
import { LocalizerType, ThemeType } from '../../types/Util';
|
||||
import { Spinner } from '../Spinner';
|
||||
|
||||
import {
|
||||
AttachmentType,
|
||||
hasNotDownloaded,
|
||||
getImageDimensions,
|
||||
defaultBlurHash,
|
||||
} from '../../types/Attachment';
|
||||
|
||||
const MAX_GIF_REPEAT = 4;
|
||||
const MAX_GIF_TIME = 8;
|
||||
|
||||
export type Props = {
|
||||
readonly attachment: AttachmentType;
|
||||
readonly size?: number;
|
||||
readonly tabIndex: number;
|
||||
|
||||
readonly i18n: LocalizerType;
|
||||
readonly theme?: ThemeType;
|
||||
|
||||
readonly reducedMotion?: boolean;
|
||||
|
||||
onError(): void;
|
||||
kickOffAttachmentDownload(): void;
|
||||
};
|
||||
|
||||
type MediaEvent = React.SyntheticEvent<HTMLVideoElement, Event>;
|
||||
|
||||
export const GIF: React.FC<Props> = props => {
|
||||
const {
|
||||
attachment,
|
||||
size,
|
||||
tabIndex,
|
||||
|
||||
i18n,
|
||||
theme,
|
||||
|
||||
reducedMotion = Boolean(
|
||||
window.Accessibility && window.Accessibility.reducedMotionSetting
|
||||
),
|
||||
|
||||
onError,
|
||||
kickOffAttachmentDownload,
|
||||
} = props;
|
||||
|
||||
const tapToPlay = reducedMotion;
|
||||
|
||||
const videoRef = useRef<HTMLVideoElement | null>(null);
|
||||
const { height, width } = getImageDimensions(attachment, size);
|
||||
|
||||
const [repeatCount, setRepeatCount] = useState(0);
|
||||
const [playTime, setPlayTime] = useState(MAX_GIF_TIME);
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
const [isFocused, setIsFocused] = useState(true);
|
||||
const [isPlaying, setIsPlaying] = useState(!tapToPlay);
|
||||
|
||||
useEffect(() => {
|
||||
const onFocus = () => setIsFocused(true);
|
||||
const onBlur = () => setIsFocused(false);
|
||||
|
||||
window.addEventListener('focus', onFocus);
|
||||
window.addEventListener('blur', onBlur);
|
||||
return () => {
|
||||
window.removeEventListener('focus', onFocus);
|
||||
window.removeEventListener('blur', onBlur);
|
||||
};
|
||||
});
|
||||
|
||||
//
|
||||
// Play & Pause video in response to change of `isPlaying` and `repeatCount`.
|
||||
//
|
||||
useEffect(() => {
|
||||
const { current: video } = videoRef;
|
||||
if (!video) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isPlaying) {
|
||||
video.play().catch(error => {
|
||||
window.log.info(
|
||||
"Failed to match GIF playback to window's state",
|
||||
(error && error.stack) || error
|
||||
);
|
||||
});
|
||||
} else {
|
||||
video.pause();
|
||||
}
|
||||
}, [isPlaying, repeatCount]);
|
||||
|
||||
//
|
||||
// Change `isPlaying` in response to focus, play time, and repeat count
|
||||
// changes.
|
||||
//
|
||||
useEffect(() => {
|
||||
const { current: video } = videoRef;
|
||||
if (!video) {
|
||||
return;
|
||||
}
|
||||
|
||||
let isTapToPlayPaused = false;
|
||||
if (tapToPlay) {
|
||||
if (
|
||||
playTime + currentTime >= MAX_GIF_TIME ||
|
||||
repeatCount >= MAX_GIF_REPEAT
|
||||
) {
|
||||
isTapToPlayPaused = true;
|
||||
}
|
||||
}
|
||||
|
||||
setIsPlaying(isFocused && !isTapToPlayPaused);
|
||||
}, [isFocused, playTime, currentTime, repeatCount, tapToPlay]);
|
||||
|
||||
const onTimeUpdate = async (event: MediaEvent): Promise<void> => {
|
||||
const { currentTime: reportedTime } = event.currentTarget;
|
||||
if (!Number.isNaN(reportedTime)) {
|
||||
setCurrentTime(reportedTime);
|
||||
}
|
||||
};
|
||||
|
||||
const onEnded = async (event: MediaEvent): Promise<void> => {
|
||||
const { currentTarget: video } = event;
|
||||
const { duration } = video;
|
||||
|
||||
setRepeatCount(repeatCount + 1);
|
||||
if (!Number.isNaN(duration)) {
|
||||
video.currentTime = 0;
|
||||
|
||||
setCurrentTime(0);
|
||||
setPlayTime(playTime + duration);
|
||||
}
|
||||
};
|
||||
|
||||
const onOverlayClick = (event: React.MouseEvent): void => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
if (!attachment.url) {
|
||||
kickOffAttachmentDownload();
|
||||
} else if (tapToPlay) {
|
||||
setPlayTime(0);
|
||||
setCurrentTime(0);
|
||||
setRepeatCount(0);
|
||||
}
|
||||
};
|
||||
|
||||
const onOverlayKeyDown = (event: React.KeyboardEvent): void => {
|
||||
if (event.key !== 'Enter' && event.key !== 'Space') {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
kickOffAttachmentDownload();
|
||||
};
|
||||
|
||||
const isPending = Boolean(attachment.pending);
|
||||
const isNotDownloaded = hasNotDownloaded(attachment) && !isPending;
|
||||
|
||||
let fileSize: JSX.Element | undefined;
|
||||
if (isNotDownloaded && attachment.fileSize) {
|
||||
fileSize = (
|
||||
<div className="module-image--gif__filesize">
|
||||
{formatFileSize(attachment.fileSize || 0)} · GIF
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
let gif: JSX.Element | undefined;
|
||||
if (isNotDownloaded || isPending) {
|
||||
gif = (
|
||||
<Blurhash
|
||||
hash={attachment.blurHash || defaultBlurHash(theme)}
|
||||
width={width}
|
||||
height={height}
|
||||
style={{ display: 'block' }}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
gif = (
|
||||
<video
|
||||
ref={videoRef}
|
||||
onTimeUpdate={onTimeUpdate}
|
||||
onEnded={onEnded}
|
||||
onError={onError}
|
||||
className="module-image--gif__video"
|
||||
autoPlay
|
||||
playsInline
|
||||
muted
|
||||
poster={attachment.screenshot && attachment.screenshot.url}
|
||||
height={height}
|
||||
width={width}
|
||||
src={attachment.url}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
let overlay: JSX.Element | undefined;
|
||||
if ((tapToPlay && !isPlaying) || isNotDownloaded) {
|
||||
const className = classNames([
|
||||
'module-image__border-overlay',
|
||||
'module-image__border-overlay--with-click-handler',
|
||||
'module-image--soft-corners',
|
||||
isNotDownloaded
|
||||
? 'module-image--not-downloaded'
|
||||
: 'module-image--tap-to-play',
|
||||
]);
|
||||
|
||||
overlay = (
|
||||
<button
|
||||
type="button"
|
||||
className={className}
|
||||
onClick={onOverlayClick}
|
||||
onKeyDown={onOverlayKeyDown}
|
||||
tabIndex={tabIndex}
|
||||
>
|
||||
<span />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
let spinner: JSX.Element | undefined;
|
||||
if (isPending) {
|
||||
spinner = (
|
||||
<div className="module-image__download-pending--spinner-container">
|
||||
<div
|
||||
className="module-image__download-pending--spinner"
|
||||
title={i18n('loading')}
|
||||
>
|
||||
<Spinner moduleClassName="module-image-spinner" svgSize="small" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="module-image module-image--gif">
|
||||
{gif}
|
||||
{overlay}
|
||||
{spinner}
|
||||
{fileSize}
|
||||
</div>
|
||||
);
|
||||
};
|
|
@ -7,7 +7,11 @@ import { Blurhash } from 'react-blurhash';
|
|||
|
||||
import { Spinner } from '../Spinner';
|
||||
import { LocalizerType, ThemeType } from '../../types/Util';
|
||||
import { AttachmentType, hasNotDownloaded } from '../../types/Attachment';
|
||||
import {
|
||||
AttachmentType,
|
||||
hasNotDownloaded,
|
||||
defaultBlurHash,
|
||||
} from '../../types/Attachment';
|
||||
|
||||
export type Props = {
|
||||
alt: string;
|
||||
|
@ -160,11 +164,7 @@ export class Image extends React.Component<Props> {
|
|||
const canClick = this.canClick();
|
||||
const imgNotDownloaded = hasNotDownloaded(attachment);
|
||||
|
||||
const defaulBlurHash =
|
||||
theme === ThemeType.dark
|
||||
? 'L05OQnoffQofoffQfQfQfQfQfQfQ'
|
||||
: 'L1Q]+w-;fQ-;~qfQfQfQfQfQfQfQ';
|
||||
const resolvedBlurHash = blurHash || defaulBlurHash;
|
||||
const resolvedBlurHash = blurHash || defaultBlurHash(theme);
|
||||
|
||||
const overlayClassName = classNames('module-image__border-overlay', {
|
||||
'module-image__border-overlay--with-border': !noBorder,
|
||||
|
@ -189,7 +189,7 @@ export class Image extends React.Component<Props> {
|
|||
onKeyDown={this.handleKeyDown}
|
||||
tabIndex={tabIndex}
|
||||
>
|
||||
{imgNotDownloaded ? <i /> : null}
|
||||
{imgNotDownloaded ? <span /> : null}
|
||||
</button>
|
||||
) : null;
|
||||
|
||||
|
|
|
@ -8,6 +8,7 @@ import { action } from '@storybook/addon-actions';
|
|||
import { boolean, number, text, select } from '@storybook/addon-knobs';
|
||||
import { storiesOf } from '@storybook/react';
|
||||
|
||||
import { SignalService } from '../../protobuf';
|
||||
import { Colors } from '../../types/Colors';
|
||||
import { EmojiPicker } from '../emoji/EmojiPicker';
|
||||
import { Message, Props, AudioAttachmentProps } from './Message';
|
||||
|
@ -78,6 +79,7 @@ const createAuthorProp = (
|
|||
const createProps = (overrideProps: Partial<Props> = {}): Props => ({
|
||||
attachments: overrideProps.attachments,
|
||||
author: overrideProps.author || createAuthorProp(),
|
||||
reducedMotion: boolean('reducedMotion', false),
|
||||
bodyRanges: overrideProps.bodyRanges,
|
||||
canReply: true,
|
||||
canDownload: true,
|
||||
|
@ -729,6 +731,63 @@ story.add('Image with Caption', () => {
|
|||
return renderBothDirections(props);
|
||||
});
|
||||
|
||||
story.add('GIF', () => {
|
||||
const props = createProps({
|
||||
attachments: [
|
||||
{
|
||||
contentType: VIDEO_MP4,
|
||||
flags: SignalService.AttachmentPointer.Flags.GIF,
|
||||
fileName: 'cat-gif.mp4',
|
||||
url: '/fixtures/cat-gif.mp4',
|
||||
width: 400,
|
||||
height: 332,
|
||||
},
|
||||
],
|
||||
status: 'sent',
|
||||
});
|
||||
|
||||
return renderBothDirections(props);
|
||||
});
|
||||
|
||||
story.add('Not Downloaded GIF', () => {
|
||||
const props = createProps({
|
||||
attachments: [
|
||||
{
|
||||
contentType: VIDEO_MP4,
|
||||
flags: SignalService.AttachmentPointer.Flags.GIF,
|
||||
fileName: 'cat-gif.mp4',
|
||||
fileSize: 188610,
|
||||
blurHash: 'LDA,FDBnm+I=p{tkIUI;~UkpELV]',
|
||||
width: 400,
|
||||
height: 332,
|
||||
},
|
||||
],
|
||||
status: 'sent',
|
||||
});
|
||||
|
||||
return renderBothDirections(props);
|
||||
});
|
||||
|
||||
story.add('Pending GIF', () => {
|
||||
const props = createProps({
|
||||
attachments: [
|
||||
{
|
||||
pending: true,
|
||||
contentType: VIDEO_MP4,
|
||||
flags: SignalService.AttachmentPointer.Flags.GIF,
|
||||
fileName: 'cat-gif.mp4',
|
||||
fileSize: 188610,
|
||||
blurHash: 'LDA,FDBnm+I=p{tkIUI;~UkpELV]',
|
||||
width: 400,
|
||||
height: 332,
|
||||
},
|
||||
],
|
||||
status: 'sent',
|
||||
});
|
||||
|
||||
return renderBothDirections(props);
|
||||
});
|
||||
|
||||
story.add('Audio', () => {
|
||||
const props = createProps({
|
||||
attachments: [
|
||||
|
|
|
@ -14,6 +14,7 @@ import { Spinner } from '../Spinner';
|
|||
import { MessageBody } from './MessageBody';
|
||||
import { ExpireTimer } from './ExpireTimer';
|
||||
import { ImageGrid } from './ImageGrid';
|
||||
import { GIF } from './GIF';
|
||||
import { Image } from './Image';
|
||||
import { Timestamp } from './Timestamp';
|
||||
import { ContactName } from './ContactName';
|
||||
|
@ -42,6 +43,7 @@ import {
|
|||
isImage,
|
||||
isImageAttachment,
|
||||
isVideo,
|
||||
isGIF,
|
||||
} from '../../types/Attachment';
|
||||
import { ContactType } from '../../types/Contact';
|
||||
|
||||
|
@ -58,6 +60,7 @@ type Trigger = {
|
|||
};
|
||||
|
||||
const STICKER_SIZE = 200;
|
||||
const GIF_SIZE = 300;
|
||||
const SELECTED_TIMEOUT = 1000;
|
||||
const THREE_HOURS = 3 * 60 * 60 * 1000;
|
||||
|
||||
|
@ -116,6 +119,7 @@ export type PropsData = {
|
|||
| 'profileName'
|
||||
| 'title'
|
||||
>;
|
||||
reducedMotion?: boolean;
|
||||
conversationType: ConversationTypesType;
|
||||
attachments?: Array<AttachmentType>;
|
||||
quote?: {
|
||||
|
@ -696,6 +700,7 @@ export class Message extends React.Component<Props, State> {
|
|||
isSticker,
|
||||
text,
|
||||
theme,
|
||||
reducedMotion,
|
||||
|
||||
renderAudioAttachment,
|
||||
} = this.props;
|
||||
|
@ -714,52 +719,72 @@ export class Message extends React.Component<Props, State> {
|
|||
(conversationType === 'group' && direction === 'incoming');
|
||||
const displayImage = canDisplayImage(attachments);
|
||||
|
||||
if (
|
||||
displayImage &&
|
||||
!imageBroken &&
|
||||
(isImage(attachments) || isVideo(attachments))
|
||||
) {
|
||||
if (displayImage && !imageBroken) {
|
||||
const prefix = isSticker ? 'sticker' : 'attachment';
|
||||
const bottomOverlay = !isSticker && !collapseMetadata;
|
||||
// We only want users to tab into this if there's more than one
|
||||
const tabIndex = attachments.length > 1 ? 0 : -1;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
`module-message__${prefix}-container`,
|
||||
withContentAbove
|
||||
? `module-message__${prefix}-container--with-content-above`
|
||||
: null,
|
||||
withContentBelow
|
||||
? 'module-message__attachment-container--with-content-below'
|
||||
: null,
|
||||
isSticker && !collapseMetadata
|
||||
? 'module-message__sticker-container--with-content-below'
|
||||
: null
|
||||
)}
|
||||
>
|
||||
<ImageGrid
|
||||
attachments={attachments}
|
||||
withContentAbove={isSticker || withContentAbove}
|
||||
withContentBelow={isSticker || withContentBelow}
|
||||
isSticker={isSticker}
|
||||
stickerSize={STICKER_SIZE}
|
||||
bottomOverlay={bottomOverlay}
|
||||
i18n={i18n}
|
||||
theme={theme}
|
||||
onError={this.handleImageError}
|
||||
tabIndex={tabIndex}
|
||||
onClick={attachment => {
|
||||
if (hasNotDownloaded(attachment)) {
|
||||
kickOffAttachmentDownload({ attachment, messageId: id });
|
||||
} else {
|
||||
showVisualAttachment({ attachment, messageId: id });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
const containerClassName = classNames(
|
||||
`module-message__${prefix}-container`,
|
||||
withContentAbove
|
||||
? `module-message__${prefix}-container--with-content-above`
|
||||
: null,
|
||||
withContentBelow
|
||||
? 'module-message__attachment-container--with-content-below'
|
||||
: null,
|
||||
isSticker && !collapseMetadata
|
||||
? 'module-message__sticker-container--with-content-below'
|
||||
: null
|
||||
);
|
||||
|
||||
if (isGIF(attachments)) {
|
||||
return (
|
||||
<div className={containerClassName}>
|
||||
<GIF
|
||||
attachment={firstAttachment}
|
||||
size={GIF_SIZE}
|
||||
theme={theme}
|
||||
i18n={i18n}
|
||||
tabIndex={0}
|
||||
reducedMotion={reducedMotion}
|
||||
onError={this.handleImageError}
|
||||
kickOffAttachmentDownload={() => {
|
||||
kickOffAttachmentDownload({
|
||||
attachment: firstAttachment,
|
||||
messageId: id,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isImage(attachments) || isVideo(attachments)) {
|
||||
const bottomOverlay = !isSticker && !collapseMetadata;
|
||||
// We only want users to tab into this if there's more than one
|
||||
const tabIndex = attachments.length > 1 ? 0 : -1;
|
||||
|
||||
return (
|
||||
<div className={containerClassName}>
|
||||
<ImageGrid
|
||||
attachments={attachments}
|
||||
withContentAbove={isSticker || withContentAbove}
|
||||
withContentBelow={isSticker || withContentBelow}
|
||||
isSticker={isSticker}
|
||||
stickerSize={STICKER_SIZE}
|
||||
bottomOverlay={bottomOverlay}
|
||||
i18n={i18n}
|
||||
theme={theme}
|
||||
onError={this.handleImageError}
|
||||
tabIndex={tabIndex}
|
||||
onClick={attachment => {
|
||||
if (hasNotDownloaded(attachment)) {
|
||||
kickOffAttachmentDownload({ attachment, messageId: id });
|
||||
} else {
|
||||
showVisualAttachment({ attachment, messageId: id });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
if (isAudio(attachments)) {
|
||||
return renderAudioAttachment({
|
||||
|
@ -1553,6 +1578,11 @@ export class Message extends React.Component<Props, State> {
|
|||
const { attachments, isSticker, previews } = this.props;
|
||||
|
||||
if (attachments && attachments.length) {
|
||||
if (isGIF(attachments)) {
|
||||
// Message container border + image border
|
||||
return GIF_SIZE + 4;
|
||||
}
|
||||
|
||||
if (isSticker) {
|
||||
// Padding is 8px, on both sides, plus two for 1px border
|
||||
return STICKER_SIZE + 8 * 2 + 2;
|
||||
|
@ -2009,6 +2039,11 @@ export class Message extends React.Component<Props, State> {
|
|||
|
||||
const isAttachmentPending = this.isAttachmentPending();
|
||||
|
||||
// Don't show lightbox for GIFs
|
||||
if (isGIF(attachments)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isTapToView) {
|
||||
if (isAttachmentPending) {
|
||||
return;
|
||||
|
@ -2186,6 +2221,7 @@ export class Message extends React.Component<Props, State> {
|
|||
|
||||
public renderContainer(): JSX.Element {
|
||||
const {
|
||||
attachments,
|
||||
author,
|
||||
deletedForEveryone,
|
||||
direction,
|
||||
|
@ -2203,6 +2239,7 @@ export class Message extends React.Component<Props, State> {
|
|||
|
||||
const containerClassnames = classNames(
|
||||
'module-message__container',
|
||||
isGIF(attachments) ? 'module-message__container--gif' : null,
|
||||
isSelected && !isSticker ? 'module-message__container--selected' : null,
|
||||
isSticker ? 'module-message__container--with-sticker' : null,
|
||||
!isSticker ? `module-message__container--${direction}` : null,
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue