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 { useBlurhash } from 'mastodon/initial_state'; import { decode } from 'blurhash'; import { debounce } from 'lodash'; const IDNA_PREFIX = 'xn--'; const decodeIDNA = domain => { return domain .split('.') .map(part => part.indexOf(IDNA_PREFIX) === 0 ? punycode.decode(part.slice(IDNA_PREFIX.length)) : part) .join('.'); }; const getHostname = url => { const parser = document.createElement('a'); parser.href = url; return parser.hostname; }; const trim = (text, len) => { const cut = text.indexOf(' ', len); if (cut === -1) { return text; } return text.substring(0, cut) + (text.length > len ? '…' : ''); }; const domParser = new DOMParser(); const addAutoPlay = html => { const document = domParser.parseFromString(html, 'text/html').documentElement; const iframe = document.querySelector('iframe'); if (iframe) { if (iframe.src.indexOf('?') !== -1) { iframe.src += '&'; } else { iframe.src += '?'; } iframe.src += 'autoplay=1&auto_play=1'; // DOM parser creates html/body elements around original HTML fragment, // so we need to get innerHTML out of the body and not the entire document return document.querySelector('body').innerHTML; } return html; }; export default class Card extends React.PureComponent { static propTypes = { card: ImmutablePropTypes.map, maxDescription: PropTypes.number, onOpenMedia: PropTypes.func.isRequired, compact: PropTypes.bool, defaultWidth: PropTypes.number, cacheWidth: PropTypes.func, sensitive: PropTypes.bool, }; static defaultProps = { maxDescription: 50, compact: false, }; 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, previewLoaded: false }); } if (this.props.sensitive !== nextProps.sensitive) { this.setState({ revealed: !nextProps.sensitive }); } } componentDidMount () { window.addEventListener('resize', this.handleResize, { passive: true }); if (this.props.card && this.props.card.get('blurhash')) { this._decode(); } } componentWillUnmount () { window.removeEventListener('resize', this.handleResize); } componentDidUpdate (prevProps) { const { card } = this.props; if (card.get('blurhash') && (!prevProps.card || prevProps.card.get('blurhash') !== card.get('blurhash')) && this.canvas) { 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); } } _setDimensions () { const width = this.node.offsetWidth; if (this.props.cacheWidth) { this.props.cacheWidth(width); } this.setState({ width }); } handleResize = debounce(() => { if (this.node) { this._setDimensions(); } }, 250, { trailing: true, }); handlePhotoClick = () => { const { card, onOpenMedia } = this.props; onOpenMedia( Immutable.fromJS([ { type: 'image', url: card.get('embed_url'), description: card.get('title'), meta: { original: { width: card.get('width'), height: card.get('height'), }, }, }, ]), 0, ); }; handleEmbedClick = () => { const { card } = this.props; if (card.get('type') === 'photo') { this.handlePhotoClick(); } else { this.setState({ embedded: true }); } } setRef = c => { this.node = c; if (this.node) { this._setDimensions(); } } setCanvasRef = c => { this.canvas = c; } handleImageLoad = () => { this.setState({ previewLoaded: true }); } handleReveal = e => { e.preventDefault(); e.stopPropagation(); this.setState({ revealed: true }); } renderVideo () { const { card } = this.props; const content = { __html: addAutoPlay(card.get('html')) }; const { width } = this.state; const ratio = card.get('width') / card.get('height'); const height = width / ratio; return (
); } render () { const { card, maxDescription, compact } = this.props; const { width, embedded, revealed } = this.state; if (card === null) { return null; } const provider = card.get('provider_name').length === 0 ? decodeIDNA(getHostname(card.get('url'))) : card.get('provider_name'); const horizontal = (!compact && card.get('width') > card.get('height') && (card.get('width') + 100 >= width)) || card.get('type') !== 'link' || embedded; const interactive = card.get('type') !== 'link'; const className = classnames('status-card', { horizontal, compact, interactive }); const title = interactive ? {card.get('title')} : {card.get('title')}; const ratio = card.get('width') / card.get('height'); const height = (compact && !embedded) ? (width / (16 / 9)) : (width / ratio); const description = (
{title} {!(horizontal || compact) &&

{trim(card.get('description') || '', maxDescription)}

} {provider}
); let embed = ''; let canvas = ; let thumbnail = ; let spoilerButton = ( ); spoilerButton = (
{spoilerButton}
); if (interactive) { if (embedded) { embed = this.renderVideo(); } else { let iconVariant = 'play'; if (card.get('type') === 'photo') { iconVariant = 'search-plus'; } embed = (
{canvas} {thumbnail} {revealed && (
{horizontal && }
)} {!revealed && spoilerButton}
); } return (
{embed} {!compact && description}
); } else if (card.get('image')) { embed = (
{canvas} {thumbnail}
); } else { embed = (
); } return ( {embed} {description} ); } }