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