Add blurhash (#10630)
* Add blurhash * Use fallback color for spoiler when blurhash missing * Federate the blurhash and accept it as long as it's at most 5x5 * Display unknown media attachments as blurhash placeholders * Improve style of embed actions and spoiler button * Change blurhash resolution from 3x3 to 4x4 * Improve dependency definitions * Fix code style issuesmain
parent
c008911249
commit
fba96c808d
1
Gemfile
1
Gemfile
|
@ -21,6 +21,7 @@ gem 'fog-openstack', '~> 0.3', require: false
|
||||||
gem 'paperclip', '~> 6.0'
|
gem 'paperclip', '~> 6.0'
|
||||||
gem 'paperclip-av-transcoder', '~> 0.6'
|
gem 'paperclip-av-transcoder', '~> 0.6'
|
||||||
gem 'streamio-ffmpeg', '~> 3.0'
|
gem 'streamio-ffmpeg', '~> 3.0'
|
||||||
|
gem 'blurhash', '~> 0.1'
|
||||||
|
|
||||||
gem 'active_model_serializers', '~> 0.10'
|
gem 'active_model_serializers', '~> 0.10'
|
||||||
gem 'addressable', '~> 2.6'
|
gem 'addressable', '~> 2.6'
|
||||||
|
|
|
@ -99,6 +99,8 @@ GEM
|
||||||
rack (>= 0.9.0)
|
rack (>= 0.9.0)
|
||||||
binding_of_caller (0.8.0)
|
binding_of_caller (0.8.0)
|
||||||
debug_inspector (>= 0.0.1)
|
debug_inspector (>= 0.0.1)
|
||||||
|
blurhash (0.1.2)
|
||||||
|
ffi (~> 1.10.0)
|
||||||
bootsnap (1.4.4)
|
bootsnap (1.4.4)
|
||||||
msgpack (~> 1.0)
|
msgpack (~> 1.0)
|
||||||
brakeman (4.5.0)
|
brakeman (4.5.0)
|
||||||
|
@ -661,6 +663,7 @@ DEPENDENCIES
|
||||||
aws-sdk-s3 (~> 1.36)
|
aws-sdk-s3 (~> 1.36)
|
||||||
better_errors (~> 2.5)
|
better_errors (~> 2.5)
|
||||||
binding_of_caller (~> 0.7)
|
binding_of_caller (~> 0.7)
|
||||||
|
blurhash (~> 0.1)
|
||||||
bootsnap (~> 1.4)
|
bootsnap (~> 1.4)
|
||||||
brakeman (~> 4.5)
|
brakeman (~> 4.5)
|
||||||
browser
|
browser
|
||||||
|
|
|
@ -7,6 +7,7 @@ import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
|
||||||
import { isIOS } from '../is_mobile';
|
import { isIOS } from '../is_mobile';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import { autoPlayGif, displayMedia } from '../initial_state';
|
import { autoPlayGif, displayMedia } from '../initial_state';
|
||||||
|
import { decode } from 'blurhash';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
toggle_visible: { id: 'media_gallery.toggle_visible', defaultMessage: 'Toggle visibility' },
|
toggle_visible: { id: 'media_gallery.toggle_visible', defaultMessage: 'Toggle visibility' },
|
||||||
|
@ -21,6 +22,7 @@ class Item extends React.PureComponent {
|
||||||
size: PropTypes.number.isRequired,
|
size: PropTypes.number.isRequired,
|
||||||
onClick: PropTypes.func.isRequired,
|
onClick: PropTypes.func.isRequired,
|
||||||
displayWidth: PropTypes.number,
|
displayWidth: PropTypes.number,
|
||||||
|
visible: PropTypes.bool.isRequired,
|
||||||
};
|
};
|
||||||
|
|
||||||
static defaultProps = {
|
static defaultProps = {
|
||||||
|
@ -29,6 +31,10 @@ class Item extends React.PureComponent {
|
||||||
size: 1,
|
size: 1,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
state = {
|
||||||
|
loaded: false,
|
||||||
|
};
|
||||||
|
|
||||||
handleMouseEnter = (e) => {
|
handleMouseEnter = (e) => {
|
||||||
if (this.hoverToPlay()) {
|
if (this.hoverToPlay()) {
|
||||||
e.target.play();
|
e.target.play();
|
||||||
|
@ -62,8 +68,40 @@ class Item extends React.PureComponent {
|
||||||
e.stopPropagation();
|
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 });
|
||||||
|
}
|
||||||
|
|
||||||
render () {
|
render () {
|
||||||
const { attachment, index, size, standalone, displayWidth } = this.props;
|
const { attachment, index, size, standalone, displayWidth, visible } = this.props;
|
||||||
|
|
||||||
let width = 50;
|
let width = 50;
|
||||||
let height = 100;
|
let height = 100;
|
||||||
|
@ -116,12 +154,20 @@ class Item extends React.PureComponent {
|
||||||
|
|
||||||
let thumbnail = '';
|
let thumbnail = '';
|
||||||
|
|
||||||
if (attachment.get('type') === 'image') {
|
if (attachment.get('type') === 'unknown') {
|
||||||
|
return (
|
||||||
|
<div className={classNames('media-gallery__item', { standalone })} key={attachment.get('id')} style={{ left: left, top: top, right: right, bottom: bottom, width: `${width}%`, height: `${height}%` }}>
|
||||||
|
<a className='media-gallery__item-thumbnail' href={attachment.get('remote_url')} target='_blank' style={{ cursor: 'pointer' }} >
|
||||||
|
<canvas width={32} height={32} ref={this.setCanvasRef} className='media-gallery__preview' />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
} else if (attachment.get('type') === 'image') {
|
||||||
const previewUrl = attachment.get('preview_url');
|
const previewUrl = attachment.get('preview_url');
|
||||||
const previewWidth = attachment.getIn(['meta', 'small', 'width']);
|
const previewWidth = attachment.getIn(['meta', 'small', 'width']);
|
||||||
|
|
||||||
const originalUrl = attachment.get('url');
|
const originalUrl = attachment.get('url');
|
||||||
const originalWidth = attachment.getIn(['meta', 'original', 'width']);
|
const originalWidth = attachment.getIn(['meta', 'original', 'width']);
|
||||||
|
|
||||||
const hasSize = typeof originalWidth === 'number' && typeof previewWidth === 'number';
|
const hasSize = typeof originalWidth === 'number' && typeof previewWidth === 'number';
|
||||||
|
|
||||||
|
@ -147,6 +193,7 @@ class Item extends React.PureComponent {
|
||||||
alt={attachment.get('description')}
|
alt={attachment.get('description')}
|
||||||
title={attachment.get('description')}
|
title={attachment.get('description')}
|
||||||
style={{ objectPosition: `${x}% ${y}%` }}
|
style={{ objectPosition: `${x}% ${y}%` }}
|
||||||
|
onLoad={this.handleImageLoad}
|
||||||
/>
|
/>
|
||||||
</a>
|
</a>
|
||||||
);
|
);
|
||||||
|
@ -176,7 +223,8 @@ class Item extends React.PureComponent {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={classNames('media-gallery__item', { standalone })} key={attachment.get('id')} style={{ left: left, top: top, right: right, bottom: bottom, width: `${width}%`, height: `${height}%` }}>
|
<div className={classNames('media-gallery__item', { standalone })} key={attachment.get('id')} style={{ left: left, top: top, right: right, bottom: bottom, width: `${width}%`, height: `${height}%` }}>
|
||||||
{thumbnail}
|
<canvas width={32} height={32} ref={this.setCanvasRef} className={classNames('media-gallery__preview', { 'media-gallery__preview--hidden': visible && this.state.loaded })} />
|
||||||
|
{visible && thumbnail}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -225,6 +273,7 @@ class MediaGallery extends React.PureComponent {
|
||||||
if (node /*&& this.isStandaloneEligible()*/) {
|
if (node /*&& this.isStandaloneEligible()*/) {
|
||||||
// offsetWidth triggers a layout, so only calculate when we need to
|
// offsetWidth triggers a layout, so only calculate when we need to
|
||||||
if (this.props.cacheWidth) this.props.cacheWidth(node.offsetWidth);
|
if (this.props.cacheWidth) this.props.cacheWidth(node.offsetWidth);
|
||||||
|
|
||||||
this.setState({
|
this.setState({
|
||||||
width: node.offsetWidth,
|
width: node.offsetWidth,
|
||||||
});
|
});
|
||||||
|
@ -242,7 +291,7 @@ class MediaGallery extends React.PureComponent {
|
||||||
|
|
||||||
const width = this.state.width || defaultWidth;
|
const width = this.state.width || defaultWidth;
|
||||||
|
|
||||||
let children;
|
let children, spoilerButton;
|
||||||
|
|
||||||
const style = {};
|
const style = {};
|
||||||
|
|
||||||
|
@ -256,35 +305,28 @@ class MediaGallery extends React.PureComponent {
|
||||||
style.height = height;
|
style.height = height;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!visible) {
|
const size = media.take(4).size;
|
||||||
let warning;
|
|
||||||
|
|
||||||
if (sensitive) {
|
if (this.isStandaloneEligible()) {
|
||||||
warning = <FormattedMessage id='status.sensitive_warning' defaultMessage='Sensitive content' />;
|
children = <Item standalone onClick={this.handleClick} attachment={media.get(0)} displayWidth={width} visible={visible} />;
|
||||||
} else {
|
} else {
|
||||||
warning = <FormattedMessage id='status.media_hidden' defaultMessage='Media hidden' />;
|
children = media.take(4).map((attachment, i) => <Item key={attachment.get('id')} onClick={this.handleClick} attachment={attachment} index={i} size={size} displayWidth={width} visible={visible} />);
|
||||||
}
|
}
|
||||||
|
|
||||||
children = (
|
if (visible) {
|
||||||
<button type='button' className='media-spoiler' onClick={this.handleOpen} style={style} ref={this.handleRef}>
|
spoilerButton = <IconButton title={intl.formatMessage(messages.toggle_visible)} icon={visible ? 'eye' : 'eye-slash'} overlay onClick={this.handleOpen} />;
|
||||||
<span className='media-spoiler__warning'>{warning}</span>
|
} else {
|
||||||
<span className='media-spoiler__trigger'><FormattedMessage id='status.sensitive_toggle' defaultMessage='Click to view' /></span>
|
spoilerButton = (
|
||||||
|
<button type='button' onClick={this.handleOpen} className='spoiler-button__overlay'>
|
||||||
|
<span className='spoiler-button__overlay__label'>{sensitive ? <FormattedMessage id='status.sensitive_warning' defaultMessage='Sensitive content' /> : <FormattedMessage id='status.media_hidden' defaultMessage='Media hidden' />}</span>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
} else {
|
|
||||||
const size = media.take(4).size;
|
|
||||||
|
|
||||||
if (this.isStandaloneEligible()) {
|
|
||||||
children = <Item standalone onClick={this.handleClick} attachment={media.get(0)} displayWidth={width} />;
|
|
||||||
} else {
|
|
||||||
children = media.take(4).map((attachment, i) => <Item key={attachment.get('id')} onClick={this.handleClick} attachment={attachment} index={i} size={size} displayWidth={width} />);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='media-gallery' style={style} ref={this.handleRef}>
|
<div className='media-gallery' style={style} ref={this.handleRef}>
|
||||||
<div className={classNames('spoiler-button', { 'spoiler-button--visible': visible })}>
|
<div className={classNames('spoiler-button', { 'spoiler-button--minified': visible })}>
|
||||||
<IconButton title={intl.formatMessage(messages.toggle_visible)} icon={visible ? 'eye' : 'eye-slash'} overlay onClick={this.handleOpen} />
|
{spoilerButton}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{children}
|
{children}
|
||||||
|
|
|
@ -274,7 +274,7 @@ class Status extends ImmutablePureComponent {
|
||||||
if (status.get('poll')) {
|
if (status.get('poll')) {
|
||||||
media = <PollContainer pollId={status.get('poll')} />;
|
media = <PollContainer pollId={status.get('poll')} />;
|
||||||
} else if (status.get('media_attachments').size > 0) {
|
} else if (status.get('media_attachments').size > 0) {
|
||||||
if (this.props.muted || status.get('media_attachments').some(item => item.get('type') === 'unknown')) {
|
if (this.props.muted) {
|
||||||
media = (
|
media = (
|
||||||
<AttachmentList
|
<AttachmentList
|
||||||
compact
|
compact
|
||||||
|
@ -289,6 +289,7 @@ class Status extends ImmutablePureComponent {
|
||||||
{Component => (
|
{Component => (
|
||||||
<Component
|
<Component
|
||||||
preview={video.get('preview_url')}
|
preview={video.get('preview_url')}
|
||||||
|
blurhash={video.get('blurhash')}
|
||||||
src={video.get('url')}
|
src={video.get('url')}
|
||||||
alt={video.get('description')}
|
alt={video.get('description')}
|
||||||
width={this.props.cachedMediaWidth}
|
width={this.props.cachedMediaWidth}
|
||||||
|
|
|
@ -35,6 +35,7 @@ export default class StatusCheckBox extends React.PureComponent {
|
||||||
{Component => (
|
{Component => (
|
||||||
<Component
|
<Component
|
||||||
preview={video.get('preview_url')}
|
preview={video.get('preview_url')}
|
||||||
|
blurhash={video.get('blurhash')}
|
||||||
src={video.get('url')}
|
src={video.get('url')}
|
||||||
alt={video.get('description')}
|
alt={video.get('description')}
|
||||||
width={239}
|
width={239}
|
||||||
|
|
|
@ -5,7 +5,6 @@ import Avatar from '../../../components/avatar';
|
||||||
import DisplayName from '../../../components/display_name';
|
import DisplayName from '../../../components/display_name';
|
||||||
import StatusContent from '../../../components/status_content';
|
import StatusContent from '../../../components/status_content';
|
||||||
import MediaGallery from '../../../components/media_gallery';
|
import MediaGallery from '../../../components/media_gallery';
|
||||||
import AttachmentList from '../../../components/attachment_list';
|
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { FormattedDate, FormattedNumber } from 'react-intl';
|
import { FormattedDate, FormattedNumber } from 'react-intl';
|
||||||
import Card from './card';
|
import Card from './card';
|
||||||
|
@ -109,14 +108,13 @@ export default class DetailedStatus extends ImmutablePureComponent {
|
||||||
if (status.get('poll')) {
|
if (status.get('poll')) {
|
||||||
media = <PollContainer pollId={status.get('poll')} />;
|
media = <PollContainer pollId={status.get('poll')} />;
|
||||||
} else if (status.get('media_attachments').size > 0) {
|
} else if (status.get('media_attachments').size > 0) {
|
||||||
if (status.get('media_attachments').some(item => item.get('type') === 'unknown')) {
|
if (status.getIn(['media_attachments', 0, 'type']) === 'video') {
|
||||||
media = <AttachmentList media={status.get('media_attachments')} />;
|
|
||||||
} else if (status.getIn(['media_attachments', 0, 'type']) === 'video') {
|
|
||||||
const video = status.getIn(['media_attachments', 0]);
|
const video = status.getIn(['media_attachments', 0]);
|
||||||
|
|
||||||
media = (
|
media = (
|
||||||
<Video
|
<Video
|
||||||
preview={video.get('preview_url')}
|
preview={video.get('preview_url')}
|
||||||
|
blurhash={video.get('blurhash')}
|
||||||
src={video.get('url')}
|
src={video.get('url')}
|
||||||
alt={video.get('description')}
|
alt={video.get('description')}
|
||||||
width={300}
|
width={300}
|
||||||
|
|
|
@ -144,6 +144,7 @@ class MediaModal extends ImmutablePureComponent {
|
||||||
return (
|
return (
|
||||||
<Video
|
<Video
|
||||||
preview={image.get('preview_url')}
|
preview={image.get('preview_url')}
|
||||||
|
blurhash={image.get('blurhash')}
|
||||||
src={image.get('url')}
|
src={image.get('url')}
|
||||||
width={image.get('width')}
|
width={image.get('width')}
|
||||||
height={image.get('height')}
|
height={image.get('height')}
|
||||||
|
|
|
@ -20,6 +20,7 @@ export default class VideoModal extends ImmutablePureComponent {
|
||||||
<div>
|
<div>
|
||||||
<Video
|
<Video
|
||||||
preview={media.get('preview_url')}
|
preview={media.get('preview_url')}
|
||||||
|
blurhash={media.get('blurhash')}
|
||||||
src={media.get('url')}
|
src={media.get('url')}
|
||||||
startTime={time}
|
startTime={time}
|
||||||
onCloseVideo={onClose}
|
onCloseVideo={onClose}
|
||||||
|
|
|
@ -7,6 +7,7 @@ import classNames from 'classnames';
|
||||||
import { isFullscreen, requestFullscreen, exitFullscreen } from '../ui/util/fullscreen';
|
import { isFullscreen, requestFullscreen, exitFullscreen } from '../ui/util/fullscreen';
|
||||||
import { displayMedia } from '../../initial_state';
|
import { displayMedia } from '../../initial_state';
|
||||||
import Icon from 'mastodon/components/icon';
|
import Icon from 'mastodon/components/icon';
|
||||||
|
import { decode } from 'blurhash';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
play: { id: 'video.play', defaultMessage: 'Play' },
|
play: { id: 'video.play', defaultMessage: 'Play' },
|
||||||
|
@ -102,6 +103,7 @@ class Video extends React.PureComponent {
|
||||||
inline: PropTypes.bool,
|
inline: PropTypes.bool,
|
||||||
cacheWidth: PropTypes.func,
|
cacheWidth: PropTypes.func,
|
||||||
intl: PropTypes.object.isRequired,
|
intl: PropTypes.object.isRequired,
|
||||||
|
blurhash: PropTypes.string,
|
||||||
};
|
};
|
||||||
|
|
||||||
state = {
|
state = {
|
||||||
|
@ -139,6 +141,7 @@ class Video extends React.PureComponent {
|
||||||
|
|
||||||
setVideoRef = c => {
|
setVideoRef = c => {
|
||||||
this.video = c;
|
this.video = c;
|
||||||
|
|
||||||
if (this.video) {
|
if (this.video) {
|
||||||
this.setState({ volume: this.video.volume, muted: this.video.muted });
|
this.setState({ volume: this.video.volume, muted: this.video.muted });
|
||||||
}
|
}
|
||||||
|
@ -152,6 +155,10 @@ class Video extends React.PureComponent {
|
||||||
this.volume = c;
|
this.volume = c;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setCanvasRef = c => {
|
||||||
|
this.canvas = c;
|
||||||
|
}
|
||||||
|
|
||||||
handleClickRoot = e => e.stopPropagation();
|
handleClickRoot = e => e.stopPropagation();
|
||||||
|
|
||||||
handlePlay = () => {
|
handlePlay = () => {
|
||||||
|
@ -170,7 +177,6 @@ class Video extends React.PureComponent {
|
||||||
}
|
}
|
||||||
|
|
||||||
handleVolumeMouseDown = e => {
|
handleVolumeMouseDown = e => {
|
||||||
|
|
||||||
document.addEventListener('mousemove', this.handleMouseVolSlide, true);
|
document.addEventListener('mousemove', this.handleMouseVolSlide, true);
|
||||||
document.addEventListener('mouseup', this.handleVolumeMouseUp, true);
|
document.addEventListener('mouseup', this.handleVolumeMouseUp, true);
|
||||||
document.addEventListener('touchmove', this.handleMouseVolSlide, true);
|
document.addEventListener('touchmove', this.handleMouseVolSlide, true);
|
||||||
|
@ -190,7 +196,6 @@ class Video extends React.PureComponent {
|
||||||
}
|
}
|
||||||
|
|
||||||
handleMouseVolSlide = throttle(e => {
|
handleMouseVolSlide = throttle(e => {
|
||||||
|
|
||||||
const rect = this.volume.getBoundingClientRect();
|
const rect = this.volume.getBoundingClientRect();
|
||||||
const x = (e.clientX - rect.left) / this.volWidth; //x position within the element.
|
const x = (e.clientX - rect.left) / this.volWidth; //x position within the element.
|
||||||
|
|
||||||
|
@ -261,6 +266,10 @@ class Video extends React.PureComponent {
|
||||||
document.addEventListener('webkitfullscreenchange', this.handleFullscreenChange, true);
|
document.addEventListener('webkitfullscreenchange', this.handleFullscreenChange, true);
|
||||||
document.addEventListener('mozfullscreenchange', this.handleFullscreenChange, true);
|
document.addEventListener('mozfullscreenchange', this.handleFullscreenChange, true);
|
||||||
document.addEventListener('MSFullscreenChange', this.handleFullscreenChange, true);
|
document.addEventListener('MSFullscreenChange', this.handleFullscreenChange, true);
|
||||||
|
|
||||||
|
if (this.props.blurhash) {
|
||||||
|
this._decode();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
componentWillUnmount () {
|
componentWillUnmount () {
|
||||||
|
@ -270,6 +279,24 @@ class Video extends React.PureComponent {
|
||||||
document.removeEventListener('MSFullscreenChange', this.handleFullscreenChange, true);
|
document.removeEventListener('MSFullscreenChange', this.handleFullscreenChange, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
componentDidUpdate (prevProps) {
|
||||||
|
if (prevProps.blurhash !== this.props.blurhash && this.props.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 = () => {
|
handleFullscreenChange = () => {
|
||||||
this.setState({ fullscreen: isFullscreen() });
|
this.setState({ fullscreen: isFullscreen() });
|
||||||
}
|
}
|
||||||
|
@ -314,6 +341,7 @@ class Video extends React.PureComponent {
|
||||||
|
|
||||||
handleOpenVideo = () => {
|
handleOpenVideo = () => {
|
||||||
const { src, preview, width, height, alt } = this.props;
|
const { src, preview, width, height, alt } = this.props;
|
||||||
|
|
||||||
const media = fromJS({
|
const media = fromJS({
|
||||||
type: 'video',
|
type: 'video',
|
||||||
url: src,
|
url: src,
|
||||||
|
@ -351,6 +379,7 @@ class Video extends React.PureComponent {
|
||||||
}
|
}
|
||||||
|
|
||||||
let preload;
|
let preload;
|
||||||
|
|
||||||
if (startTime || fullscreen || dragging) {
|
if (startTime || fullscreen || dragging) {
|
||||||
preload = 'auto';
|
preload = 'auto';
|
||||||
} else if (detailed) {
|
} else if (detailed) {
|
||||||
|
@ -360,6 +389,7 @@ class Video extends React.PureComponent {
|
||||||
}
|
}
|
||||||
|
|
||||||
let warning;
|
let warning;
|
||||||
|
|
||||||
if (sensitive) {
|
if (sensitive) {
|
||||||
warning = <FormattedMessage id='status.sensitive_warning' defaultMessage='Sensitive content' />;
|
warning = <FormattedMessage id='status.sensitive_warning' defaultMessage='Sensitive content' />;
|
||||||
} else {
|
} else {
|
||||||
|
@ -377,7 +407,9 @@ class Video extends React.PureComponent {
|
||||||
onClick={this.handleClickRoot}
|
onClick={this.handleClickRoot}
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
>
|
>
|
||||||
<video
|
<canvas width={32} height={32} ref={this.setCanvasRef} className={classNames('media-gallery__preview', { 'media-gallery__preview--hidden': revealed })} />
|
||||||
|
|
||||||
|
{revealed && <video
|
||||||
ref={this.setVideoRef}
|
ref={this.setVideoRef}
|
||||||
src={src}
|
src={src}
|
||||||
poster={preview}
|
poster={preview}
|
||||||
|
@ -397,12 +429,13 @@ class Video extends React.PureComponent {
|
||||||
onLoadedData={this.handleLoadedData}
|
onLoadedData={this.handleLoadedData}
|
||||||
onProgress={this.handleProgress}
|
onProgress={this.handleProgress}
|
||||||
onVolumeChange={this.handleVolumeChange}
|
onVolumeChange={this.handleVolumeChange}
|
||||||
/>
|
/>}
|
||||||
|
|
||||||
<button type='button' className={classNames('video-player__spoiler', { active: !revealed })} onClick={this.toggleReveal}>
|
<div className={classNames('spoiler-button', { 'spoiler-button--hidden': revealed })}>
|
||||||
<span className='video-player__spoiler__title'>{warning}</span>
|
<button type='button' className='spoiler-button__overlay' onClick={this.toggleReveal}>
|
||||||
<span className='video-player__spoiler__subtitle'><FormattedMessage id='status.sensitive_toggle' defaultMessage='Click to view' /></span>
|
<span className='spoiler-button__overlay__label'>{warning}</span>
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className={classNames('video-player__controls', { active: paused || hovered })}>
|
<div className={classNames('video-player__controls', { active: paused || hovered })}>
|
||||||
<div className='video-player__seek' onMouseDown={this.handleMouseDown} ref={this.setSeekRef}>
|
<div className='video-player__seek' onMouseDown={this.handleMouseDown} ref={this.setSeekRef}>
|
||||||
|
|
|
@ -2412,7 +2412,7 @@ a.account__display-name {
|
||||||
|
|
||||||
& > div {
|
& > div {
|
||||||
background: rgba($base-shadow-color, 0.6);
|
background: rgba($base-shadow-color, 0.6);
|
||||||
border-radius: 4px;
|
border-radius: 8px;
|
||||||
padding: 12px 9px;
|
padding: 12px 9px;
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
@ -2423,19 +2423,18 @@ a.account__display-name {
|
||||||
button,
|
button,
|
||||||
a {
|
a {
|
||||||
display: inline;
|
display: inline;
|
||||||
color: $primary-text-color;
|
color: $secondary-text-color;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: 0;
|
border: 0;
|
||||||
padding: 0 5px;
|
padding: 0 8px;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
opacity: 0.6;
|
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
line-height: 18px;
|
line-height: 18px;
|
||||||
|
|
||||||
&:hover,
|
&:hover,
|
||||||
&:active,
|
&:active,
|
||||||
&:focus {
|
&:focus {
|
||||||
opacity: 1;
|
color: $primary-text-color;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -2932,15 +2931,49 @@ a.status-card.compact:hover {
|
||||||
}
|
}
|
||||||
|
|
||||||
.spoiler-button {
|
.spoiler-button {
|
||||||
display: none;
|
top: 0;
|
||||||
left: 4px;
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
text-shadow: 0 1px 1px $base-shadow-color, 1px 0 1px $base-shadow-color;
|
|
||||||
top: 4px;
|
|
||||||
z-index: 100;
|
z-index: 100;
|
||||||
|
|
||||||
&.spoiler-button--visible {
|
&--minified {
|
||||||
display: block;
|
display: block;
|
||||||
|
left: 4px;
|
||||||
|
top: 4px;
|
||||||
|
width: auto;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__overlay {
|
||||||
|
display: block;
|
||||||
|
background: transparent;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border: 0;
|
||||||
|
|
||||||
|
&__label {
|
||||||
|
display: inline-block;
|
||||||
|
background: rgba($base-overlay-background, 0.5);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
color: $primary-text-color;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover,
|
||||||
|
&:focus,
|
||||||
|
&:active {
|
||||||
|
.spoiler-button__overlay__label {
|
||||||
|
background: rgba($base-overlay-background, 0.8);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -4313,6 +4346,8 @@ a.status-card.compact:hover {
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
color: $secondary-text-color;
|
color: $secondary-text-color;
|
||||||
line-height: 0;
|
line-height: 0;
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
|
||||||
&,
|
&,
|
||||||
img {
|
img {
|
||||||
|
@ -4325,6 +4360,21 @@ a.status-card.compact:hover {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.media-gallery__preview {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
z-index: 0;
|
||||||
|
background: $base-overlay-background;
|
||||||
|
|
||||||
|
&--hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.media-gallery__gifv {
|
.media-gallery__gifv {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|
|
@ -194,7 +194,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
|
||||||
next if attachment['url'].blank?
|
next if attachment['url'].blank?
|
||||||
|
|
||||||
href = Addressable::URI.parse(attachment['url']).normalize.to_s
|
href = Addressable::URI.parse(attachment['url']).normalize.to_s
|
||||||
media_attachment = MediaAttachment.create(account: @account, remote_url: href, description: attachment['name'].presence, focus: attachment['focalPoint'])
|
media_attachment = MediaAttachment.create(account: @account, remote_url: href, description: attachment['name'].presence, focus: attachment['focalPoint'], blurhash: supported_blurhash?(attachment['blurhash']) ? attachment['blurhash'] : nil)
|
||||||
media_attachments << media_attachment
|
media_attachments << media_attachment
|
||||||
|
|
||||||
next if unsupported_media_type?(attachment['mediaType']) || skip_download?
|
next if unsupported_media_type?(attachment['mediaType']) || skip_download?
|
||||||
|
@ -369,6 +369,11 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
|
||||||
mime_type.present? && !(MediaAttachment::IMAGE_MIME_TYPES + MediaAttachment::VIDEO_MIME_TYPES).include?(mime_type)
|
mime_type.present? && !(MediaAttachment::IMAGE_MIME_TYPES + MediaAttachment::VIDEO_MIME_TYPES).include?(mime_type)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def supported_blurhash?(blurhash)
|
||||||
|
components = blurhash.blank? ? nil : Blurhash.components(blurhash)
|
||||||
|
components.present? && components.none? { |comp| comp > 5 }
|
||||||
|
end
|
||||||
|
|
||||||
def skip_download?
|
def skip_download?
|
||||||
return @skip_download if defined?(@skip_download)
|
return @skip_download if defined?(@skip_download)
|
||||||
@skip_download ||= DomainBlock.find_by(domain: @account.domain)&.reject_media?
|
@skip_download ||= DomainBlock.find_by(domain: @account.domain)&.reject_media?
|
||||||
|
|
|
@ -19,6 +19,7 @@ class ActivityPub::Adapter < ActiveModelSerializers::Adapter::Base
|
||||||
conversation: { 'ostatus' => 'http://ostatus.org#', 'inReplyToAtomUri' => 'ostatus:inReplyToAtomUri', 'conversation' => 'ostatus:conversation' },
|
conversation: { 'ostatus' => 'http://ostatus.org#', 'inReplyToAtomUri' => 'ostatus:inReplyToAtomUri', 'conversation' => 'ostatus:conversation' },
|
||||||
focal_point: { 'toot' => 'http://joinmastodon.org/ns#', 'focalPoint' => { '@container' => '@list', '@id' => 'toot:focalPoint' } },
|
focal_point: { 'toot' => 'http://joinmastodon.org/ns#', 'focalPoint' => { '@container' => '@list', '@id' => 'toot:focalPoint' } },
|
||||||
identity_proof: { 'toot' => 'http://joinmastodon.org/ns#', 'IdentityProof' => 'toot:IdentityProof' },
|
identity_proof: { 'toot' => 'http://joinmastodon.org/ns#', 'IdentityProof' => 'toot:IdentityProof' },
|
||||||
|
blurhash: { 'toot' => 'http://joinmastodon.org/ns#', 'blurhash' => 'toot:blurhash' },
|
||||||
}.freeze
|
}.freeze
|
||||||
|
|
||||||
def self.default_key_transform
|
def self.default_key_transform
|
||||||
|
|
|
@ -18,6 +18,7 @@
|
||||||
# account_id :bigint(8)
|
# account_id :bigint(8)
|
||||||
# description :text
|
# description :text
|
||||||
# scheduled_status_id :bigint(8)
|
# scheduled_status_id :bigint(8)
|
||||||
|
# blurhash :string
|
||||||
#
|
#
|
||||||
|
|
||||||
class MediaAttachment < ApplicationRecord
|
class MediaAttachment < ApplicationRecord
|
||||||
|
@ -32,6 +33,11 @@ class MediaAttachment < ApplicationRecord
|
||||||
VIDEO_MIME_TYPES = ['video/webm', 'video/mp4', 'video/quicktime'].freeze
|
VIDEO_MIME_TYPES = ['video/webm', 'video/mp4', 'video/quicktime'].freeze
|
||||||
VIDEO_CONVERTIBLE_MIME_TYPES = ['video/webm', 'video/quicktime'].freeze
|
VIDEO_CONVERTIBLE_MIME_TYPES = ['video/webm', 'video/quicktime'].freeze
|
||||||
|
|
||||||
|
BLURHASH_OPTIONS = {
|
||||||
|
x_comp: 4,
|
||||||
|
y_comp: 4,
|
||||||
|
}.freeze
|
||||||
|
|
||||||
IMAGE_STYLES = {
|
IMAGE_STYLES = {
|
||||||
original: {
|
original: {
|
||||||
pixels: 1_638_400, # 1280x1280px
|
pixels: 1_638_400, # 1280x1280px
|
||||||
|
@ -41,6 +47,7 @@ class MediaAttachment < ApplicationRecord
|
||||||
small: {
|
small: {
|
||||||
pixels: 160_000, # 400x400px
|
pixels: 160_000, # 400x400px
|
||||||
file_geometry_parser: FastGeometryParser,
|
file_geometry_parser: FastGeometryParser,
|
||||||
|
blurhash: BLURHASH_OPTIONS,
|
||||||
},
|
},
|
||||||
}.freeze
|
}.freeze
|
||||||
|
|
||||||
|
@ -53,6 +60,8 @@ class MediaAttachment < ApplicationRecord
|
||||||
},
|
},
|
||||||
format: 'png',
|
format: 'png',
|
||||||
time: 0,
|
time: 0,
|
||||||
|
file_geometry_parser: FastGeometryParser,
|
||||||
|
blurhash: BLURHASH_OPTIONS,
|
||||||
},
|
},
|
||||||
}.freeze
|
}.freeze
|
||||||
|
|
||||||
|
@ -166,11 +175,11 @@ class MediaAttachment < ApplicationRecord
|
||||||
|
|
||||||
def file_processors(f)
|
def file_processors(f)
|
||||||
if f.file_content_type == 'image/gif'
|
if f.file_content_type == 'image/gif'
|
||||||
[:gif_transcoder]
|
[:gif_transcoder, :blurhash_transcoder]
|
||||||
elsif VIDEO_MIME_TYPES.include? f.file_content_type
|
elsif VIDEO_MIME_TYPES.include? f.file_content_type
|
||||||
[:video_transcoder]
|
[:video_transcoder, :blurhash_transcoder]
|
||||||
else
|
else
|
||||||
[:lazy_thumbnail]
|
[:lazy_thumbnail, :blurhash_transcoder]
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
class ActivityPub::NoteSerializer < ActivityPub::Serializer
|
class ActivityPub::NoteSerializer < ActivityPub::Serializer
|
||||||
context_extensions :atom_uri, :conversation, :sensitive,
|
context_extensions :atom_uri, :conversation, :sensitive,
|
||||||
:hashtag, :emoji, :focal_point
|
:hashtag, :emoji, :focal_point, :blurhash
|
||||||
|
|
||||||
attributes :id, :type, :summary,
|
attributes :id, :type, :summary,
|
||||||
:in_reply_to, :published, :url,
|
:in_reply_to, :published, :url,
|
||||||
|
@ -153,7 +153,7 @@ class ActivityPub::NoteSerializer < ActivityPub::Serializer
|
||||||
class MediaAttachmentSerializer < ActivityPub::Serializer
|
class MediaAttachmentSerializer < ActivityPub::Serializer
|
||||||
include RoutingHelper
|
include RoutingHelper
|
||||||
|
|
||||||
attributes :type, :media_type, :url, :name
|
attributes :type, :media_type, :url, :name, :blurhash
|
||||||
attribute :focal_point, if: :focal_point?
|
attribute :focal_point, if: :focal_point?
|
||||||
|
|
||||||
def type
|
def type
|
||||||
|
|
|
@ -5,7 +5,7 @@ class REST::MediaAttachmentSerializer < ActiveModel::Serializer
|
||||||
|
|
||||||
attributes :id, :type, :url, :preview_url,
|
attributes :id, :type, :url, :preview_url,
|
||||||
:remote_url, :text_url, :meta,
|
:remote_url, :text_url, :meta,
|
||||||
:description
|
:description, :blurhash
|
||||||
|
|
||||||
def id
|
def id
|
||||||
object.id.to_s
|
object.id.to_s
|
||||||
|
|
|
@ -28,7 +28,7 @@
|
||||||
- elsif !status.media_attachments.empty?
|
- elsif !status.media_attachments.empty?
|
||||||
- if status.media_attachments.first.video?
|
- if status.media_attachments.first.video?
|
||||||
- video = status.media_attachments.first
|
- video = status.media_attachments.first
|
||||||
= react_component :video, src: video.file.url(:original), preview: video.file.url(:small), sensitive: !current_account&.user&.show_all_media? && status.sensitive? || current_account&.user&.hide_all_media?, width: 670, height: 380, detailed: true, inline: true, alt: video.description do
|
= react_component :video, src: video.file.url(:original), preview: video.file.url(:small), blurhash: video.blurhash, sensitive: !current_account&.user&.show_all_media? && status.sensitive? || current_account&.user&.hide_all_media?, width: 670, height: 380, detailed: true, inline: true, alt: video.description do
|
||||||
= render partial: 'stream_entries/attachment_list', locals: { attachments: status.media_attachments }
|
= render partial: 'stream_entries/attachment_list', locals: { attachments: status.media_attachments }
|
||||||
- else
|
- else
|
||||||
= react_component :media_gallery, height: 380, sensitive: !current_account&.user&.show_all_media? && status.sensitive? || current_account&.user&.hide_all_media?, standalone: true, 'autoPlayGif': current_account&.user&.setting_auto_play_gif || autoplay, 'reduceMotion': current_account&.user&.setting_reduce_motion, media: status.media_attachments.map { |a| ActiveModelSerializers::SerializableResource.new(a, serializer: REST::MediaAttachmentSerializer).as_json } do
|
= react_component :media_gallery, height: 380, sensitive: !current_account&.user&.show_all_media? && status.sensitive? || current_account&.user&.hide_all_media?, standalone: true, 'autoPlayGif': current_account&.user&.setting_auto_play_gif || autoplay, 'reduceMotion': current_account&.user&.setting_reduce_motion, media: status.media_attachments.map { |a| ActiveModelSerializers::SerializableResource.new(a, serializer: REST::MediaAttachmentSerializer).as_json } do
|
||||||
|
|
|
@ -32,7 +32,7 @@
|
||||||
- elsif !status.media_attachments.empty?
|
- elsif !status.media_attachments.empty?
|
||||||
- if status.media_attachments.first.video?
|
- if status.media_attachments.first.video?
|
||||||
- video = status.media_attachments.first
|
- video = status.media_attachments.first
|
||||||
= react_component :video, src: video.file.url(:original), preview: video.file.url(:small), sensitive: !current_account&.user&.show_all_media? && status.sensitive? || current_account&.user&.hide_all_media?, width: 610, height: 343, inline: true, alt: video.description do
|
= react_component :video, src: video.file.url(:original), preview: video.file.url(:small), blurhash: video.blurhash, sensitive: !current_account&.user&.show_all_media? && status.sensitive? || current_account&.user&.hide_all_media?, width: 610, height: 343, inline: true, alt: video.description do
|
||||||
= render partial: 'stream_entries/attachment_list', locals: { attachments: status.media_attachments }
|
= render partial: 'stream_entries/attachment_list', locals: { attachments: status.media_attachments }
|
||||||
- else
|
- else
|
||||||
= react_component :media_gallery, height: 343, sensitive: !current_account&.user&.show_all_media? && status.sensitive? || current_account&.user&.hide_all_media?, 'autoPlayGif': current_account&.user&.setting_auto_play_gif || autoplay, media: status.media_attachments.map { |a| ActiveModelSerializers::SerializableResource.new(a, serializer: REST::MediaAttachmentSerializer).as_json } do
|
= react_component :media_gallery, height: 343, sensitive: !current_account&.user&.show_all_media? && status.sensitive? || current_account&.user&.hide_all_media?, 'autoPlayGif': current_account&.user&.setting_auto_play_gif || autoplay, media: status.media_attachments.map { |a| ActiveModelSerializers::SerializableResource.new(a, serializer: REST::MediaAttachmentSerializer).as_json } do
|
||||||
|
|
|
@ -0,0 +1,5 @@
|
||||||
|
class AddBlurhashToMediaAttachments < ActiveRecord::Migration[5.2]
|
||||||
|
def change
|
||||||
|
add_column :media_attachments, :blurhash, :string
|
||||||
|
end
|
||||||
|
end
|
|
@ -10,7 +10,7 @@
|
||||||
#
|
#
|
||||||
# It's strongly recommended that you check this file into your version control system.
|
# It's strongly recommended that you check this file into your version control system.
|
||||||
|
|
||||||
ActiveRecord::Schema.define(version: 2019_04_09_054914) do
|
ActiveRecord::Schema.define(version: 2019_04_20_025523) do
|
||||||
|
|
||||||
# These are extensions that must be enabled in order to support this database
|
# These are extensions that must be enabled in order to support this database
|
||||||
enable_extension "plpgsql"
|
enable_extension "plpgsql"
|
||||||
|
@ -362,6 +362,7 @@ ActiveRecord::Schema.define(version: 2019_04_09_054914) do
|
||||||
t.bigint "account_id"
|
t.bigint "account_id"
|
||||||
t.text "description"
|
t.text "description"
|
||||||
t.bigint "scheduled_status_id"
|
t.bigint "scheduled_status_id"
|
||||||
|
t.string "blurhash"
|
||||||
t.index ["account_id"], name: "index_media_attachments_on_account_id"
|
t.index ["account_id"], name: "index_media_attachments_on_account_id"
|
||||||
t.index ["scheduled_status_id"], name: "index_media_attachments_on_scheduled_status_id"
|
t.index ["scheduled_status_id"], name: "index_media_attachments_on_scheduled_status_id"
|
||||||
t.index ["shortcode"], name: "index_media_attachments_on_shortcode", unique: true
|
t.index ["shortcode"], name: "index_media_attachments_on_shortcode", unique: true
|
||||||
|
|
|
@ -0,0 +1,16 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module Paperclip
|
||||||
|
class BlurhashTranscoder < Paperclip::Processor
|
||||||
|
def make
|
||||||
|
return @file unless options[:style] == :small
|
||||||
|
|
||||||
|
pixels = convert(':source RGB:-', source: File.expand_path(@file.path)).unpack('C*')
|
||||||
|
geometry = options.fetch(:file_geometry_parser).from_file(@file)
|
||||||
|
|
||||||
|
attachment.instance.blurhash = Blurhash.encode(geometry.width, geometry.height, pixels, options[:blurhash] || {})
|
||||||
|
|
||||||
|
@file
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
|
@ -78,6 +78,7 @@
|
||||||
"babel-plugin-react-intl": "^3.0.1",
|
"babel-plugin-react-intl": "^3.0.1",
|
||||||
"babel-plugin-transform-react-remove-prop-types": "^0.4.24",
|
"babel-plugin-transform-react-remove-prop-types": "^0.4.24",
|
||||||
"babel-runtime": "^6.26.0",
|
"babel-runtime": "^6.26.0",
|
||||||
|
"blurhash": "^1.0.0",
|
||||||
"classnames": "^2.2.5",
|
"classnames": "^2.2.5",
|
||||||
"compression-webpack-plugin": "^2.0.0",
|
"compression-webpack-plugin": "^2.0.0",
|
||||||
"cross-env": "^5.1.4",
|
"cross-env": "^5.1.4",
|
||||||
|
|
|
@ -1743,6 +1743,11 @@ bluebird@^3.5.1, bluebird@^3.5.3:
|
||||||
resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.5.3.tgz#7d01c6f9616c9a51ab0f8c549a79dfe6ec33efa7"
|
resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.5.3.tgz#7d01c6f9616c9a51ab0f8c549a79dfe6ec33efa7"
|
||||||
integrity sha512-/qKPUQlaW1OyR51WeCPBvRnAlnZFUJkCSG5HzGnuIqhgyJtF+T94lFnn33eiazjRm2LAHVy2guNnaq48X9SJuw==
|
integrity sha512-/qKPUQlaW1OyR51WeCPBvRnAlnZFUJkCSG5HzGnuIqhgyJtF+T94lFnn33eiazjRm2LAHVy2guNnaq48X9SJuw==
|
||||||
|
|
||||||
|
blurhash@^1.0.0:
|
||||||
|
version "1.0.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/blurhash/-/blurhash-1.0.0.tgz#9087bc5cc4d482f1305059d7410df4133adcab2e"
|
||||||
|
integrity sha512-x6fpZnd6AWde4U9m7xhUB44qIvGV4W6OdTAXGabYm4oZUOOGh5K1HAEoGAQn3iG4gbbPn9RSGce3VfNgGsX/Vw==
|
||||||
|
|
||||||
bn.js@^4.0.0, bn.js@^4.1.0, bn.js@^4.1.1, bn.js@^4.4.0:
|
bn.js@^4.0.0, bn.js@^4.1.0, bn.js@^4.1.1, bn.js@^4.4.0:
|
||||||
version "4.11.8"
|
version "4.11.8"
|
||||||
resolved "https://registry.yarnpkg.com/bn.js/-/bn.js-4.11.8.tgz#2cde09eb5ee341f484746bb0309b3253b1b1442f"
|
resolved "https://registry.yarnpkg.com/bn.js/-/bn.js-4.11.8.tgz#2cde09eb5ee341f484746bb0309b3253b1b1442f"
|
||||||
|
|
Loading…
Reference in New Issue