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 issues
This commit is contained in:
		
							parent
							
								
									c008911249
								
							
						
					
					
						commit
						fba96c808d
					
				
					 22 changed files with 234 additions and 60 deletions
				
			
		
							
								
								
									
										1
									
								
								Gemfile
									
										
									
									
									
								
							
							
						
						
									
										1
									
								
								Gemfile
									
										
									
									
									
								
							| 
						 | 
				
			
			@ -21,6 +21,7 @@ gem 'fog-openstack', '~> 0.3', require: false
 | 
			
		|||
gem 'paperclip', '~> 6.0'
 | 
			
		||||
gem 'paperclip-av-transcoder', '~> 0.6'
 | 
			
		||||
gem 'streamio-ffmpeg', '~> 3.0'
 | 
			
		||||
gem 'blurhash', '~> 0.1'
 | 
			
		||||
 | 
			
		||||
gem 'active_model_serializers', '~> 0.10'
 | 
			
		||||
gem 'addressable', '~> 2.6'
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -99,6 +99,8 @@ GEM
 | 
			
		|||
      rack (>= 0.9.0)
 | 
			
		||||
    binding_of_caller (0.8.0)
 | 
			
		||||
      debug_inspector (>= 0.0.1)
 | 
			
		||||
    blurhash (0.1.2)
 | 
			
		||||
      ffi (~> 1.10.0)
 | 
			
		||||
    bootsnap (1.4.4)
 | 
			
		||||
      msgpack (~> 1.0)
 | 
			
		||||
    brakeman (4.5.0)
 | 
			
		||||
| 
						 | 
				
			
			@ -661,6 +663,7 @@ DEPENDENCIES
 | 
			
		|||
  aws-sdk-s3 (~> 1.36)
 | 
			
		||||
  better_errors (~> 2.5)
 | 
			
		||||
  binding_of_caller (~> 0.7)
 | 
			
		||||
  blurhash (~> 0.1)
 | 
			
		||||
  bootsnap (~> 1.4)
 | 
			
		||||
  brakeman (~> 4.5)
 | 
			
		||||
  browser
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -7,6 +7,7 @@ import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
 | 
			
		|||
import { isIOS } from '../is_mobile';
 | 
			
		||||
import classNames from 'classnames';
 | 
			
		||||
import { autoPlayGif, displayMedia } from '../initial_state';
 | 
			
		||||
import { decode } from 'blurhash';
 | 
			
		||||
 | 
			
		||||
