1 import classNames
from 'classnames';
2 import React
from 'react';
3 import { HotKeys
} from 'react-hotkeys';
4 import { defineMessages
, injectIntl
} from 'react-intl';
5 import { connect
} from 'react-redux';
6 import { Redirect
, withRouter
} from 'react-router-dom';
7 import PropTypes
from 'prop-types';
8 import NotificationsContainer
from './containers/notifications_container';
9 import LoadingBarContainer
from './containers/loading_bar_container';
10 import TabsBar
from './components/tabs_bar';
11 import ModalContainer
from './containers/modal_container';
12 import { isMobile
} from '../../is_mobile';
13 import { debounce
} from 'lodash';
14 import { uploadCompose
, resetCompose
} from '../../actions/compose';
15 import { expandHomeTimeline
} from '../../actions/timelines';
16 import { expandNotifications
} from '../../actions/notifications';
17 import { fetchFilters
} from '../../actions/filters';
18 import { clearHeight
} from '../../actions/height_cache';
19 import { WrappedSwitch
, WrappedRoute
} from './util/react_router_helpers';
20 import UploadArea
from './components/upload_area';
21 import ColumnsAreaContainer
from './containers/columns_area_container';
48 } from './util/async-components';
49 import { me
} from '../../initial_state';
50 import { previewState
} from './components/media_modal';
52 // Dummy import, to make sure that <Status /> ends up in the application bundle.
53 // Without this it ends up in ~8 very commonly used bundles.
54 import '../../components/status';
56 const messages
= defineMessages({
57 beforeUnload: { id: 'ui.beforeunload', defaultMessage: 'Your draft will be lost if you leave Mastodon.' },
60 const mapStateToProps
= state
=> ({
61 isComposing: state
.getIn(['compose', 'is_composing']),
62 hasComposingText: state
.getIn(['compose', 'text']) !== '',
63 dropdownMenuIsOpen: state
.getIn(['dropdown_menu', 'openId']) !== null,
71 focusColumn: ['1', '2', '3', '4', '5', '6', '7', '8', '9'],
78 moveDown: ['down', 'j'],
82 goToNotifications: 'g n',
87 goToFavourites: 'g f',
96 class SwitchingColumnsArea
extends React
.PureComponent
{
99 children: PropTypes
.node
,
100 location: PropTypes
.object
,
101 onLayoutChange: PropTypes
.func
.isRequired
,
105 mobile: isMobile(window
.innerWidth
),
108 componentWillMount () {
109 window
.addEventListener('resize', this.handleResize
, { passive: true });
112 componentDidUpdate (prevProps
) {
113 if (![this.props
.location
.pathname
, '/'].includes(prevProps
.location
.pathname
)) {
114 this.node
.handleChildrenContentChange();
118 componentWillUnmount () {
119 window
.removeEventListener('resize', this.handleResize
);
122 shouldUpdateScroll (_
, { location
}) {
123 return location
.state
!== previewState
;
126 handleResize
= debounce(() => {
127 // The cached heights are no longer accurate, invalidate
128 this.props
.onLayoutChange();
130 this.setState({ mobile: isMobile(window
.innerWidth
) });
136 this.node
= c
.getWrappedInstance().getWrappedInstance();
140 const { children
} = this.props
;
141 const { mobile
} = this.state
;
142 const redirect
= mobile
? <Redirect
from='/' to
='/timelines/home' exact
/> : <Redirect
from='/' to
='/getting-started' exact
/>;
145 <ColumnsAreaContainer ref
={this.setRef
} singleColumn
={mobile
}>
148 <WrappedRoute path
='/getting-started' component
={GettingStarted
} content
={children
} />
149 <WrappedRoute path
='/keyboard-shortcuts' component
={KeyboardShortcuts
} content
={children
} />
150 <WrappedRoute path
='/timelines/home' component
={HomeTimeline
} content
={children
} componentParams
={{ shouldUpdateScroll: this.shouldUpdateScroll
}} />
151 <WrappedRoute path
='/timelines/public' exact component
={PublicTimeline
} content
={children
} componentParams
={{ shouldUpdateScroll: this.shouldUpdateScroll
}} />
152 <WrappedRoute path
='/timelines/public/media' component
={PublicTimeline
} content
={children
} componentParams
={{ shouldUpdateScroll: this.shouldUpdateScroll
, onlyMedia: true }} />
153 <WrappedRoute path
='/timelines/public/local' exact component
={CommunityTimeline
} content
={children
} componentParams
={{ shouldUpdateScroll: this.shouldUpdateScroll
}} />
154 <WrappedRoute path
='/timelines/public/local/media' component
={CommunityTimeline
} content
={children
} componentParams
={{ shouldUpdateScroll: this.shouldUpdateScroll
, onlyMedia: true }} />
155 <WrappedRoute path
='/timelines/direct' component
={DirectTimeline
} content
={children
} componentParams
={{ shouldUpdateScroll: this.shouldUpdateScroll
}} />
156 <WrappedRoute path
='/timelines/tag/:id' component
={HashtagTimeline
} content
={children
} componentParams
={{ shouldUpdateScroll: this.shouldUpdateScroll
}} />
157 <WrappedRoute path
='/timelines/list/:id' component
={ListTimeline
} content
={children
} componentParams
={{ shouldUpdateScroll: this.shouldUpdateScroll
}} />
159 <WrappedRoute path
='/notifications' component
={Notifications
} content
={children
} componentParams
={{ shouldUpdateScroll: this.shouldUpdateScroll
}} />
160 <WrappedRoute path
='/favourites' component
={FavouritedStatuses
} content
={children
} componentParams
={{ shouldUpdateScroll: this.shouldUpdateScroll
}} />
161 <WrappedRoute path
='/pinned' component
={PinnedStatuses
} content
={children
} componentParams
={{ shouldUpdateScroll: this.shouldUpdateScroll
}} />
163 <WrappedRoute path
='/search' component
={Compose
} content
={children
} componentParams
={{ isSearchPage: true }} />
165 <WrappedRoute path
='/statuses/new' component
={Compose
} content
={children
} />
166 <WrappedRoute path
='/statuses/:statusId' exact component
={Status
} content
={children
} componentParams
={{ shouldUpdateScroll: this.shouldUpdateScroll
}} />
167 <WrappedRoute path
='/statuses/:statusId/reblogs' component
={Reblogs
} content
={children
} componentParams
={{ shouldUpdateScroll: this.shouldUpdateScroll
}} />
168 <WrappedRoute path
='/statuses/:statusId/favourites' component
={Favourites
} content
={children
} componentParams
={{ shouldUpdateScroll: this.shouldUpdateScroll
}} />
170 <WrappedRoute path
='/accounts/:accountId' exact component
={AccountTimeline
} content
={children
} componentParams
={{ shouldUpdateScroll: this.shouldUpdateScroll
}} />
171 <WrappedRoute path
='/accounts/:accountId/with_replies' component
={AccountTimeline
} content
={children
} componentParams
={{ shouldUpdateScroll: this.shouldUpdateScroll
, withReplies: true }} />
172 <WrappedRoute path
='/accounts/:accountId/followers' component
={Followers
} content
={children
} componentParams
={{ shouldUpdateScroll: this.shouldUpdateScroll
}} />
173 <WrappedRoute path
='/accounts/:accountId/following' component
={Following
} content
={children
} componentParams
={{ shouldUpdateScroll: this.shouldUpdateScroll
}} />
174 <WrappedRoute path
='/accounts/:accountId/media' component
={AccountGallery
} content
={children
} componentParams
={{ shouldUpdateScroll: this.shouldUpdateScroll
}} />
176 <WrappedRoute path
='/follow_requests' component
={FollowRequests
} content
={children
} componentParams
={{ shouldUpdateScroll: this.shouldUpdateScroll
}} />
177 <WrappedRoute path
='/blocks' component
={Blocks
} content
={children
} componentParams
={{ shouldUpdateScroll: this.shouldUpdateScroll
}} />
178 <WrappedRoute path
='/domain_blocks' component
={DomainBlocks
} content
={children
} componentParams
={{ shouldUpdateScroll: this.shouldUpdateScroll
}} />
179 <WrappedRoute path
='/mutes' component
={Mutes
} content
={children
} componentParams
={{ shouldUpdateScroll: this.shouldUpdateScroll
}} />
180 <WrappedRoute path
='/lists' component
={Lists
} content
={children
} componentParams
={{ shouldUpdateScroll: this.shouldUpdateScroll
}} />
182 <WrappedRoute component
={GenericNotFound
} content
={children
} />
184 </ColumnsAreaContainer
>
190 @connect(mapStateToProps
)
193 export default class UI
extends React
.PureComponent
{
195 static contextTypes
= {
196 router: PropTypes
.object
.isRequired
,
200 dispatch: PropTypes
.func
.isRequired
,
201 children: PropTypes
.node
,
202 isComposing: PropTypes
.bool
,
203 hasComposingText: PropTypes
.bool
,
204 location: PropTypes
.object
,
205 intl: PropTypes
.object
.isRequired
,
206 dropdownMenuIsOpen: PropTypes
.bool
,
213 handleBeforeUnload
= (e
) => {
214 const { intl
, isComposing
, hasComposingText
} = this.props
;
216 if (isComposing
&& hasComposingText
) {
217 // Setting returnValue to any string causes confirmation dialog.
218 // Many browsers no longer display this text to users,
219 // but we set user-friendly message for other browsers, e.g. Edge.
220 e
.returnValue
= intl
.formatMessage(messages
.beforeUnload
);
224 handleLayoutChange
= () => {
225 // The cached heights are no longer accurate, invalidate
226 this.props
.dispatch(clearHeight());
229 handleDragEnter
= (e
) => {
232 if (!this.dragTargets
) {
233 this.dragTargets
= [];
236 if (this.dragTargets
.indexOf(e
.target
) === -1) {
237 this.dragTargets
.push(e
.target
);
240 if (e
.dataTransfer
&& Array
.from(e
.dataTransfer
.types
).includes('Files')) {
241 this.setState({ draggingOver: true });
245 handleDragOver
= (e
) => {
250 e
.dataTransfer
.dropEffect
= 'copy';
258 handleDrop
= (e
) => {
261 this.setState({ draggingOver: false });
263 if (e
.dataTransfer
&& e
.dataTransfer
.files
.length
=== 1) {
264 this.props
.dispatch(uploadCompose(e
.dataTransfer
.files
));
268 handleDragLeave
= (e
) => {
272 this.dragTargets
= this.dragTargets
.filter(el
=> el
!== e
.target
&& this.node
.contains(el
));
274 if (this.dragTargets
.length
> 0) {
278 this.setState({ draggingOver: false });
281 closeUploadModal
= () => {
282 this.setState({ draggingOver: false });
285 handleServiceWorkerPostMessage
= ({ data
}) => {
286 if (data
.type
=== 'navigate') {
287 this.context
.router
.history
.push(data
.path
);
289 console
.warn('Unknown message type:', data
.type
);
293 componentWillMount () {
294 window
.addEventListener('beforeunload', this.handleBeforeUnload
, false);
295 document
.addEventListener('dragenter', this.handleDragEnter
, false);
296 document
.addEventListener('dragover', this.handleDragOver
, false);
297 document
.addEventListener('drop', this.handleDrop
, false);
298 document
.addEventListener('dragleave', this.handleDragLeave
, false);
299 document
.addEventListener('dragend', this.handleDragEnd
, false);
301 if ('serviceWorker' in navigator
) {
302 navigator
.serviceWorker
.addEventListener('message', this.handleServiceWorkerPostMessage
);
305 this.props
.dispatch(expandHomeTimeline());
306 this.props
.dispatch(expandNotifications());
307 setTimeout(() => this.props
.dispatch(fetchFilters()), 500);
310 componentDidMount () {
311 this.hotkeys
.__mousetrap__
.stopCallback
= (e
, element
) => {
312 return ['TEXTAREA', 'SELECT', 'INPUT'].includes(element
.tagName
);
316 componentWillUnmount () {
317 window
.removeEventListener('beforeunload', this.handleBeforeUnload
);
318 document
.removeEventListener('dragenter', this.handleDragEnter
);
319 document
.removeEventListener('dragover', this.handleDragOver
);
320 document
.removeEventListener('drop', this.handleDrop
);
321 document
.removeEventListener('dragleave', this.handleDragLeave
);
322 document
.removeEventListener('dragend', this.handleDragEnd
);
329 handleHotkeyNew
= e
=> {
332 const element
= this.node
.querySelector('.compose-form__autosuggest-wrapper textarea');
339 handleHotkeySearch
= e
=> {
342 const element
= this.node
.querySelector('.search__input');
349 handleHotkeyForceNew
= e
=> {
350 this.handleHotkeyNew(e
);
351 this.props
.dispatch(resetCompose());
354 handleHotkeyFocusColumn
= e
=> {
355 const index
= (e
.key
* 1) + 1; // First child is drawer, skip that
356 const column
= this.node
.querySelector(`.column:nth-child(${index})`);
359 const status
= column
.querySelector('.focusable');
367 handleHotkeyBack
= () => {
368 if (window
.history
&& window
.history
.length
=== 1) {
369 this.context
.router
.history
.push('/');
371 this.context
.router
.history
.goBack();
375 setHotkeysRef
= c
=> {
379 handleHotkeyToggleHelp
= () => {
380 if (this.props
.location
.pathname
=== '/keyboard-shortcuts') {
381 this.context
.router
.history
.goBack();
383 this.context
.router
.history
.push('/keyboard-shortcuts');
387 handleHotkeyGoToHome
= () => {
388 this.context
.router
.history
.push('/timelines/home');
391 handleHotkeyGoToNotifications
= () => {
392 this.context
.router
.history
.push('/notifications');
395 handleHotkeyGoToLocal
= () => {
396 this.context
.router
.history
.push('/timelines/public/local');
399 handleHotkeyGoToFederated
= () => {
400 this.context
.router
.history
.push('/timelines/public');
403 handleHotkeyGoToDirect
= () => {
404 this.context
.router
.history
.push('/timelines/direct');
407 handleHotkeyGoToStart
= () => {
408 this.context
.router
.history
.push('/getting-started');
411 handleHotkeyGoToFavourites
= () => {
412 this.context
.router
.history
.push('/favourites');
415 handleHotkeyGoToPinned
= () => {
416 this.context
.router
.history
.push('/pinned');
419 handleHotkeyGoToProfile
= () => {
420 this.context
.router
.history
.push(`/accounts/${me}`);
423 handleHotkeyGoToBlocked
= () => {
424 this.context
.router
.history
.push('/blocks');
427 handleHotkeyGoToMuted
= () => {
428 this.context
.router
.history
.push('/mutes');
431 handleHotkeyGoToRequests
= () => {
432 this.context
.router
.history
.push('/follow_requests');
436 const { draggingOver
} = this.state
;
437 const { children
, isComposing
, location
, dropdownMenuIsOpen
} = this.props
;
440 help: this.handleHotkeyToggleHelp
,
441 new: this.handleHotkeyNew
,
442 search: this.handleHotkeySearch
,
443 forceNew: this.handleHotkeyForceNew
,
444 focusColumn: this.handleHotkeyFocusColumn
,
445 back: this.handleHotkeyBack
,
446 goToHome: this.handleHotkeyGoToHome
,
447 goToNotifications: this.handleHotkeyGoToNotifications
,
448 goToLocal: this.handleHotkeyGoToLocal
,
449 goToFederated: this.handleHotkeyGoToFederated
,
450 goToDirect: this.handleHotkeyGoToDirect
,
451 goToStart: this.handleHotkeyGoToStart
,
452 goToFavourites: this.handleHotkeyGoToFavourites
,
453 goToPinned: this.handleHotkeyGoToPinned
,
454 goToProfile: this.handleHotkeyGoToProfile
,
455 goToBlocked: this.handleHotkeyGoToBlocked
,
456 goToMuted: this.handleHotkeyGoToMuted
,
457 goToRequests: this.handleHotkeyGoToRequests
,
461 <HotKeys keyMap
={keyMap
} handlers
={handlers
} ref
={this.setHotkeysRef
}>
462 <div className
={classNames('ui', { 'is-composing': isComposing
})} ref
={this.setRef
} style
={{ pointerEvents: dropdownMenuIsOpen
? 'none' : null }}>
465 <SwitchingColumnsArea location
={location
} onLayoutChange
={this.handleLayoutChange
}>
467 </SwitchingColumnsArea
>
469 <NotificationsContainer
/>
470 <LoadingBarContainer className
='loading-bar' />
472 <UploadArea active
={draggingOver
} onClose
={this.closeUploadModal
} />