You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

311 lines
8.6 KiB

  1. import React from 'react';
  2. import PropTypes from 'prop-types';
  3. import Immutable from 'immutable';
  4. import ImmutablePropTypes from 'react-immutable-proptypes';
  5. import { FormattedMessage } from 'react-intl';
  6. import punycode from 'punycode';
  7. import classnames from 'classnames';
  8. import Icon from 'mastodon/components/icon';
  9. import { useBlurhash } from 'mastodon/initial_state';
  10. import { decode } from 'blurhash';
  11. import { debounce } from 'lodash';
  12. const IDNA_PREFIX = 'xn--';
  13. const decodeIDNA = domain => {
  14. return domain
  15. .split('.')
  16. .map(part => part.indexOf(IDNA_PREFIX) === 0 ? punycode.decode(part.slice(IDNA_PREFIX.length)) : part)
  17. .join('.');
  18. };
  19. const getHostname = url => {
  20. const parser = document.createElement('a');
  21. parser.href = url;
  22. return parser.hostname;
  23. };
  24. const trim = (text, len) => {
  25. const cut = text.indexOf(' ', len);
  26. if (cut === -1) {
  27. return text;
  28. }
  29. return text.substring(0, cut) + (text.length > len ? '…' : '');
  30. };
  31. const domParser = new DOMParser();
  32. const addAutoPlay = html => {
  33. const document = domParser.parseFromString(html, 'text/html').documentElement;
  34. const iframe = document.querySelector('iframe');
  35. if (iframe) {
  36. if (iframe.src.indexOf('?') !== -1) {
  37. iframe.src += '&';
  38. } else {
  39. iframe.src += '?';
  40. }
  41. iframe.src += 'autoplay=1&auto_play=1';
  42. // DOM parser creates html/body elements around original HTML fragment,
  43. // so we need to get innerHTML out of the body and not the entire document
  44. return document.querySelector('body').innerHTML;
  45. }
  46. return html;
  47. };
  48. export default class Card extends React.PureComponent {
  49. static propTypes = {
  50. card: ImmutablePropTypes.map,
  51. maxDescription: PropTypes.number,
  52. onOpenMedia: PropTypes.func.isRequired,
  53. compact: PropTypes.bool,
  54. defaultWidth: PropTypes.number,
  55. cacheWidth: PropTypes.func,
  56. sensitive: PropTypes.bool,
  57. };
  58. static defaultProps = {
  59. maxDescription: 50,
  60. compact: false,
  61. };
  62. state = {
  63. width: this.props.defaultWidth || 280,
  64. previewLoaded: false,
  65. embedded: false,
  66. revealed: !this.props.sensitive,
  67. };
  68. componentWillReceiveProps (nextProps) {
  69. if (!Immutable.is(this.props.card, nextProps.card)) {
  70. this.setState({ embedded: false, previewLoaded: false });
  71. }
  72. if (this.props.sensitive !== nextProps.sensitive) {
  73. this.setState({ revealed: !nextProps.sensitive });
  74. }
  75. }
  76. componentDidMount () {
  77. window.addEventListener('resize', this.handleResize, { passive: true });
  78. if (this.props.card && this.props.card.get('blurhash')) {
  79. this._decode();
  80. }
  81. }
  82. componentWillUnmount () {
  83. window.removeEventListener('resize', this.handleResize);
  84. }
  85. componentDidUpdate (prevProps) {
  86. const { card } = this.props;
  87. if (card.get('blurhash') && (!prevProps.card || prevProps.card.get('blurhash') !== card.get('blurhash')) && this.canvas) {
  88. this._decode();
  89. }
  90. }
  91. _decode () {
  92. if (!useBlurhash) return;
  93. const hash = this.props.card.get('blurhash');
  94. const pixels = decode(hash, 32, 32);
  95. if (pixels) {
  96. const ctx = this.canvas.getContext('2d');
  97. const imageData = new ImageData(pixels, 32, 32);
  98. ctx.putImageData(imageData, 0, 0);
  99. }
  100. }
  101. _setDimensions () {
  102. const width = this.node.offsetWidth;
  103. if (this.props.cacheWidth) {
  104. this.props.cacheWidth(width);
  105. }
  106. this.setState({ width });
  107. }
  108. handleResize = debounce(() => {
  109. if (this.node) {
  110. this._setDimensions();
  111. }
  112. }, 250, {
  113. trailing: true,
  114. });
  115. handlePhotoClick = () => {
  116. const { card, onOpenMedia } = this.props;
  117. onOpenMedia(
  118. Immutable.fromJS([
  119. {
  120. type: 'image',
  121. url: card.get('embed_url'),
  122. description: card.get('title'),
  123. meta: {
  124. original: {
  125. width: card.get('width'),
  126. height: card.get('height'),
  127. },
  128. },
  129. },
  130. ]),
  131. 0,
  132. );
  133. };
  134. handleEmbedClick = () => {
  135. const { card } = this.props;
  136. if (card.get('type') === 'photo') {
  137. this.handlePhotoClick();
  138. } else {
  139. this.setState({ embedded: true });
  140. }
  141. }
  142. setRef = c => {
  143. this.node = c;
  144. if (this.node) {
  145. this._setDimensions();
  146. }
  147. }
  148. setCanvasRef = c => {
  149. this.canvas = c;
  150. }
  151. handleImageLoad = () => {
  152. this.setState({ previewLoaded: true });
  153. }
  154. handleReveal = e => {
  155. e.preventDefault();
  156. e.stopPropagation();
  157. this.setState({ revealed: true });
  158. }
  159. renderVideo () {
  160. const { card } = this.props;
  161. const content = { __html: addAutoPlay(card.get('html')) };
  162. const { width } = this.state;
  163. const ratio = card.get('width') / card.get('height');
  164. const height = width / ratio;
  165. return (
  166. <div
  167. ref={this.setRef}
  168. className='status-card__image status-card-video'
  169. dangerouslySetInnerHTML={content}
  170. style={{ height }}
  171. />
  172. );
  173. }
  174. render () {
  175. const { card, maxDescription, compact } = this.props;
  176. const { width, embedded, revealed } = this.state;
  177. if (card === null) {
  178. return null;
  179. }
  180. const provider = card.get('provider_name').length === 0 ? decodeIDNA(getHostname(card.get('url'))) : card.get('provider_name');
  181. const horizontal = (!compact && card.get('width') > card.get('height') && (card.get('width') + 100 >= width)) || card.get('type') !== 'link' || embedded;
  182. const interactive = card.get('type') !== 'link';
  183. const className = classnames('status-card', { horizontal, compact, interactive });
  184. const title = interactive ? <a className='status-card__title' href={card.get('url')} title={card.get('title')} rel='noopener noreferrer' target='_blank'><strong>{card.get('title')}</strong></a> : <strong className='status-card__title' title={card.get('title')}>{card.get('title')}</strong>;
  185. const ratio = card.get('width') / card.get('height');
  186. const height = (compact && !embedded) ? (width / (16 / 9)) : (width / ratio);
  187. const description = (
  188. <div className='status-card__content'>
  189. {title}
  190. {!(horizontal || compact) && <p className='status-card__description'>{trim(card.get('description') || '', maxDescription)}</p>}
  191. <span className='status-card__host'>{provider}</span>
  192. </div>
  193. );
  194. let embed = '';
  195. 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 })} />;
  196. 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' />;
  197. let spoilerButton = (
  198. <button type='button' onClick={this.handleReveal} className='spoiler-button__overlay'>
  199. <span className='spoiler-button__overlay__label'><FormattedMessage id='status.sensitive_warning' defaultMessage='Sensitive content' /></span>
  200. </button>
  201. );
  202. spoilerButton = (
  203. <div className={classnames('spoiler-button', { 'spoiler-button--minified': revealed })}>
  204. {spoilerButton}
  205. </div>
  206. );
  207. if (interactive) {
  208. if (embedded) {
  209. embed = this.renderVideo();
  210. } else {
  211. let iconVariant = 'play';
  212. if (card.get('type') === 'photo') {
  213. iconVariant = 'search-plus';
  214. }
  215. embed = (
  216. <div className='status-card__image'>
  217. {canvas}
  218. {thumbnail}
  219. {revealed && (
  220. <div className='status-card__actions'>
  221. <div>
  222. <button onClick={this.handleEmbedClick}><Icon id={iconVariant} /></button>
  223. {horizontal && <a href={card.get('url')} target='_blank' rel='noopener noreferrer'><Icon id='external-link' /></a>}
  224. </div>
  225. </div>
  226. )}
  227. {!revealed && spoilerButton}
  228. </div>
  229. );
  230. }
  231. return (
  232. <div className={className} ref={this.setRef} onClick={revealed ? null : this.handleReveal} role={revealed ? 'button' : null}>
  233. {embed}
  234. {!compact && description}
  235. </div>
  236. );
  237. } else if (card.get('image')) {
  238. embed = (
  239. <div className='status-card__image'>
  240. {canvas}
  241. {thumbnail}
  242. </div>
  243. );
  244. } else {
  245. embed = (
  246. <div className='status-card__image'>
  247. <Icon id='file-text' />
  248. </div>
  249. );
  250. }
  251. return (
  252. <a href={card.get('url')} className={className} target='_blank' rel='noopener noreferrer' ref={this.setRef}>
  253. {embed}
  254. {description}
  255. </a>
  256. );
  257. }
  258. }