const messages = defineMessages({
 | 
			
		||||
  toggle_visible: { id: 'media_gallery.toggle_visible', defaultMessage: 'Toggle visibility' },
 | 
			
		||||
| 
						 | 
				
			
			@ -21,6 +22,7 @@ class Item extends React.PureComponent {
 | 
			
		|||
    size: PropTypes.number.isRequired,
 | 
			
		||||
    onClick: PropTypes.func.isRequired,
 | 
			
		||||
    displayWidth: PropTypes.number,
 | 
			
		||||
    visible: PropTypes.bool.isRequired,
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  static defaultProps = {
 | 
			
		||||
| 
						 | 
				
			
			@ -29,6 +31,10 @@ class Item extends React.PureComponent {
 | 
			
		|||
    size: 1,
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  state = {
 | 
			
		||||
    loaded: false,
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  handleMouseEnter = (e) => {
 | 
			
		||||
    if (this.hoverToPlay()) {
 | 
			
		||||
      e.target.play();
 | 
			
		||||
| 
						 | 
				
			
			@ -62,8 +68,40 @@ 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 });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  render () {
 | 
			
		||||
    const { attachment, index, size, standalone, displayWidth } = this.props;
 | 
			
		||||
    const { attachment, index, size, standalone, displayWidth, visible } = this.props;
 | 
			
		||||
 | 
			
		||||
    let width  = 50;
 | 
			
		||||
    let height = 100;
 | 
			
		||||
| 
						 | 
				
			
			@ -116,12 +154,20 @@ class Item extends React.PureComponent {
 | 
			
		|||
 | 
			
		||||
    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 previewWidth = attachment.getIn(['meta', 'small', 'width']);
 | 
			
		||||
 | 
			
		||||
      const originalUrl    = attachment.get('url');
 | 
			
		||||
      const originalWidth  = attachment.getIn(['meta', 'original', 'width']);
 | 
			
		||||
      const originalUrl   = attachment.get('url');
 | 
			
		||||
      const originalWidth = attachment.getIn(['meta', 'original', 'width']);
 | 
			
		||||
 | 
			
		||||
      const hasSize = typeof originalWidth === 'number' && typeof previewWidth === 'number';
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -147,6 +193,7 @@ class Item extends React.PureComponent {
 | 
			
		|||
            alt={attachment.get('description')}
 | 
			
		||||
            title={attachment.get('description')}
 | 
			
		||||
            style={{ objectPosition: `${x}% ${y}%` }}
 | 
			
		||||
            onLoad={this.handleImageLoad}
 | 
			
		||||
          />
 | 
			
		||||
        </a>
 | 
			
		||||
      );
 | 
			
		||||
| 
						 | 
				
			
			@ -176,7 +223,8 @@ class Item extends React.PureComponent {
 | 
			
		|||
 | 
			
		||||
    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}%` }}>
 | 
			
		||||
        {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>
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
| 
						 | 
				
			
			@ -225,6 +273,7 @@ class MediaGallery extends React.PureComponent {
 | 
			
		|||
    if (node /*&& this.isStandaloneEligible()*/) {
 | 
			
		||||
      // offsetWidth triggers a layout, so only calculate when we need to
 | 
			
		||||
      if (this.props.cacheWidth) this.props.cacheWidth(node.offsetWidth);
 | 
			
		||||
 | 
			
		||||
      this.setState({
 | 
			
		||||
        width: node.offsetWidth,
 | 
			
		||||
      });
 | 
			
		||||
| 
						 | 
				
			
			@ -242,7 +291,7 @@ class MediaGallery extends React.PureComponent {
 | 
			
		|||
 | 
			
		||||
    const width = this.state.width || defaultWidth;
 | 
			
		||||
 | 
			
		||||
    let children;
 | 
			
		||||
    let children, spoilerButton;
 | 
			
		||||
 | 
			
		||||
    const style = {};
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -256,35 +305,28 @@ class MediaGallery extends React.PureComponent {
 | 
			
		|||
      style.height = height;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (!visible) {
 | 
			
		||||
      let warning;
 | 
			
		||||
    const size = media.take(4).size;
 | 
			
		||||
 | 
			
		||||
      if (sensitive) {
 | 
			
		||||
        warning = <FormattedMessage id='status.sensitive_warning' defaultMessage='Sensitive content' />;
 | 
			
		||||
      } else {
 | 
			
		||||
        warning = <FormattedMessage id='status.media_hidden' defaultMessage='Media hidden' />;
 | 
			
		||||
      }
 | 
			
		||||
    if (this.isStandaloneEligible()) {
 | 
			
		||||
      children = <Item standalone onClick={this.handleClick} attachment={media.get(0)} displayWidth={width} visible={visible} />;
 | 
			
		||||
    } else {
 | 
			
		||||
      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 = (
 | 
			
		||||
        <button type='button' className='media-spoiler' onClick={this.handleOpen} style={style} ref={this.handleRef}>
 | 
			
		||||
          <span className='media-spoiler__warning'>{warning}</span>
 | 
			
		||||
          <span className='media-spoiler__trigger'><FormattedMessage id='status.sensitive_toggle' defaultMessage='Click to view' /></span>
 | 
			
		||||
    if (visible) {
 | 
			
		||||
      spoilerButton = <IconButton title={intl.formatMessage(messages.toggle_visible)} icon={visible ? 'eye' : 'eye-slash'} overlay onClick={this.handleOpen} />;
 | 
			
		||||
    } else {
 | 
			
		||||
      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>
 | 
			
		||||
      );
 | 
			
		||||
    } 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 (
 | 
			
		||||
      <div className='media-gallery' style={style} ref={this.handleRef}>
 | 
			
		||||
        <div className={classNames('spoiler-button', { 'spoiler-button--visible': visible })}>
 | 
			
		||||
          <IconButton title={intl.formatMessage(messages.toggle_visible)} icon={visible ? 'eye' : 'eye-slash'} overlay onClick={this.handleOpen} />
 | 
			
		||||
        <div className={classNames('spoiler-button', { 'spoiler-button--minified': visible })}>
 | 
			
		||||
          {spoilerButton}
 | 
			
		||||
        </div>
 | 
			
		||||
 | 
			
		||||
        {children}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -274,7 +274,7 @@ class Status extends ImmutablePureComponent {
 | 
			
		|||
    if (status.get('poll')) {
 | 
			
		||||
      media = <PollContainer pollId={status.get('poll')} />;
 | 
			
		||||
    } 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 = (
 | 
			
		||||
          <AttachmentList
 | 
			
		||||
            compact
 | 
			
		||||
| 
						 | 
				
			
			@ -289,6 +289,7 @@ class Status extends ImmutablePureComponent {
 | 
			
		|||
            {Component => (
 | 
			
		||||
              <Component
 | 
			
		||||
                preview={video.get('preview_url')}
 | 
			
		||||
                blurhash={video.get('blurhash')}
 | 
			
		||||
                src={video.get('url')}
 | 
			
		||||
                alt={video.get('description')}
 | 
			
		||||
                width={this.props.cachedMediaWidth}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -35,6 +35,7 @@ export default class StatusCheckBox extends React.PureComponent {
 | 
			
		|||
            {Component => (
 | 
			
		||||
              <Component
 | 
			
		||||
                preview={video.get('preview_url')}
 | 
			
		||||
                blurhash={video.get('blurhash')}
 | 
			
		||||
                src={video.get('url')}
 | 
			
		||||
                alt={video.get('description')}
 | 
			
		||||
                width={239}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -5,7 +5,6 @@ import Avatar from '../../../components/avatar';
 | 
			
		|||
import DisplayName from '../../../components/display_name';
 | 
			
		||||
import StatusContent from '../../../components/status_content';
 | 
			
		||||
import MediaGallery from '../../../components/media_gallery';
 | 
			
		||||
import AttachmentList from '../../../components/attachment_list';
 | 
			
		||||
import { Link } from 'react-router-dom';
 | 
			
		||||
import { FormattedDate, FormattedNumber } from 'react-intl';
 | 
			
		||||
import Card from './card';
 | 
			
		||||
| 
						 | 
				
			
			@ -109,14 +108,13 @@ export default class DetailedStatus extends ImmutablePureComponent {
 | 
			
		|||
    if (status.get('poll')) {
 | 
			
		||||
      media = <PollContainer pollId={status.get('poll')} />;
 | 
			
		||||
    } else if (status.get('media_attachments').size > 0) {
 | 
			
		||||
      if (status.get('media_attachments').some(item => item.get('type') === 'unknown')) {
 | 
			
		||||
        media = <AttachmentList media={status.get('media_attachments')} />;
 | 
			
		||||
      } else if (status.getIn(['media_attachments', 0, 'type']) === 'video') {
 | 
			
		||||
      if (status.getIn(['media_attachments', 0, 'type']) === 'video') {
 | 
			
		||||
        const video = status.getIn(['media_attachments', 0]);
 | 
			
		||||
 | 
			
		||||
        media = (
 | 
			
		||||
          <Video
 | 
			
		||||
            preview={video.get('preview_url')}
 | 
			
		||||
            blurhash={video.get('blurhash')}
 | 
			
		||||
            src={video.get('url')}
 | 
			
		||||
            alt={video.get('description')}
 | 
			
		||||
            width={300}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -144,6 +144,7 @@ class MediaModal extends ImmutablePureComponent {
 | 
			
		|||
        return (
 | 
			
		||||
          <Video
 | 
			
		||||
            preview={image.get('preview_url')}
 | 
			
		||||
            blurhash={image.get('blurhash')}
 | 
			
		||||
            src={image.get('url')}
 | 
			
		||||
            width={image.get('width')}
 | 
			
		||||
            height={image.get('height')}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -20,6 +20,7 @@ export default class VideoModal extends ImmutablePureComponent {
 | 
			
		|||
        <div>
 | 
			
		||||
          <Video
 | 
			
		||||
            preview={media.get('preview_url')}
 | 
			
		||||
            blurhash={media.get('blurhash')}
 | 
			
		||||
            src={media.get('url')}
 | 
			
		||||
            startTime={time}
 | 
			
		||||
            onCloseVideo={onClose}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -7,6 +7,7 @@ import classNames from 'classnames';
 | 
			
		|||
import { isFullscreen, requestFullscreen, exitFullscreen } from '../ui/util/fullscreen';
 | 
			
		||||
import { displayMedia } from '../../initial_state';
 | 
			
		||||
import Icon from 'mastodon/components/icon';
 | 
			
		||||
import { decode } from 'blurhash';
 | 
			
		||||
 | 
			
		||||
const messages = defineMessages({
 | 
			
		||||
  play: { id: 'video.play', defaultMessage: 'Play' },
 | 
			
		||||
| 
						 | 
				
			
			@ -102,6 +103,7 @@ class Video extends React.PureComponent {
 | 
			
		|||
    inline: PropTypes.bool,
 | 
			
		||||
    cacheWidth: PropTypes.func,
 | 
			
		||||
    intl: PropTypes.object.isRequired,
 | 
			
		||||
    blurhash: PropTypes.string,
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  state = {
 | 
			
		||||
| 
						 | 
				
			
			@ -139,6 +141,7 @@ class Video extends React.PureComponent {
 | 
			
		|||
 | 
			
		||||
  setVideoRef = c => {
 | 
			
		||||
    this.video = c;
 | 
			
		||||
 | 
			
		||||
    if (this.video) {
 | 
			
		||||
      this.setState({ volume: this.video.volume, muted: this.video.muted });
 | 
			
		||||
    }
 | 
			
		||||
| 
						 | 
				
			
			@ -152,6 +155,10 @@ class Video extends React.PureComponent {
 | 
			
		|||
    this.volume = c;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  setCanvasRef = c => {
 | 
			
		||||
    this.canvas = c;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  handleClickRoot = e => e.stopPropagation();
 | 
			
		||||
 | 
			
		||||
  handlePlay = () => {
 | 
			
		||||
| 
						 | 
				
			
			@ -170,7 +177,6 @@ class Video extends React.PureComponent {
 | 
			
		|||
  }
 | 
			
		||||
 | 
			
		||||
  handleVolumeMouseDown = e => {
 | 
			
		||||
 | 
			
		||||
    document.addEventListener('mousemove', this.handleMouseVolSlide, true);
 | 
			
		||||
    document.addEventListener('mouseup', this.handleVolumeMouseUp, true);
 | 
			
		||||
    document.addEventListener('touchmove', this.handleMouseVolSlide, true);
 | 
			
		||||
| 
						 | 
				
			
			@ -190,7 +196,6 @@ class Video extends React.PureComponent {
 | 
			
		|||
  }
 | 
			
		||||
 | 
			
		||||
  handleMouseVolSlide = throttle(e => {
 | 
			
		||||
 | 
			
		||||
    const rect = this.volume.getBoundingClientRect();
 | 
			
		||||
    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('mozfullscreenchange', this.handleFullscreenChange, true);
 | 
			
		||||
    document.addEventListener('MSFullscreenChange', this.handleFullscreenChange, true);
 | 
			
		||||
 | 
			
		||||
    if (this.props.blurhash) {
 | 
			
		||||
      this._decode();
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  componentWillUnmount () {
 | 
			
		||||
| 
						 | 
				
			
			@ -270,6 +279,24 @@ class Video extends React.PureComponent {
 | 
			
		|||
    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 = () => {
 | 
			
		||||
    this.setState({ fullscreen: isFullscreen() });
 | 
			
		||||
  }
 | 
			
		||||
| 
						 | 
				
			
			@ -314,6 +341,7 @@ class Video extends React.PureComponent {
 | 
			
		|||
 | 
			
		||||
  handleOpenVideo = () => {
 | 
			
		||||
    const { src, preview, width, height, alt } = this.props;
 | 
			
		||||
 | 
			
		||||
    const media = fromJS({
 | 
			
		||||
      type: 'video',
 | 
			
		||||
      url: src,
 | 
			
		||||
| 
						 | 
				
			
			@ -351,6 +379,7 @@ class Video extends React.PureComponent {
 | 
			
		|||
    }
 | 
			
		||||
 | 
			
		||||
    let preload;
 | 
			
		||||
 | 
			
		||||
    if (startTime || fullscreen || dragging) {
 | 
			
		||||
      preload = 'auto';
 | 
			
		||||
    } else if (detailed) {
 | 
			
		||||
| 
						 | 
				
			
			@ -360,6 +389,7 @@ class Video extends React.PureComponent {
 | 
			
		|||
    }
 | 
			
		||||
 | 
			
		||||
    let warning;
 | 
			
		||||
 | 
			
		||||
    if (sensitive) {
 | 
			
		||||
      warning = <FormattedMessage id='status.sensitive_warning' defaultMessage='Sensitive content' />;
 | 
			
		||||
    } else {
 | 
			
		||||
| 
						 | 
				
			
			@ -377,7 +407,9 @@ class Video extends React.PureComponent {
 | 
			
		|||
        onClick={this.handleClickRoot}
 | 
			
		||||
        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}
 | 
			
		||||
          src={src}
 | 
			
		||||
          poster={preview}
 | 
			
		||||
| 
						 | 
				
			
			@ -397,12 +429,13 @@ class Video extends React.PureComponent {
 | 
			
		|||
          onLoadedData={this.handleLoadedData}
 | 
			
		||||
          onProgress={this.handleProgress}
 | 
			
		||||
          onVolumeChange={this.handleVolumeChange}
 | 
			
		||||
        />
 | 
			
		||||
        />}
 | 
			
		||||
 | 
			
		||||
        <button type='button' className={classNames('video-player__spoiler', { active: !revealed })} onClick={this.toggleReveal}>
 | 
			
		||||
          <span className='video-player__spoiler__title'>{warning}</span>
 | 
			
		||||
          <span className='video-player__spoiler__subtitle'><FormattedMessage id='status.sensitive_toggle' defaultMessage='Click to view' /></span>
 | 
			
		||||
        </button>
 | 
			
		||||
        <div className={classNames('spoiler-button', { 'spoiler-button--hidden': revealed })}>
 | 
			
		||||
          <button type='button' className='spoiler-button__overlay' onClick={this.toggleReveal}>
 | 
			
		||||
            <span className='spoiler-button__overlay__label'>{warning}</span>
 | 
			
		||||
          </button>
 | 
			
		||||
        </div>
 | 
			
		||||
 | 
			
		||||
        <div className={classNames('video-player__controls', { active: paused || hovered })}>
 | 
			
		||||
          <div className='video-player__seek' onMouseDown={this.handleMouseDown} ref={this.setSeekRef}>
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -2412,7 +2412,7 @@ a.account__display-name {
 | 
			
		|||
 | 
			
		||||
    & > div {
 | 
			
		||||
      background: rgba($base-shadow-color, 0.6);
 | 
			
		||||
      border-radius: 4px;
 | 
			
		||||
      border-radius: 8px;
 | 
			
		||||
      padding: 12px 9px;
 | 
			
		||||
      flex: 0 0 auto;
 | 
			
		||||
      display: flex;
 | 
			
		||||
| 
						 | 
				
			
			@ -2423,19 +2423,18 @@ a.account__display-name {
 | 
			
		|||
    button,
 | 
			
		||||
    a {
 | 
			
		||||
      display: inline;
 | 
			
		||||
      color: $primary-text-color;
 | 
			
		||||
      color: $secondary-text-color;
 | 
			
		||||
      background: transparent;
 | 
			
		||||
      border: 0;
 | 
			
		||||
      padding: 0 5px;
 | 
			
		||||
      padding: 0 8px;
 | 
			
		||||
      text-decoration: none;
 | 
			
		||||
      opacity: 0.6;
 | 
			
		||||
      font-size: 18px;
 | 
			
		||||
      line-height: 18px;
 | 
			
		||||
 | 
			
		||||
      &:hover,
 | 
			
		||||
      &:active,
 | 
			
		||||
      &:focus {
 | 
			
		||||
        opacity: 1;
 | 
			
		||||
        color: $primary-text-color;
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -2932,15 +2931,49 @@ a.status-card.compact:hover {
 | 
			
		|||
}
 | 
			
		||||
 | 
			
		||||
.spoiler-button {
 | 
			
		||||
  display: none;
 | 
			
		||||
  left: 4px;
 | 
			
		||||
  top: 0;
 | 
			
		||||
  left: 0;
 | 
			
		||||
  width: 100%;
 | 
			
		||||
  height: 100%;
 | 
			
		||||
  position: absolute;
 | 
			
		||||
  text-shadow: 0 1px 1px $base-shadow-color, 1px 0 1px $base-shadow-color;
 | 
			
		||||
  top: 4px;
 | 
			
		||||
  z-index: 100;
 | 
			
		||||
 | 
			
		||||
  &.spoiler-button--visible {
 | 
			
		||||
  &--minified {
 | 
			
		||||
    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;
 | 
			
		||||
  color: $secondary-text-color;
 | 
			
		||||
  line-height: 0;
 | 
			
		||||
  position: relative;
 | 
			
		||||
  z-index: 1;
 | 
			
		||||
 | 
			
		||||
  &,
 | 
			
		||||
  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 {
 | 
			
		||||
  height: 100%;
 | 
			
		||||
  overflow: hidden;
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -194,7 +194,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
 | 
			
		|||
      next if attachment['url'].blank?
 | 
			
		||||
 | 
			
		||||
      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
 | 
			
		||||
 | 
			
		||||
      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)
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def supported_blurhash?(blurhash)
 | 
			
		||||
    components = blurhash.blank? ? nil : Blurhash.components(blurhash)
 | 
			
		||||
    components.present? && components.none? { |comp| comp > 5 }
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def skip_download?
 | 
			
		||||
    return @skip_download if defined?(@skip_download)
 | 
			
		||||
    @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' },
 | 
			
		||||
    focal_point: { 'toot' => 'http://joinmastodon.org/ns#', 'focalPoint' => { '@container' => '@list', '@id' => 'toot:focalPoint' } },
 | 
			
		||||
    identity_proof: { 'toot' => 'http://joinmastodon.org/ns#', 'IdentityProof' => 'toot:IdentityProof' },
 | 
			
		||||
    blurhash: { 'toot' => 'http://joinmastodon.org/ns#', 'blurhash' => 'toot:blurhash' },
 | 
			
		||||
  }.freeze
 | 
			
		||||
 | 
			
		||||
  def self.default_key_transform
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -18,6 +18,7 @@
 | 
			
		|||
#  account_id          :bigint(8)
 | 
			
		||||
#  description         :text
 | 
			
		||||
#  scheduled_status_id :bigint(8)
 | 
			
		||||
#  blurhash            :string
 | 
			
		||||
#
 | 
			
		||||
 | 
			
		||||
class MediaAttachment < ApplicationRecord
 | 
			
		||||
| 
						 | 
				
			
			@ -32,6 +33,11 @@ class MediaAttachment < ApplicationRecord
 | 
			
		|||
  VIDEO_MIME_TYPES             = ['video/webm', 'video/mp4', 'video/quicktime'].freeze
 | 
			
		||||
  VIDEO_CONVERTIBLE_MIME_TYPES = ['video/webm', 'video/quicktime'].freeze
 | 
			
		||||
 | 
			
		||||
  BLURHASH_OPTIONS = {
 | 
			
		||||
    x_comp: 4,
 | 
			
		||||
    y_comp: 4,
 | 
			
		||||
  }.freeze
 | 
			
		||||
 | 
			
		||||
  IMAGE_STYLES = {
 | 
			
		||||
    original: {
 | 
			
		||||
      pixels: 1_638_400, # 1280x1280px
 | 
			
		||||
| 
						 | 
				
			
			@ -41,6 +47,7 @@ class MediaAttachment < ApplicationRecord
 | 
			
		|||
    small: {
 | 
			
		||||
      pixels: 160_000, # 400x400px
 | 
			
		||||
      file_geometry_parser: FastGeometryParser,
 | 
			
		||||
      blurhash: BLURHASH_OPTIONS,
 | 
			
		||||
    },
 | 
			
		||||
  }.freeze
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -53,6 +60,8 @@ class MediaAttachment < ApplicationRecord
 | 
			
		|||
      },
 | 
			
		||||
      format: 'png',
 | 
			
		||||
      time: 0,
 | 
			
		||||
      file_geometry_parser: FastGeometryParser,
 | 
			
		||||
      blurhash: BLURHASH_OPTIONS,
 | 
			
		||||
    },
 | 
			
		||||
  }.freeze
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -166,11 +175,11 @@ class MediaAttachment < ApplicationRecord
 | 
			
		|||
 | 
			
		||||
    def file_processors(f)
 | 
			
		||||
      if f.file_content_type == 'image/gif'
 | 
			
		||||
        [:gif_transcoder]
 | 
			
		||||
        [:gif_transcoder, :blurhash_transcoder]
 | 
			
		||||
      elsif VIDEO_MIME_TYPES.include? f.file_content_type
 | 
			
		||||
        [:video_transcoder]
 | 
			
		||||
        [:video_transcoder, :blurhash_transcoder]
 | 
			
		||||
      else
 | 
			
		||||
        [:lazy_thumbnail]
 | 
			
		||||
        [:lazy_thumbnail, :blurhash_transcoder]
 | 
			
		||||
      end
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -2,7 +2,7 @@
 | 
			
		|||
 | 
			
		||||
class ActivityPub::NoteSerializer < ActivityPub::Serializer
 | 
			
		||||
  context_extensions :atom_uri, :conversation, :sensitive,
 | 
			
		||||
                     :hashtag, :emoji, :focal_point
 | 
			
		||||
                     :hashtag, :emoji, :focal_point, :blurhash
 | 
			
		||||
 | 
			
		||||
  attributes :id, :type, :summary,
 | 
			
		||||
             :in_reply_to, :published, :url,
 | 
			
		||||
| 
						 | 
				
			
			@ -153,7 +153,7 @@ class ActivityPub::NoteSerializer < ActivityPub::Serializer
 | 
			
		|||
  class MediaAttachmentSerializer < ActivityPub::Serializer
 | 
			
		||||
    include RoutingHelper
 | 
			
		||||
 | 
			
		||||
    attributes :type, :media_type, :url, :name
 | 
			
		||||
    attributes :type, :media_type, :url, :name, :blurhash
 | 
			
		||||
    attribute :focal_point, if: :focal_point?
 | 
			
		||||
 | 
			
		||||
    def type
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -5,7 +5,7 @@ class REST::MediaAttachmentSerializer < ActiveModel::Serializer
 | 
			
		|||
 | 
			
		||||
  attributes :id, :type, :url, :preview_url,
 | 
			
		||||
             :remote_url, :text_url, :meta,
 | 
			
		||||
             :description
 | 
			
		||||
             :description, :blurhash
 | 
			
		||||
 | 
			
		||||
  def id
 | 
			
		||||
    object.id.to_s
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -28,7 +28,7 @@
 | 
			
		|||
  - elsif !status.media_attachments.empty?
 | 
			
		||||
    - if status.media_attachments.first.video?
 | 
			
		||||
      - 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 }
 | 
			
		||||
    - 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
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -32,7 +32,7 @@
 | 
			
		|||
  - elsif !status.media_attachments.empty?
 | 
			
		||||
    - if status.media_attachments.first.video?
 | 
			
		||||
      - 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 }
 | 
			
		||||
    - 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
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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.
 | 
			
		||||
 | 
			
		||||
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
 | 
			
		||||
  enable_extension "plpgsql"
 | 
			
		||||
| 
						 | 
				
			
			@ -362,6 +362,7 @@ ActiveRecord::Schema.define(version: 2019_04_09_054914) do
 | 
			
		|||
    t.bigint "account_id"
 | 
			
		||||
    t.text "description"
 | 
			
		||||
    t.bigint "scheduled_status_id"
 | 
			
		||||
    t.string "blurhash"
 | 
			
		||||
    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 ["shortcode"], name: "index_media_attachments_on_shortcode", unique: true
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										16
									
								
								lib/paperclip/blurhash_transcoder.rb
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										16
									
								
								lib/paperclip/blurhash_transcoder.rb
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -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-transform-react-remove-prop-types": "^0.4.24",
 | 
			
		||||
    "babel-runtime": "^6.26.0",
 | 
			
		||||
    "blurhash": "^1.0.0",
 | 
			
		||||
    "classnames": "^2.2.5",
 | 
			
		||||
    "compression-webpack-plugin": "^2.0.0",
 | 
			
		||||
    "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"
 | 
			
		||||
  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:
 | 
			
		||||
  version "4.11.8"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/bn.js/-/bn.js-4.11.8.tgz#2cde09eb5ee341f484746bb0309b3253b1b1442f"
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
		Reference in a new issue