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'; | ||||
| 
 | ||||
| export const STORE_HYDRATE = 'STORE_HYDRATE'; | ||||
| export const STORE_HYDRATE_LAZY = 'STORE_HYDRATE_LAZY'; | ||||
| 
 | ||||
| const convertState = rawState => | ||||
|   Immutable.fromJS(rawState, (k, v) => | ||||
|  | @ -15,3 +16,10 @@ export function hydrateStore(rawState) { | |||
|     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 RelativeTimestamp from './relative_timestamp'; | ||||
| import DisplayName from './display_name'; | ||||
| import MediaGallery from './media_gallery'; | ||||
| import VideoPlayer from './video_player'; | ||||
| import StatusContent from './status_content'; | ||||
| import StatusActionBar from './status_action_bar'; | ||||
| import { FormattedMessage } from 'react-intl'; | ||||
|  | @ -14,6 +12,11 @@ import emojify from '../emoji'; | |||
| import escapeTextContentForBrowser from 'escape-html'; | ||||
| import ImmutablePureComponent from 'react-immutable-pure-component'; | ||||
| 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 { | ||||
| 
 | ||||
|  | @ -154,6 +157,14 @@ export default class Status extends ImmutablePureComponent { | |||
|     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 () { | ||||
|     let media = null; | ||||
|     let statusAvatar; | ||||
|  | @ -201,9 +212,17 @@ export default class Status extends ImmutablePureComponent { | |||
|       if (status.get('media_attachments').some(item => item.get('type') === 'unknown')) { | ||||
| 
 | ||||
|       } 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 { | ||||
|         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(); | ||||
| addLocaleData(localeData); | ||||
| 
 | ||||
| const store = configureStore(); | ||||
| export const store = configureStore(); | ||||
| 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 { | ||||
| 
 | ||||
|  |  | |||
|  | @ -2,6 +2,7 @@ import React from 'react'; | |||
| import Dropdown, { DropdownTrigger, DropdownContent } from 'react-simple-dropdown'; | ||||
| import PropTypes from 'prop-types'; | ||||
| import { defineMessages, injectIntl } from 'react-intl'; | ||||
| import { EmojiPicker as EmojiPickerAsync } from '../../ui/util/async-components'; | ||||
| 
 | ||||
| const messages = defineMessages({ | ||||
|   emoji: { id: 'emoji_button.label', defaultMessage: 'Insert emoji' }, | ||||
|  | @ -50,7 +51,7 @@ export default class EmojiPickerDropdown extends React.PureComponent { | |||
|     this.setState({ active: true }); | ||||
|     if (!EmojiPicker) { | ||||
|       this.setState({ loading: true }); | ||||
|       import(/* webpackChunkName: "emojione_picker" */ 'emojione-picker').then(TheEmojiPicker => { | ||||
|       EmojiPickerAsync().then(TheEmojiPicker => { | ||||
|         EmojiPicker = TheEmojiPicker.default; | ||||
|         this.setState({ loading: false }); | ||||
|       }).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 ImmutablePropTypes from 'react-immutable-proptypes'; | ||||
| import ImmutablePureComponent from 'react-immutable-pure-component'; | ||||
| 
 | ||||
| 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 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 = { | ||||
|   'COMPOSE': Compose, | ||||
|   'HOME': HomeTimeline, | ||||
|  | @ -48,6 +48,14 @@ export default class ColumnsArea extends ImmutablePureComponent { | |||
|     } | ||||
|   }; | ||||
| 
 | ||||
|   renderLoading = () => { | ||||
|     return <ColumnLoading />; | ||||
|   } | ||||
| 
 | ||||
|   renderError = (props) => { | ||||
|     return <BundleColumnError {...props} />; | ||||
|   } | ||||
| 
 | ||||
|   render () { | ||||
|     const { columns, children, singleColumn } = this.props; | ||||
| 
 | ||||
|  | @ -62,9 +70,13 @@ export default class ColumnsArea extends ImmutablePureComponent { | |||
|     return ( | ||||
|       <div className='columns-area'> | ||||
|         {columns.map(column => { | ||||
|           const SpecificComponent = componentMap[column.get('id')]; | ||||
|           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 }))} | ||||
|  |  | |||
|  | @ -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 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 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 = { | ||||
|   'MEDIA': MediaModal, | ||||
|  | @ -49,6 +54,22 @@ export default class ModalRoot extends React.PureComponent { | |||
|     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 () { | ||||
|     const { type, props, onClose } = this.props; | ||||
|     const visible = !!type; | ||||
|  | @ -70,18 +91,14 @@ export default class ModalRoot extends React.PureComponent { | |||
|       > | ||||
|         {interpolatedStyles => | ||||
|           <div className='modal-root'> | ||||
|             {interpolatedStyles.map(({ key, data: { type, props }, style }) => { | ||||
|               const SpecificComponent = MODAL_COMPONENTS[type]; | ||||
| 
 | ||||
|               return ( | ||||
|                 <div key={key} style={{ pointerEvents: visible ? 'auto' : 'none' }}> | ||||
|                   <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})` }}> | ||||
|                     <SpecificComponent {...props} onClose={onClose} /> | ||||
|                   </div> | ||||
|             {interpolatedStyles.map(({ key, data: { type }, style }) => ( | ||||
|               <div key={key} style={{ pointerEvents: visible ? 'auto' : 'none' }}> | ||||
|                 <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})` }}> | ||||
|                   <BundleContainer fetchComponent={MODAL_COMPONENTS[type]} loading={this.renderLoading} error={this.renderError} renderDelay={200}>{this.renderModal}</BundleContainer> | ||||
|                 </div> | ||||
|               ); | ||||
|             })} | ||||
|               </div> | ||||
|             ))} | ||||
|           </div> | ||||
|         } | ||||
|       </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 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 NotificationsContainer from './containers/notifications_container'; | ||||
| import PropTypes from 'prop-types'; | ||||
|  | @ -14,64 +12,40 @@ import { debounce } from 'lodash'; | |||
| import { uploadCompose } from '../../actions/compose'; | ||||
| import { refreshHomeTimeline } from '../../actions/timelines'; | ||||
| import { refreshNotifications } from '../../actions/notifications'; | ||||
| import { WrappedSwitch, WrappedRoute } from './util/react_router_helpers'; | ||||
| import UploadArea from './components/upload_area'; | ||||
| import { store } from '../../containers/mastodon'; | ||||
| import ColumnsAreaContainer from './containers/columns_area_container'; | ||||
| import Status from '../../features/status'; | ||||
| import GettingStarted from '../../features/getting_started'; | ||||
| import PublicTimeline from '../../features/public_timeline'; | ||||
| import CommunityTimeline from '../../features/community_timeline'; | ||||
| import AccountTimeline from '../../features/account_timeline'; | ||||
| import AccountGallery from '../../features/account_gallery'; | ||||
| import HomeTimeline from '../../features/home_timeline'; | ||||
| import Compose from '../../features/compose'; | ||||
| import Followers from '../../features/followers'; | ||||
| import Following from '../../features/following'; | ||||
| import Reblogs from '../../features/reblogs'; | ||||
| import Favourites from '../../features/favourites'; | ||||
| import HashtagTimeline from '../../features/hashtag_timeline'; | ||||
| import Notifications from '../../features/notifications'; | ||||
| import FollowRequests from '../../features/follow_requests'; | ||||
| import GenericNotFound from '../../features/generic_not_found'; | ||||
| import FavouritedStatuses from '../../features/favourited_statuses'; | ||||
| import Blocks from '../../features/blocks'; | ||||
| import Mutes from '../../features/mutes'; | ||||
| import { | ||||
|   Compose, | ||||
|   Status, | ||||
|   GettingStarted, | ||||
|   PublicTimeline, | ||||
|   CommunityTimeline, | ||||
|   AccountTimeline, | ||||
|   AccountGallery, | ||||
|   HomeTimeline, | ||||
|   Followers, | ||||
|   Following, | ||||
|   Reblogs, | ||||
|   Favourites, | ||||
|   HashtagTimeline, | ||||
|   Notifications as AsyncNotifications, | ||||
|   FollowRequests, | ||||
|   GenericNotFound, | ||||
|   FavouritedStatuses, | ||||
|   Blocks, | ||||
|   Mutes, | ||||
| } from './util/async-components'; | ||||
| 
 | ||||
| // Small wrapper to pass multiColumn to the route components
 | ||||
| const WrappedSwitch = ({ multiColumn, children }) => ( | ||||
|   <Switch> | ||||
|     {React.Children.map(children, child => React.cloneElement(child, { multiColumn }))} | ||||
|   </Switch> | ||||
| ); | ||||
| const Notifications = () => AsyncNotifications().then(component => { | ||||
|   store.dispatch(refreshNotifications()); | ||||
|   return component; | ||||
| }); | ||||
| 
 | ||||
| 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)
 | ||||
| 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} />; | ||||
|   } | ||||
| 
 | ||||
| } | ||||
| // Dummy import, to make sure that <Status /> ends up in the application bundle.
 | ||||
| // Without this it ends up in ~8 very commonly used bundles.
 | ||||
| import '../../components/status'; | ||||
| 
 | ||||
| const mapStateToProps = state => ({ | ||||
|   systemFontUi: state.getIn(['meta', 'system_font_ui']), | ||||
|  | @ -162,7 +136,6 @@ export default class UI extends React.PureComponent { | |||
|     document.addEventListener('dragend', this.handleDragEnd, false); | ||||
| 
 | ||||
|     this.props.dispatch(refreshHomeTimeline()); | ||||
|     this.props.dispatch(refreshNotifications()); | ||||
|   } | ||||
| 
 | ||||
|   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, | ||||
| } from '../actions/compose'; | ||||
| import { TIMELINE_DELETE } from '../actions/timelines'; | ||||
| import { STORE_HYDRATE } from '../actions/store'; | ||||
| import { STORE_HYDRATE_LAZY } from '../actions/store'; | ||||
| import Immutable from 'immutable'; | ||||
| import uuid from '../uuid'; | ||||
| 
 | ||||
|  | @ -134,7 +134,7 @@ const privacyPreference = (a, b) => { | |||
| 
 | ||||
| export default function compose(state = initialState, action) { | ||||
|   switch(action.type) { | ||||
|   case STORE_HYDRATE: | ||||
|   case `${STORE_HYDRATE_LAZY}-compose`: | ||||
|     return clearAll(state.merge(action.state.get('compose'))); | ||||
|   case COMPOSE_MOUNT: | ||||
|     return state.set('mounted', true); | ||||
|  |  | |||
|  | @ -1,7 +1,6 @@ | |||
| import { combineReducers } from 'redux-immutable'; | ||||
| import timelines from './timelines'; | ||||
| import meta from './meta'; | ||||
| import compose from './compose'; | ||||
| import alerts from './alerts'; | ||||
| import { loadingBarReducer } from 'react-redux-loading-bar'; | ||||
| import modal from './modal'; | ||||
|  | @ -9,20 +8,16 @@ import user_lists from './user_lists'; | |||
| import accounts from './accounts'; | ||||
| import accounts_counters from './accounts_counters'; | ||||
| import statuses from './statuses'; | ||||
| import media_attachments from './media_attachments'; | ||||
| import relationships from './relationships'; | ||||
| import search from './search'; | ||||
| import notifications from './notifications'; | ||||
| import settings from './settings'; | ||||
| import status_lists from './status_lists'; | ||||
| import cards from './cards'; | ||||
| import reports from './reports'; | ||||
| import contexts from './contexts'; | ||||
| 
 | ||||
| export default combineReducers({ | ||||
| const reducers = { | ||||
|   timelines, | ||||
|   meta, | ||||
|   compose, | ||||
|   alerts, | ||||
|   loadingBar: loadingBarReducer, | ||||
|   modal, | ||||
|  | @ -30,13 +25,19 @@ export default combineReducers({ | |||
|   status_lists, | ||||
|   accounts, | ||||
|   accounts_counters, | ||||
|   media_attachments, | ||||
|   statuses, | ||||
|   relationships, | ||||
|   search, | ||||
|   notifications, | ||||
|   settings, | ||||
|   cards, | ||||
|   reports, | ||||
|   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'; | ||||
| 
 | ||||
| const initialState = Immutable.Map({ | ||||
|  | @ -7,7 +7,7 @@ const initialState = Immutable.Map({ | |||
| 
 | ||||
| export default function meta(state = initialState, action) { | ||||
|   switch(action.type) { | ||||
|   case STORE_HYDRATE: | ||||
|   case `${STORE_HYDRATE_LAZY}-media_attachments`: | ||||
|     return state.merge(action.state.get('media_attachments')); | ||||
|   default: | ||||
|     return state; | ||||
|  |  | |||
|  | @ -1,15 +1,36 @@ | |||
| import { createStore, applyMiddleware, compose } from 'redux'; | ||||
| 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 errorsMiddleware from '../middleware/errors'; | ||||
| import soundsMiddleware from '../middleware/sounds'; | ||||
| 
 | ||||
| export default function configureStore() { | ||||
|   return createStore(appReducer, compose(applyMiddleware( | ||||
|   const store = createStore(appReducer, compose(applyMiddleware( | ||||
|     thunk, | ||||
|     loadingBarMiddleware({ promiseTypeSuffixes: ['REQUEST', 'SUCCESS', 'FAIL'] }), | ||||
|     errorsMiddleware(), | ||||
|     soundsMiddleware() | ||||
|   ), 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; | ||||
| } | ||||
| 
 | ||||
| .empty-column-indicator { | ||||
| .empty-column-indicator, | ||||
| .error-column { | ||||
|   color: lighten($ui-base-color, 20%); | ||||
|   background: $ui-base-color; | ||||
|   text-align: center; | ||||
|  | @ -2326,6 +2327,10 @@ button.icon-button.active i.fa-retweet { | |||
|   } | ||||
| } | ||||
| 
 | ||||
| .error-column { | ||||
|   flex-direction: column; | ||||
| } | ||||
| 
 | ||||
| @keyframes pulse { | ||||
|   0% { | ||||
|     opacity: 1; | ||||
|  | @ -2909,7 +2914,8 @@ button.icon-button.active i.fa-retweet { | |||
|   z-index: 100; | ||||
| } | ||||
| 
 | ||||
| .onboarding-modal { | ||||
| .onboarding-modal, | ||||
| .error-modal { | ||||
|   background: $ui-secondary-color; | ||||
|   color: $ui-base-color; | ||||
|   border-radius: 8px; | ||||
|  | @ -2918,7 +2924,8 @@ button.icon-button.active i.fa-retweet { | |||
|   flex-direction: column; | ||||
| } | ||||
| 
 | ||||
| .onboarding-modal__pager { | ||||
| .onboarding-modal__pager, | ||||
| .error-modal__body { | ||||
|   height: 80vh; | ||||
|   width: 80vw; | ||||
|   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) { | ||||
|   .onboarding-modal { | ||||
|     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; | ||||
|   background: darken($ui-secondary-color, 8%); | ||||
|   display: flex; | ||||
|  | @ -2969,7 +2985,8 @@ button.icon-button.active i.fa-retweet { | |||
|     min-width: 33px; | ||||
|   } | ||||
| 
 | ||||
|   .onboarding-modal__nav { | ||||
|   .onboarding-modal__nav, | ||||
|   .error-modal__nav { | ||||
|     color: darken($ui-secondary-color, 34%); | ||||
|     background-color: transparent; | ||||
|     border: 0; | ||||
|  | @ -2992,6 +3009,10 @@ button.icon-button.active i.fa-retweet { | |||
|   } | ||||
| } | ||||
| 
 | ||||
| .error-modal__footer { | ||||
|   justify-content: center; | ||||
| } | ||||
| 
 | ||||
| .onboarding-modal__dots { | ||||
|   flex: 1 1 auto; | ||||
|   display: flex; | ||||
|  |  | |||
|  | @ -20,6 +20,23 @@ | |||
| 
 | ||||
|     = stylesheet_pack_tag 'application', media: 'all' | ||||
|     = 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' | ||||
|     = csrf_meta_tags | ||||
| 
 | ||||
|  |  | |||
		Loading…
	
		Reference in a new issue