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.

240 lines
7.3 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, FormattedMessage } 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. const messages = defineMessages({
  14. close: { id: 'lightbox.close', defaultMessage: 'Close' },
  15. previous: { id: 'lightbox.previous', defaultMessage: 'Previous' },
  16. next: { id: 'lightbox.next', defaultMessage: 'Next' },
  17. });
  18. export const previewState = 'previewMediaModal';
  19. export default @injectIntl
  20. class MediaModal extends ImmutablePureComponent {
  21. static propTypes = {
  22. media: ImmutablePropTypes.list.isRequired,
  23. status: ImmutablePropTypes.map,
  24. index: PropTypes.number.isRequired,
  25. onClose: PropTypes.func.isRequired,
  26. intl: PropTypes.object.isRequired,
  27. };
  28. static contextTypes = {
  29. router: PropTypes.object,
  30. };
  31. state = {
  32. index: null,
  33. navigationHidden: false,
  34. };
  35. handleSwipe = (index) => {
  36. this.setState({ index: index % this.props.media.size });
  37. }
  38. handleNextClick = () => {
  39. this.setState({ index: (this.getIndex() + 1) % this.props.media.size });
  40. }
  41. handlePrevClick = () => {
  42. this.setState({ index: (this.props.media.size + this.getIndex() - 1) % this.props.media.size });
  43. }
  44. handleChangeIndex = (e) => {
  45. const index = Number(e.currentTarget.getAttribute('data-index'));
  46. this.setState({ index: index % this.props.media.size });
  47. }
  48. handleKeyDown = (e) => {
  49. switch(e.key) {
  50. case 'ArrowLeft':
  51. this.handlePrevClick();
  52. e.preventDefault();
  53. e.stopPropagation();
  54. break;
  55. case 'ArrowRight':
  56. this.handleNextClick();
  57. e.preventDefault();
  58. e.stopPropagation();
  59. break;
  60. }
  61. }
  62. componentDidMount () {
  63. window.addEventListener('keydown', this.handleKeyDown, false);
  64. if (this.context.router) {
  65. const history = this.context.router.history;
  66. history.push(history.location.pathname, previewState);
  67. this.unlistenHistory = history.listen(() => {
  68. this.props.onClose();
  69. });
  70. }
  71. }
  72. componentWillUnmount () {
  73. window.removeEventListener('keydown', this.handleKeyDown);
  74. if (this.context.router) {
  75. this.unlistenHistory();
  76. if (this.context.router.history.location.state === previewState) {
  77. this.context.router.history.goBack();
  78. }
  79. }
  80. }
  81. getIndex () {
  82. return this.state.index !== null ? this.state.index : this.props.index;
  83. }
  84. toggleNavigation = () => {
  85. this.setState(prevState => ({
  86. navigationHidden: !prevState.navigationHidden,
  87. }));
  88. };
  89. handleStatusClick = e => {
  90. if (e.button === 0 && !(e.ctrlKey || e.metaKey)) {
  91. e.preventDefault();
  92. this.context.router.history.push(`/statuses/${this.props.status.get('id')}`);
  93. }
  94. }
  95. render () {
  96. const { media, status, intl, onClose } = this.props;
  97. const { navigationHidden } = this.state;
  98. const index = this.getIndex();
  99. let pagination = [];
  100. 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>;
  101. 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>;
  102. if (media.size > 1) {
  103. pagination = media.map((item, i) => {
  104. const classes = ['media-modal__button'];
  105. if (i === index) {
  106. classes.push('media-modal__button--active');
  107. }
  108. return (<li className='media-modal__page-dot' key={i}><button tabIndex='0' className={classes.join(' ')} onClick={this.handleChangeIndex} data-index={i}>{i + 1}</button></li>);
  109. });
  110. }
  111. const content = media.map((image) => {
  112. const width = image.getIn(['meta', 'original', 'width']) || null;
  113. const height = image.getIn(['meta', 'original', 'height']) || null;
  114. if (image.get('type') === 'image') {
  115. return (
  116. <ImageLoader
  117. previewSrc={image.get('preview_url')}
  118. src={image.get('url')}
  119. width={width}
  120. height={height}
  121. alt={image.get('description')}
  122. key={image.get('url')}
  123. onClick={this.toggleNavigation}
  124. />
  125. );
  126. } else if (image.get('type') === 'video') {
  127. const { time } = this.props;
  128. return (
  129. <Video
  130. preview={image.get('preview_url')}
  131. blurhash={image.get('blurhash')}
  132. src={image.get('url')}
  133. width={image.get('width')}
  134. height={image.get('height')}
  135. startTime={time || 0}
  136. onCloseVideo={onClose}
  137. detailed
  138. alt={image.get('description')}
  139. key={image.get('url')}
  140. />
  141. );
  142. } else if (image.get('type') === 'gifv') {
  143. return (
  144. <GIFV
  145. src={image.get('url')}
  146. width={width}
  147. height={height}
  148. key={image.get('preview_url')}
  149. alt={image.get('description')}
  150. onClick={this.toggleNavigation}
  151. />
  152. );
  153. }
  154. return null;
  155. }).toArray();
  156. // you can't use 100vh, because the viewport height is taller
  157. // than the visible part of the document in some mobile
  158. // browsers when it's address bar is visible.
  159. // https://developers.google.com/web/updates/2016/12/url-bar-resizing
  160. const swipeableViewsStyle = {
  161. width: '100%',
  162. height: '100%',
  163. };
  164. const containerStyle = {
  165. alignItems: 'center', // center vertically
  166. };
  167. const navigationClassName = classNames('media-modal__navigation', {
  168. 'media-modal__navigation--hidden': navigationHidden,
  169. });
  170. return (
  171. <div className='modal-root__modal media-modal'>
  172. <div
  173. className='media-modal__closer'
  174. role='presentation'
  175. onClick={onClose}
  176. >
  177. <ReactSwipeableViews
  178. style={swipeableViewsStyle}
  179. containerStyle={containerStyle}
  180. onChangeIndex={this.handleSwipe}
  181. index={index}
  182. >
  183. {content}
  184. </ReactSwipeableViews>
  185. </div>
  186. <div className={navigationClassName}>
  187. <IconButton className='media-modal__close' title={intl.formatMessage(messages.close)} icon='times' onClick={onClose} size={40} />
  188. {leftNav}
  189. {rightNav}
  190. {status && (
  191. <div className={classNames('media-modal__meta', { 'media-modal__meta--shifted': media.size > 1 })}>
  192. <a href={status.get('url')} onClick={this.handleStatusClick}><Icon id='comments' /> <FormattedMessage id='lightbox.view_context' defaultMessage='View context' /></a>
  193. </div>
  194. )}
  195. <ul className='media-modal__pagination'>
  196. {pagination}
  197. </ul>
  198. </div>
  199. </div>
  200. );
  201. }
  202. }