about summary refs log tree commit diff
path: root/app/javascript/mastodon/features/ui/index.js
diff options
context:
space:
mode:
Diffstat (limited to 'app/javascript/mastodon/features/ui/index.js')
-rw-r--r--app/javascript/mastodon/features/ui/index.js223
1 files changed, 183 insertions, 40 deletions
diff --git a/app/javascript/mastodon/features/ui/index.js b/app/javascript/mastodon/features/ui/index.js
index 0e4796fcb..21f2395ba 100644
--- a/app/javascript/mastodon/features/ui/index.js
+++ b/app/javascript/mastodon/features/ui/index.js
@@ -8,7 +8,7 @@ import { connect } from 'react-redux';
 import { Redirect, withRouter } from 'react-router-dom';
 import { isMobile } from '../../is_mobile';
 import { debounce } from 'lodash';
-import { uploadCompose } from '../../actions/compose';
+import { uploadCompose, resetCompose } from '../../actions/compose';
 import { refreshHomeTimeline } from '../../actions/timelines';
 import { refreshNotifications } from '../../actions/notifications';
 import { clearHeight } from '../../actions/height_cache';
@@ -37,15 +37,43 @@ import {
   Mutes,
   PinnedStatuses,
 } from './util/async-components';
+import { HotKeys } from 'react-hotkeys';
 
 // Dummy import, to make sure that <Status /> ends up in the application bundle.
 // Without this it ends up in ~8 very commonly used bundles.
 import '../../components/status';
 
 const mapStateToProps = state => ({
+  me: state.getIn(['meta', 'me']),
   isComposing: state.getIn(['compose', 'is_composing']),
 });
 
