GIF attachments

This commit is contained in:
Fedor Indutny 2021-04-27 15:11:59 -07:00 committed by Scott Nonnenberg
parent 5f17d01f49
commit caf1d4c4da
15 changed files with 526 additions and 93 deletions

View 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>
);
};

View file

@ -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;

View file

@ -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: [

View file

@ -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,