diff --git a/app/soapbox/components/blurhash.js b/app/soapbox/components/blurhash.js new file mode 100644 index 000000000..2af5cfc56 --- /dev/null +++ b/app/soapbox/components/blurhash.js @@ -0,0 +1,65 @@ +// @ts-check + +import { decode } from 'blurhash'; +import React, { useRef, useEffect } from 'react'; +import PropTypes from 'prop-types'; + +/** + * @typedef BlurhashPropsBase + * @property {string?} hash Hash to render + * @property {number} width + * Width of the blurred region in pixels. Defaults to 32 + * @property {number} [height] + * Height of the blurred region in pixels. Defaults to width + * @property {boolean} [dummy] + * Whether dummy mode is enabled. If enabled, nothing is rendered + * and canvas left untouched + */ + +/** @typedef {JSX.IntrinsicElements['canvas'] & BlurhashPropsBase} BlurhashProps */ + +/** + * Component that is used to render blurred of blurhash string + * + * @param {BlurhashProps} param1 Props of the component + * @returns Canvas which will render blurred region element to embed + */ +function Blurhash({ + hash, + width = 32, + height = width, + dummy = false, + ...canvasProps +}) { + const canvasRef = /** @type {import('react').MutableRefObject} */ (useRef()); + + useEffect(() => { + const { current: canvas } = canvasRef; + canvas.width = canvas.width; // resets canvas + + if (dummy || !hash) return; + + try { + const pixels = decode(hash, width, height); + const ctx = canvas.getContext('2d'); + const imageData = new ImageData(pixels, width, height); + + ctx.putImageData(imageData, 0, 0); + } catch (err) { + console.error('Blurhash decoding failure', { err, hash }); + } + }, [dummy, hash, width, height]); + + return ( + + ); +} + +Blurhash.propTypes = { + hash: PropTypes.string.isRequired, + width: PropTypes.number, + height: PropTypes.number, + dummy: PropTypes.bool, +}; + +export default React.memo(Blurhash); diff --git a/app/soapbox/components/media_gallery.js b/app/soapbox/components/media_gallery.js index b9dc9007d..01638b0b8 100644 --- a/app/soapbox/components/media_gallery.js +++ b/app/soapbox/components/media_gallery.js @@ -8,12 +8,12 @@ import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; import { isIOS } from '../is_mobile'; import { truncateFilename } from 'soapbox/utils/media'; import classNames from 'classnames'; -import { decode } from 'blurhash'; import { isPanoramic, isPortrait, isNonConformingRatio, minimumAspectRatio, maximumAspectRatio } from '../utils/media_aspect_ratio'; import { Map as ImmutableMap } from 'immutable'; import { getSettings } from 'soapbox/actions/settings'; import Icon from 'soapbox/components/icon'; import StillImage from 'soapbox/components/still_image'; +import Blurhash from 'soapbox/components/blurhash'; const ATTACHMENT_LIMIT = 4; const MAX_FILENAME_LENGTH = 45; @@ -102,34 +102,6 @@ class Item extends React.PureComponent { e.stopPropagation(); } - componentDidMount() { - if (this.props.attachment.get('blurhash')) { - this._decode(); - } - } - - componentDidUpdate(prevProps) { - if (prevProps.attachment.get('blurhash') !== this.props.attachment.get('blurhash') && this.props.attachment.get('blurhash')) { - this._decode(); - } - } - - _decode() { - const hash = this.props.attachment.get('blurhash'); - const pixels = decode(hash, 32, 32); - - if (pixels) { - const ctx = this.canvas.getContext('2d'); - const imageData = new ImageData(pixels, 32, 32); - - ctx.putImageData(imageData, 0, 0); - } - } - - setCanvasRef = c => { - this.canvas = c; - } - handleImageLoad = () => { this.setState({ loaded: true }); } @@ -174,7 +146,7 @@ class Item extends React.PureComponent { return (
- + {filename} @@ -273,7 +245,12 @@ class Item extends React.PureComponent { +{total - ATTACHMENT_LIMIT + 1}
)} - + {visible && thumbnail} ); diff --git a/app/soapbox/features/account_gallery/components/media_item.js b/app/soapbox/features/account_gallery/components/media_item.js index 3a59ca55c..e3781eb05 100644 --- a/app/soapbox/features/account_gallery/components/media_item.js +++ b/app/soapbox/features/account_gallery/components/media_item.js @@ -5,7 +5,7 @@ import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePureComponent from 'react-immutable-pure-component'; import Icon from 'soapbox/components/icon'; import classNames from 'classnames'; -import { decode } from 'blurhash'; +import Blurhash from 'soapbox/components/blurhash'; import { isIOS } from 'soapbox/is_mobile'; import { getSettings } from 'soapbox/actions/settings'; import StillImage from 'soapbox/components/still_image'; @@ -31,34 +31,6 @@ class MediaItem extends ImmutablePureComponent { loaded: false, }; - componentDidMount() { - if (this.props.attachment.get('blurhash')) { - this._decode(); - } - } - - componentDidUpdate(prevProps) { - if (prevProps.attachment.get('blurhash') !== this.props.attachment.get('blurhash') && this.props.attachment.get('blurhash')) { - this._decode(); - } - } - - _decode() { - const hash = this.props.attachment.get('blurhash'); - const pixels = decode(hash, 32, 32); - - if (pixels) { - const ctx = this.canvas.getContext('2d'); - const imageData = new ImageData(pixels, 32, 32); - - ctx.putImageData(imageData, 0, 0); - } - } - - setCanvasRef = c => { - this.canvas = c; - } - handleImageLoad = () => { this.setState({ loaded: true }); } @@ -169,7 +141,12 @@ class MediaItem extends ImmutablePureComponent { return (
- + {visible && thumbnail} {!visible && icon} diff --git a/app/soapbox/features/video/index.js b/app/soapbox/features/video/index.js index 04ce94133..ccc32057d 100644 --- a/app/soapbox/features/video/index.js +++ b/app/soapbox/features/video/index.js @@ -7,7 +7,7 @@ import { throttle } from 'lodash'; import classNames from 'classnames'; import { isFullscreen, requestFullscreen, exitFullscreen } from '../ui/util/fullscreen'; import Icon from 'soapbox/components/icon'; -import { decode } from 'blurhash'; +import Blurhash from 'soapbox/components/blurhash'; import { isPanoramic, isPortrait, minimumAspectRatio, maximumAspectRatio } from '../../utils/media_aspect_ratio'; import { getSettings } from 'soapbox/actions/settings'; @@ -167,10 +167,6 @@ class Video extends React.PureComponent { this.volume = c; } - setCanvasRef = c => { - this.canvas = c; - } - handleClickRoot = e => e.stopPropagation(); handlePlay = () => { @@ -278,10 +274,6 @@ class Video extends React.PureComponent { document.addEventListener('webkitfullscreenchange', this.handleFullscreenChange, true); document.addEventListener('mozfullscreenchange', this.handleFullscreenChange, true); document.addEventListener('MSFullscreenChange', this.handleFullscreenChange, true); - - if (this.props.blurhash) { - this._decode(); - } } componentWillUnmount() { @@ -292,7 +284,7 @@ class Video extends React.PureComponent { } componentDidUpdate(prevProps, prevState) { - const { visible, blurhash } = this.props; + const { visible } = this.props; if (!is(visible, prevProps.visible) && visible !== undefined) { this.setState({ revealed: visible }); @@ -301,22 +293,6 @@ class Video extends React.PureComponent { if (prevState.revealed && !this.state.revealed && this.video) { this.video.pause(); } - - if (prevProps.blurhash !== blurhash && blurhash) { - this._decode(); - } - } - - _decode() { - const hash = this.props.blurhash; - const pixels = decode(hash, 32, 32); - - if (pixels) { - const ctx = this.canvas.getContext('2d'); - const imageData = new ImageData(pixels, 32, 32); - - ctx.putImageData(imageData, 0, 0); - } } handleFullscreenChange = () => { @@ -396,7 +372,7 @@ class Video extends React.PureComponent { } render() { - const { src, inline, onOpenVideo, onCloseVideo, intl, alt, detailed, sensitive, link, aspectRatio } = this.props; + const { src, inline, onOpenVideo, onCloseVideo, intl, alt, detailed, sensitive, link, aspectRatio, blurhash } = this.props; const { containerWidth, currentTime, duration, volume, buffer, dragging, paused, fullscreen, hovered, muted, revealed } = this.state; const progress = (currentTime / duration) * 100; const playerStyle = {}; @@ -437,7 +413,12 @@ class Video extends React.PureComponent { onClick={this.handleClickRoot} tabIndex={0} > - + {revealed &&