+const keyMap = {
+  new: 'n',
+  search: 's',
+  forceNew: 'option+n',
+  focusColumn: ['1', '2', '3', '4', '5', '6', '7', '8', '9'],
+  reply: 'r',
+  favourite: 'f',
+  boost: 'b',
+  mention: 'm',
+  open: ['enter', 'o'],
+  openProfile: 'p',
+  moveDown: ['down', 'j'],
+  moveUp: ['up', 'k'],
+  back: 'backspace',
+  goToHome: 'g h',
+  goToNotifications: 'g n',
+  goToLocal: 'g l',
+  goToFederated: 'g t',
+  goToStart: 'g s',
+  goToFavourites: 'g f',
+  goToPinned: 'g p',
+  goToProfile: 'g u',
+  goToBlocked: 'g b',
+  goToMuted: 'g m',
+};
+
 @connect(mapStateToProps)
 @withRouter
 export default class UI extends React.Component {
@@ -58,6 +86,7 @@ export default class UI extends React.Component {
     dispatch: PropTypes.func.isRequired,
     children: PropTypes.node,
     isComposing: PropTypes.bool,
+    me: PropTypes.string,
     location: PropTypes.object,
   };
 
@@ -155,6 +184,12 @@ export default class UI extends React.Component {
     this.props.dispatch(refreshNotifications());
   }
 
+  componentDidMount () {
+    this.hotkeys.__mousetrap__.stopCallback = (e, element) => {
+      return !(e.altKey || e.ctrlKey || e.shiftKey || e.metaKey) && ['TEXTAREA', 'SELECT', 'INPUT'].includes(element.tagName);
+    };
+  }
+
   shouldComponentUpdate (nextProps) {
     if (nextProps.isComposing !== this.props.isComposing) {
       // Avoid expensive update just to toggle a class
@@ -191,52 +226,160 @@ export default class UI extends React.Component {
     this.columnsAreaNode = c.getWrappedInstance().getWrappedInstance();
   }
 
-  setOverlayRef = c => {
-    this.overlay = c;
+  handleHotkeyNew = e => {
+    e.preventDefault();
+
+    const element = this.node.querySelector('.compose-form__autosuggest-wrapper textarea');
+
+    if (element) {
+      element.focus();
+    }
+  }
+
+  handleHotkeySearch = e => {
+    e.preventDefault();
+
+    const element = this.node.querySelector('.search__input');
+
+    if (element) {
+      element.focus();
+    }
+  }
+
+  handleHotkeyForceNew = e => {
+    this.handleHotkeyNew(e);
+    this.props.dispatch(resetCompose());
+  }
+
+  handleHotkeyFocusColumn = e => {
+    const index  = (e.key * 1) + 1; // First child is drawer, skip that
+    const column = this.node.querySelector(`.column:nth-child(${index})`);
+
+    if (column) {
+      const status = column.querySelector('.focusable');
+
+      if (status) {
+        status.focus();
+      }
+    }
+  }
+
+  handleHotkeyBack = () => {
+    if (window.history && window.history.length === 1) {
+      this.context.router.history.push('/');
+    } else {
+      this.context.router.history.goBack();
+    }
+  }
+
+  setHotkeysRef = c => {
+    this.hotkeys = c;
+  }
+
+  handleHotkeyGoToHome = () => {
+    this.context.router.history.push('/timelines/home');
+  }
+
+  handleHotkeyGoToNotifications = () => {
+    this.context.router.history.push('/notifications');
+  }
+
+  handleHotkeyGoToLocal = () => {
+    this.context.router.history.push('/timelines/public/local');
+  }
+
+  handleHotkeyGoToFederated = () => {
+    this.context.router.history.push('/timelines/public');
+  }
+
+  handleHotkeyGoToStart = () => {
+    this.context.router.history.push('/getting-started');
+  }
+
+  handleHotkeyGoToFavourites = () => {
+    this.context.router.history.push('/favourites');
+  }
+
+  handleHotkeyGoToPinned = () => {
+    this.context.router.history.push('/pinned');
+  }
+
+  handleHotkeyGoToProfile = () => {
+    this.context.router.history.push(`/accounts/${this.props.me}`);
+  }
+
+  handleHotkeyGoToBlocked = () => {
+    this.context.router.history.push('/blocks');
+  }
+
+  handleHotkeyGoToMuted = () => {
+    this.context.router.history.push('/mutes');
   }
 
   render () {
     const { width, draggingOver } = this.state;
     const { children } = this.props;
 
+    const handlers = {
+      new: this.handleHotkeyNew,
+      search: this.handleHotkeySearch,
+      forceNew: this.handleHotkeyForceNew,
+      focusColumn: this.handleHotkeyFocusColumn,
+      back: this.handleHotkeyBack,
+      goToHome: this.handleHotkeyGoToHome,
+      goToNotifications: this.handleHotkeyGoToNotifications,
+      goToLocal: this.handleHotkeyGoToLocal,
+      goToFederated: this.handleHotkeyGoToFederated,
+      goToStart: this.handleHotkeyGoToStart,
+      goToFavourites: this.handleHotkeyGoToFavourites,
+      goToPinned: this.handleHotkeyGoToPinned,
+      goToProfile: this.handleHotkeyGoToProfile,
+      goToBlocked: this.handleHotkeyGoToBlocked,
+      goToMuted: this.handleHotkeyGoToMuted,
+    };
+
     return (
-      <div className='ui' ref={this.setRef}>
-        <TabsBar />
-        <ColumnsAreaContainer ref={this.setColumnsAreaRef} singleColumn={isMobile(width)}>
-          <WrappedSwitch>
-            <Redirect from='/' to='/getting-started' exact />
-            <WrappedRoute path='/getting-started' component={GettingStarted} content={children} />
-            <WrappedRoute path='/timelines/home' component={HomeTimeline} content={children} />
-            <WrappedRoute path='/timelines/public' exact component={PublicTimeline} content={children} />
-            <WrappedRoute path='/timelines/public/local' component={CommunityTimeline} content={children} />
-            <WrappedRoute path='/timelines/tag/:id' component={HashtagTimeline} content={children} />
-
-            <WrappedRoute path='/notifications' component={Notifications} content={children} />
-            <WrappedRoute path='/favourites' component={FavouritedStatuses} content={children} />
-            <WrappedRoute path='/pinned' component={PinnedStatuses} content={children} />
-
-            <WrappedRoute path='/statuses/new' component={Compose} content={children} />
-            <WrappedRoute path='/statuses/:statusId' exact component={Status} content={children} />
-            <WrappedRoute path='/statuses/:statusId/reblogs' component={Reblogs} content={children} />
-            <WrappedRoute path='/statuses/:statusId/favourites' component={Favourites} content={children} />
-
-            <WrappedRoute path='/accounts/:accountId' exact component={AccountTimeline} content={children} />
-            <WrappedRoute path='/accounts/:accountId/followers' component={Followers} content={children} />
-            <WrappedRoute path='/accounts/:accountId/following' component={Following} content={children} />
-            <WrappedRoute path='/accounts/:accountId/media' component={AccountGallery} content={children} />
-
-            <WrappedRoute path='/follow_requests' component={FollowRequests} content={children} />
-            <WrappedRoute path='/blocks' component={Blocks} content={children} />
-            <WrappedRoute path='/mutes' component={Mutes} content={children} />
-
-            <WrappedRoute component={GenericNotFound} content={children} />
-          </WrappedSwitch>
-        </ColumnsAreaContainer>
-        <NotificationsContainer />
-        <LoadingBarContainer className='loading-bar' />
-        <ModalContainer />
-        <UploadArea active={draggingOver} onClose={this.closeUploadModal} />
-      </div>
+      <HotKeys keyMap={keyMap} handlers={handlers} ref={this.setHotkeysRef}>
+        <div className='ui' ref={this.setRef}>
+          <TabsBar />
+
+          <ColumnsAreaContainer ref={this.setColumnsAreaRef} singleColumn={isMobile(width)}>
+            <WrappedSwitch>
+              <Redirect from='/' to='/getting-started' exact />
+              <WrappedRoute path='/getting-started' component={GettingStarted} content={children} />
+              <WrappedRoute path='/timelines/home' component={HomeTimeline} content={children} />
+              <WrappedRoute path='/timelines/public' exact component={PublicTimeline} content={children} />
+              <WrappedRoute path='/timelines/public/local' component={CommunityTimeline} content={children} />
+              <WrappedRoute path='/timelines/tag/:id' component={HashtagTimeline} content={children} />
+
+              <WrappedRoute path='/notifications' component={Notifications} content={children} />
+              <WrappedRoute path='/favourites' component={FavouritedStatuses} content={children} />
+              <WrappedRoute path='/pinned' component={PinnedStatuses} content={children} />
+
+              <WrappedRoute path='/statuses/new' component={Compose} content={children} />
+              <WrappedRoute path='/statuses/:statusId' exact component={Status} content={children} />
+              <WrappedRoute path='/statuses/:statusId/reblogs' component={Reblogs} content={children} />
+              <WrappedRoute path='/statuses/:statusId/favourites' component={Favourites} content={children} />
+
+              <WrappedRoute path='/accounts/:accountId' exact component={AccountTimeline} content={children} />
+              <WrappedRoute path='/accounts/:accountId/followers' component={Followers} content={children} />
+              <WrappedRoute path='/accounts/:accountId/following' component={Following} content={children} />
+              <WrappedRoute path='/accounts/:accountId/media' component={AccountGallery} content={children} />
+
+              <WrappedRoute path='/follow_requests' component={FollowRequests} content={children} />
+              <WrappedRoute path='/blocks' component={Blocks} content={children} />
+              <WrappedRoute path='/mutes' component={Mutes} content={children} />
+
+              <WrappedRoute component={GenericNotFound} content={children} />
+            </WrappedSwitch>
+          </ColumnsAreaContainer>
+
+          <NotificationsContainer />
+          <LoadingBarContainer className='loading-bar' />
+          <ModalContainer />
+          <UploadArea active={draggingOver} onClose={this.closeUploadModal} />
+        </div>
+      </HotKeys>
     );
   }