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.

376 lines
8.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. const messages = defineMessages({
  16. close: { id: 'lightbox.close', defaultMessage: 'Close' },
  17. previous: { id: 'lightbox.previous', defaultMessage: 'Previous' },
  18. next: { id: 'lightbox.next', defaultMessage: 'Next' },
  19. });
  20. export const previewState = 'previewMediaModal';
  21. const digitCharacters = [
  22. '0',
  23. '1',
  24. '2',
  25. '3',
  26. '4',
  27. '5',
  28. '6',
  29. '7',
  30. '8',
  31. '9',
  32. 'A',
  33. 'B',
  34. 'C',
  35. 'D',
  36. 'E',
  37. 'F',
  38. 'G',
  39. 'H',
  40. 'I',
  41. 'J',
  42. 'K',
  43. 'L',
  44. 'M',
  45. 'N',
  46. 'O',
  47. 'P',
  48. 'Q',
  49. 'R',
  50. 'S',
  51. 'T',
  52. 'U',
  53. 'V',
  54. 'W',
  55. 'X',
  56. 'Y',
  57. 'Z',
  58. 'a',
  59. 'b',
  60. 'c',
  61. 'd',
  62. 'e',
  63. 'f',
  64. 'g',
  65. 'h',
  66. 'i',
  67. 'j',
  68. 'k',
  69. 'l',
  70. 'm',
  71. 'n',
  72. 'o',
  73. 'p',
  74. 'q',
  75. 'r',
  76. 's',
  77. 't',
  78. 'u',
  79. 'v',
  80. 'w',
  81. 'x',
  82. 'y',
  83. 'z',
  84. '#',
  85. '$',
  86. '%',
  87. '*',
  88. '+',
  89. ',',
  90. '-',
  91. '.',
  92. ':',
  93. ';',
  94. '=',
  95. '?',
  96. '@',
  97. '[',
  98. ']',
  99. '^',
  100. '_',
  101. '{',
  102. '|',
  103. '}',
  104. '~',
  105. ];
  106. const decode83 = (str) => {
  107. let value = 0;
  108. let c, digit;
  109. for (let i = 0; i < str.length; i++) {
  110. c = str[i];
  111. digit = digitCharacters.indexOf(c);
  112. value = value * 83 + digit;
  113. }
  114. return value;
  115. };
  116. const decodeRGB = int => ({
  117. r: Math.max(0, (int >> 16)),
  118. g: Math.max(0, (int >> 8) & 255),
  119. b: Math.max(0, (int & 255)),
  120. });
  121. export default @injectIntl
  122. class MediaModal extends ImmutablePureComponent {
  123. static propTypes = {
  124. media: ImmutablePropTypes.list.isRequired,
  125. statusId: PropTypes.string,
  126. index: PropTypes.number.isRequired,
  127. onClose: PropTypes.func.isRequired,
  128. intl: PropTypes.object.isRequired,
  129. onChangeBackgroundColor: PropTypes.func.isRequired,
  130. };
  131. static contextTypes = {
  132. router: PropTypes.object,
  133. };
  134. state = {
  135. index: null,
  136. navigationHidden: false,
  137. zoomButtonHidden: false,
  138. };
  139. handleSwipe = (index) => {
  140. this.setState({ index: index % this.props.media.size });
  141. }
  142. handleTransitionEnd = () => {
  143. this.setState({
  144. zoomButtonHidden: false,
  145. });
  146. }
  147. handleNextClick = () => {
  148. this.setState({
  149. index: (this.getIndex() + 1) % this.props.media.size,
  150. zoomButtonHidden: true,
  151. });
  152. }
  153. handlePrevClick = () => {
  154. this.setState({
  155. index: (this.props.media.size + this.getIndex() - 1) % this.props.media.size,
  156. zoomButtonHidden: true,
  157. });
  158. }
  159. handleChangeIndex = (e) => {
  160. const index = Number(e.currentTarget.getAttribute('data-index'));
  161. this.setState({
  162. index: index % this.props.media.size,
  163. zoomButtonHidden: true,
  164. });
  165. }
  166. handleKeyDown = (e) => {
  167. switch(e.key) {
  168. case 'ArrowLeft':
  169. this.handlePrevClick();
  170. e.preventDefault();
  171. e.stopPropagation();
  172. break;
  173. case 'ArrowRight':
  174. this.handleNextClick();
  175. e.preventDefault();
  176. e.stopPropagation();
  177. break;
  178. }
  179. }
  180. componentDidMount () {
  181. window.addEventListener('keydown', this.handleKeyDown, false);
  182. if (this.context.router) {
  183. const history = this.context.router.history;
  184. history.push(history.location.pathname, previewState);
  185. this.unlistenHistory = history.listen(() => {
  186. this.props.onClose();
  187. });
  188. }
  189. this._sendBackgroundColor();
  190. }
  191. componentDidUpdate (prevProps, prevState) {
  192. if (prevState.index !== this.state.index) {
  193. this._sendBackgroundColor();
  194. }
  195. }
  196. _sendBackgroundColor () {
  197. const { media, onChangeBackgroundColor } = this.props;
  198. const index = this.getIndex();
  199. const backgroundColor = decodeRGB(decode83(media.getIn([index, 'blurhash']).slice(2, 6)));
  200. onChangeBackgroundColor(backgroundColor);
  201. }
  202. componentWillUnmount () {
  203. window.removeEventListener('keydown', this.handleKeyDown);
  204. if (this.context.router) {
  205. this.unlistenHistory();
  206. if (this.context.router.history.location.state === previewState) {
  207. this.context.router.history.goBack();
  208. }
  209. }
  210. this.props.onChangeBackgroundColor(null);
  211. }
  212. getIndex () {
  213. return this.state.index !== null ? this.state.index : this.props.index;
  214. }
  215. toggleNavigation = () => {
  216. this.setState(prevState => ({
  217. navigationHidden: !prevState.navigationHidden,
  218. }));
  219. };
  220. handleStatusClick = e => {
  221. if (e.button === 0 && !(e.ctrlKey || e.metaKey)) {
  222. e.preventDefault();
  223. this.context.router.history.push(`/statuses/${this.props.statusId}`);
  224. }
  225. }
  226. render () {
  227. const { media, statusId, intl, onClose } = this.props;
  228. const { navigationHidden } = this.state;
  229. const index = this.getIndex();
  230. 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>;
  231. 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>;
  232. const content = media.map((image) => {
  233. const width = image.getIn(['meta', 'original', 'width']) || null;
  234. const height = image.getIn(['meta', 'original', 'height']) || null;
  235. if (image.get('type') === 'image') {
  236. return (
  237. <ImageLoader
  238. previewSrc={image.get('preview_url')}
  239. src={image.get('url')}
  240. width={width}
  241. height={height}
  242. alt={image.get('description')}
  243. key={image.get('url')}
  244. onClick={this.toggleNavigation}
  245. zoomButtonHidden={this.state.zoomButtonHidden}
  246. />
  247. );
  248. } else if (image.get('type') === 'video') {
  249. const { time } = this.props;
  250. return (
  251. <Video
  252. preview={image.get('preview_url')}
  253. blurhash={image.get('blurhash')}
  254. src={image.get('url')}
  255. width={image.get('width')}
  256. height={image.get('height')}
  257. currentTime={time || 0}
  258. onCloseVideo={onClose}
  259. detailed
  260. alt={image.get('description')}
  261. key={image.get('url')}
  262. />
  263. );
  264. } else if (image.get('type') === 'gifv') {
  265. return (
  266. <GIFV
  267. src={image.get('url')}
  268. width={width}
  269. height={height}
  270. key={image.get('preview_url')}
  271. alt={image.get('description')}
  272. onClick={this.toggleNavigation}
  273. />
  274. );
  275. }
  276. return null;
  277. }).toArray();
  278. // you can't use 100vh, because the viewport height is taller
  279. // than the visible part of the document in some mobile
  280. // browsers when it's address bar is visible.
  281. // https://developers.google.com/web/updates/2016/12/url-bar-resizing
  282. const swipeableViewsStyle = {
  283. width: '100%',
  284. height: '100%',
  285. };
  286. const containerStyle = {
  287. alignItems: 'center', // center vertically
  288. };
  289. const navigationClassName = classNames('media-modal__navigation', {
  290. 'media-modal__navigation--hidden': navigationHidden,
  291. });
  292. let pagination;
  293. if (media.size > 1) {
  294. pagination = media.map((item, i) => (
  295. <button key={i} className={classNames('media-modal__page-dot', { active: i === index })} data-index={i} onClick={this.handleChangeIndex}>
  296. {i + 1}
  297. </button>
  298. ));
  299. }
  300. return (
  301. <div className='modal-root__modal media-modal'>
  302. <div className='media-modal__closer' role='presentation' onClick={onClose} >
  303. <ReactSwipeableViews
  304. style={swipeableViewsStyle}
  305. containerStyle={containerStyle}
  306. onChangeIndex={this.handleSwipe}
  307. onTransitionEnd={this.handleTransitionEnd}
  308. index={index}
  309. disabled={disableSwiping}
  310. >
  311. {content}
  312. </ReactSwipeableViews>
  313. </div>
  314. <div className={navigationClassName}>
  315. <IconButton className='media-modal__close' title={intl.formatMessage(messages.close)} icon='times' onClick={onClose} size={40} />
  316. {leftNav}
  317. {rightNav}
  318. <div className='media-modal__overlay'>
  319. {pagination && <ul className='media-modal__pagination'>{pagination}</ul>}
  320. {statusId && <Footer statusId={statusId} withOpenButton onClose={onClose} />}
  321. </div>
  322. </div>
  323. </div>
  324. );
  325. }
  326. }