diff --git a/fixtures/cat-gif.mp4 b/fixtures/cat-gif.mp4 new file mode 100644 index 000000000..f3d265491 Binary files /dev/null and b/fixtures/cat-gif.mp4 differ diff --git a/fixtures/cat-screenshot.png b/fixtures/cat-screenshot.png new file mode 100644 index 000000000..85e1d74c2 Binary files /dev/null and b/fixtures/cat-screenshot.png differ diff --git a/js/modules/types/attachment.js b/js/modules/types/attachment.js index 5202a6b5f..c1a263861 100644 --- a/js/modules/types/attachment.js +++ b/js/modules/types/attachment.js @@ -215,6 +215,7 @@ exports.deleteData = deleteOnDisk => { exports.isImage = AttachmentTS.isImage; exports.isVideo = AttachmentTS.isVideo; +exports.isGIF = AttachmentTS.isGIF; exports.isAudio = AttachmentTS.isAudio; exports.isVoiceMessage = AttachmentTS.isVoiceMessage; exports.getUploadSizeLimitKb = AttachmentTS.getUploadSizeLimitKb; diff --git a/main.js b/main.js index 1d134e0ea..d14b652db 100644 --- a/main.js +++ b/main.js @@ -42,6 +42,8 @@ const { systemPreferences, } = electron; +const animationSettings = systemPreferences.getAnimationSettings(); + const appUserModelId = `org.whispersystems.${packageJson.name}`; console.log('Set Windows Application User Model ID (AUMID)', { appUserModelId, @@ -245,6 +247,9 @@ function prepareURL(pathSegments, moreKeys) { contentProxyUrl: config.contentProxyUrl, sfuUrl: config.get('sfuUrl'), importMode: importMode ? true : undefined, // for stringify() + reducedMotionSetting: animationSettings.prefersReducedMotion + ? true + : undefined, serverPublicParams: config.get('serverPublicParams'), serverTrustRoot: config.get('serverTrustRoot'), appStartInitialSpellcheckSetting, diff --git a/preload.js b/preload.js index f3d2ae707..5421f1617 100644 --- a/preload.js +++ b/preload.js @@ -476,6 +476,10 @@ try { activeWindowService ); + window.Accessibility = { + reducedMotionSetting: Boolean(config.reducedMotionSetting), + }; + window.isValidGuid = maybeGuid => /^[0-9A-F]{8}-[0-9A-F]{4}-4[0-9A-F]{3}-[89AB][0-9A-F]{3}-[0-9A-F]{12}$/i.test( maybeGuid diff --git a/stylesheets/_modules.scss b/stylesheets/_modules.scss index 8e6adb61c..53e66c2e9 100644 --- a/stylesheets/_modules.scss +++ b/stylesheets/_modules.scss @@ -663,6 +663,10 @@ border-top-left-radius: 0px; border-top-right-radius: 0px; } + + .module-message__container--gif & { + border-radius: inherit; + } } .module-message__sticker-container { @@ -4188,42 +4192,30 @@ button.module-conversation-details__action-button { overflow: hidden; } +.module-image--tap-to-play, .module-image--not-downloaded { align-items: center; display: flex; justify-content: center; - i { + span { align-items: center; display: flex; justify-content: center; border-radius: 48px; height: 48px; width: 48px; - - @include light-theme { - background-color: $color-gray-65; - } - @include dark-theme { - background-color: $color-gray-75; - } - - &:after { - content: ''; - height: 17px; - width: 17px; - @include color-svg('../images/icons/v2/arrow-down-24.svg', $color-white); - } + background-color: $color-black-alpha-70; } &:hover { - i { - background-color: $color-black; + span { + background-color: $color-black-alpha-80; } } &:focus { - i { + span { background-color: $color-gray-75; border: 4px solid $ultramarine-ui-light; box-sizing: border-box; @@ -4232,6 +4224,28 @@ button.module-conversation-details__action-button { } } +.module-image--not-downloaded { + span:after { + content: ''; + height: 24px; + width: 24px; + + @include color-svg('../images/icons/v2/arrow-down-24.svg', $color-white); + } +} + +.module-image--tap-to-play { + span:after { + content: 'GIF'; + height: 24px; + width: 24px; + + @include font-body-1; + line-height: 24px; + color: $color-white; + } +} + .module-image__download-pending { position: relative; @@ -4336,6 +4350,26 @@ button.module-conversation-details__action-button { } } +.module-image--gif { + border-radius: 18px; + + &__filesize { + position: absolute; + top: 10px; + left: 10px; + padding: 2px 8px; + + color: $color-white; + background: $color-black-alpha-70; + + /* The height is: 14px + 2x2px from the padding */ + border-radius: 9px; + + font-size: 11px; + line-height: 14px; + } +} + // Only if it's a sticker do we put the outline inside it .module-message--selected .module-message__container--with-sticker @@ -10677,6 +10711,11 @@ $contact-modal-padding: 18px; &--deleted-for-everyone { font-style: italic; } + + &--gif { + border-radius: inherit; + background: inherit; + } } .module-message__context { diff --git a/stylesheets/_variables.scss b/stylesheets/_variables.scss index 8542077a6..746778cc7 100644 --- a/stylesheets/_variables.scss +++ b/stylesheets/_variables.scss @@ -39,6 +39,7 @@ $color-black-alpha-20: rgba($color-black, 0.2); $color-black-alpha-40: rgba($color-black, 0.4); $color-black-alpha-50: rgba($color-black, 0.5); $color-black-alpha-60: rgba($color-black, 0.6); +$color-black-alpha-70: rgba($color-black, 0.7); $color-black-alpha-80: rgba($color-black, 0.8); $ultramarine-brand-light: #3a76f0; diff --git a/ts/components/conversation/GIF.tsx b/ts/components/conversation/GIF.tsx new file mode 100644 index 000000000..cbcf51f08 --- /dev/null +++ b/ts/components/conversation/GIF.tsx @@ -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; + +export const GIF: React.FC = props => { + const { + attachment, + size, + tabIndex, + + i18n, + theme, + + reducedMotion = Boolean( + window.Accessibility && window.Accessibility.reducedMotionSetting + ), + + onError, + kickOffAttachmentDownload, + } = props; + + const tapToPlay = reducedMotion; + + const videoRef = useRef(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 => { + const { currentTime: reportedTime } = event.currentTarget; + if (!Number.isNaN(reportedTime)) { + setCurrentTime(reportedTime); + } + }; + + const onEnded = async (event: MediaEvent): Promise => { + 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 = ( +
+ {formatFileSize(attachment.fileSize || 0)} · GIF +
+ ); + } + + let gif: JSX.Element | undefined; + if (isNotDownloaded || isPending) { + gif = ( + + ); + } else { + gif = ( +