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 dropdownMenuIsOpen: state
.getIn(['dropdown_menu', 'openId']) !== null,
77 focusColumn: ['1', '2', '3', '4', '5', '6', '7', '8', '9'],
84 moveDown: ['down', 'j'],
88 goToNotifications: 'g n',
93 goToFavourites: 'g f',
100 toggleSensitive: 'h',
103 class SwitchingColumnsArea
extends React
.PureComponent
{
106 children: PropTypes
.node
,
107 location: PropTypes
.object
,
108 onLayoutChange: PropTypes
.func
.isRequired
,
112 mobile: isMobile(window
.innerWidth
),
115 componentWillMount () {
116 window
.addEventListener('resize', this.handleResize
, { passive: true });
118 if (this.state
.mobile
|| forceSingleColumn
) {
119 document
.body
.classList
.toggle('layout-single-column', true);
120 document
.body
.classList
.toggle('layout-multiple-columns', false);
122 document
.body
.classList
.toggle('layout-single-column', false);
123 document
.body
.classList
.toggle('layout-multiple-columns', true);
127 componentDidUpdate (prevProps
, prevState
) {
128 if (![this.props
.location
.pathname
, '/'].includes(prevProps
.location
.pathname
)) {
129 this.node
.handleChildrenContentChange();
132 if (prevState
.mobile
!== this.state
.mobile
&& !forceSingleColumn
) {
133 document
.body
.classList
.toggle('layout-single-column', this.state
.mobile
);
134 document
.body
.classList
.toggle('layout-multiple-columns', !this.state
.mobile
);
138 componentWillUnmount () {
139 window
.removeEventListener('resize', this.handleResize
);
142 shouldUpdateScroll (_
, { location
}) {
143 return location
.state
!== previewMediaState
&& location
.state
!== previewVideoState
;
146 handleLayoutChange
= debounce(() => {
147 // The cached heights are no longer accurate, invalidate
148 this.props
.onLayoutChange();
153 handleResize
= () => {
154 const mobile
= isMobile(window
.innerWidth
);
156 if (mobile
!== this.state
.mobile
) {
157 this.handleLayoutChange
.cancel();
158 this.props
.onLayoutChange();
159 this.setState({ mobile
});
161 this.handleLayoutChange();
166 this.node
= c
.getWrappedInstance();
170 const { children
} = this.props
;
171 const { mobile
} = this.state
;
172 const singleColumn
= forceSingleColumn
|| mobile
;
173 const redirect
= singleColumn
? <Redirect
from='/' to
='/timelines/home' exact
/> : <Redirect
from='/' to
='/getting-started' exact
/>;
176 <ColumnsAreaContainer ref
={this.setRef
} singleColumn
={singleColumn
}>
179 <WrappedRoute path
='/getting-started' component
={GettingStarted
} content
={children
} />
180 <WrappedRoute path
='/keyboard-shortcuts' component
={KeyboardShortcuts
} content
={children
} />
181 <WrappedRoute path
='/timelines/home' component
={HomeTimeline
} content
={children
} componentParams
={{ shouldUpdateScroll: this.shouldUpdateScroll
}} />
182 <WrappedRoute path
='/timelines/public' exact component
={PublicTimeline
} content
={children
} componentParams
={{ shouldUpdateScroll: this.shouldUpdateScroll
}} />
183 <WrappedRoute path
='/timelines/public/local' exact component
={CommunityTimeline
} content
={children
} componentParams
={{ shouldUpdateScroll: this.shouldUpdateScroll
}} />
184 <WrappedRoute path
='/timelines/direct' component
={DirectTimeline
} content
={children
} componentParams
={{ shouldUpdateScroll: this.shouldUpdateScroll
}} />
185 <WrappedRoute path
='/timelines/tag/:id' component
={HashtagTimeline
} content
={children
} componentParams
={{ shouldUpdateScroll: this.shouldUpdateScroll
}} />
186 <WrappedRoute path
='/timelines/list/:id' component
={ListTimeline
} content
={children
} componentParams
={{ shouldUpdateScroll: this.shouldUpdateScroll
}} />
188 <WrappedRoute path
='/notifications' component
={Notifications
} content
={children
} componentParams
={{ shouldUpdateScroll: this.shouldUpdateScroll
}} />
189 <WrappedRoute path
='/favourites' component
={FavouritedStatuses
} content
={children
} componentParams
={{ shouldUpdateScroll: this.shouldUpdateScroll
}} />
190 <WrappedRoute path
='/pinned' component
={PinnedStatuses
} content
={children
} componentParams
={{ shouldUpdateScroll: this.shouldUpdateScroll
}} />
192 <WrappedRoute path
='/search' component
={Search
} content
={children
} />
193 <WrappedRoute path
='/directory' component
={Directory
} content
={children
} componentParams
={{ shouldUpdateScroll: this.shouldUpdateScroll
}} />
195 <WrappedRoute path
='/statuses/new' component
={Compose
} content
={children
} />
196 <WrappedRoute path
='/statuses/:statusId' exact component
={Status
} content
={children
} componentParams
={{ shouldUpdateScroll: this.shouldUpdateScroll
}} />
197 <WrappedRoute path
='/statuses/:statusId/reblogs' component
={Reblogs
} content
={children
} componentParams
={{ shouldUpdateScroll: this.shouldUpdateScroll
}} />
198 <WrappedRoute path
='/statuses/:statusId/favourites' component
={Favourites
} content
={children
} componentParams
={{ shouldUpdateScroll: this.shouldUpdateScroll
}} />
200 <WrappedRoute path
='/accounts/:accountId' exact component
={AccountTimeline
} content
={children
} componentParams
={{ shouldUpdateScroll: this.shouldUpdateScroll
}} />
201 <WrappedRoute path
='/accounts/:accountId/with_replies' component
={AccountTimeline
} content
={children
} componentParams
={{ shouldUpdateScroll: this.shouldUpdateScroll
, withReplies: true }} />
202 <WrappedRoute path
='/accounts/:accountId/followers' component
={Followers
} content
={children
} componentParams
={{ shouldUpdateScroll: this.shouldUpdateScroll
}} />
203 <WrappedRoute path
='/accounts/:accountId/following' component
={Following
} content
={children
} componentParams
={{ shouldUpdateScroll: this.shouldUpdateScroll
}} />
204 <WrappedRoute path
='/accounts/:accountId/media' component
={AccountGallery
} content
={children
} componentParams
={{ shouldUpdateScroll: this.shouldUpdateScroll
}} />
206 <WrappedRoute path
='/follow_requests' component
={FollowRequests
} content
={children
} componentParams
={{ shouldUpdateScroll: this.shouldUpdateScroll
}} />
207 <WrappedRoute path
='/blocks' component
={Blocks
} content
={children
} componentParams
={{ shouldUpdateScroll: this.shouldUpdateScroll
}} />
208 <WrappedRoute path
='/domain_blocks' component
={DomainBlocks
} content
={children
} componentParams
={{ shouldUpdateScroll: this.shouldUpdateScroll
}} />
209 <WrappedRoute path
='/mutes' component
={Mutes
} content
={children
} componentParams
={{ shouldUpdateScroll: this.shouldUpdateScroll
}} />
210 <WrappedRoute path
='/lists' component
={Lists
} content
={children
} componentParams
={{ shouldUpdateScroll: this.shouldUpdateScroll
}} />
212 <WrappedRoute component
={GenericNotFound
} content
={children
} />
214 </ColumnsAreaContainer
>
220 export default @connect(mapStateToProps
)
223 class UI
extends React
.PureComponent
{
225 static contextTypes
= {
226 router: PropTypes
.object
.isRequired
,
230 dispatch: PropTypes
.func
.isRequired
,
231 children: PropTypes
.node
,
232 isComposing: PropTypes
.bool
,
233 hasComposingText: PropTypes
.bool
,
234 hasMediaAttachments: PropTypes
.bool
,
235 location: PropTypes
.object
,
236 intl: PropTypes
.object
.isRequired
,
237 dropdownMenuIsOpen: PropTypes
.bool
,
244 handleBeforeUnload
= e
=> {
245 const { intl
, dispatch
, isComposing
, hasComposingText
, hasMediaAttachments
} = this.props
;
247 dispatch(submitMarkers());
249 if (isComposing
&& (hasComposingText
|| hasMediaAttachments
)) {
250 // Setting returnValue to any string causes confirmation dialog.
251 // Many browsers no longer display this text to users,
252 // but we set user-friendly message for other browsers, e.g. Edge.
253 e
.returnValue
= intl
.formatMessage(messages
.beforeUnload
);
257 handleWindowFocus
= () => {
258 this.props
.dispatch(focusApp());
261 handleWindowBlur
= () => {
262 this.props
.dispatch(unfocusApp());
265 handleLayoutChange
= () => {
266 // The cached heights are no longer accurate, invalidate
267 this.props
.dispatch(clearHeight());
270 handleDragEnter
= (e
) => {
273 if (!this.dragTargets
) {
274 this.dragTargets
= [];
277 if (this.dragTargets
.indexOf(e
.target
) === -1) {
278 this.dragTargets
.push(e
.target
);
281 if (e
.dataTransfer
&& Array
.from(e
.dataTransfer
.types
).includes('Files')) {
282 this.setState({ draggingOver: true });
286 handleDragOver
= (e
) => {
287 if (this.dataTransferIsText(e
.dataTransfer
)) return false;
292 e
.dataTransfer
.dropEffect
= 'copy';
300 handleDrop
= (e
) => {
301 if (this.dataTransferIsText(e
.dataTransfer
)) return;
304 this.setState({ draggingOver: false });
305 this.dragTargets
= [];
307 if (e
.dataTransfer
&& e
.dataTransfer
.files
.length
>= 1) {
308 this.props
.dispatch(uploadCompose(e
.dataTransfer
.files
));
312 handleDragLeave
= (e
) => {
316 this.dragTargets
= this.dragTargets
.filter(el
=> el
!== e
.target
&& this.node
.contains(el
));
318 if (this.dragTargets
.length
> 0) {
322 this.setState({ draggingOver: false });
325 dataTransferIsText
= (dataTransfer
) => {
326 return (dataTransfer
&& Array
.from(dataTransfer
.types
).includes('text/plain') && dataTransfer
.items
.length
=== 1);
329 closeUploadModal
= () => {
330 this.setState({ draggingOver: false });
333 handleServiceWorkerPostMessage
= ({ data
}) => {
334 if (data
.type
=== 'navigate') {
335 this.context
.router
.history
.push(data
.path
);
337 console
.warn('Unknown message type:', data
.type
);
341 componentWillMount () {
342 window
.addEventListener('focus', this.handleWindowFocus
, false);
343 window
.addEventListener('blur', this.handleWindowBlur
, false);
344 window
.addEventListener('beforeunload', this.handleBeforeUnload
, false);
346 document
.addEventListener('dragenter', this.handleDragEnter
, false);
347 document
.addEventListener('dragover', this.handleDragOver
, false);
348 document
.addEventListener('drop', this.handleDrop
, false);
349 document
.addEventListener('dragleave', this.handleDragLeave
, false);
350 document
.addEventListener('dragend', this.handleDragEnd
, false);
352 if ('serviceWorker' in navigator
) {
353 navigator
.serviceWorker
.addEventListener('message', this.handleServiceWorkerPostMessage
);
356 if (typeof window
.Notification
!== 'undefined' && Notification
.permission
=== 'default') {
357 window
.setTimeout(() => Notification
.requestPermission(), 120 * 1000);
360 this.props
.dispatch(expandHomeTimeline());
361 this.props
.dispatch(expandNotifications());
363 setTimeout(() => this.props
.dispatch(fetchFilters()), 500);
366 componentDidMount () {
367 this.hotkeys
.__mousetrap__
.stopCallback
= (e
, element
) => {
368 return ['TEXTAREA', 'SELECT', 'INPUT'].includes(element
.tagName
);
372 componentWillUnmount () {
373 window
.removeEventListener('focus', this.handleWindowFocus
);
374 window
.removeEventListener('blur', this.handleWindowBlur
);
375 window
.removeEventListener('beforeunload', this.handleBeforeUnload
);
377 document
.removeEventListener('dragenter', this.handleDragEnter
);
378 document
.removeEventListener('dragover', this.handleDragOver
);
379 document
.removeEventListener('drop', this.handleDrop
);
380 document
.removeEventListener('dragleave', this.handleDragLeave
);
381 document
.removeEventListener('dragend', this.handleDragEnd
);
388 handleHotkeyNew
= e
=> {
391 const element
= this.node
.querySelector('.compose-form__autosuggest-wrapper textarea');
398 handleHotkeySearch
= e
=> {
401 const element
= this.node
.querySelector('.search__input');
408 handleHotkeyForceNew
= e
=> {
409 this.handleHotkeyNew(e
);
410 this.props
.dispatch(resetCompose());
413 handleHotkeyFocusColumn
= e
=> {
414 const index
= (e
.key
* 1) + 1; // First child is drawer, skip that
415 const column
= this.node
.querySelector(`.column:nth-child(${index})`);
417 const container
= column
.querySelector('.scrollable');
420 const status
= container
.querySelector('.focusable');
423 if (container
.scrollTop
> status
.offsetTop
) {
424 status
.scrollIntoView(true);
431 handleHotkeyBack
= () => {
432 if (window
.history
&& window
.history
.length
=== 1) {
433 this.context
.router
.history
.push('/');
435 this.context
.router
.history
.goBack();
439 setHotkeysRef
= c
=> {
443 handleHotkeyToggleHelp
= () => {
444 if (this.props
.location
.pathname
=== '/keyboard-shortcuts') {
445 this.context
.router
.history
.goBack();
447 this.context
.router
.history
.push('/keyboard-shortcuts');
451 handleHotkeyGoToHome
= () => {
452 this.context
.router
.history
.push('/timelines/home');
455 handleHotkeyGoToNotifications
= () => {
456 this.context
.router
.history
.push('/notifications');
459 handleHotkeyGoToLocal
= () => {
460 this.context
.router
.history
.push('/timelines/public/local');
463 handleHotkeyGoToFederated
= () => {
464 this.context
.router
.history
.push('/timelines/public');
467 handleHotkeyGoToDirect
= () => {
468 this.context
.router
.history
.push('/timelines/direct');
471 handleHotkeyGoToStart
= () => {
472 this.context
.router
.history
.push('/getting-started');
475 handleHotkeyGoToFavourites
= () => {
476 this.context
.router
.history
.push('/favourites');
479 handleHotkeyGoToPinned
= () => {
480 this.context
.router
.history
.push('/pinned');
483 handleHotkeyGoToProfile
= () => {
484 this.context
.router
.history
.push(`/accounts/${me}`);
487 handleHotkeyGoToBlocked
= () => {
488 this.context
.router
.history
.push('/blocks');
491 handleHotkeyGoToMuted
= () => {
492 this.context
.router
.history
.push('/mutes');
495 handleHotkeyGoToRequests
= () => {
496 this.context
.router
.history
.push('/follow_requests');
500 const { draggingOver
} = this.state
;
501 const { children
, isComposing
, location
, dropdownMenuIsOpen
} = this.props
;
504 help: this.handleHotkeyToggleHelp
,
505 new: this.handleHotkeyNew
,
506 search: this.handleHotkeySearch
,
507 forceNew: this.handleHotkeyForceNew
,
508 focusColumn: this.handleHotkeyFocusColumn
,
509 back: this.handleHotkeyBack
,
510 goToHome: this.handleHotkeyGoToHome
,
511 goToNotifications: this.handleHotkeyGoToNotifications
,
512 goToLocal: this.handleHotkeyGoToLocal
,
513 goToFederated: this.handleHotkeyGoToFederated
,
514 goToDirect: this.handleHotkeyGoToDirect
,
515 goToStart: this.handleHotkeyGoToStart
,
516 goToFavourites: this.handleHotkeyGoToFavourites
,
517 goToPinned: this.handleHotkeyGoToPinned
,
518 goToProfile: this.handleHotkeyGoToProfile
,
519 goToBlocked: this.handleHotkeyGoToBlocked
,
520 goToMuted: this.handleHotkeyGoToMuted
,
521 goToRequests: this.handleHotkeyGoToRequests
,
525 <HotKeys keyMap
={keyMap
} handlers
={handlers
} ref
={this.setHotkeysRef
} attach
={window
} focused
>
526 <div className
={classNames('ui', { 'is-composing': isComposing
})} ref
={this.setRef
} style
={{ pointerEvents: dropdownMenuIsOpen
? 'none' : null }}>
527 <SwitchingColumnsArea location
={location
} onLayoutChange
={this.handleLayoutChange
}>
529 </SwitchingColumnsArea
>
531 <NotificationsContainer
/>
532 <LoadingBarContainer className
='loading-bar' />
534 <UploadArea active
={draggingOver
} onClose
={this.closeUploadModal
} />