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.

257 lines
7.8 KiB

  1. import React from 'react';
  2. import ReactSwipeableViews from 'react-swipeable-views';
  3. import ImmutablePropTypes from 'react-immutable-proptypes';
  4. import PropTypes from 'prop-types';
  5. import Video from 'mastodon/features/video';
  6. import classNames from 'classnames';
  7. import { defineMessages, injectIntl } from 'react-intl';
  8. import IconButton from 'mastodon/components/icon_button';
  9. import ImmutablePureComponent from 'react-immutable-pure-component';
  10. import ImageLoader from './image_loader';
  11. import Icon from 'mastodon/components/icon';
  12. import GIFV from 'mastodon/components/gifv';
  13. import { disableSwiping } from 'mastodon/initial_state';
  14. import Footer from 'mastodon/features/picture_in_picture/components/footer';
  15. import { getAverageFromBlurhash } from 'mastodon/blurhash';
  16. const messages = defineMessages({
  17. close: { id: 'lightbox.close', defaultMessage: 'Close' },
  18. previous: { id: 'lightbox.previous', defaultMessage: 'Previous' },
  19. next: { id: 'lightbox.next', defaultMessage: 'Next' },
  20. });
  21. export default @injectIntl
  22. class MediaModal extends ImmutablePureComponent {
  23. static propTypes = {
  24. media: ImmutablePropTypes.list.isRequired,
  25. statusId: PropTypes.string,
  26. index: PropTypes.number.isRequired,
  27. onClose: PropTypes.func.isRequired,
  28. intl: PropTypes.object.isRequired,
  29. onChangeBackgroundColor: PropTypes.func.isRequired,
  30. currentTime: PropTypes.number,
  31. autoPlay: PropTypes.bool,
  32. volume: PropTypes.number,
  33. };
  34. state = {
  35. index: null,
  36. navigationHidden: false,
  37. zoomButtonHidden: false,
  38. };
  39. handleSwipe = (index) => {
  40. this.setState({ index: index % this.props.media.size });
  41. }
  42. handleTransitionEnd = () => {
  43. this.setState({
  44. zoomButtonHidden: false,
  45. });
  46. }
  47. handleNextClick = () => {
  48. this.setState({
  49. index: (this.getIndex() + 1) % this.props.media.size,
  50. zoomButtonHidden: true,
  51. });
  52. }
  53. handlePrevClick = () => {
  54. this.setState({
  55. index: (this.props.media.size + this.getIndex() - 1) % this.props.media.size,
  56. zoomButtonHidden: true,
  57. });
  58. }
  59. handleChangeIndex = (e) => {
  60. const index = Number(e.currentTarget.getAttribute('data-index'));
  61. this.setState({
  62. index: index % this.props.media.size,
  63. zoomButtonHidden: true,
  64. });
  65. }
  66. handleKeyDown = (e) => {
  67. switch(e.key) {
  68. case 'ArrowLeft':
  69. this.handlePrevClick();
  70. e.preventDefault();
  71. e.stopPropagation();
  72. break;
  73. case 'ArrowRight':
  74. this.handleNextClick();
  75. e.preventDefault();
  76. e.stopPropagation();
  77. break;
  78. }
  79. }
  80. componentDidMount () {
  81. window.addEventListener('keydown', this.handleKeyDown, false);
  82. this._sendBackgroundColor();
  83. }
  84. componentDidUpdate (prevProps, prevState) {
  85. if (prevState.index !== this.state.index) {
  86. this._sendBackgroundColor();
  87. }
  88. }
  89. _sendBackgroundColor () {
  90. const { media, onChangeBackgroundColor } = this.props;
  91. const index = this.getIndex();
  92. const blurhash = media.getIn([index, 'blurhash']);
  93. if (blurhash) {
  94. const backgroundColor = getAverageFromBlurhash(blurhash);
  95. onChangeBackgroundColor(backgroundColor);
  96. }
  97. }
  98. componentWillUnmount () {
  99. window.removeEventListener('keydown', this.handleKeyDown);
  100. this.props.onChangeBackgroundColor(null);
  101. }
  102. getIndex () {
  103. return this.state.index !== null ? this.state.index : this.props.index;
  104. }
  105. toggleNavigation = () => {
  106. this.setState(prevState => ({
  107. navigationHidden: !prevState.navigationHidden,
  108. }));
  109. };
  110. handleStatusClick = e => {
  111. if (e.button === 0 && !(e.ctrlKey || e.metaKey)) {
  112. e.preventDefault();
  113. this.context.router.history.push(`/statuses/${this.props.statusId}`);
  114. }
  115. }
  116. render () {
  117. const { media, statusId, intl, onClose } = this.props;
  118. const { navigationHidden } = this.state;
  119. const index = this.getIndex();
  120. const leftNav = media.size > 1 && <button tabIndex='0' className='media-modal__nav media-modal__nav--left' onClick={this.handlePrevClick} aria-label={intl.formatMessage(messages.previous)}><Icon id='chevron-left' fixedWidth /></button>;
  121. const rightNav = media.size > 1 && <button tabIndex='0' className='media-modal__nav media-modal__nav--right' onClick={this.handleNextClick} aria-label={intl.formatMessage(messages.next)}><Icon id='chevron-right' fixedWidth /></button>;
  122. const content = media.map((image) => {
  123. const width = image.getIn(['meta', 'original', 'width']) || null;
  124. const height = image.getIn(['meta', 'original', 'height']) || null;
  125. if (image.get('type') === 'image') {
  126. return (
  127. <ImageLoader
  128. previewSrc={image.get('preview_url')}
  129. src={image.get('url')}
  130. width={width}
  131. height={height}
  132. alt={image.get('description')}
  133. key={image.get('url')}
  134. onClick={this.toggleNavigation}
  135. zoomButtonHidden={this.state.zoomButtonHidden}
  136. />
  137. );
  138. } else if (image.get('type') === 'video') {
  139. const { currentTime, autoPlay, volume } = this.props;
  140. return (
  141. <Video
  142. preview={image.get('preview_url')}
  143. blurhash={image.get('blurhash')}
  144. src={image.get('url')}
  145. width={image.get('width')}
  146. height={image.get('height')}
  147. frameRate={image.getIn(['meta', 'original', 'frame_rate'])}
  148. currentTime={currentTime || 0}
  149. autoPlay={autoPlay || false}
  150. volume={volume || 1}
  151. onCloseVideo={onClose}
  152. detailed
  153. alt={image.get('description')}
  154. key={image.get('url')}
  155. />
  156. );
  157. } else if (image.get('type') === 'gifv') {
  158. return (
  159. <GIFV
  160. src={image.get('url')}
  161. width={width}
  162. height={height}
  163. key={image.get('preview_url')}
  164. alt={image.get('description')}
  165. onClick={this.toggleNavigation}
  166. />
  167. );
  168. }
  169. return null;
  170. }).toArray();
  171. // you can't use 100vh, because the viewport height is taller
  172. // than the visible part of the document in some mobile
  173. // browsers when it's address bar is visible.
  174. // https://developers.google.com/web/updates/2016/12/url-bar-resizing
  175. const swipeableViewsStyle = {
  176. width: '100%',
  177. height: '100%',
  178. };
  179. const containerStyle = {
  180. alignItems: 'center', // center vertically
  181. };
  182. const navigationClassName = classNames('media-modal__navigation', {
  183. 'media-modal__navigation--hidden': navigationHidden,
  184. });
  185. let pagination;
  186. if (media.size > 1) {
  187. pagination = media.map((item, i) => (
  188. <button key={i} className={classNames('media-modal__page-dot', { active: i === index })} data-index={i} onClick={this.handleChangeIndex}>
  189. {i + 1}
  190. </button>
  191. ));
  192. }
  193. return (
  194. <div className='modal-root__modal media-modal'>
  195. <div className='media-modal__closer' role='presentation' onClick={onClose} >
  196. <ReactSwipeableViews
  197. style={swipeableViewsStyle}
  198. containerStyle={containerStyle}
  199. onChangeIndex={this.handleSwipe}
  200. onTransitionEnd={this.handleTransitionEnd}
  201. index={index}
  202. disabled={disableSwiping}
  203. >
  204. {content}
  205. </ReactSwipeableViews>
  206. </div>
  207. <div className={navigationClassName}>
  208. <IconButton className='media-modal__close' title={intl.formatMessage(messages.close)} icon='times' onClick={onClose} size={40} />
  209. {leftNav}
  210. {rightNav}
  211. <div className='media-modal__overlay'>
  212. {pagination && <ul className='media-modal__pagination'>{pagination}</ul>}
  213. {statusId && <Footer statusId={statusId} withOpenButton onClose={onClose} />}
  214. </div>
  215. </div>
  216. </div>
  217. );
  218. }
  219. }