]> cat aescling's git repositories - mastodon.git/blob - app/javascript/mastodon/features/ui/index.js
f5e48ed311b82a8d690fd16c1a74d8cfed67f30e
[mastodon.git] / app / javascript / mastodon / features / ui / index.js
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';
24 import {
25 Compose,
26 Status,
27 GettingStarted,
28 KeyboardShortcuts,
29 PublicTimeline,
30 CommunityTimeline,
31 AccountTimeline,
32 AccountGallery,
33 HomeTimeline,
34 Followers,
35 Following,
36 Reblogs,
37 Favourites,
38 DirectTimeline,
39 HashtagTimeline,
40 Notifications,
41 FollowRequests,
42 GenericNotFound,
43 FavouritedStatuses,
44 ListTimeline,
45 Blocks,
46 DomainBlocks,
47 Mutes,
48 PinnedStatuses,
49 Lists,
50 Search,
51 Directory,
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';
56
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';
60
61 const messages = defineMessages({
62 beforeUnload: { id: 'ui.beforeunload', defaultMessage: 'Your draft will be lost if you leave Mastodon.' },
63 });
64
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,
71 });
72
73 const keyMap = {
74 help: '?',
75 new: 'n',
76 search: 's',
77 forceNew: 'option+n',
78 focusColumn: ['1', '2', '3', '4', '5', '6', '7', '8', '9'],
79 reply: 'r',
80 favourite: 'f',
81 boost: 'b',
82 mention: 'm',
83 open: ['enter', 'o'],
84 openProfile: 'p',
85 moveDown: ['down', 'j'],
86 moveUp: ['up', 'k'],
87 back: 'backspace',
88 goToHome: 'g h',
89 goToNotifications: 'g n',
90 goToLocal: 'g l',
91 goToFederated: 'g t',
92 goToDirect: 'g d',
93 goToStart: 'g s',
94 goToFavourites: 'g f',
95 goToPinned: 'g p',
96 goToProfile: 'g u',
97 goToBlocked: 'g b',
98 goToMuted: 'g m',
99 goToRequests: 'g r',
100 toggleHidden: 'x',
101 toggleSensitive: 'h',
102 };
103
104 class SwitchingColumnsArea extends React.PureComponent {
105
106 static propTypes = {
107 children: PropTypes.node,
108 location: PropTypes.object,
109 onLayoutChange: PropTypes.func.isRequired,
110 };
111
112 state = {
113 mobile: isMobile(window.innerWidth),
114 };
115
116 componentWillMount () {
117 window.addEventListener('resize', this.handleResize, { passive: true });
118
119 if (this.state.mobile || forceSingleColumn) {
120 document.body.classList.toggle('layout-single-column', true);
121 document.body.classList.toggle('layout-multiple-columns', false);
122 } else {
123 document.body.classList.toggle('layout-single-column', false);
124 document.body.classList.toggle('layout-multiple-columns', true);
125 }
126 }
127
128 componentDidUpdate (prevProps, prevState) {
129 if (![this.props.location.pathname, '/'].includes(prevProps.location.pathname)) {
130 this.node.handleChildrenContentChange();
131 }
132
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);
136 }
137 }
138
139 componentWillUnmount () {
140 window.removeEventListener('resize', this.handleResize);
141 }
142
143 shouldUpdateScroll (_, { location }) {
144 return location.state !== previewMediaState && location.state !== previewVideoState;
145 }
146
147 handleLayoutChange = debounce(() => {
148 // The cached heights are no longer accurate, invalidate
149 this.props.onLayoutChange();
150 }, 500, {
151 trailing: true,
152 })
153
154 handleResize = () => {
155 const mobile = isMobile(window.innerWidth);
156
157 if (mobile !== this.state.mobile) {
158 this.handleLayoutChange.cancel();
159 this.props.onLayoutChange();
160 this.setState({ mobile });
161 } else {
162 this.handleLayoutChange();
163 }
164 }
165
166 setRef = c => {
167 this.node = c.getWrappedInstance();
168 }
169
170 render () {
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 />;
175
176 return (
177 <ColumnsAreaContainer ref={this.setRef} singleColumn={singleColumn}>
178 <WrappedSwitch>
179 {redirect}
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 }} />
188
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 }} />
192
193 <WrappedRoute path='/search' component={Search} content={children} />
194 <WrappedRoute path='/directory' component={Directory} content={children} componentParams={{ shouldUpdateScroll: this.shouldUpdateScroll }} />
195
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 }} />
200
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 }} />
206
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 }} />
212
213 <WrappedRoute component={GenericNotFound} content={children} />
214 </WrappedSwitch>
215 </ColumnsAreaContainer>
216 );
217 }
218
219 }
220
221 export default @connect(mapStateToProps)
222 @injectIntl
223 @withRouter
224 class UI extends React.PureComponent {
225
226 static contextTypes = {
227 router: PropTypes.object.isRequired,
228 };
229
230 static propTypes = {
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,
240 };
241
242 state = {
243 draggingOver: false,
244 };
245
246 handleBeforeUnload = e => {
247 const { intl, dispatch, isComposing, hasComposingText, hasMediaAttachments } = this.props;
248
249 dispatch(submitMarkers());
250
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);
256 }
257 }
258
259 handleWindowFocus = () => {
260 this.props.dispatch(focusApp());
261 }
262
263 handleWindowBlur = () => {
264 this.props.dispatch(unfocusApp());
265 }
266
267 handleLayoutChange = () => {
268 // The cached heights are no longer accurate, invalidate
269 this.props.dispatch(clearHeight());
270 }
271
272 handleDragEnter = (e) => {
273 e.preventDefault();
274
275 if (!this.dragTargets) {
276 this.dragTargets = [];
277 }
278
279 if (this.dragTargets.indexOf(e.target) === -1) {
280 this.dragTargets.push(e.target);
281 }
282
283 if (e.dataTransfer && Array.from(e.dataTransfer.types).includes('Files') && this.props.canUploadMore) {
284 this.setState({ draggingOver: true });
285 }
286 }
287
288 handleDragOver = (e) => {
289 if (this.dataTransferIsText(e.dataTransfer)) return false;
290
291 e.preventDefault();
292 e.stopPropagation();
293
294 try {
295 e.dataTransfer.dropEffect = 'copy';
296 } catch (err) {
297
298 }
299
300 return false;
301 }
302
303 handleDrop = (e) => {
304 if (this.dataTransferIsText(e.dataTransfer)) return;
305
306 e.preventDefault();
307
308 this.setState({ draggingOver: false });
309 this.dragTargets = [];
310
311 if (e.dataTransfer && e.dataTransfer.files.length >= 1 && this.props.canUploadMore) {
312 this.props.dispatch(uploadCompose(e.dataTransfer.files));
313 }
314 }
315
316 handleDragLeave = (e) => {
317 e.preventDefault();
318 e.stopPropagation();
319
320 this.dragTargets = this.dragTargets.filter(el => el !== e.target && this.node.contains(el));
321
322 if (this.dragTargets.length > 0) {
323 return;
324 }
325
326 this.setState({ draggingOver: false });
327 }
328
329 dataTransferIsText = (dataTransfer) => {
330 return (dataTransfer && Array.from(dataTransfer.types).includes('text/plain') && dataTransfer.items.length === 1);
331 }
332
333 closeUploadModal = () => {
334 this.setState({ draggingOver: false });
335 }
336
337 handleServiceWorkerPostMessage = ({ data }) => {
338 if (data.type === 'navigate') {
339 this.context.router.history.push(data.path);
340 } else {
341 console.warn('Unknown message type:', data.type);
342 }
343 }
344
345 componentWillMount () {
346 window.addEventListener('focus', this.handleWindowFocus, false);
347 window.addEventListener('blur', this.handleWindowBlur, false);
348 window.addEventListener('beforeunload', this.handleBeforeUnload, false);
349
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);
355
356 if ('serviceWorker' in navigator) {
357 navigator.serviceWorker.addEventListener('message', this.handleServiceWorkerPostMessage);
358 }
359
360 if (typeof window.Notification !== 'undefined' && Notification.permission === 'default') {
361 window.setTimeout(() => Notification.requestPermission(), 120 * 1000);
362 }
363
364 this.props.dispatch(expandHomeTimeline());
365 this.props.dispatch(expandNotifications());
366
367 setTimeout(() => this.props.dispatch(fetchFilters()), 500);
368 }
369
370 componentDidMount () {
371 this.hotkeys.__mousetrap__.stopCallback = (e, element) => {
372 return ['TEXTAREA', 'SELECT', 'INPUT'].includes(element.tagName);
373 };
374 }
375
376 componentWillUnmount () {
377 window.removeEventListener('focus', this.handleWindowFocus);
378 window.removeEventListener('blur', this.handleWindowBlur);
379 window.removeEventListener('beforeunload', this.handleBeforeUnload);
380
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);
386 }
387
388 setRef = c => {
389 this.node = c;
390 }
391
392 handleHotkeyNew = e => {
393 e.preventDefault();
394
395 const element = this.node.querySelector('.compose-form__autosuggest-wrapper textarea');
396
397 if (element) {
398 element.focus();
399 }
400 }
401
402 handleHotkeySearch = e => {
403 e.preventDefault();
404
405 const element = this.node.querySelector('.search__input');
406
407 if (element) {
408 element.focus();
409 }
410 }
411
412 handleHotkeyForceNew = e => {
413 this.handleHotkeyNew(e);
414 this.props.dispatch(resetCompose());
415 }
416
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})`);
420 if (!column) return;
421 const container = column.querySelector('.scrollable');
422
423 if (container) {
424 const status = container.querySelector('.focusable');
425
426 if (status) {
427 if (container.scrollTop > status.offsetTop) {
428 status.scrollIntoView(true);
429 }
430 status.focus();
431 }
432 }
433 }
434
435 handleHotkeyBack = () => {
436 if (window.history && window.history.length === 1) {
437 this.context.router.history.push('/');
438 } else {
439 this.context.router.history.goBack();
440 }
441 }
442
443 setHotkeysRef = c => {
444 this.hotkeys = c;
445 }
446
447 handleHotkeyToggleHelp = () => {
448 if (this.props.location.pathname === '/keyboard-shortcuts') {
449 this.context.router.history.goBack();
450 } else {
451 this.context.router.history.push('/keyboard-shortcuts');
452 }
453 }
454
455 handleHotkeyGoToHome = () => {
456 this.context.router.history.push('/timelines/home');
457 }
458
459 handleHotkeyGoToNotifications = () => {
460 this.context.router.history.push('/notifications');
461 }
462
463 handleHotkeyGoToLocal = () => {
464 this.context.router.history.push('/timelines/public/local');
465 }
466
467 handleHotkeyGoToFederated = () => {
468 this.context.router.history.push('/timelines/public');
469 }
470
471 handleHotkeyGoToDirect = () => {
472 this.context.router.history.push('/timelines/direct');
473 }
474
475 handleHotkeyGoToStart = () => {
476 this.context.router.history.push('/getting-started');
477 }
478
479 handleHotkeyGoToFavourites = () => {
480 this.context.router.history.push('/favourites');
481 }
482
483 handleHotkeyGoToPinned = () => {
484 this.context.router.history.push('/pinned');
485 }
486
487 handleHotkeyGoToProfile = () => {
488 this.context.router.history.push(`/accounts/${me}`);
489 }
490
491 handleHotkeyGoToBlocked = () => {
492 this.context.router.history.push('/blocks');
493 }
494
495 handleHotkeyGoToMuted = () => {
496 this.context.router.history.push('/mutes');
497 }
498
499 handleHotkeyGoToRequests = () => {
500 this.context.router.history.push('/follow_requests');
501 }
502
503 render () {
504 const { draggingOver } = this.state;
505 const { children, isComposing, location, dropdownMenuIsOpen } = this.props;
506
507 const handlers = {
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,
526 };
527
528 return (
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}>
532 {children}
533 </SwitchingColumnsArea>
534
535 <NotificationsContainer />
536 <LoadingBarContainer className='loading-bar' />
537 <ModalContainer />
538 <UploadArea active={draggingOver} onClose={this.closeUploadModal} />
539 <DocumentTitle />
540 </div>
541 </HotKeys>
542 );
543 }
544
545 }
This page took 0.154886 seconds and 2 git commands to generate.