Lazy load components (#3879)
* feat: Lazy-load routes * feat: Lazy-load modals * feat: Lazy-load columns * refactor: Simplify Bundle API * feat: Optimize bundles * feat: Prevent flashing the waiting state * feat: Preload commonly used bundles * feat: Lazy load Compose reducers * feat: Lazy load Notifications reducer * refactor: Move all dynamic imports into one file * fix: Minor bugs * fix: Manually hydrate the lazy-loaded reducers * refactor: Move all dynamic imports to async-components * fix: Loading modal style * refactor: Avoid converting the raw state for each lazy hydration * refactor: Remove unused component * refactor: Maintain modal name * fix: Add as=script to preload link * chore: Fix lint error * fix(components/bundle): Check if timestamp is set when computing elapsed * fix: Load compose reducers for the onboarding modal
This commit is contained in:
		
							parent
							
								
									0217e15dd3
								
							
						
					
					
						commit
						40b32ffb12
					
				
					 22 changed files with 679 additions and 110 deletions
				
			
		
							
								
								
									
										25
									
								
								app/javascript/mastodon/actions/bundles.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										25
									
								
								app/javascript/mastodon/actions/bundles.js
									
									
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,25 @@ | ||||||
|  | export const BUNDLE_FETCH_REQUEST = 'BUNDLE_FETCH_REQUEST'; | ||||||
|  | export const BUNDLE_FETCH_SUCCESS = 'BUNDLE_FETCH_SUCCESS'; | ||||||
|  | export const BUNDLE_FETCH_FAIL = 'BUNDLE_FETCH_FAIL'; | ||||||
|  | 
 | ||||||
|  | export function fetchBundleRequest(skipLoading) { | ||||||
|  |   return { | ||||||
|  |     type: BUNDLE_FETCH_REQUEST, | ||||||
|  |     skipLoading, | ||||||
|  |   }; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | export function fetchBundleSuccess(skipLoading) { | ||||||
|  |   return { | ||||||
|  |     type: BUNDLE_FETCH_SUCCESS, | ||||||
|  |     skipLoading, | ||||||
|  |   }; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | export function fetchBundleFail(error, skipLoading) { | ||||||
|  |   return { | ||||||
|  |     type: BUNDLE_FETCH_FAIL, | ||||||
|  |     error, | ||||||
|  |     skipLoading, | ||||||
|  |   }; | ||||||
|  | } | ||||||
|  | @ -1,6 +1,7 @@ | ||||||
| import Immutable from 'immutable'; | import Immutable from 'immutable'; | ||||||
| 
 | 
 | ||||||
| export const STORE_HYDRATE = 'STORE_HYDRATE'; | export const STORE_HYDRATE = 'STORE_HYDRATE'; | ||||||
|  | export const STORE_HYDRATE_LAZY = 'STORE_HYDRATE_LAZY'; | ||||||
| 
 | 
 | ||||||
| const convertState = rawState => | const convertState = rawState => | ||||||
|   Immutable.fromJS(rawState, (k, v) => |   Immutable.fromJS(rawState, (k, v) => | ||||||
|  | @ -15,3 +16,10 @@ export function hydrateStore(rawState) { | ||||||
|     state, |     state, | ||||||
|   }; |   }; | ||||||
| }; | }; | ||||||
|  | 
 | ||||||
|  | export function hydrateStoreLazy(name, state) { | ||||||
|  |   return { | ||||||
|  |     type: `${STORE_HYDRATE_LAZY}-${name}`, | ||||||
|  |     state, | ||||||
|  |   }; | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | @ -5,8 +5,6 @@ import Avatar from './avatar'; | ||||||
| import AvatarOverlay from './avatar_overlay'; | import AvatarOverlay from './avatar_overlay'; | ||||||
| import RelativeTimestamp from './relative_timestamp'; | import RelativeTimestamp from './relative_timestamp'; | ||||||
| import DisplayName from './display_name'; | import DisplayName from './display_name'; | ||||||
| import MediaGallery from './media_gallery'; |  | ||||||
| import VideoPlayer from './video_player'; |  | ||||||
| import StatusContent from './status_content'; | import StatusContent from './status_content'; | ||||||
| import StatusActionBar from './status_action_bar'; | import StatusActionBar from './status_action_bar'; | ||||||
| import { FormattedMessage } from 'react-intl'; | import { FormattedMessage } from 'react-intl'; | ||||||
|  | @ -14,6 +12,11 @@ import emojify from '../emoji'; | ||||||
| import escapeTextContentForBrowser from 'escape-html'; | import escapeTextContentForBrowser from 'escape-html'; | ||||||
| import ImmutablePureComponent from 'react-immutable-pure-component'; | import ImmutablePureComponent from 'react-immutable-pure-component'; | ||||||
| import scheduleIdleTask from '../features/ui/util/schedule_idle_task'; | import scheduleIdleTask from '../features/ui/util/schedule_idle_task'; | ||||||
|  | import { MediaGallery, VideoPlayer } from '../features/ui/util/async-components'; | ||||||
|  | 
 | ||||||
|  | // We use the component (and not the container) since we do not want
 | ||||||
|  | // to use the progress bar to show download progress
 | ||||||
|  | import Bundle from '../features/ui/components/bundle'; | ||||||
| 
 | 
 | ||||||
| export default class Status extends ImmutablePureComponent { | export default class Status extends ImmutablePureComponent { | ||||||
| 
 | 
 | ||||||
|  | @ -154,6 +157,14 @@ export default class Status extends ImmutablePureComponent { | ||||||
|     this.setState({ isExpanded: !this.state.isExpanded }); |     this.setState({ isExpanded: !this.state.isExpanded }); | ||||||
|   }; |   }; | ||||||
| 
 | 
 | ||||||
|  |   renderLoadingMediaGallery () { | ||||||
|  |     return <div className='media_gallery' style={{ height: '110px' }} />; | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   renderLoadingVideoPlayer () { | ||||||
|  |     return <div className='media-spoiler-video' style={{ height: '110px' }} />; | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|   render () { |   render () { | ||||||
|     let media = null; |     let media = null; | ||||||
|     let statusAvatar; |     let statusAvatar; | ||||||
|  | @ -201,9 +212,17 @@ export default class Status extends ImmutablePureComponent { | ||||||
|       if (status.get('media_attachments').some(item => item.get('type') === 'unknown')) { |       if (status.get('media_attachments').some(item => item.get('type') === 'unknown')) { | ||||||
| 
 | 
 | ||||||
|       } else if (status.getIn(['media_attachments', 0, 'type']) === 'video') { |       } else if (status.getIn(['media_attachments', 0, 'type']) === 'video') { | ||||||
|         media = <VideoPlayer media={status.getIn(['media_attachments', 0])} sensitive={status.get('sensitive')} onOpenVideo={this.props.onOpenVideo} />; |         media = ( | ||||||
|  |           <Bundle fetchComponent={VideoPlayer} loading={this.renderLoadingVideoPlayer} onRender={this.saveHeight} > | ||||||
|  |             {Component => <Component media={status.getIn(['media_attachments', 0])} sensitive={status.get('sensitive')} onOpenVideo={this.props.onOpenVideo} />} | ||||||
|  |           </Bundle> | ||||||
|  |         ); | ||||||
|       } else { |       } else { | ||||||
|         media = <MediaGallery media={status.get('media_attachments')} sensitive={status.get('sensitive')} height={110} onOpenMedia={this.props.onOpenMedia} autoPlayGif={this.props.autoPlayGif} />; |         media = ( | ||||||
|  |           <Bundle fetchComponent={MediaGallery} loading={this.renderLoadingMediaGallery} onRender={this.saveHeight} > | ||||||
|  |             {Component => <Component media={status.get('media_attachments')} sensitive={status.get('sensitive')} height={110} onOpenMedia={this.props.onOpenMedia} autoPlayGif={this.props.autoPlayGif} />} | ||||||
|  |           </Bundle> | ||||||
|  |         ); | ||||||
|       } |       } | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -22,9 +22,10 @@ import { getLocale } from '../locales'; | ||||||
| const { localeData, messages } = getLocale(); | const { localeData, messages } = getLocale(); | ||||||
| addLocaleData(localeData); | addLocaleData(localeData); | ||||||
| 
 | 
 | ||||||
| const store = configureStore(); | export const store = configureStore(); | ||||||
| const initialState = JSON.parse(document.getElementById('initial-state').textContent); | const initialState = JSON.parse(document.getElementById('initial-state').textContent); | ||||||
| store.dispatch(hydrateStore(initialState)); | export const hydrateAction = hydrateStore(initialState); | ||||||
|  | store.dispatch(hydrateAction); | ||||||
| 
 | 
 | ||||||
| export default class Mastodon extends React.PureComponent { | export default class Mastodon extends React.PureComponent { | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -2,6 +2,7 @@ import React from 'react'; | ||||||
| import Dropdown, { DropdownTrigger, DropdownContent } from 'react-simple-dropdown'; | import Dropdown, { DropdownTrigger, DropdownContent } from 'react-simple-dropdown'; | ||||||
| import PropTypes from 'prop-types'; | import PropTypes from 'prop-types'; | ||||||
| import { defineMessages, injectIntl } from 'react-intl'; | import { defineMessages, injectIntl } from 'react-intl'; | ||||||
|  | import { EmojiPicker as EmojiPickerAsync } from '../../ui/util/async-components'; | ||||||
| 
 | 
 | ||||||
| const messages = defineMessages({ | const messages = defineMessages({ | ||||||
|   emoji: { id: 'emoji_button.label', defaultMessage: 'Insert emoji' }, |   emoji: { id: 'emoji_button.label', defaultMessage: 'Insert emoji' }, | ||||||
|  | @ -50,7 +51,7 @@ export default class EmojiPickerDropdown extends React.PureComponent { | ||||||
|     this.setState({ active: true }); |     this.setState({ active: true }); | ||||||
|     if (!EmojiPicker) { |     if (!EmojiPicker) { | ||||||
|       this.setState({ loading: true }); |       this.setState({ loading: true }); | ||||||
|       import(/* webpackChunkName: "emojione_picker" */ 'emojione-picker').then(TheEmojiPicker => { |       EmojiPickerAsync().then(TheEmojiPicker => { | ||||||
|         EmojiPicker = TheEmojiPicker.default; |         EmojiPicker = TheEmojiPicker.default; | ||||||
|         this.setState({ loading: false }); |         this.setState({ loading: false }); | ||||||
|       }).catch(() => { |       }).catch(() => { | ||||||
|  |  | ||||||
							
								
								
									
										96
									
								
								app/javascript/mastodon/features/ui/components/bundle.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										96
									
								
								app/javascript/mastodon/features/ui/components/bundle.js
									
									
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,96 @@ | ||||||
|  | import React from 'react'; | ||||||
|  | import PropTypes from 'prop-types'; | ||||||
|  | 
 | ||||||
|  | const emptyComponent = () => null; | ||||||
|  | const noop = () => { }; | ||||||
|  | 
 | ||||||
|  | class Bundle extends React.Component { | ||||||
|  | 
 | ||||||
|  |   static propTypes = { | ||||||
|  |     fetchComponent: PropTypes.func.isRequired, | ||||||
|  |     loading: PropTypes.func, | ||||||
|  |     error: PropTypes.func, | ||||||
|  |     children: PropTypes.func.isRequired, | ||||||
|  |     renderDelay: PropTypes.number, | ||||||
|  |     onRender: PropTypes.func, | ||||||
|  |     onFetch: PropTypes.func, | ||||||
|  |     onFetchSuccess: PropTypes.func, | ||||||
|  |     onFetchFail: PropTypes.func, | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   static defaultProps = { | ||||||
|  |     loading: emptyComponent, | ||||||
|  |     error: emptyComponent, | ||||||
|  |     renderDelay: 0, | ||||||
|  |     onRender: noop, | ||||||
|  |     onFetch: noop, | ||||||
|  |     onFetchSuccess: noop, | ||||||
|  |     onFetchFail: noop, | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   state = { | ||||||
|  |     mod: undefined, | ||||||
|  |     forceRender: false, | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   componentWillMount() { | ||||||
|  |     this.load(this.props); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   componentWillReceiveProps(nextProps) { | ||||||
|  |     if (nextProps.fetchComponent !== this.props.fetchComponent) { | ||||||
|  |       this.load(nextProps); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   componentDidUpdate () { | ||||||
|  |     this.props.onRender(); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   componentWillUnmount () { | ||||||
|  |     if (this.timeout) { | ||||||
|  |       clearTimeout(this.timeout); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   load = (props) => { | ||||||
|  |     const { fetchComponent, onFetch, onFetchSuccess, onFetchFail, renderDelay } = props || this.props; | ||||||
|  | 
 | ||||||
|  |     this.setState({ mod: undefined }); | ||||||
|  |     onFetch(); | ||||||
|  | 
 | ||||||
|  |     if (renderDelay !== 0) { | ||||||
|  |       this.timestamp = new Date(); | ||||||
|  |       this.timeout = setTimeout(() => this.setState({ forceRender: true }), renderDelay); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     return fetchComponent() | ||||||
|  |       .then((mod) => { | ||||||
|  |         this.setState({ mod: mod.default }); | ||||||
|  |         onFetchSuccess(); | ||||||
|  |       }) | ||||||
|  |       .catch((error) => { | ||||||
|  |         this.setState({ mod: null }); | ||||||
|  |         onFetchFail(error); | ||||||
|  |       }); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   render() { | ||||||
|  |     const { loading: Loading, error: Error, children, renderDelay } = this.props; | ||||||
|  |     const { mod, forceRender } = this.state; | ||||||
|  |     const elapsed = this.timestamp ? (new Date() - this.timestamp) : renderDelay; | ||||||
|  | 
 | ||||||
|  |     if (mod === undefined) { | ||||||
|  |       return (elapsed >= renderDelay || forceRender) ? <Loading /> : null; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     if (mod === null) { | ||||||
|  |       return <Error onRetry={this.load} />; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     return children(mod); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | export default Bundle; | ||||||
|  | @ -0,0 +1,44 @@ | ||||||
|  | import React from 'react'; | ||||||
|  | import PropTypes from 'prop-types'; | ||||||
|  | import { defineMessages, injectIntl } from 'react-intl'; | ||||||
|  | 
 | ||||||
|  | import Column from './column'; | ||||||
|  | import ColumnHeader from './column_header'; | ||||||
|  | import ColumnBackButtonSlim from '../../../components/column_back_button_slim'; | ||||||
|  | import IconButton from '../../../components/icon_button'; | ||||||
|  | 
 | ||||||
|  | const messages = defineMessages({ | ||||||
|  |   title: { id: 'bundle_column_error.title', defaultMessage: 'Network error' }, | ||||||
|  |   body: { id: 'bundle_column_error.body', defaultMessage: 'Something went wrong while loading this component.' }, | ||||||
|  |   retry: { id: 'bundle_column_error.retry', defaultMessage: 'Try again' }, | ||||||
|  | }); | ||||||
|  | 
 | ||||||
|  | class BundleColumnError extends React.Component { | ||||||
|  | 
 | ||||||
|  |   static propTypes = { | ||||||
|  |     onRetry: PropTypes.func.isRequired, | ||||||
|  |     intl: PropTypes.object.isRequired, | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   handleRetry = () => { | ||||||
|  |     this.props.onRetry(); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   render () { | ||||||
|  |     const { intl: { formatMessage } } = this.props; | ||||||
|  | 
 | ||||||
|  |     return ( | ||||||
|  |       <Column> | ||||||
|  |         <ColumnHeader icon='exclamation-circle' type={formatMessage(messages.title)} /> | ||||||
|  |         <ColumnBackButtonSlim /> | ||||||
|  |         <div className='error-column'> | ||||||
|  |           <IconButton title={formatMessage(messages.retry)} icon='refresh' onClick={this.handleRetry} size={64} /> | ||||||
|  |           {formatMessage(messages.body)} | ||||||
|  |         </div> | ||||||
|  |       </Column> | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | export default injectIntl(BundleColumnError); | ||||||
|  | @ -0,0 +1,53 @@ | ||||||
|  | import React from 'react'; | ||||||
|  | import PropTypes from 'prop-types'; | ||||||
|  | import { defineMessages, injectIntl } from 'react-intl'; | ||||||
|  | 
 | ||||||
|  | import IconButton from '../../../components/icon_button'; | ||||||
|  | 
 | ||||||
|  | const messages = defineMessages({ | ||||||
|  |   error: { id: 'bundle_modal_error.message', defaultMessage: 'Something went wrong while loading this component.' }, | ||||||
|  |   retry: { id: 'bundle_modal_error.retry', defaultMessage: 'Try again' }, | ||||||
|  |   close: { id: 'bundle_modal_error.close', defaultMessage: 'Close' }, | ||||||
|  | }); | ||||||
|  | 
 | ||||||
|  | class BundleModalError extends React.Component { | ||||||
|  | 
 | ||||||
|  |   static propTypes = { | ||||||
|  |     onRetry: PropTypes.func.isRequired, | ||||||
|  |     onClose: PropTypes.func.isRequired, | ||||||
|  |     intl: PropTypes.object.isRequired, | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   handleRetry = () => { | ||||||
|  |     this.props.onRetry(); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   render () { | ||||||
|  |     const { onClose, intl: { formatMessage } } = this.props; | ||||||
|  | 
 | ||||||
|  |     // Keep the markup in sync with <ModalLoading />
 | ||||||
|  |     // (make sure they have the same dimensions)
 | ||||||
|  |     return ( | ||||||
|  |       <div className='modal-root__modal error-modal'> | ||||||
|  |         <div className='error-modal__body'> | ||||||
|  |           <IconButton title={formatMessage(messages.retry)} icon='refresh' onClick={this.handleRetry} size={64} /> | ||||||
|  |           {formatMessage(messages.error)} | ||||||
|  |         </div> | ||||||
|  | 
 | ||||||
|  |         <div className='error-modal__footer'> | ||||||
|  |           <div> | ||||||
|  |             <button | ||||||
|  |               onClick={onClose} | ||||||
|  |               className='error-modal__nav onboarding-modal__skip' | ||||||
|  |             > | ||||||
|  |               {formatMessage(messages.close)} | ||||||
|  |             </button> | ||||||
|  |           </div> | ||||||
|  |         </div> | ||||||
|  |       </div> | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | export default injectIntl(BundleModalError); | ||||||
|  | @ -0,0 +1,13 @@ | ||||||
|  | import React from 'react'; | ||||||
|  | 
 | ||||||
|  | import Column from '../../../components/column'; | ||||||
|  | import ColumnHeader from '../../../components/column_header'; | ||||||
|  | 
 | ||||||
|  | const ColumnLoading = () => ( | ||||||
|  |   <Column> | ||||||
|  |     <ColumnHeader icon=' ' title='' multiColumn={false} /> | ||||||
|  |     <div className='scrollable' /> | ||||||
|  |   </Column> | ||||||
|  | ); | ||||||
|  | 
 | ||||||
|  | export default ColumnLoading; | ||||||
|  | @ -2,15 +2,15 @@ import React from 'react'; | ||||||
| import PropTypes from 'prop-types'; | import PropTypes from 'prop-types'; | ||||||
| import ImmutablePropTypes from 'react-immutable-proptypes'; | import ImmutablePropTypes from 'react-immutable-proptypes'; | ||||||
| import ImmutablePureComponent from 'react-immutable-pure-component'; | import ImmutablePureComponent from 'react-immutable-pure-component'; | ||||||
|  | 
 | ||||||
| import ReactSwipeable from 'react-swipeable'; | import ReactSwipeable from 'react-swipeable'; | ||||||
| import HomeTimeline from '../../home_timeline'; |  | ||||||
| import Notifications from '../../notifications'; |  | ||||||
| import PublicTimeline from '../../public_timeline'; |  | ||||||
| import CommunityTimeline from '../../community_timeline'; |  | ||||||
| import HashtagTimeline from '../../hashtag_timeline'; |  | ||||||
| import Compose from '../../compose'; |  | ||||||
| import { getPreviousLink, getNextLink } from './tabs_bar'; | import { getPreviousLink, getNextLink } from './tabs_bar'; | ||||||
| 
 | 
 | ||||||
|  | import BundleContainer from '../containers/bundle_container'; | ||||||
|  | import ColumnLoading from './column_loading'; | ||||||
|  | import BundleColumnError from './bundle_column_error'; | ||||||
|  | import { Compose, Notifications, HomeTimeline, CommunityTimeline, PublicTimeline, HashtagTimeline } from '../../ui/util/async-components'; | ||||||
|  | 
 | ||||||
| const componentMap = { | const componentMap = { | ||||||
|   'COMPOSE': Compose, |   'COMPOSE': Compose, | ||||||
|   'HOME': HomeTimeline, |   'HOME': HomeTimeline, | ||||||
|  | @ -48,6 +48,14 @@ export default class ColumnsArea extends ImmutablePureComponent { | ||||||
|     } |     } | ||||||
|   }; |   }; | ||||||
| 
 | 
 | ||||||
|  |   renderLoading = () => { | ||||||
|  |     return <ColumnLoading />; | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   renderError = (props) => { | ||||||
|  |     return <BundleColumnError {...props} />; | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|   render () { |   render () { | ||||||
|     const { columns, children, singleColumn } = this.props; |     const { columns, children, singleColumn } = this.props; | ||||||
| 
 | 
 | ||||||
|  | @ -62,9 +70,13 @@ export default class ColumnsArea extends ImmutablePureComponent { | ||||||
|     return ( |     return ( | ||||||
|       <div className='columns-area'> |       <div className='columns-area'> | ||||||
|         {columns.map(column => { |         {columns.map(column => { | ||||||
|           const SpecificComponent = componentMap[column.get('id')]; |  | ||||||
|           const params = column.get('params', null) === null ? null : column.get('params').toJS(); |           const params = column.get('params', null) === null ? null : column.get('params').toJS(); | ||||||
|           return <SpecificComponent key={column.get('uuid')} columnId={column.get('uuid')} params={params} multiColumn />; | 
 | ||||||
|  |           return ( | ||||||
|  |             <BundleContainer key={column.get('uuid')} fetchComponent={componentMap[column.get('id')]} loading={this.renderLoading} error={this.renderError}> | ||||||
|  |               {SpecificComponent => <SpecificComponent columnId={column.get('uuid')} params={params} multiColumn />} | ||||||
|  |             </BundleContainer> | ||||||
|  |           ); | ||||||
|         })} |         })} | ||||||
| 
 | 
 | ||||||
|         {React.Children.map(children, child => React.cloneElement(child, { multiColumn: true }))} |         {React.Children.map(children, child => React.cloneElement(child, { multiColumn: true }))} | ||||||
|  |  | ||||||
|  | @ -0,0 +1,20 @@ | ||||||
|  | import React from 'react'; | ||||||
|  | 
 | ||||||
|  | import LoadingIndicator from '../../../components/loading_indicator'; | ||||||
|  | 
 | ||||||
|  | // Keep the markup in sync with <BundleModalError />
 | ||||||
|  | // (make sure they have the same dimensions)
 | ||||||
|  | const ModalLoading = () => ( | ||||||
|  |   <div className='modal-root__modal error-modal'> | ||||||
|  |     <div className='error-modal__body'> | ||||||
|  |       <LoadingIndicator /> | ||||||
|  |     </div> | ||||||
|  |     <div className='error-modal__footer'> | ||||||
|  |       <div> | ||||||
|  |         <button className='error-modal__nav onboarding-modal__skip' /> | ||||||
|  |       </div> | ||||||
|  |     </div> | ||||||
|  |   </div> | ||||||
|  | ); | ||||||
|  | 
 | ||||||
|  | export default ModalLoading; | ||||||
|  | @ -1,13 +1,18 @@ | ||||||
| import React from 'react'; | import React from 'react'; | ||||||
| import PropTypes from 'prop-types'; | import PropTypes from 'prop-types'; | ||||||
| import MediaModal from './media_modal'; |  | ||||||
| import OnboardingModal from './onboarding_modal'; |  | ||||||
| import VideoModal from './video_modal'; |  | ||||||
| import BoostModal from './boost_modal'; |  | ||||||
| import ConfirmationModal from './confirmation_modal'; |  | ||||||
| import ReportModal from './report_modal'; |  | ||||||
| import TransitionMotion from 'react-motion/lib/TransitionMotion'; | import TransitionMotion from 'react-motion/lib/TransitionMotion'; | ||||||
| import spring from 'react-motion/lib/spring'; | import spring from 'react-motion/lib/spring'; | ||||||
|  | import BundleContainer from '../containers/bundle_container'; | ||||||
|  | import BundleModalError from './bundle_modal_error'; | ||||||
|  | import ModalLoading from './modal_loading'; | ||||||
|  | import { | ||||||
|  |   MediaModal, | ||||||
|  |   OnboardingModal, | ||||||
|  |   VideoModal, | ||||||
|  |   BoostModal, | ||||||
|  |   ConfirmationModal, | ||||||
|  |   ReportModal, | ||||||
|  | } from '../../../features/ui/util/async-components'; | ||||||
| 
 | 
 | ||||||
| const MODAL_COMPONENTS = { | const MODAL_COMPONENTS = { | ||||||
|   'MEDIA': MediaModal, |   'MEDIA': MediaModal, | ||||||
|  | @ -49,6 +54,22 @@ export default class ModalRoot extends React.PureComponent { | ||||||
|     return { opacity: spring(0), scale: spring(0.98) }; |     return { opacity: spring(0), scale: spring(0.98) }; | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|  |   renderModal = (SpecificComponent) => { | ||||||
|  |     const { props, onClose } = this.props; | ||||||
|  | 
 | ||||||
|  |     return <SpecificComponent {...props} onClose={onClose} />; | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   renderLoading = () => { | ||||||
|  |     return <ModalLoading />; | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   renderError = (props) => { | ||||||
|  |     const { onClose } = this.props; | ||||||
|  | 
 | ||||||
|  |     return <BundleModalError {...props} onClose={onClose} />; | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|   render () { |   render () { | ||||||
|     const { type, props, onClose } = this.props; |     const { type, props, onClose } = this.props; | ||||||
|     const visible = !!type; |     const visible = !!type; | ||||||
|  | @ -70,18 +91,14 @@ export default class ModalRoot extends React.PureComponent { | ||||||
|       > |       > | ||||||
|         {interpolatedStyles => |         {interpolatedStyles => | ||||||
|           <div className='modal-root'> |           <div className='modal-root'> | ||||||
|             {interpolatedStyles.map(({ key, data: { type, props }, style }) => { |             {interpolatedStyles.map(({ key, data: { type }, style }) => ( | ||||||
|               const SpecificComponent = MODAL_COMPONENTS[type]; |  | ||||||
| 
 |  | ||||||
|               return ( |  | ||||||
|               <div key={key} style={{ pointerEvents: visible ? 'auto' : 'none' }}> |               <div key={key} style={{ pointerEvents: visible ? 'auto' : 'none' }}> | ||||||
|                 <div role='presentation' className='modal-root__overlay' style={{ opacity: style.opacity }} onClick={onClose} /> |                 <div role='presentation' className='modal-root__overlay' style={{ opacity: style.opacity }} onClick={onClose} /> | ||||||
|                 <div className='modal-root__container' style={{ opacity: style.opacity, transform: `translateZ(0px) scale(${style.scale})` }}> |                 <div className='modal-root__container' style={{ opacity: style.opacity, transform: `translateZ(0px) scale(${style.scale})` }}> | ||||||
|                     <SpecificComponent {...props} onClose={onClose} /> |                   <BundleContainer fetchComponent={MODAL_COMPONENTS[type]} loading={this.renderLoading} error={this.renderError} renderDelay={200}>{this.renderModal}</BundleContainer> | ||||||
|                 </div> |                 </div> | ||||||
|               </div> |               </div> | ||||||
|               ); |             ))} | ||||||
|             })} |  | ||||||
|           </div> |           </div> | ||||||
|         } |         } | ||||||
|       </TransitionMotion> |       </TransitionMotion> | ||||||
|  |  | ||||||
|  | @ -0,0 +1,19 @@ | ||||||
|  | import { connect } from 'react-redux'; | ||||||
|  | 
 | ||||||
|  | import Bundle from '../components/bundle'; | ||||||
|  | 
 | ||||||
|  | import { fetchBundleRequest, fetchBundleSuccess, fetchBundleFail } from '../../../actions/bundles'; | ||||||
|  | 
 | ||||||
|  | const mapDispatchToProps = dispatch => ({ | ||||||
|  |   onFetch () { | ||||||
|  |     dispatch(fetchBundleRequest()); | ||||||
|  |   }, | ||||||
|  |   onFetchSuccess () { | ||||||
|  |     dispatch(fetchBundleSuccess()); | ||||||
|  |   }, | ||||||
|  |   onFetchFail (error) { | ||||||
|  |     dispatch(fetchBundleFail(error)); | ||||||
|  |   }, | ||||||
|  | }); | ||||||
|  | 
 | ||||||
|  | export default connect(null, mapDispatchToProps)(Bundle); | ||||||
|  | @ -1,7 +1,5 @@ | ||||||
| import React from 'react'; | import React from 'react'; | ||||||
| import classNames from 'classnames'; | import classNames from 'classnames'; | ||||||
| import Switch from 'react-router-dom/Switch'; |  | ||||||
| import Route from 'react-router-dom/Route'; |  | ||||||
| import Redirect from 'react-router-dom/Redirect'; | import Redirect from 'react-router-dom/Redirect'; | ||||||
| import NotificationsContainer from './containers/notifications_container'; | import NotificationsContainer from './containers/notifications_container'; | ||||||
| import PropTypes from 'prop-types'; | import PropTypes from 'prop-types'; | ||||||
|  | @ -14,64 +12,40 @@ import { debounce } from 'lodash'; | ||||||
| import { uploadCompose } from '../../actions/compose'; | import { uploadCompose } from '../../actions/compose'; | ||||||
| import { refreshHomeTimeline } from '../../actions/timelines'; | import { refreshHomeTimeline } from '../../actions/timelines'; | ||||||
| import { refreshNotifications } from '../../actions/notifications'; | import { refreshNotifications } from '../../actions/notifications'; | ||||||
|  | import { WrappedSwitch, WrappedRoute } from './util/react_router_helpers'; | ||||||
| import UploadArea from './components/upload_area'; | import UploadArea from './components/upload_area'; | ||||||
|  | import { store } from '../../containers/mastodon'; | ||||||
| import ColumnsAreaContainer from './containers/columns_area_container'; | import ColumnsAreaContainer from './containers/columns_area_container'; | ||||||
| import Status from '../../features/status'; | import { | ||||||
| import GettingStarted from '../../features/getting_started'; |   Compose, | ||||||
| import PublicTimeline from '../../features/public_timeline'; |   Status, | ||||||
| import CommunityTimeline from '../../features/community_timeline'; |   GettingStarted, | ||||||
| import AccountTimeline from '../../features/account_timeline'; |   PublicTimeline, | ||||||
| import AccountGallery from '../../features/account_gallery'; |   CommunityTimeline, | ||||||
| import HomeTimeline from '../../features/home_timeline'; |   AccountTimeline, | ||||||
| import Compose from '../../features/compose'; |   AccountGallery, | ||||||
| import Followers from '../../features/followers'; |   HomeTimeline, | ||||||
| import Following from '../../features/following'; |   Followers, | ||||||
| import Reblogs from '../../features/reblogs'; |   Following, | ||||||
| import Favourites from '../../features/favourites'; |   Reblogs, | ||||||
| import HashtagTimeline from '../../features/hashtag_timeline'; |   Favourites, | ||||||
| import Notifications from '../../features/notifications'; |   HashtagTimeline, | ||||||
| import FollowRequests from '../../features/follow_requests'; |   Notifications as AsyncNotifications, | ||||||
| import GenericNotFound from '../../features/generic_not_found'; |   FollowRequests, | ||||||
| import FavouritedStatuses from '../../features/favourited_statuses'; |   GenericNotFound, | ||||||
| import Blocks from '../../features/blocks'; |   FavouritedStatuses, | ||||||
| import Mutes from '../../features/mutes'; |   Blocks, | ||||||
|  |   Mutes, | ||||||
|  | } from './util/async-components'; | ||||||
| 
 | 
 | ||||||
| // Small wrapper to pass multiColumn to the route components
 | const Notifications = () => AsyncNotifications().then(component => { | ||||||
| const WrappedSwitch = ({ multiColumn, children }) => ( |   store.dispatch(refreshNotifications()); | ||||||
|   <Switch> |   return component; | ||||||
|     {React.Children.map(children, child => React.cloneElement(child, { multiColumn }))} | }); | ||||||
|   </Switch> |  | ||||||
| ); |  | ||||||
| 
 | 
 | ||||||
| WrappedSwitch.propTypes = { | // Dummy import, to make sure that <Status /> ends up in the application bundle.
 | ||||||
|   multiColumn: PropTypes.bool, | // Without this it ends up in ~8 very commonly used bundles.
 | ||||||
|   children: PropTypes.node, | import '../../components/status'; | ||||||
| }; |  | ||||||
| 
 |  | ||||||
| // Small Wraper to extract the params from the route and pass
 |  | ||||||
| // them to the rendered component, together with the content to
 |  | ||||||
| // be rendered inside (the children)
 |  | ||||||
| class WrappedRoute extends React.Component { |  | ||||||
| 
 |  | ||||||
|   static propTypes = { |  | ||||||
|     component: PropTypes.func.isRequired, |  | ||||||
|     content: PropTypes.node, |  | ||||||
|     multiColumn: PropTypes.bool, |  | ||||||
|   } |  | ||||||
| 
 |  | ||||||
|   renderComponent = ({ match: { params } }) => { |  | ||||||
|     const { component: Component, content, multiColumn } = this.props; |  | ||||||
| 
 |  | ||||||
|     return <Component params={params} multiColumn={multiColumn}>{content}</Component>; |  | ||||||
|   } |  | ||||||
| 
 |  | ||||||
|   render () { |  | ||||||
|     const { component: Component, content, ...rest } = this.props; |  | ||||||
| 
 |  | ||||||
|     return <Route {...rest} render={this.renderComponent} />; |  | ||||||
|   } |  | ||||||
| 
 |  | ||||||
| } |  | ||||||
| 
 | 
 | ||||||
| const mapStateToProps = state => ({ | const mapStateToProps = state => ({ | ||||||
|   systemFontUi: state.getIn(['meta', 'system_font_ui']), |   systemFontUi: state.getIn(['meta', 'system_font_ui']), | ||||||
|  | @ -162,7 +136,6 @@ export default class UI extends React.PureComponent { | ||||||
|     document.addEventListener('dragend', this.handleDragEnd, false); |     document.addEventListener('dragend', this.handleDragEnd, false); | ||||||
| 
 | 
 | ||||||
|     this.props.dispatch(refreshHomeTimeline()); |     this.props.dispatch(refreshHomeTimeline()); | ||||||
|     this.props.dispatch(refreshNotifications()); |  | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   componentWillUnmount () { |   componentWillUnmount () { | ||||||
|  |  | ||||||
							
								
								
									
										143
									
								
								app/javascript/mastodon/features/ui/util/async-components.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										143
									
								
								app/javascript/mastodon/features/ui/util/async-components.js
									
									
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,143 @@ | ||||||
|  | import { store } from '../../../containers/mastodon'; | ||||||
|  | import { injectAsyncReducer } from '../../../store/configureStore'; | ||||||
|  | 
 | ||||||
|  | // NOTE: When lazy-loading reducers, make sure to add them
 | ||||||
|  | // to application.html.haml (if the component is preloaded there)
 | ||||||
|  | 
 | ||||||
|  | export function EmojiPicker () { | ||||||
|  |   return import(/* webpackChunkName: "emojione_picker" */'emojione-picker'); | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | export function Compose () { | ||||||
|  |   return Promise.all([ | ||||||
|  |     import(/* webpackChunkName: "features/compose" */'../../compose'), | ||||||
|  |     import(/* webpackChunkName: "reducers/compose" */'../../../reducers/compose'), | ||||||
|  |     import(/* webpackChunkName: "reducers/media_attachments" */'../../../reducers/media_attachments'), | ||||||
|  |     import(/* webpackChunkName: "reducers/search" */'../../../reducers/search'), | ||||||
|  |   ]).then(([component, composeReducer, mediaAttachmentsReducer, searchReducer]) => { | ||||||
|  |     injectAsyncReducer(store, 'compose', composeReducer.default); | ||||||
|  |     injectAsyncReducer(store, 'media_attachments', mediaAttachmentsReducer.default); | ||||||
|  |     injectAsyncReducer(store, 'search', searchReducer.default); | ||||||
|  | 
 | ||||||
|  |     return component; | ||||||
|  |   }); | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | export function Notifications () { | ||||||
|  |   return Promise.all([ | ||||||
|  |     import(/* webpackChunkName: "features/notifications" */'../../notifications'), | ||||||
|  |     import(/* webpackChunkName: "reducers/notifications" */'../../../reducers/notifications'), | ||||||
|  |   ]).then(([component, notificationsReducer]) => { | ||||||
|  |     injectAsyncReducer(store, 'notifications', notificationsReducer.default); | ||||||
|  | 
 | ||||||
|  |     return component; | ||||||
|  |   }); | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | export function HomeTimeline () { | ||||||
|  |   return import(/* webpackChunkName: "features/home_timeline" */'../../home_timeline'); | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | export function PublicTimeline () { | ||||||
|  |   return import(/* webpackChunkName: "features/public_timeline" */'../../public_timeline'); | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | export function CommunityTimeline () { | ||||||
|  |   return import(/* webpackChunkName: "features/community_timeline" */'../../community_timeline'); | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | export function HashtagTimeline () { | ||||||
|  |   return import(/* webpackChunkName: "features/hashtag_timeline" */'../../hashtag_timeline'); | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | export function Status () { | ||||||
|  |   return import(/* webpackChunkName: "features/status" */'../../status'); | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | export function GettingStarted () { | ||||||
|  |   return import(/* webpackChunkName: "features/getting_started" */'../../getting_started'); | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | export function AccountTimeline () { | ||||||
|  |   return import(/* webpackChunkName: "features/account_timeline" */'../../account_timeline'); | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | export function AccountGallery () { | ||||||
|  |   return import(/* webpackChunkName: "features/account_gallery" */'../../account_gallery'); | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | export function Followers () { | ||||||
|  |   return import(/* webpackChunkName: "features/followers" */'../../followers'); | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | export function Following () { | ||||||
|  |   return import(/* webpackChunkName: "features/following" */'../../following'); | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | export function Reblogs () { | ||||||
|  |   return import(/* webpackChunkName: "features/reblogs" */'../../reblogs'); | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | export function Favourites () { | ||||||
|  |   return import(/* webpackChunkName: "features/favourites" */'../../favourites'); | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | export function FollowRequests () { | ||||||
|  |   return import(/* webpackChunkName: "features/follow_requests" */'../../follow_requests'); | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | export function GenericNotFound () { | ||||||
|  |   return import(/* webpackChunkName: "features/generic_not_found" */'../../generic_not_found'); | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | export function FavouritedStatuses () { | ||||||
|  |   return import(/* webpackChunkName: "features/favourited_statuses" */'../../favourited_statuses'); | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | export function Blocks () { | ||||||
|  |   return import(/* webpackChunkName: "features/blocks" */'../../blocks'); | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | export function Mutes () { | ||||||
|  |   return import(/* webpackChunkName: "features/mutes" */'../../mutes'); | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | export function MediaModal () { | ||||||
|  |   return import(/* webpackChunkName: "modals/media_modal" */'../components/media_modal'); | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | export function OnboardingModal () { | ||||||
|  |   return Promise.all([ | ||||||
|  |     import(/* webpackChunkName: "modals/onboarding_modal" */'../components/onboarding_modal'), | ||||||
|  |     import(/* webpackChunkName: "reducers/compose" */'../../../reducers/compose'), | ||||||
|  |     import(/* webpackChunkName: "reducers/media_attachments" */'../../../reducers/media_attachments'), | ||||||
|  |   ]).then(([component, composeReducer, mediaAttachmentsReducer]) => { | ||||||
|  |     injectAsyncReducer(store, 'compose', composeReducer.default); | ||||||
|  |     injectAsyncReducer(store, 'media_attachments', mediaAttachmentsReducer.default); | ||||||
|  |     return component; | ||||||
|  |   }); | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | export function VideoModal () { | ||||||
|  |   return import(/* webpackChunkName: "modals/video_modal" */'../components/video_modal'); | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | export function BoostModal () { | ||||||
|  |   return import(/* webpackChunkName: "modals/boost_modal" */'../components/boost_modal'); | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | export function ConfirmationModal () { | ||||||
|  |   return import(/* webpackChunkName: "modals/confirmation_modal" */'../components/confirmation_modal'); | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | export function ReportModal () { | ||||||
|  |   return import(/* webpackChunkName: "modals/report_modal" */'../components/report_modal'); | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | export function MediaGallery () { | ||||||
|  |   return import(/* webpackChunkName: "status/MediaGallery" */'../../../components/media_gallery'); | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | export function VideoPlayer () { | ||||||
|  |   return import(/* webpackChunkName: "status/VideoPlayer" */'../../../components/video_player'); | ||||||
|  | } | ||||||
|  | @ -0,0 +1,65 @@ | ||||||
|  | import React from 'react'; | ||||||
|  | import PropTypes from 'prop-types'; | ||||||
|  | import Switch from 'react-router-dom/Switch'; | ||||||
|  | import Route from 'react-router-dom/Route'; | ||||||
|  | 
 | ||||||
|  | import ColumnLoading from '../components/column_loading'; | ||||||
|  | import BundleColumnError from '../components/bundle_column_error'; | ||||||
|  | import BundleContainer from '../containers/bundle_container'; | ||||||
|  | 
 | ||||||
|  | // Small wrapper to pass multiColumn to the route components
 | ||||||
|  | export const WrappedSwitch = ({ multiColumn, children }) => ( | ||||||
|  |   <Switch> | ||||||
|  |     {React.Children.map(children, child => React.cloneElement(child, { multiColumn }))} | ||||||
|  |   </Switch> | ||||||
|  | ); | ||||||
|  | 
 | ||||||
|  | WrappedSwitch.propTypes = { | ||||||
|  |   multiColumn: PropTypes.bool, | ||||||
|  |   children: PropTypes.node, | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | // Small Wraper to extract the params from the route and pass
 | ||||||
|  | // them to the rendered component, together with the content to
 | ||||||
|  | // be rendered inside (the children)
 | ||||||
|  | export class WrappedRoute extends React.Component { | ||||||
|  | 
 | ||||||
|  |   static propTypes = { | ||||||
|  |     component: PropTypes.func.isRequired, | ||||||
|  |     content: PropTypes.node, | ||||||
|  |     multiColumn: PropTypes.bool, | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   renderComponent = ({ match }) => { | ||||||
|  |     this.match = match; // Needed for this.renderBundle
 | ||||||
|  | 
 | ||||||
|  |     const { component } = this.props; | ||||||
|  | 
 | ||||||
|  |     return ( | ||||||
|  |       <BundleContainer fetchComponent={component} loading={this.renderLoading} error={this.renderError}> | ||||||
|  |         {this.renderBundle} | ||||||
|  |       </BundleContainer> | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   renderLoading = () => { | ||||||
|  |     return <ColumnLoading />; | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   renderError = (props) => { | ||||||
|  |     return <BundleColumnError {...props} />; | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   renderBundle = (Component) => { | ||||||
|  |     const { match: { params }, props: { content, multiColumn } } = this; | ||||||
|  | 
 | ||||||
|  |     return <Component params={params} multiColumn={multiColumn}>{content}</Component>; | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   render () { | ||||||
|  |     const { component: Component, content, ...rest } = this.props; | ||||||
|  | 
 | ||||||
|  |     return <Route {...rest} render={this.renderComponent} />; | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  | } | ||||||
|  | @ -23,7 +23,7 @@ import { | ||||||
|   COMPOSE_EMOJI_INSERT, |   COMPOSE_EMOJI_INSERT, | ||||||
| } from '../actions/compose'; | } from '../actions/compose'; | ||||||
| import { TIMELINE_DELETE } from '../actions/timelines'; | import { TIMELINE_DELETE } from '../actions/timelines'; | ||||||
| import { STORE_HYDRATE } from '../actions/store'; | import { STORE_HYDRATE_LAZY } from '../actions/store'; | ||||||
| import Immutable from 'immutable'; | import Immutable from 'immutable'; | ||||||
| import uuid from '../uuid'; | import uuid from '../uuid'; | ||||||
| 
 | 
 | ||||||
|  | @ -134,7 +134,7 @@ const privacyPreference = (a, b) => { | ||||||
| 
 | 
 | ||||||
| export default function compose(state = initialState, action) { | export default function compose(state = initialState, action) { | ||||||
|   switch(action.type) { |   switch(action.type) { | ||||||
|   case STORE_HYDRATE: |   case `${STORE_HYDRATE_LAZY}-compose`: | ||||||
|     return clearAll(state.merge(action.state.get('compose'))); |     return clearAll(state.merge(action.state.get('compose'))); | ||||||
|   case COMPOSE_MOUNT: |   case COMPOSE_MOUNT: | ||||||
|     return state.set('mounted', true); |     return state.set('mounted', true); | ||||||
|  |  | ||||||
|  | @ -1,7 +1,6 @@ | ||||||
| import { combineReducers } from 'redux-immutable'; | import { combineReducers } from 'redux-immutable'; | ||||||
| import timelines from './timelines'; | import timelines from './timelines'; | ||||||
| import meta from './meta'; | import meta from './meta'; | ||||||
| import compose from './compose'; |  | ||||||
| import alerts from './alerts'; | import alerts from './alerts'; | ||||||
| import { loadingBarReducer } from 'react-redux-loading-bar'; | import { loadingBarReducer } from 'react-redux-loading-bar'; | ||||||
| import modal from './modal'; | import modal from './modal'; | ||||||
|  | @ -9,20 +8,16 @@ import user_lists from './user_lists'; | ||||||
| import accounts from './accounts'; | import accounts from './accounts'; | ||||||
| import accounts_counters from './accounts_counters'; | import accounts_counters from './accounts_counters'; | ||||||
| import statuses from './statuses'; | import statuses from './statuses'; | ||||||
| import media_attachments from './media_attachments'; |  | ||||||
| import relationships from './relationships'; | import relationships from './relationships'; | ||||||
| import search from './search'; |  | ||||||
| import notifications from './notifications'; |  | ||||||
| import settings from './settings'; | import settings from './settings'; | ||||||
| import status_lists from './status_lists'; | import status_lists from './status_lists'; | ||||||
| import cards from './cards'; | import cards from './cards'; | ||||||
| import reports from './reports'; | import reports from './reports'; | ||||||
| import contexts from './contexts'; | import contexts from './contexts'; | ||||||
| 
 | 
 | ||||||
| export default combineReducers({ | const reducers = { | ||||||
|   timelines, |   timelines, | ||||||
|   meta, |   meta, | ||||||
|   compose, |  | ||||||
|   alerts, |   alerts, | ||||||
|   loadingBar: loadingBarReducer, |   loadingBar: loadingBarReducer, | ||||||
|   modal, |   modal, | ||||||
|  | @ -30,13 +25,19 @@ export default combineReducers({ | ||||||
|   status_lists, |   status_lists, | ||||||
|   accounts, |   accounts, | ||||||
|   accounts_counters, |   accounts_counters, | ||||||
|   media_attachments, |  | ||||||
|   statuses, |   statuses, | ||||||
|   relationships, |   relationships, | ||||||
|   search, |  | ||||||
|   notifications, |  | ||||||
|   settings, |   settings, | ||||||
|   cards, |   cards, | ||||||
|   reports, |   reports, | ||||||
|   contexts, |   contexts, | ||||||
| }); | }; | ||||||
|  | 
 | ||||||
|  | export function createReducer(asyncReducers) { | ||||||
|  |   return combineReducers({ | ||||||
|  |     ...reducers, | ||||||
|  |     ...asyncReducers, | ||||||
|  |   }); | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | export default combineReducers(reducers); | ||||||
|  |  | ||||||
|  | @ -1,4 +1,4 @@ | ||||||
| import { STORE_HYDRATE } from '../actions/store'; | import { STORE_HYDRATE_LAZY } from '../actions/store'; | ||||||
| import Immutable from 'immutable'; | import Immutable from 'immutable'; | ||||||
| 
 | 
 | ||||||
| const initialState = Immutable.Map({ | const initialState = Immutable.Map({ | ||||||
|  | @ -7,7 +7,7 @@ const initialState = Immutable.Map({ | ||||||
| 
 | 
 | ||||||
| export default function meta(state = initialState, action) { | export default function meta(state = initialState, action) { | ||||||
|   switch(action.type) { |   switch(action.type) { | ||||||
|   case STORE_HYDRATE: |   case `${STORE_HYDRATE_LAZY}-media_attachments`: | ||||||
|     return state.merge(action.state.get('media_attachments')); |     return state.merge(action.state.get('media_attachments')); | ||||||
|   default: |   default: | ||||||
|     return state; |     return state; | ||||||
|  |  | ||||||
|  | @ -1,15 +1,36 @@ | ||||||
| import { createStore, applyMiddleware, compose } from 'redux'; | import { createStore, applyMiddleware, compose } from 'redux'; | ||||||
| import thunk from 'redux-thunk'; | import thunk from 'redux-thunk'; | ||||||
| import appReducer from '../reducers'; | import appReducer, { createReducer } from '../reducers'; | ||||||
|  | import { hydrateStoreLazy } from '../actions/store'; | ||||||
|  | import { hydrateAction } from '../containers/mastodon'; | ||||||
| import loadingBarMiddleware from '../middleware/loading_bar'; | import loadingBarMiddleware from '../middleware/loading_bar'; | ||||||
| import errorsMiddleware from '../middleware/errors'; | import errorsMiddleware from '../middleware/errors'; | ||||||
| import soundsMiddleware from '../middleware/sounds'; | import soundsMiddleware from '../middleware/sounds'; | ||||||
| 
 | 
 | ||||||
| export default function configureStore() { | export default function configureStore() { | ||||||
|   return createStore(appReducer, compose(applyMiddleware( |   const store = createStore(appReducer, compose(applyMiddleware( | ||||||
|     thunk, |     thunk, | ||||||
|     loadingBarMiddleware({ promiseTypeSuffixes: ['REQUEST', 'SUCCESS', 'FAIL'] }), |     loadingBarMiddleware({ promiseTypeSuffixes: ['REQUEST', 'SUCCESS', 'FAIL'] }), | ||||||
|     errorsMiddleware(), |     errorsMiddleware(), | ||||||
|     soundsMiddleware() |     soundsMiddleware() | ||||||
|   ), window.devToolsExtension ? window.devToolsExtension() : f => f)); |   ), window.devToolsExtension ? window.devToolsExtension() : f => f)); | ||||||
|  | 
 | ||||||
|  |   store.asyncReducers = { }; | ||||||
|  | 
 | ||||||
|  |   return store; | ||||||
| }; | }; | ||||||
|  | 
 | ||||||
|  | export function injectAsyncReducer(store, name, asyncReducer) { | ||||||
|  |   if (!store.asyncReducers[name]) { | ||||||
|  |     // Keep track that we injected this reducer
 | ||||||
|  |     store.asyncReducers[name] = asyncReducer; | ||||||
|  | 
 | ||||||
|  |     // Add the current reducer to the store
 | ||||||
|  |     store.replaceReducer(createReducer(store.asyncReducers)); | ||||||
|  | 
 | ||||||
|  |     // The state this reducer handles defaults to its initial state (stored inside the reducer)
 | ||||||
|  |     // But that state may be out of date because of the server-side hydration, so we replay
 | ||||||
|  |     // the hydration action but only for this reducer (all async reducers must listen for this dynamic action)
 | ||||||
|  |     store.dispatch(hydrateStoreLazy(name, hydrateAction.state)); | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | @ -2300,7 +2300,8 @@ button.icon-button.active i.fa-retweet { | ||||||
|   vertical-align: middle; |   vertical-align: middle; | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| .empty-column-indicator { | .empty-column-indicator, | ||||||
|  | .error-column { | ||||||
|   color: lighten($ui-base-color, 20%); |   color: lighten($ui-base-color, 20%); | ||||||
|   background: $ui-base-color; |   background: $ui-base-color; | ||||||
|   text-align: center; |   text-align: center; | ||||||
|  | @ -2326,6 +2327,10 @@ button.icon-button.active i.fa-retweet { | ||||||
|   } |   } | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | .error-column { | ||||||
|  |   flex-direction: column; | ||||||
|  | } | ||||||
|  | 
 | ||||||
| @keyframes pulse { | @keyframes pulse { | ||||||
|   0% { |   0% { | ||||||
|     opacity: 1; |     opacity: 1; | ||||||
|  | @ -2909,7 +2914,8 @@ button.icon-button.active i.fa-retweet { | ||||||
|   z-index: 100; |   z-index: 100; | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| .onboarding-modal { | .onboarding-modal, | ||||||
|  | .error-modal { | ||||||
|   background: $ui-secondary-color; |   background: $ui-secondary-color; | ||||||
|   color: $ui-base-color; |   color: $ui-base-color; | ||||||
|   border-radius: 8px; |   border-radius: 8px; | ||||||
|  | @ -2918,7 +2924,8 @@ button.icon-button.active i.fa-retweet { | ||||||
|   flex-direction: column; |   flex-direction: column; | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| .onboarding-modal__pager { | .onboarding-modal__pager, | ||||||
|  | .error-modal__body { | ||||||
|   height: 80vh; |   height: 80vh; | ||||||
|   width: 80vw; |   width: 80vw; | ||||||
|   max-width: 520px; |   max-width: 520px; | ||||||
|  | @ -2943,6 +2950,14 @@ button.icon-button.active i.fa-retweet { | ||||||
|   } |   } | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | .error-modal__body { | ||||||
|  |   display: flex; | ||||||
|  |   flex-direction: column; | ||||||
|  |   justify-content: center; | ||||||
|  |   align-items: center; | ||||||
|  |   text-align: center; | ||||||
|  | } | ||||||
|  | 
 | ||||||
| @media screen and (max-width: 550px) { | @media screen and (max-width: 550px) { | ||||||
|   .onboarding-modal { |   .onboarding-modal { | ||||||
|     width: 100%; |     width: 100%; | ||||||
|  | @ -2959,7 +2974,8 @@ button.icon-button.active i.fa-retweet { | ||||||
|   } |   } | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| .onboarding-modal__paginator { | .onboarding-modal__paginator, | ||||||
|  | .error-modal__footer { | ||||||
|   flex: 0 0 auto; |   flex: 0 0 auto; | ||||||
|   background: darken($ui-secondary-color, 8%); |   background: darken($ui-secondary-color, 8%); | ||||||
|   display: flex; |   display: flex; | ||||||
|  | @ -2969,7 +2985,8 @@ button.icon-button.active i.fa-retweet { | ||||||
|     min-width: 33px; |     min-width: 33px; | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   .onboarding-modal__nav { |   .onboarding-modal__nav, | ||||||
|  |   .error-modal__nav { | ||||||
|     color: darken($ui-secondary-color, 34%); |     color: darken($ui-secondary-color, 34%); | ||||||
|     background-color: transparent; |     background-color: transparent; | ||||||
|     border: 0; |     border: 0; | ||||||
|  | @ -2992,6 +3009,10 @@ button.icon-button.active i.fa-retweet { | ||||||
|   } |   } | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | .error-modal__footer { | ||||||
|  |   justify-content: center; | ||||||
|  | } | ||||||
|  | 
 | ||||||
| .onboarding-modal__dots { | .onboarding-modal__dots { | ||||||
|   flex: 1 1 auto; |   flex: 1 1 auto; | ||||||
|   display: flex; |   display: flex; | ||||||
|  |  | ||||||
|  | @ -20,6 +20,23 @@ | ||||||
| 
 | 
 | ||||||
|     = stylesheet_pack_tag 'application', media: 'all' |     = stylesheet_pack_tag 'application', media: 'all' | ||||||
|     = javascript_pack_tag 'common', integrity: true, crossorigin: 'anonymous' |     = javascript_pack_tag 'common', integrity: true, crossorigin: 'anonymous' | ||||||
|  | 
 | ||||||
|  |     = javascript_pack_tag 'features/getting_started', integrity: true, crossorigin: 'anonymous', rel: 'preload', as: 'script' | ||||||
|  | 
 | ||||||
|  |     = javascript_pack_tag 'features/compose', integrity: true, crossorigin: 'anonymous', rel: 'preload', as: 'script' | ||||||
|  |     = javascript_pack_tag 'reducers/compose', integrity: true, crossorigin: 'anonymous', rel: 'preload', as: 'script' | ||||||
|  |     = javascript_pack_tag 'reducers/media_attachments', integrity: true, crossorigin: 'anonymous', rel: 'preload', as: 'script' | ||||||
|  |     = javascript_pack_tag 'reducers/search', integrity: true, crossorigin: 'anonymous', rel: 'preload', as: 'script' | ||||||
|  | 
 | ||||||
|  |     = javascript_pack_tag 'features/home_timeline', integrity: true, crossorigin: 'anonymous', rel: 'preload', as: 'script' | ||||||
|  | 
 | ||||||
|  |     = javascript_pack_tag 'features/notifications', integrity: true, crossorigin: 'anonymous', rel: 'preload', as: 'script' | ||||||
|  |     = javascript_pack_tag 'reducers/notifications', integrity: true, crossorigin: 'anonymous', rel: 'preload', as: 'script' | ||||||
|  | 
 | ||||||
|  |     = javascript_pack_tag 'features/community_timeline', integrity: true, crossorigin: 'anonymous', rel: 'preload', as: 'script' | ||||||
|  | 
 | ||||||
|  |     = javascript_pack_tag 'features/public_timeline', integrity: true, crossorigin: 'anonymous', rel: 'preload', as: 'script' | ||||||
|  | 
 | ||||||
|     = javascript_pack_tag "locale_#{I18n.locale}", integrity: true, crossorigin: 'anonymous' |     = javascript_pack_tag "locale_#{I18n.locale}", integrity: true, crossorigin: 'anonymous' | ||||||
|     = csrf_meta_tags |     = csrf_meta_tags | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
		Loading…
	
		Reference in a new issue