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.

218 lines
6.0 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 punycode from 'punycode';
  6. import classnames from 'classnames';
  7. import Icon from 'mastodon/components/icon';
  8. const IDNA_PREFIX = 'xn--';
  9. const decodeIDNA = domain => {
  10. return domain
  11. .split('.')
  12. .map(part => part.indexOf(IDNA_PREFIX) === 0 ? punycode.decode(part.slice(IDNA_PREFIX.length)) : part)
  13. .join('.');
  14. };
  15. const getHostname = url => {
  16. const parser = document.createElement('a');
  17. parser.href = url;
  18. return parser.hostname;
  19. };
  20. const trim = (text, len) => {
  21. const cut = text.indexOf(' ', len);
  22. if (cut === -1) {
  23. return text;
  24. }
  25. return text.substring(0, cut) + (text.length > len ? '…' : '');
  26. };
  27. const domParser = new DOMParser();
  28. const addAutoPlay = html => {
  29. const document = domParser.parseFromString(html, 'text/html').documentElement;
  30. const iframe = document.querySelector('iframe');
  31. if (iframe) {
  32. if (iframe.src.indexOf('?') !== -1) {
  33. iframe.src += '&';
  34. } else {
  35. iframe.src += '?';
  36. }
  37. iframe.src += 'autoplay=1&auto_play=1';
  38. // DOM parser creates html/body elements around original HTML fragment,
  39. // so we need to get innerHTML out of the body and not the entire document
  40. return document.querySelector('body').innerHTML;
  41. }
  42. return html;
  43. };
  44. export default class Card extends React.PureComponent {
  45. static propTypes = {
  46. card: ImmutablePropTypes.map,
  47. maxDescription: PropTypes.number,
  48. onOpenMedia: PropTypes.func.isRequired,
  49. compact: PropTypes.bool,
  50. defaultWidth: PropTypes.number,
  51. cacheWidth: PropTypes.func,
  52. };
  53. static defaultProps = {
  54. maxDescription: 50,
  55. compact: false,
  56. };
  57. state = {
  58. width: this.props.defaultWidth || 280,
  59. embedded: false,
  60. };
  61. componentWillReceiveProps (nextProps) {
  62. if (!Immutable.is(this.props.card, nextProps.card)) {
  63. this.setState({ embedded: false });
  64. }
  65. }
  66. handlePhotoClick = () => {
  67. const { card, onOpenMedia } = this.props;
  68. onOpenMedia(
  69. Immutable.fromJS([
  70. {
  71. type: 'image',
  72. url: card.get('embed_url'),
  73. description: card.get('title'),
  74. meta: {
  75. original: {
  76. width: card.get('width'),
  77. height: card.get('height'),
  78. },
  79. },
  80. },
  81. ]),
  82. 0
  83. );
  84. };
  85. handleEmbedClick = () => {
  86. const { card } = this.props;
  87. if (card.get('type') === 'photo') {
  88. this.handlePhotoClick();
  89. } else {
  90. this.setState({ embedded: true });
  91. }
  92. }
  93. setRef = c => {
  94. if (c) {
  95. if (this.props.cacheWidth) this.props.cacheWidth(c.offsetWidth);
  96. this.setState({ width: c.offsetWidth });
  97. }
  98. }
  99. renderVideo () {
  100. const { card } = this.props;
  101. const content = { __html: addAutoPlay(card.get('html')) };
  102. const { width } = this.state;
  103. const ratio = card.get('width') / card.get('height');
  104. const height = width / ratio;
  105. return (
  106. <div
  107. ref={this.setRef}
  108. className='status-card__image status-card-video'
  109. dangerouslySetInnerHTML={content}
  110. style={{ height }}
  111. />
  112. );
  113. }
  114. render () {
  115. const { card, maxDescription, compact } = this.props;
  116. const { width, embedded } = this.state;
  117. if (card === null) {
  118. return null;
  119. }
  120. const provider = card.get('provider_name').length === 0 ? decodeIDNA(getHostname(card.get('url'))) : card.get('provider_name');
  121. const horizontal = (!compact && card.get('width') > card.get('height') && (card.get('width') + 100 >= width)) || card.get('type') !== 'link' || embedded;
  122. const interactive = card.get('type') !== 'link';
  123. const className = classnames('status-card', { horizontal, compact, interactive });
  124. 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>;
  125. const ratio = card.get('width') / card.get('height');
  126. const height = (compact && !embedded) ? (width / (16 / 9)) : (width / ratio);
  127. const description = (
  128. <div className='status-card__content'>
  129. {title}
  130. {!(horizontal || compact) && <p className='status-card__description'>{trim(card.get('description') || '', maxDescription)}</p>}
  131. <span className='status-card__host'>{provider}</span>
  132. </div>
  133. );
  134. let embed = '';
  135. let thumbnail = <div style={{ backgroundImage: `url(${card.get('image')})`, width: horizontal ? width : null, height: horizontal ? height : null }} className='status-card__image-image' />;
  136. if (interactive) {
  137. if (embedded) {
  138. embed = this.renderVideo();
  139. } else {
  140. let iconVariant = 'play';
  141. if (card.get('type') === 'photo') {
  142. iconVariant = 'search-plus';
  143. }
  144. embed = (
  145. <div className='status-card__image'>
  146. {thumbnail}
  147. <div className='status-card__actions'>
  148. <div>
  149. <button onClick={this.handleEmbedClick}><Icon id={iconVariant} /></button>
  150. {horizontal && <a href={card.get('url')} target='_blank' rel='noopener noreferrer'><Icon id='external-link' /></a>}
  151. </div>
  152. </div>
  153. </div>
  154. );
  155. }
  156. return (
  157. <div className={className} ref={this.setRef}>
  158. {embed}
  159. {!compact && description}
  160. </div>
  161. );
  162. } else if (card.get('image')) {
  163. embed = (
  164. <div className='status-card__image'>
  165. {thumbnail}
  166. </div>
  167. );
  168. } else {
  169. embed = (
  170. <div className='status-card__image'>
  171. <Icon id='file-text' />
  172. </div>
  173. );
  174. }
  175. return (
  176. <a href={card.get('url')} className={className} target='_blank' rel='noopener noreferrer' ref={this.setRef}>
  177. {embed}
  178. {description}
  179. </a>
  180. );
  181. }
  182. }