* 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 issuesmaster^2
@@ -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 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'; | 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) { | |||||
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> | </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 })}> | |||||
<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> | </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')) { | |||||
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]); | 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}> | |||||
<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={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; | |||||
left: 4px; | |||||
top: 0; | |||||
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" | ||||