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.

271 lines
9.1 KiB

  1. import React from 'react';
  2. import PropTypes from 'prop-types';
  3. import { defineMessages, injectIntl } from 'react-intl';
  4. import ImmutablePropTypes from 'react-immutable-proptypes';
  5. import ImmutablePureComponent from 'react-immutable-pure-component';
  6. import ReactSwipeableViews from 'react-swipeable-views';
  7. import TabsBar, { links, getIndex, getLink } from './tabs_bar';
  8. import { Link } from 'react-router-dom';
  9. import { disableSwiping } from 'mastodon/initial_state';
  10. import BundleContainer from '../containers/bundle_container';
  11. import ColumnLoading from './column_loading';
  12. import DrawerLoading from './drawer_loading';
  13. import BundleColumnError from './bundle_column_error';
  14. import {
  15. Compose,
  16. Notifications,
  17. HomeTimeline,
  18. CommunityTimeline,
  19. PublicTimeline,
  20. HashtagTimeline,
  21. DirectTimeline,
  22. FavouritedStatuses,
  23. BookmarkedStatuses,
  24. ListTimeline,
  25. Directory,
  26. } from '../../ui/util/async-components';
  27. import Icon from 'mastodon/components/icon';
  28. import ComposePanel from './compose_panel';
  29. import NavigationPanel from './navigation_panel';
  30. import { supportsPassiveEvents } from 'detect-passive-events';
  31. import { scrollRight } from '../../../scroll';
  32. const componentMap = {
  33. 'COMPOSE': Compose,
  34. 'HOME': HomeTimeline,
  35. 'NOTIFICATIONS': Notifications,
  36. 'PUBLIC': PublicTimeline,
  37. 'REMOTE': PublicTimeline,
  38. 'COMMUNITY': CommunityTimeline,
  39. 'HASHTAG': HashtagTimeline,
  40. 'DIRECT': DirectTimeline,
  41. 'FAVOURITES': FavouritedStatuses,
  42. 'BOOKMARKS': BookmarkedStatuses,
  43. 'LIST': ListTimeline,
  44. 'DIRECTORY': Directory,
  45. };
  46. const messages = defineMessages({
  47. publish: { id: 'compose_form.publish', defaultMessage: 'Toot' },
  48. });
  49. const shouldHideFAB = path => path.match(/^\/statuses\/|^\/search|^\/getting-started/);
  50. export default @(component => injectIntl(component, { withRef: true }))
  51. class ColumnsArea extends ImmutablePureComponent {
  52. static contextTypes = {
  53. router: PropTypes.object.isRequired,
  54. };
  55. static propTypes = {
  56. intl: PropTypes.object.isRequired,
  57. columns: ImmutablePropTypes.list.isRequired,
  58. isModalOpen: PropTypes.bool.isRequired,
  59. singleColumn: PropTypes.bool,
  60. children: PropTypes.node,
  61. };
  62. // Corresponds to (max-width: 600px + (285px * 1) + (10px * 1)) in SCSS
  63. mediaQuery = 'matchMedia' in window && window.matchMedia('(max-width: 895px)');
  64. state = {
  65. shouldAnimate: false,
  66. renderComposePanel: !(this.mediaQuery && this.mediaQuery.matches),
  67. }
  68. componentWillReceiveProps() {
  69. if (typeof this.pendingIndex !== 'number' && this.lastIndex !== getIndex(this.context.router.history.location.pathname)) {
  70. this.setState({ shouldAnimate: false });
  71. }
  72. }
  73. componentDidMount() {
  74. if (!this.props.singleColumn) {
  75. this.node.addEventListener('wheel', this.handleWheel, supportsPassiveEvents ? { passive: true } : false);
  76. }
  77. if (this.mediaQuery) {
  78. if (this.mediaQuery.addEventListener) {
  79. this.mediaQuery.addEventListener('change', this.handleLayoutChange);
  80. } else {
  81. this.mediaQuery.addListener(this.handleLayoutChange);
  82. }
  83. this.setState({ renderComposePanel: !this.mediaQuery.matches });
  84. }
  85. this.lastIndex = getIndex(this.context.router.history.location.pathname);
  86. this.isRtlLayout = document.getElementsByTagName('body')[0].classList.contains('rtl');
  87. this.setState({ shouldAnimate: true });
  88. }
  89. componentWillUpdate(nextProps) {
  90. if (this.props.singleColumn !== nextProps.singleColumn && nextProps.singleColumn) {
  91. this.node.removeEventListener('wheel', this.handleWheel);
  92. }
  93. }
  94. componentDidUpdate(prevProps) {
  95. if (this.props.singleColumn !== prevProps.singleColumn && !this.props.singleColumn) {
  96. this.node.addEventListener('wheel', this.handleWheel, supportsPassiveEvents ? { passive: true } : false);
  97. }
  98. const newIndex = getIndex(this.context.router.history.location.pathname);
  99. if (this.lastIndex !== newIndex) {
  100. this.lastIndex = newIndex;
  101. this.setState({ shouldAnimate: true });
  102. }
  103. }
  104. componentWillUnmount () {
  105. if (!this.props.singleColumn) {
  106. this.node.removeEventListener('wheel', this.handleWheel);
  107. }
  108. if (this.mediaQuery) {
  109. if (this.mediaQuery.removeEventListener) {
  110. this.mediaQuery.removeEventListener('change', this.handleLayoutChange);
  111. } else {
  112. this.mediaQuery.removeListener(this.handleLayouteChange);
  113. }
  114. }
  115. }
  116. handleChildrenContentChange() {
  117. if (!this.props.singleColumn) {
  118. const modifier = this.isRtlLayout ? -1 : 1;
  119. this._interruptScrollAnimation = scrollRight(this.node, (this.node.scrollWidth - window.innerWidth) * modifier);
  120. }
  121. }
  122. handleLayoutChange = (e) => {
  123. this.setState({ renderComposePanel: !e.matches });
  124. }
  125. handleSwipe = (index) => {
  126. this.pendingIndex = index;
  127. const nextLinkTranslationId = links[index].props['data-preview-title-id'];
  128. const currentLinkSelector = '.tabs-bar__link.active';
  129. const nextLinkSelector = `.tabs-bar__link[data-preview-title-id="${nextLinkTranslationId}"]`;
  130. // HACK: Remove the active class from the current link and set it to the next one
  131. // React-router does this for us, but too late, feeling laggy.
  132. document.querySelector(currentLinkSelector).classList.remove('active');
  133. document.querySelector(nextLinkSelector).classList.add('active');
  134. if (!this.state.shouldAnimate && typeof this.pendingIndex === 'number') {
  135. this.context.router.history.push(getLink(this.pendingIndex));
  136. this.pendingIndex = null;
  137. }
  138. }
  139. handleAnimationEnd = () => {
  140. if (typeof this.pendingIndex === 'number') {
  141. this.context.router.history.push(getLink(this.pendingIndex));
  142. this.pendingIndex = null;
  143. }
  144. }
  145. handleWheel = () => {
  146. if (typeof this._interruptScrollAnimation !== 'function') {
  147. return;
  148. }
  149. this._interruptScrollAnimation();
  150. }
  151. setRef = (node) => {
  152. this.node = node;
  153. }
  154. renderView = (link, index) => {
  155. const columnIndex = getIndex(this.context.router.history.location.pathname);
  156. const title = this.props.intl.formatMessage({ id: link.props['data-preview-title-id'] });
  157. const icon = link.props['data-preview-icon'];
  158. const view = (index === columnIndex) ?
  159. React.cloneElement(this.props.children) :
  160. <ColumnLoading title={title} icon={icon} />;
  161. return (
  162. <div className='columns-area columns-area--mobile' key={index}>
  163. {view}
  164. </div>
  165. );
  166. }
  167. renderLoading = columnId => () => {
  168. return columnId === 'COMPOSE' ? <DrawerLoading /> : <ColumnLoading />;
  169. }
  170. renderError = (props) => {
  171. return <BundleColumnError {...props} />;
  172. }
  173. render () {
  174. const { columns, children, singleColumn, isModalOpen, intl } = this.props;
  175. const { shouldAnimate, renderComposePanel } = this.state;
  176. const columnIndex = getIndex(this.context.router.history.location.pathname);
  177. if (singleColumn) {
  178. const floatingActionButton = shouldHideFAB(this.context.router.history.location.pathname) ? null : <Link key='floating-action-button' to='/statuses/new' className='floating-action-button' aria-label={intl.formatMessage(messages.publish)}><Icon id='pencil' /></Link>;
  179. const content = columnIndex !== -1 ? (
  180. <ReactSwipeableViews key='content' hysteresis={0.2} threshold={15} index={columnIndex} onChangeIndex={this.handleSwipe} onTransitionEnd={this.handleAnimationEnd} animateTransitions={shouldAnimate} springConfig={{ duration: '400ms', delay: '0s', easeFunction: 'ease' }} style={{ height: '100%' }} disabled={disableSwiping}>
  181. {links.map(this.renderView)}
  182. </ReactSwipeableViews>
  183. ) : (
  184. <div key='content' className='columns-area columns-area--mobile'>{children}</div>
  185. );
  186. return (
  187. <div className='columns-area__panels'>
  188. <div className='columns-area__panels__pane columns-area__panels__pane--compositional'>
  189. <div className='columns-area__panels__pane__inner'>
  190. {renderComposePanel && <ComposePanel />}
  191. </div>
  192. </div>
  193. <div className='columns-area__panels__main'>
  194. <TabsBar key='tabs' />
  195. {content}
  196. </div>
  197. <div className='columns-area__panels__pane columns-area__panels__pane--start columns-area__panels__pane--navigational'>
  198. <div className='columns-area__panels__pane__inner'>
  199. <NavigationPanel />
  200. </div>
  201. </div>
  202. {floatingActionButton}
  203. </div>
  204. );
  205. }
  206. return (
  207. <div className={`columns-area ${ isModalOpen ? 'unscrollable' : '' }`} ref={this.setRef}>
  208. {columns.map(column => {
  209. const params = column.get('params', null) === null ? null : column.get('params').toJS();
  210. const other = params && params.other ? params.other : {};
  211. return (
  212. <BundleContainer key={column.get('uuid')} fetchComponent={componentMap[column.get('id')]} loading={this.renderLoading(column.get('id'))} error={this.renderError}>
  213. {SpecificComponent => <SpecificComponent columnId={column.get('uuid')} params={params} multiColumn {...other} />}
  214. </BundleContainer>
  215. );
  216. })}
  217. {React.Children.map(children, child => React.cloneElement(child, { multiColumn: true }))}
  218. </div>
  219. );
  220. }
  221. }