media_gallery.jsx 11 KB

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