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.

408 lines
12 KiB

  1. import React from 'react';
  2. import ImmutablePropTypes from 'react-immutable-proptypes';
  3. import PropTypes from 'prop-types';
  4. import { is } from 'immutable';
  5. import IconButton from './icon_button';
  6. import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
  7. import classNames from 'classnames';
  8. import { autoPlayGif, displayMedia, useBlurhash } from 'flavours/glitch/initial_state';
  9. import { debounce } from 'lodash';
  10. import Blurhash from 'flavours/glitch/components/blurhash';
  11. const messages = defineMessages({
  12. hidden: {
  13. defaultMessage: 'Media hidden',
  14. id: 'status.media_hidden',
  15. },
  16. sensitive: {
  17. defaultMessage: 'Sensitive',
  18. id: 'media_gallery.sensitive',
  19. },
  20. toggle: {
  21. defaultMessage: 'Click to view',
  22. id: 'status.sensitive_toggle',
  23. },
  24. toggle_visible: {
  25. defaultMessage: '{number, plural, one {Hide image} other {Hide images}}',
  26. id: 'media_gallery.toggle_visible',
  27. },
  28. warning: {
  29. defaultMessage: 'Sensitive content',
  30. id: 'status.sensitive_warning',
  31. },
  32. });
  33. class Item extends React.PureComponent {
  34. static propTypes = {
  35. attachment: ImmutablePropTypes.map.isRequired,
  36. lang: PropTypes.string,
  37. standalone: PropTypes.bool,
  38. index: PropTypes.number.isRequired,
  39. size: PropTypes.number.isRequired,
  40. letterbox: PropTypes.bool,
  41. onClick: PropTypes.func.isRequired,
  42. displayWidth: PropTypes.number,
  43. visible: PropTypes.bool.isRequired,
  44. autoplay: PropTypes.bool,
  45. };
  46. static defaultProps = {
  47. standalone: false,
  48. index: 0,
  49. size: 1,
  50. };
  51. state = {
  52. loaded: false,
  53. };
  54. handleMouseEnter = (e) => {
  55. if (this.hoverToPlay()) {
  56. e.target.play();
  57. }
  58. };
  59. handleMouseLeave = (e) => {
  60. if (this.hoverToPlay()) {
  61. e.target.pause();
  62. e.target.currentTime = 0;
  63. }
  64. };
  65. getAutoPlay() {
  66. return this.props.autoplay || autoPlayGif;
  67. }
  68. hoverToPlay () {
  69. const { attachment } = this.props;
  70. return !this.getAutoPlay() && attachment.get('type') === 'gifv';
  71. }
  72. handleClick = (e) => {
  73. const { index, onClick } = this.props;
  74. if (e.button === 0 && !(e.ctrlKey || e.metaKey)) {
  75. if (this.hoverToPlay()) {
  76. e.target.pause();
  77. e.target.currentTime = 0;
  78. }
  79. e.preventDefault();
  80. onClick(index);
  81. }
  82. e.stopPropagation();
  83. };
  84. handleImageLoad = () => {
  85. this.setState({ loaded: true });
  86. };
  87. render () {
  88. const { attachment, lang, index, size, standalone, letterbox, displayWidth, visible } = this.props;
  89. let width = 50;
  90. let height = 100;
  91. let top = 'auto';
  92. let left = 'auto';
  93. let bottom = 'auto';
  94. let right = 'auto';
  95. if (size === 1) {
  96. width = 100;
  97. }
  98. if (size === 4 || (size === 3 && index > 0)) {
  99. height = 50;
  100. }
  101. if (size === 2) {
  102. if (index === 0) {
  103. right = '2px';
  104. } else {
  105. left = '2px';
  106. }
  107. } else if (size === 3) {
  108. if (index === 0) {
  109. right = '2px';
  110. } else if (index > 0) {
  111. left = '2px';
  112. }
  113. if (index === 1) {
  114. bottom = '2px';
  115. } else if (index > 1) {
  116. top = '2px';
  117. }
  118. } else if (size === 4) {
  119. if (index === 0 || index === 2) {
  120. right = '2px';
  121. }
  122. if (index === 1 || index === 3) {
  123. left = '2px';
  124. }
  125. if (index < 2) {
  126. bottom = '2px';
  127. } else {
  128. top = '2px';
  129. }
  130. }
  131. let thumbnail = '';
  132. if (attachment.get('type') === 'unknown') {
  133. return (
  134. <div className={classNames('media-gallery__item', { standalone })} key={attachment.get('id')} style={{ left: left, top: top, right: right, bottom: bottom, width: `${width}%`, height: `${height}%` }}>
  135. <a className='media-gallery__item-thumbnail' href={attachment.get('remote_url') || attachment.get('url')} style={{ cursor: 'pointer' }} title={attachment.get('description')} lang={lang} target='_blank' rel='noopener noreferrer'>
  136. <Blurhash
  137. hash={attachment.get('blurhash')}
  138. className='media-gallery__preview'
  139. dummy={!useBlurhash}
  140. />
  141. </a>
  142. </div>
  143. );
  144. } else if (attachment.get('type') === 'image') {
  145. const previewUrl = attachment.get('preview_url');
  146. const previewWidth = attachment.getIn(['meta', 'small', 'width']);
  147. const originalUrl = attachment.get('url');
  148. const originalWidth = attachment.getIn(['meta', 'original', 'width']);
  149. const hasSize = typeof originalWidth === 'number' && typeof previewWidth === 'number';
  150. const srcSet = hasSize ? `${originalUrl} ${originalWidth}w, ${previewUrl} ${previewWidth}w` : null;
  151. const sizes = hasSize && (displayWidth > 0) ? `${displayWidth * (width / 100)}px` : null;
  152. const focusX = attachment.getIn(['meta', 'focus', 'x']) || 0;
  153. const focusY = attachment.getIn(['meta', 'focus', 'y']) || 0;
  154. const x = ((focusX / 2) + .5) * 100;
  155. const y = ((focusY / -2) + .5) * 100;
  156. thumbnail = (
  157. <a
  158. className='media-gallery__item-thumbnail'
  159. href={attachment.get('remote_url') || originalUrl}
  160. onClick={this.handleClick}
  161. target='_blank'
  162. rel='noopener noreferrer'
  163. >
  164. <img
  165. className={letterbox ? 'letterbox' : null}
  166. src={previewUrl}
  167. srcSet={srcSet}
  168. sizes={sizes}
  169. alt={attachment.get('description')}
  170. title={attachment.get('description')}
  171. lang={lang}
  172. style={{ objectPosition: letterbox ? null : `${x}% ${y}%` }}
  173. onLoad={this.handleImageLoad}
  174. />
  175. </a>
  176. );
  177. } else if (attachment.get('type') === 'gifv') {
  178. const autoPlay = this.getAutoPlay();
  179. thumbnail = (
  180. <div className={classNames('media-gallery__gifv', { autoplay: autoPlay })}>
  181. <video
  182. className={`media-gallery__item-gifv-thumbnail${letterbox ? ' letterbox' : ''}`}
  183. aria-label={attachment.get('description')}
  184. title={attachment.get('description')}
  185. lang={lang}
  186. role='application'
  187. src={attachment.get('url')}
  188. onClick={this.handleClick}
  189. onMouseEnter={this.handleMouseEnter}
  190. onMouseLeave={this.handleMouseLeave}
  191. autoPlay={autoPlay}
  192. playsInline
  193. loop
  194. muted
  195. />
  196. <span className='media-gallery__gifv__label'>GIF</span>
  197. </div>
  198. );
  199. }
  200. return (
  201. <div className={classNames('media-gallery__item', { standalone, letterbox })} key={attachment.get('id')} style={{ left: left, top: top, right: right, bottom: bottom, width: `${width}%`, height: `${height}%` }}>
  202. <Blurhash
  203. hash={attachment.get('blurhash')}
  204. dummy={!useBlurhash}
  205. className={classNames('media-gallery__preview', {
  206. 'media-gallery__preview--hidden': visible && this.state.loaded,
  207. })}
  208. />
  209. {visible && thumbnail}
  210. </div>
  211. );
  212. }
  213. }
  214. export default @injectIntl
  215. class MediaGallery extends React.PureComponent {
  216. static propTypes = {
  217. sensitive: PropTypes.bool,
  218. standalone: PropTypes.bool,
  219. letterbox: PropTypes.bool,
  220. fullwidth: PropTypes.bool,
  221. hidden: PropTypes.bool,
  222. media: ImmutablePropTypes.list.isRequired,
  223. lang: PropTypes.string,
  224. size: PropTypes.object,
  225. onOpenMedia: PropTypes.func.isRequired,
  226. intl: PropTypes.object.isRequired,
  227. defaultWidth: PropTypes.number,
  228. cacheWidth: PropTypes.func,
  229. visible: PropTypes.bool,
  230. autoplay: PropTypes.bool,
  231. onToggleVisibility: PropTypes.func,
  232. };
  233. static defaultProps = {
  234. standalone: false,
  235. };
  236. state = {
  237. visible: this.props.visible !== undefined ? this.props.visible : (displayMedia !== 'hide_all' && !this.props.sensitive || displayMedia === 'show_all'),
  238. width: this.props.defaultWidth,
  239. };
  240. componentDidMount () {
  241. window.addEventListener('resize', this.handleResize, { passive: true });
  242. }
  243. componentWillUnmount () {
  244. window.removeEventListener('resize', this.handleResize);
  245. }
  246. componentWillReceiveProps (nextProps) {
  247. if (!is(nextProps.media, this.props.media) && nextProps.visible === undefined) {
  248. this.setState({ visible: displayMedia !== 'hide_all' && !nextProps.sensitive || displayMedia === 'show_all' });
  249. } else if (!is(nextProps.visible, this.props.visible) && nextProps.visible !== undefined) {
  250. this.setState({ visible: nextProps.visible });
  251. }
  252. }
  253. componentDidUpdate (prevProps) {
  254. if (this.node) {
  255. this.handleResize();
  256. }
  257. }
  258. handleResize = debounce(() => {
  259. if (this.node) {
  260. this._setDimensions();
  261. }
  262. }, 250, {
  263. leading: true,
  264. trailing: true,
  265. });
  266. handleOpen = () => {
  267. if (this.props.onToggleVisibility) {
  268. this.props.onToggleVisibility();
  269. } else {
  270. this.setState({ visible: !this.state.visible });
  271. }
  272. };
  273. handleClick = (index) => {
  274. this.props.onOpenMedia(this.props.media, index);
  275. };
  276. handleRef = (node) => {
  277. this.node = node;
  278. if (this.node) {
  279. this._setDimensions();
  280. }
  281. };
  282. _setDimensions () {
  283. const width = this.node.offsetWidth;
  284. if (width && width != this.state.width) {
  285. // offsetWidth triggers a layout, so only calculate when we need to
  286. if (this.props.cacheWidth) {
  287. this.props.cacheWidth(width);
  288. }
  289. this.setState({
  290. width: width,
  291. });
  292. }
  293. }
  294. isStandaloneEligible() {
  295. const { media, standalone } = this.props;
  296. return standalone && media.size === 1 && media.getIn([0, 'meta', 'small', 'aspect']);
  297. }
  298. render () {
  299. const { media, lang, intl, sensitive, letterbox, fullwidth, defaultWidth, autoplay } = this.props;
  300. const { visible } = this.state;
  301. const size = media.take(4).size;
  302. const uncached = media.every(attachment => attachment.get('type') === 'unknown');
  303. const width = this.state.width || defaultWidth;
  304. let children, spoilerButton;
  305. const style = {};
  306. const computedClass = classNames('media-gallery', { 'full-width': fullwidth });
  307. if (this.isStandaloneEligible() && width) {
  308. style.height = width / this.props.media.getIn([0, 'meta', 'small', 'aspect']);
  309. } else if (width) {
  310. style.height = width / (16/9);
  311. } else {
  312. return (<div className={computedClass} ref={this.handleRef} />);
  313. }
  314. if (this.isStandaloneEligible()) {
  315. children = <Item standalone autoplay={autoplay} onClick={this.handleClick} attachment={media.get(0)} lang={lang} displayWidth={width} visible={visible} />;
  316. } else {
  317. children = media.take(4).map((attachment, i) => <Item key={attachment.get('id')} autoplay={autoplay} onClick={this.handleClick} attachment={attachment} index={i} lang={lang} size={size} letterbox={letterbox} displayWidth={width} visible={visible || uncached} />);
  318. }
  319. if (uncached) {
  320. spoilerButton = (
  321. <button type='button' disabled className='spoiler-button__overlay'>
  322. <span className='spoiler-button__overlay__label'><FormattedMessage id='status.uncached_media_warning' defaultMessage='Not available' /></span>
  323. </button>
  324. );
  325. } else if (visible) {
  326. spoilerButton = <IconButton title={intl.formatMessage(messages.toggle_visible, { number: size })} icon='eye-slash' overlay onClick={this.handleOpen} ariaHidden />;
  327. } else {
  328. spoilerButton = (
  329. <button type='button' onClick={this.handleOpen} className='spoiler-button__overlay'>
  330. <span className='spoiler-button__overlay__label'>{sensitive ? <FormattedMessage id='status.sensitive_warning' defaultMessage='Sensitive content' /> : <FormattedMessage id='status.media_hidden' defaultMessage='Media hidden' />}</span>
  331. </button>
  332. );
  333. }
  334. return (
  335. <div className={computedClass} style={style} ref={this.handleRef}>
  336. <div className={classNames('spoiler-button', { 'spoiler-button--minified': visible && !uncached, 'spoiler-button--click-thru': uncached })}>
  337. {spoilerButton}
  338. {visible && sensitive && (
  339. <span className='sensitive-marker'>
  340. <FormattedMessage {...messages.sensitive} />
  341. </span>
  342. )}
  343. </div>
  344. {children}
  345. </div>
  346. );
  347. }
  348. }