diff options
6 files changed, 105 insertions, 13 deletions
diff --git a/app/javascript/mastodon/components/status.js b/app/javascript/mastodon/components/status.js index 9e4442cef..f99ccd39a 100644 --- a/app/javascript/mastodon/components/status.js +++ b/app/javascript/mastodon/components/status.js @@ -401,6 +401,7 @@ class Status extends ImmutablePureComponent { compact cacheWidth={this.props.cacheMediaWidth} defaultWidth={this.props.cachedMediaWidth} + sensitive={status.get('sensitive')} /> ); } diff --git a/app/javascript/mastodon/features/status/components/card.js b/app/javascript/mastodon/features/status/components/card.js index b8344a667..630e99f2c 100644 --- a/app/javascript/mastodon/features/status/components/card.js +++ b/app/javascript/mastodon/features/status/components/card.js @@ -2,9 +2,13 @@ import React from 'react'; import PropTypes from 'prop-types'; import Immutable from 'immutable'; import ImmutablePropTypes from 'react-immutable-proptypes'; +import { FormattedMessage } from 'react-intl'; import punycode from 'punycode'; import classnames from 'classnames'; import Icon from 'mastodon/components/icon'; +import classNames from 'classnames'; +import { useBlurhash } from 'mastodon/initial_state'; +import { decode } from 'blurhash'; const IDNA_PREFIX = 'xn--'; @@ -63,6 +67,7 @@ export default class Card extends React.PureComponent { compact: PropTypes.bool, defaultWidth: PropTypes.number, cacheWidth: PropTypes.func, + sensitive: PropTypes.bool, }; static defaultProps = { @@ -72,12 +77,44 @@ export default class Card extends React.PureComponent { state = { width: this.props.defaultWidth || 280, + previewLoaded: false, embedded: false, + revealed: !this.props.sensitive, }; componentWillReceiveProps (nextProps) { if (!Immutable.is(this.props.card, nextProps.card)) { - this.setState({ embedded: false }); + this.setState({ embedded: false, previewLoaded: false }); + } + if (this.props.sensitive !== nextProps.sensitive) { + this.setState({ revealed: !nextProps.sensitive }); + } + } + + componentDidMount () { + if (this.props.card && this.props.card.get('blurhash')) { + this._decode(); + } + } + + componentDidUpdate (prevProps) { + const { card } = this.props; + if (card.get('blurhash') && (!prevProps.card || prevProps.card.get('blurhash') !== card.get('blurhash'))) { + this._decode(); + } + } + + _decode () { + if (!useBlurhash) return; + + const hash = this.props.card.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); } } @@ -119,6 +156,18 @@ export default class Card extends React.PureComponent { } } + setCanvasRef = c => { + this.canvas = c; + } + + handleImageLoad = () => { + this.setState({ previewLoaded: true }); + } + + handleReveal = () => { + this.setState({ revealed: true }); + } + renderVideo () { const { card } = this.props; const content = { __html: addAutoPlay(card.get('html')) }; @@ -138,7 +187,7 @@ export default class Card extends React.PureComponent { render () { const { card, maxDescription, compact } = this.props; - const { width, embedded } = this.state; + const { width, embedded, revealed } = this.state; if (card === null) { return null; @@ -153,7 +202,7 @@ export default class Card extends React.PureComponent { const height = (compact && !embedded) ? (width / (16 / 9)) : (width / ratio); const description = ( - <div className='status-card__content'> + <div className={classNames('status-card__content', { 'status-card__content--blurred': !revealed })}> {title} {!(horizontal || compact) && <p className='status-card__description'>{trim(card.get('description') || '', maxDescription)}</p>} <span className='status-card__host'>{provider}</span> @@ -161,7 +210,18 @@ export default class Card extends React.PureComponent { ); let embed = ''; - let thumbnail = <div style={{ backgroundImage: `url(${card.get('image')})`, width: horizontal ? width : null, height: horizontal ? height : null }} className='status-card__image-image' />; + let canvas = <canvas width={32} height={32} ref={this.setCanvasRef} className={classNames('status-card__image-preview', { 'status-card__image-preview--hidden' : revealed && this.state.previewLoaded })} />; + let thumbnail = <img src={card.get('image')} alt='' style={{ width: horizontal ? width : null, height: horizontal ? height : null, visibility: revealed ? null : 'hidden' }} onLoad={this.handleImageLoad} className='status-card__image-image' />; + let spoilerButton = ( + <button type='button' onClick={this.handleReveal} className='spoiler-button__overlay'> + <span className='spoiler-button__overlay__label'><FormattedMessage id='status.sensitive_warning' defaultMessage='Sensitive content' /></span> + </button> + ); + spoilerButton = ( + <div className={classNames('spoiler-button', { 'spoiler-button--minified': revealed })}> + {spoilerButton} + </div> + ); if (interactive) { if (embedded) { @@ -175,14 +235,18 @@ export default class Card extends React.PureComponent { embed = ( <div className='status-card__image'> + {canvas} {thumbnail} - <div className='status-card__actions'> - <div> - <button onClick={this.handleEmbedClick}><Icon id={iconVariant} /></button> - {horizontal && <a href={card.get('url')} target='_blank' rel='noopener noreferrer'><Icon id='external-link' /></a>} + {revealed && ( + <div className='status-card__actions'> + <div> + <button onClick={this.handleEmbedClick}><Icon id={iconVariant} /></button> + {horizontal && <a href={card.get('url')} target='_blank' rel='noopener noreferrer'><Icon id='external-link' /></a>} + </div> </div> - </div> + )} + {!revealed && spoilerButton} </div> ); } @@ -196,13 +260,16 @@ export default class Card extends React.PureComponent { } else if (card.get('image')) { embed = ( <div className='status-card__image'> + {canvas} {thumbnail} + {!revealed && spoilerButton} </div> ); } else { embed = ( <div className='status-card__image'> <Icon id='file-text' /> + {!revealed && spoilerButton} </div> ); } diff --git a/app/javascript/mastodon/features/status/components/detailed_status.js b/app/javascript/mastodon/features/status/components/detailed_status.js index 4201b237e..2ac47677e 100644 --- a/app/javascript/mastodon/features/status/components/detailed_status.js +++ b/app/javascript/mastodon/features/status/components/detailed_status.js @@ -153,7 +153,7 @@ export default class DetailedStatus extends ImmutablePureComponent { ); } } else if (status.get('spoiler_text').length === 0) { - media = <Card onOpenMedia={this.props.onOpenMedia} card={status.get('card', null)} />; + media = <Card sensitive={status.get('sensitive')} onOpenMedia={this.props.onOpenMedia} card={status.get('card', null)} />; } if (status.get('application')) { diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss index 2bd8ee456..5d725b908 100644 --- a/app/javascript/styles/mastodon/components.scss +++ b/app/javascript/styles/mastodon/components.scss @@ -3097,6 +3097,11 @@ a.status-card { flex: 1 1 auto; overflow: hidden; padding: 14px 14px 14px 8px; + + &--blurred { + filter: blur(2px); + pointer-events: none; + } } .status-card__description { @@ -3134,7 +3139,8 @@ a.status-card { width: 100%; } - .status-card__image-image { + .status-card__image-image, + .status-card__image-preview { border-radius: 4px 4px 0 0; } @@ -3179,6 +3185,24 @@ a.status-card.compact:hover { background-position: center center; } +.status-card__image-preview { + border-radius: 4px 0 0 4px; + display: block; + margin: 0; + width: 100%; + height: 100%; + object-fit: fill; + position: absolute; + top: 0; + left: 0; + z-index: 0; + background: $base-overlay-background; + + &--hidden { + display: none; + } +} + .load-more { display: block; color: $dark-text-color; diff --git a/app/views/statuses/_detailed_status.html.haml b/app/views/statuses/_detailed_status.html.haml index 33b81c748..8e409846a 100644 --- a/app/views/statuses/_detailed_status.html.haml +++ b/app/views/statuses/_detailed_status.html.haml @@ -39,7 +39,7 @@ = react_component :media_gallery, height: 380, sensitive: status.sensitive?, standalone: true, autoplay: autoplay, media: status.media_attachments.map { |a| ActiveModelSerializers::SerializableResource.new(a, serializer: REST::MediaAttachmentSerializer).as_json } do = render partial: 'statuses/attachment_list', locals: { attachments: status.media_attachments } - elsif status.preview_card - = react_component :card, 'maxDescription': 160, card: ActiveModelSerializers::SerializableResource.new(status.preview_card, serializer: REST::PreviewCardSerializer).as_json + = react_component :card, sensitive: status.sensitive?, 'maxDescription': 160, card: ActiveModelSerializers::SerializableResource.new(status.preview_card, serializer: REST::PreviewCardSerializer).as_json .detailed-status__meta %data.dt-published{ value: status.created_at.to_time.iso8601 } diff --git a/app/views/statuses/_simple_status.html.haml b/app/views/statuses/_simple_status.html.haml index d7853eca9..da7caf166 100644 --- a/app/views/statuses/_simple_status.html.haml +++ b/app/views/statuses/_simple_status.html.haml @@ -43,7 +43,7 @@ = react_component :media_gallery, height: 343, sensitive: status.sensitive?, autoplay: autoplay, media: status.media_attachments.map { |a| ActiveModelSerializers::SerializableResource.new(a, serializer: REST::MediaAttachmentSerializer).as_json } do = render partial: 'statuses/attachment_list', locals: { attachments: status.media_attachments } - elsif status.preview_card - = react_component :card, 'maxDescription': 160, card: ActiveModelSerializers::SerializableResource.new(status.preview_card, serializer: REST::PreviewCardSerializer).as_json + = react_component :card, sensitive: status.sensitive?, 'maxDescription': 160, card: ActiveModelSerializers::SerializableResource.new(status.preview_card, serializer: REST::PreviewCardSerializer).as_json - if !status.in_reply_to_id.nil? && status.in_reply_to_account_id == status.account.id = link_to ActivityPub::TagManager.instance.url_for(status), class: 'status__content__read-more-button', target: stream_link_target, rel: 'noopener noreferrer' do |