Hide sensitive preview cards with blurhash (#13985)

* Use preview card blurhash in WebUI

* Handle sensitive preview cards
This commit is contained in:
ThibG 2020-06-06 17:41:56 +02:00 committed by GitHub
parent a3f22bd4ca
commit 8e96510b25
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 105 additions and 13 deletions

View File

@ -401,6 +401,7 @@ class Status extends ImmutablePureComponent {
compact compact
cacheWidth={this.props.cacheMediaWidth} cacheWidth={this.props.cacheMediaWidth}
defaultWidth={this.props.cachedMediaWidth} defaultWidth={this.props.cachedMediaWidth}
sensitive={status.get('sensitive')}
/> />
); );
} }

View File

@ -2,9 +2,13 @@ import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import Immutable from 'immutable'; import Immutable from 'immutable';
import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePropTypes from 'react-immutable-proptypes';
import { FormattedMessage } from 'react-intl';
import punycode from 'punycode'; import punycode from 'punycode';
import classnames from 'classnames'; import classnames from 'classnames';
import Icon from 'mastodon/components/icon'; import Icon from 'mastodon/components/icon';
import classNames from 'classnames';
import { useBlurhash } from 'mastodon/initial_state';
import { decode } from 'blurhash';
const IDNA_PREFIX = 'xn--'; const IDNA_PREFIX = 'xn--';
@ -63,6 +67,7 @@ export default class Card extends React.PureComponent {
compact: PropTypes.bool, compact: PropTypes.bool,
defaultWidth: PropTypes.number, defaultWidth: PropTypes.number,
cacheWidth: PropTypes.func, cacheWidth: PropTypes.func,
sensitive: PropTypes.bool,
}; };
static defaultProps = { static defaultProps = {
@ -72,12 +77,44 @@ export default class Card extends React.PureComponent {
state = { state = {
width: this.props.defaultWidth || 280, width: this.props.defaultWidth || 280,
previewLoaded: false,
embedded: false, embedded: false,
revealed: !this.props.sensitive,
}; };
componentWillReceiveProps (nextProps) { componentWillReceiveProps (nextProps) {
if (!Immutable.is(this.props.card, nextProps.card)) { 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 () { renderVideo () {
const { card } = this.props; const { card } = this.props;
const content = { __html: addAutoPlay(card.get('html')) }; const content = { __html: addAutoPlay(card.get('html')) };
@ -138,7 +187,7 @@ export default class Card extends React.PureComponent {
render () { render () {
const { card, maxDescription, compact } = this.props; const { card, maxDescription, compact } = this.props;
const { width, embedded } = this.state; const { width, embedded, revealed } = this.state;
if (card === null) { if (card === null) {
return null; return null;
@ -153,7 +202,7 @@ export default class Card extends React.PureComponent {
const height = (compact && !embedded) ? (width / (16 / 9)) : (width / ratio); const height = (compact && !embedded) ? (width / (16 / 9)) : (width / ratio);
const description = ( const description = (
<div className='status-card__content'> <div className={classNames('status-card__content', { 'status-card__content--blurred': !revealed })}>
{title} {title}
{!(horizontal || compact) && <p className='status-card__description'>{trim(card.get('description') || '', maxDescription)}</p>} {!(horizontal || compact) && <p className='status-card__description'>{trim(card.get('description') || '', maxDescription)}</p>}
<span className='status-card__host'>{provider}</span> <span className='status-card__host'>{provider}</span>
@ -161,7 +210,18 @@ export default class Card extends React.PureComponent {
); );
let embed = ''; 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 (interactive) {
if (embedded) { if (embedded) {
@ -175,14 +235,18 @@ export default class Card extends React.PureComponent {
embed = ( embed = (
<div className='status-card__image'> <div className='status-card__image'>
{canvas}
{thumbnail} {thumbnail}
{revealed && (
<div className='status-card__actions'> <div className='status-card__actions'>
<div> <div>
<button onClick={this.handleEmbedClick}><Icon id={iconVariant} /></button> <button onClick={this.handleEmbedClick}><Icon id={iconVariant} /></button>
{horizontal && <a href={card.get('url')} target='_blank' rel='noopener noreferrer'><Icon id='external-link' /></a>} {horizontal && <a href={card.get('url')} target='_blank' rel='noopener noreferrer'><Icon id='external-link' /></a>}
</div> </div>
</div> </div>
)}
{!revealed && spoilerButton}
</div> </div>
); );
} }
@ -196,13 +260,16 @@ export default class Card extends React.PureComponent {
} else if (card.get('image')) { } else if (card.get('image')) {
embed = ( embed = (
<div className='status-card__image'> <div className='status-card__image'>
{canvas}
{thumbnail} {thumbnail}
{!revealed && spoilerButton}
</div> </div>
); );
} else { } else {
embed = ( embed = (
<div className='status-card__image'> <div className='status-card__image'>
<Icon id='file-text' /> <Icon id='file-text' />
{!revealed && spoilerButton}
</div> </div>
); );
} }

View File

@ -153,7 +153,7 @@ export default class DetailedStatus extends ImmutablePureComponent {
); );
} }
} else if (status.get('spoiler_text').length === 0) { } 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')) { if (status.get('application')) {

View File

@ -3097,6 +3097,11 @@ a.status-card {
flex: 1 1 auto; flex: 1 1 auto;
overflow: hidden; overflow: hidden;
padding: 14px 14px 14px 8px; padding: 14px 14px 14px 8px;
&--blurred {
filter: blur(2px);
pointer-events: none;
}
} }
.status-card__description { .status-card__description {
@ -3134,7 +3139,8 @@ a.status-card {
width: 100%; width: 100%;
} }
.status-card__image-image { .status-card__image-image,
.status-card__image-preview {
border-radius: 4px 4px 0 0; border-radius: 4px 4px 0 0;
} }
@ -3179,6 +3185,24 @@ a.status-card.compact:hover {
background-position: center center; 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 { .load-more {
display: block; display: block;
color: $dark-text-color; color: $dark-text-color;

View File

@ -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 = 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 } = render partial: 'statuses/attachment_list', locals: { attachments: status.media_attachments }
- elsif status.preview_card - 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 .detailed-status__meta
%data.dt-published{ value: status.created_at.to_time.iso8601 } %data.dt-published{ value: status.created_at.to_time.iso8601 }

View File

@ -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 = 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 } = render partial: 'statuses/attachment_list', locals: { attachments: status.media_attachments }
- elsif status.preview_card - 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 - 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 = link_to ActivityPub::TagManager.instance.url_for(status), class: 'status__content__read-more-button', target: stream_link_target, rel: 'noopener noreferrer' do