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 ModalContainer
from './containers/modal_container';
11 import { isMobile
} from '../../is_mobile';
12 import { debounce
} from 'lodash';
13 import { uploadCompose
, resetCompose
} from '../../actions/compose';
14 import { expandHomeTimeline
} from '../../actions/timelines';
15 import { expandNotifications
} from '../../actions/notifications';
16 import { fetchFilters
} from '../../actions/filters';
17 import { clearHeight
} from '../../actions/height_cache';
18 import { focusApp
, unfocusApp
} from 'mastodon/actions/app';
19 import { submitMarkers
} from 'mastodon/actions/markers';
20 import { WrappedSwitch
, WrappedRoute
} from './util/react_router_helpers';
21 import UploadArea
from './components/upload_area';
22 import ColumnsAreaContainer
from './containers/columns_area_container';
23 import DocumentTitle
from './components/document_title';
52 } from './util/async-components';
53 import { me
, forceSingleColumn
} from '../../initial_state';
54 import { previewState as previewMediaState
} from './components/media_modal';
55 import { previewState as previewVideoState
} from './components/video_modal';
57 // Dummy import, to make sure that <Status /> ends up in the application bundle.
58 // Without this it ends up in ~8 very commonly used bundles.
59 import '../../components/status';
61 const messages
= defineMessages({
62 beforeUnload: { id: 'ui.beforeunload', defaultMessage: 'Your draft will be lost if you leave Mastodon.' },
65 const mapStateToProps
= state
=> ({
66 isComposing: state
.getIn(['compose', 'is_composing']),
67 hasComposingText: state
.getIn(['compose', 'text']).trim().length
!== 0,
68 hasMediaAttachments: state
.getIn(['compose', 'media_attachments']).size
> 0,
69 canUploadMore: !state
.getIn(['compose', 'media_attachments']).some(x
=> ['audio', 'video'].includes(x
.get('type'))) && state
.getIn(['compose', 'media_attachments']).size
< 4,
70 dropdownMenuIsOpen: state
.getIn(['dropdown_menu', 'openId']) !== null,
78 focusColumn: ['1', '2', '3', '4', '5', '6', '7', '8', '9'],
85 moveDown: ['down', 'j'],
89 goToNotifications: 'g n',
94 goToFavourites: 'g f',
101 toggleSensitive: 'h',
104 class SwitchingColumnsArea
extends React
.PureComponent
{
107 children: PropTypes
.node
,
108 location: PropTypes
.object
,
109 onLayoutChange: PropTypes
.func
.isRequired
,
113 mobile: isMobile(window
.innerWidth
),
116 componentWillMount () {
117 window
.addEventListener('resize', this.handleResize
, { passive: true });
119 if (this.state
.mobile
|| forceSingleColumn
) {
120 document
.body
.classList
.toggle('layout-single-column', true);
121 document
.body
.classList
.toggle('layout-multiple-columns', false);
123 document
.body
.classList
.toggle('layout-single-column', false);
124 document
.body
.classList
.toggle('layout-multiple-columns', true);
128 componentDidUpdate (prevProps
, prevState
) {
129 if (![this.props
.location
.pathname
, '/'].includes(prevProps
.location
.pathname
)) {
130 this.node
.handleChildrenContentChange();
133 if (prevState
.mobile
!== this.state
.mobile
&& !forceSingleColumn
) {
134 document
.body
.classList
.toggle('layout-single-column', this.state
.mobile
);
135 document
.body
.classList
.toggle('layout-multiple-columns', !this.state
.mobile
);
139 componentWillUnmount () {
140 window
.removeEventListener('resize', this.handleResize
);
143 shouldUpdateScroll (_
, { location
}) {
144 return location
.state
!== previewMediaState
&& location
.state
!== previewVideoState
;
147 handleLayoutChange
= debounce(() => {
148 // The cached heights are no longer accurate, invalidate
149 this.props
.onLayoutChange();
154 handleResize
= () => {
155 const mobile
= isMobile(window
.innerWidth
);
157 if (mobile
!== this.state
.mobile
) {
158 this.handleLayoutChange
.cancel();
159 this.props
.onLayoutChange();
160 this.setState({ mobile
});
162 this.handleLayoutChange();
167 this.node
= c
.getWrappedInstance();
171 const { children
} = this.props
;
172 const { mobile
} = this.state
;
173 const singleColumn
= forceSingleColumn
|| mobile
;
174 const redirect
= singleColumn
? <Redirect
from='/' to
='/timelines/home' exact
/> : <Redirect
from='/' to
='/getting-started' exact
/>;
177 <ColumnsAreaContainer ref
={this.setRef
} singleColumn
={singleColumn
}>
180 <WrappedRoute path
='/getting-started' component
={GettingStarted
} content
={children
} />
181 <WrappedRoute path
='/keyboard-shortcuts' component
={KeyboardShortcuts
} content
={children
} />
182 <WrappedRoute path
='/timelines/home' component
={HomeTimeline
} content
={children
} componentParams
={{ shouldUpdateScroll: this.shouldUpdateScroll
}} />
183 <WrappedRoute path
='/timelines/public' exact component
={PublicTimeline
} content
={children
} componentParams
={{ shouldUpdateScroll: this.shouldUpdateScroll
}} />
184 <WrappedRoute path
='/timelines/public/local' exact component
={CommunityTimeline
} content
={children
} componentParams
={{ shouldUpdateScroll: this.shouldUpdateScroll
}} />
185 <WrappedRoute path
='/timelines/direct' component
={DirectTimeline
} content
={children
} componentParams
={{ shouldUpdateScroll: this.shouldUpdateScroll
}} />
186 <WrappedRoute path
='/timelines/tag/:id' component
={HashtagTimeline
} content
={children
} componentParams
={{ shouldUpdateScroll: this.shouldUpdateScroll
}} />
187 <WrappedRoute path
='/timelines/list/:id' component
={ListTimeline
} content
={children
} componentParams
={{ shouldUpdateScroll: this.shouldUpdateScroll
}} />
189 <WrappedRoute path
='/notifications' component
={Notifications
} content
={children
} componentParams
={{ shouldUpdateScroll: this.shouldUpdateScroll
}} />
190 <WrappedRoute path
='/favourites' component
={FavouritedStatuses
} content
={children
} componentParams
={{ shouldUpdateScroll: this.shouldUpdateScroll
}} />
191 <WrappedRoute path
='/pinned' component
={PinnedStatuses
} content
={children
} componentParams
={{ shouldUpdateScroll: this.shouldUpdateScroll
}} />
193 <WrappedRoute path
='/search' component
={Search
} content
={children
} />
194 <WrappedRoute path
='/directory' component
={Directory
} content
={children
} componentParams
={{ shouldUpdateScroll: this.shouldUpdateScroll
}} />
196 <WrappedRoute path
='/statuses/new' component
={Compose
} content
={children
} />
197 <WrappedRoute path
='/statuses/:statusId' exact component
={Status
} content
={children
} componentParams
={{ shouldUpdateScroll: this.shouldUpdateScroll
}} />
198 <WrappedRoute path
='/statuses/:statusId/reblogs' component
={Reblogs
} content
={children
} componentParams
={{ shouldUpdateScroll: this.shouldUpdateScroll
}} />
199 <WrappedRoute path
='/statuses/:statusId/favourites' component
={Favourites
} content
={children
} componentParams
={{ shouldUpdateScroll: this.shouldUpdateScroll
}} />
201 <WrappedRoute path
='/accounts/:accountId' exact component
={AccountTimeline
} content
={children
} componentParams
={{ shouldUpdateScroll: this.shouldUpdateScroll
}} />
202 <WrappedRoute path
='/accounts/:accountId/with_replies' component
={AccountTimeline
} content
={children
} componentParams
={{ shouldUpdateScroll: this.shouldUpdateScroll
, withReplies: true }} />
203 <WrappedRoute path
='/accounts/:accountId/followers' component
={Followers
} content
={children
} componentParams
={{ shouldUpdateScroll: this.shouldUpdateScroll
}} />
204 <WrappedRoute path
='/accounts/:accountId/following' component
={Following
} content
={children
} componentParams
={{ shouldUpdateScroll: this.shouldUpdateScroll
}} />
205 <WrappedRoute path
='/accounts/:accountId/media' component
={AccountGallery
} content
={children
} componentParams
={{ shouldUpdateScroll: this.shouldUpdateScroll
}} />
207 <WrappedRoute path
='/follow_requests' component
={FollowRequests
} content
={children
} componentParams
={{ shouldUpdateScroll: this.shouldUpdateScroll
}} />
208 <WrappedRoute path
='/blocks' component
={Blocks
} content
={children
} componentParams
={{ shouldUpdateScroll: this.shouldUpdateScroll
}} />
209 <WrappedRoute path
='/domain_blocks' component
={DomainBlocks
} content
={children
} componentParams
={{ shouldUpdateScroll: this.shouldUpdateScroll
}} />
210 <WrappedRoute path
='/mutes' component
={Mutes
} content
={children
} componentParams
={{ shouldUpdateScroll: this.shouldUpdateScroll
}} />
211 <WrappedRoute path
='/lists' component
={Lists
} content
={children
} componentParams
={{ shouldUpdateScroll: this.shouldUpdateScroll
}} />
213 <WrappedRoute component
={GenericNotFound
} content
={children
} />
215 </ColumnsAreaContainer
>
221 export default @connect(mapStateToProps
)
224 class UI
extends React
.PureComponent
{
226 static contextTypes
= {
227 router: PropTypes
.object
.isRequired
,
231 dispatch: PropTypes
.func
.isRequired
,
232 children: PropTypes
.node
,
233 isComposing: PropTypes
.bool
,
234 hasComposingText: PropTypes
.bool
,
235 hasMediaAttachments: PropTypes
.bool
,
236 canUploadMore: PropTypes
.bool
,
237 location: PropTypes
.object
,
238 intl: PropTypes
.object
.isRequired
,
239 dropdownMenuIsOpen: PropTypes
.bool
,
246 handleBeforeUnload
= e
=> {
247 const { intl
, dispatch
, isComposing
, hasComposingText
, hasMediaAttachments
} = this.props
;
249 dispatch(submitMarkers());
251 if (isComposing
&& (hasComposingText
|| hasMediaAttachments
)) {
252 // Setting returnValue to any string causes confirmation dialog.
253 // Many browsers no longer display this text to users,
254 // but we set user-friendly message for other browsers, e.g. Edge.
255 e
.returnValue
= intl
.formatMessage(messages
.beforeUnload
);
259 handleWindowFocus
= () => {
260 this.props
.dispatch(focusApp());
263 handleWindowBlur
= () => {
264 this.props
.dispatch(unfocusApp());
267 handleLayoutChange
= () => {
268 // The cached heights are no longer accurate, invalidate
269 this.props
.dispatch(clearHeight());
272 handleDragEnter
= (e
) => {
275 if (!this.dragTargets
) {
276 this.dragTargets
= [];
279 if (this.dragTargets
.indexOf(e
.target
) === -1) {
280 this.dragTargets
.push(e
.target
);
283 if (e
.dataTransfer
&& Array
.from(e
.dataTransfer
.types
).includes('Files') && this.props
.canUploadMore
) {
284 this.setState({ draggingOver: true });
288 handleDragOver
= (e
) => {
289 if (this.dataTransferIsText(e
.dataTransfer
)) return false;
295 e
.dataTransfer
.dropEffect
= 'copy';
303 handleDrop
= (e
) => {
304 if (this.dataTransferIsText(e
.dataTransfer
)) return;
308 this.setState({ draggingOver: false });
309 this.dragTargets
= [];
311 if (e
.dataTransfer
&& e
.dataTransfer
.files
.length
>= 1 && this.props
.canUploadMore
) {
312 this.props
.dispatch(uploadCompose(e
.dataTransfer
.files
));
316 handleDragLeave
= (e
) => {
320 this.dragTargets
= this.dragTargets
.filter(el
=> el
!== e
.target
&& this.node
.contains(el
));
322 if (this.dragTargets
.length
> 0) {
326 this.setState({ draggingOver: false });
329 dataTransferIsText
= (dataTransfer
) => {
330 return (dataTransfer
&& Array
.from(dataTransfer
.types
).includes('text/plain') && dataTransfer
.items
.length
=== 1);
333 closeUploadModal
= () => {
334 this.setState({ draggingOver: false });
337 handleServiceWorkerPostMessage
= ({ data
}) => {
338 if (data
.type
=== 'navigate') {
339 this.context
.router
.history
.push(data
.path
);
341 console
.warn('Unknown message type:', data
.type
);
345 componentWillMount () {
346 window
.addEventListener('focus', this.handleWindowFocus
, false);
347 window
.addEventListener('blur', this.handleWindowBlur
, false);
348 window
.addEventListener('beforeunload', this.handleBeforeUnload
, false);
350 document
.addEventListener('dragenter', this.handleDragEnter
, false);
351 document
.addEventListener('dragover', this.handleDragOver
, false);
352 document
.addEventListener('drop', this.handleDrop
, false);
353 document
.addEventListener('dragleave', this.handleDragLeave
, false);
354 document
.addEventListener('dragend', this.handleDragEnd
, false);
356 if ('serviceWorker' in navigator
) {
357 navigator
.serviceWorker
.addEventListener('message', this.handleServiceWorkerPostMessage
);
360 if (typeof window
.Notification
!== 'undefined' && Notification
.permission
=== 'default') {
361 window
.setTimeout(() => Notification
.requestPermission(), 120 * 1000);
364 this.props
.dispatch(expandHomeTimeline());
365 this.props
.dispatch(expandNotifications());
367 setTimeout(() => this.props
.dispatch(fetchFilters()), 500);
370 componentDidMount () {
371 this.hotkeys
.__mousetrap__
.stopCallback
= (e
, element
) => {
372 return ['TEXTAREA', 'SELECT', 'INPUT'].includes(element
.tagName
);
376 componentWillUnmount () {
377 window
.removeEventListener('focus', this.handleWindowFocus
);
378 window
.removeEventListener('blur', this.handleWindowBlur
);
379 window
.removeEventListener('beforeunload', this.handleBeforeUnload
);
381 document
.removeEventListener('dragenter', this.handleDragEnter
);
382 document
.removeEventListener('dragover', this.handleDragOver
);
383 document
.removeEventListener('drop', this.handleDrop
);
384 document
.removeEventListener('dragleave', this.handleDragLeave
);
385 document
.removeEventListener('dragend', this.handleDragEnd
);
392 handleHotkeyNew
= e
=> {
395 const element
= this.node
.querySelector('.compose-form__autosuggest-wrapper textarea');
402 handleHotkeySearch
= e
=> {
405 const element
= this.node
.querySelector('.search__input');
412 handleHotkeyForceNew
= e
=> {
413 this.handleHotkeyNew(e
);
414 this.props
.dispatch(resetCompose());
417 handleHotkeyFocusColumn
= e
=> {
418 const index
= (e
.key
* 1) + 1; // First child is drawer, skip that
419 const column
= this.node
.querySelector(`.column:nth-child(${index})`);
421 const container
= column
.querySelector('.scrollable');
424 const status
= container
.querySelector('.focusable');
427 if (container
.scrollTop
> status
.offsetTop
) {
428 status
.scrollIntoView(true);
435 handleHotkeyBack
= () => {
436 if (window
.history
&& window
.history
.length
=== 1) {
437 this.context
.router
.history
.push('/');
439 this.context
.router
.history
.goBack();
443 setHotkeysRef
= c
=> {
447 handleHotkeyToggleHelp
= () => {
448 if (this.props
.location
.pathname
=== '/keyboard-shortcuts') {
449 this.context
.router
.history
.goBack();
451 this.context
.router
.history
.push('/keyboard-shortcuts');
455 handleHotkeyGoToHome
= () => {
456 this.context
.router
.history
.push('/timelines/home');
459 handleHotkeyGoToNotifications
= () => {
460 this.context
.router
.history
.push('/notifications');
463 handleHotkeyGoToLocal
= () => {
464 this.context
.router
.history
.push('/timelines/public/local');
467 handleHotkeyGoToFederated
= () => {
468 this.context
.router
.history
.push('/timelines/public');
471 handleHotkeyGoToDirect
= () => {
472 this.context
.router
.history
.push('/timelines/direct');
475 handleHotkeyGoToStart
= () => {
476 this.context
.router
.history
.push('/getting-started');
479 handleHotkeyGoToFavourites
= () => {
480 this.context
.router
.history
.push('/favourites');
483 handleHotkeyGoToPinned
= () => {
484 this.context
.router
.history
.push('/pinned');
487 handleHotkeyGoToProfile
= () => {
488 this.context
.router
.history
.push(`/accounts/${me}`);
491 handleHotkeyGoToBlocked
= () => {
492 this.context
.router
.history
.push('/blocks');
495 handleHotkeyGoToMuted
= () => {
496 this.context
.router
.history
.push('/mutes');
499 handleHotkeyGoToRequests
= () => {
500 this.context
.router
.history
.push('/follow_requests');
504 const { draggingOver
} = this.state
;
505 const { children
, isComposing
, location
, dropdownMenuIsOpen
} = this.props
;
508 help: this.handleHotkeyToggleHelp
,
509 new: this.handleHotkeyNew
,
510 search: this.handleHotkeySearch
,
511 forceNew: this.handleHotkeyForceNew
,
512 focusColumn: this.handleHotkeyFocusColumn
,
513 back: this.handleHotkeyBack
,
514 goToHome: this.handleHotkeyGoToHome
,
515 goToNotifications: this.handleHotkeyGoToNotifications
,
516 goToLocal: this.handleHotkeyGoToLocal
,
517 goToFederated: this.handleHotkeyGoToFederated
,
518 goToDirect: this.handleHotkeyGoToDirect
,
519 goToStart: this.handleHotkeyGoToStart
,
520 goToFavourites: this.handleHotkeyGoToFavourites
,
521 goToPinned: this.handleHotkeyGoToPinned
,
522 goToProfile: this.handleHotkeyGoToProfile
,
523 goToBlocked: this.handleHotkeyGoToBlocked
,
524 goToMuted: this.handleHotkeyGoToMuted
,
525 goToRequests: this.handleHotkeyGoToRequests
,
529 <HotKeys keyMap
={keyMap
} handlers
={handlers
} ref
={this.setHotkeysRef
} attach
={window
} focused
>
530 <div className
={classNames('ui', { 'is-composing': isComposing
})} ref
={this.setRef
} style
={{ pointerEvents: dropdownMenuIsOpen
? 'none' : null }}>
531 <SwitchingColumnsArea location
={location
} onLayoutChange
={this.handleLayoutChange
}>
533 </SwitchingColumnsArea
>
535 <NotificationsContainer
/>
536 <LoadingBarContainer className
='loading-bar' />
538 <UploadArea active
={draggingOver
} onClose
={this.closeUploadModal
} />