about summary refs log tree commit diff
path: root/app/javascript/mastodon/features
diff options
context:
space:
mode:
authorEugen Rochko <eugen@zeonfederated.com>2017-10-06 01:07:59 +0200
committerGitHub <noreply@github.com>2017-10-06 01:07:59 +0200
commit7db0f8dcb2110b4ec8815bedc965cfbd01a59798 (patch)
treeff3153c334c12a75aa2875284012cded2a82d49d /app/javascript/mastodon/features
parent49cc0eb3e7d1521079e33a60216df46679082547 (diff)
Implement hotkeys for web UI (#5164)
* Fix #2102 - Implement hotkeys

Hotkeys on status list:

- r to reply
- m to mention author
- f to favourite
- b to boost
- enter to open status
- p to open author's profile
- up or k to move up in the list
- down or j to move down in the list
- 1-9 to focus a status in one of the columns
- n to focus the compose textarea
- alt+n to start a brand new toot
- backspace to navigate back

* Add navigational hotkeys

The key g followed by:

- s: start
- h: home
- n: notifications
- l: local timeline
- t: federated timeline
- f: favourites
- u: own profile
- p: pinned toots
- b: blocked users
- m: muted users

* Add hotkey for focusing search, make escape un-focus compose/search

* Fix focusing notifications column, fix hotkeys in compose textarea
Diffstat (limited to 'app/javascript/mastodon/features')
-rw-r--r--app/javascript/mastodon/features/compose/components/search.js2
-rw-r--r--app/javascript/mastodon/features/notifications/components/notification.js115
-rw-r--r--app/javascript/mastodon/features/notifications/containers/notification_container.js9
-rw-r--r--app/javascript/mastodon/features/notifications/index.js28
-rw-r--r--app/javascript/mastodon/features/status/index.js151
-rw-r--r--app/javascript/mastodon/features/ui/index.js223
6 files changed, 439 insertions, 89 deletions
diff --git a/app/javascript/mastodon/features/compose/components/search.js b/app/javascript/mastodon/features/compose/components/search.js
index 79abffad8..4c3f0dcb5 100644
--- a/app/javascript/mastodon/features/compose/components/search.js
+++ b/app/javascript/mastodon/features/compose/components/search.js
@@ -74,6 +74,8 @@ export default class Search extends React.PureComponent {
     if (e.key === 'Enter') {
       e.preventDefault();
       this.props.onSubmit();
+    } else if (e.key === 'Escape') {
+      document.querySelector('.ui').parentElement.focus();
     }
   }
 
diff --git a/app/javascript/mastodon/features/notifications/components/notification.js b/app/javascript/mastodon/features/notifications/components/notification.js
index a608a5223..9d170cad5 100644
--- a/app/javascript/mastodon/features/notifications/components/notification.js
+++ b/app/javascript/mastodon/features/notifications/components/notification.js
@@ -6,61 +6,126 @@ import AccountContainer from '../../../containers/account_container';
 import { FormattedMessage } from 'react-intl';
 import Permalink from '../../../components/permalink';
 import ImmutablePureComponent from 'react-immutable-pure-component';
+import { HotKeys } from 'react-hotkeys';
 
 export default class Notification extends ImmutablePureComponent {
 
+  static contextTypes = {
+    router: PropTypes.object,
+  };
+
   static propTypes = {
     notification: ImmutablePropTypes.map.isRequired,
     hidden: PropTypes.bool,
+    onMoveUp: PropTypes.func.isRequired,
+    onMoveDown: PropTypes.func.isRequired,
+    onMention: PropTypes.func.isRequired,
   };
 
+  handleMoveUp = () => {
+    const { notification, onMoveUp } = this.props;
+    onMoveUp(notification.get('id'));
+  }
+
+  handleMoveDown = () => {
+    const { notification, onMoveDown } = this.props;
+    onMoveDown(notification.get('id'));
+  }
+
+  handleOpen = () => {
+    const { notification } = this.props;
+
+    if (notification.get('status')) {
+      this.context.router.history.push(`/statuses/${notification.get('status')}`);
+    } else {
+      this.handleOpenProfile();
+    }
+  }
+
+  handleOpenProfile = () => {
+    const { notification } = this.props;
+    this.context.router.history.push(`/accounts/${notification.getIn(['account', 'id'])}`);
+  }
+
+  handleMention = e => {
+    e.preventDefault();
+
+    const { notification, onMention } = this.props;
+    onMention(notification.get('account'), this.context.router.history);
+  }
+
+  getHandlers () {
+    return {
+      moveUp: this.handleMoveUp,
+      moveDown: this.handleMoveDown,
+      open: this.handleOpen,
+      openProfile: this.handleOpenProfile,
+      mention: this.handleMention,
+      reply: this.handleMention,
+    };
+  }
+
   renderFollow (account, link) {
     return (
-      <div className='notification notification-follow'>
-        <div className='notification__message'>
-          <div className='notification__favourite-icon-wrapper'>
-            <i className='fa fa-fw fa-user-plus' />
+      <HotKeys handlers={this.getHandlers()}>
+        <div className='notification notification-follow focusable' tabIndex='0'>
+          <div className='notification__message'>
+            <div className='notification__favourite-icon-wrapper'>
+              <i className='fa fa-fw fa-user-plus' />
+            </div>
+
+            <FormattedMessage id='notification.follow' defaultMessage='{name} followed you' values={{ name: link }} />
           </div>
 
-          <FormattedMessage id='notification.follow' defaultMessage='{name} followed you' values={{ name: link }} />
+          <AccountContainer id={account.get('id')} withNote={false} hidden={this.props.hidden} />
         </div>
-
-        <AccountContainer id={account.get('id')} withNote={false} hidden={this.props.hidden} />
-      </div>
+      </HotKeys>
     );
   }
 
   renderMention (notification) {
-    return <StatusContainer id={notification.get('status')} withDismiss hidden={this.props.hidden} />;
+    return (
+      <StatusContainer
+        id={notification.get('status')}
+        withDismiss
+        hidden={this.props.hidden}
+        onMoveDown={this.handleMoveDown}
+        onMoveUp={this.handleMoveUp}
+      />
+    );
   }
 
   renderFavourite (notification, link) {
     return (
-      <div className='notification notification-favourite'>
-        <div className='notification__message'>
-          <div className='notification__favourite-icon-wrapper'>
-            <i className='fa fa-fw fa-star star-icon' />
+      <HotKeys handlers={this.getHandlers()}>
+        <div className='notification notification-favourite focusable' tabIndex='0'>
+          <div className='notification__message'>
+            <div className='notification__favourite-icon-wrapper'>
+              <i className='fa fa-fw fa-star star-icon' />
+            </div>
+            <FormattedMessage id='notification.favourite' defaultMessage='{name} favourited your status' values={{ name: link }} />
           </div>
-          <FormattedMessage id='notification.favourite' defaultMessage='{name} favourited your status' values={{ name: link }} />
-        </div>
 
-        <StatusContainer id={notification.get('status')} account={notification.get('account')} muted withDismiss hidden={!!this.props.hidden} />
-      </div>
+          <StatusContainer id={notification.get('status')} account={notification.get('account')} muted withDismiss hidden={!!this.props.hidden} />
+        </div>
+      </HotKeys>
     );
   }
 
   renderReblog (notification, link) {
     return (
-      <div className='notification notification-reblog'>
-        <div className='notification__message'>
-          <div className='notification__favourite-icon-wrapper'>
-            <i className='fa fa-fw fa-retweet' />
+      <HotKeys handlers={this.getHandlers()}>
+        <div className='notification notification-reblog focusable' tabIndex='0'>
+          <div className='notification__message'>
+            <div className='notification__favourite-icon-wrapper'>
+              <i className='fa fa-fw fa-retweet' />
+            </div>
+            <FormattedMessage id='notification.reblog' defaultMessage='{name} boosted your status' values={{ name: link }} />
           </div>
-          <FormattedMessage id='notification.reblog' defaultMessage='{name} boosted your status' values={{ name: link }} />
-        </div>
 
-        <StatusContainer id={notification.get('status')} account={notification.get('account')} muted withDismiss hidden={this.props.hidden} />
-      </div>
+          <StatusContainer id={notification.get('status')} account={notification.get('account')} muted withDismiss hidden={this.props.hidden} />
+        </div>
+      </HotKeys>
     );
   }
 
diff --git a/app/javascript/mastodon/features/notifications/containers/notification_container.js b/app/javascript/mastodon/features/notifications/containers/notification_container.js
index 786222967..921aa460f 100644
--- a/app/javascript/mastodon/features/notifications/containers/notification_container.js
+++ b/app/javascript/mastodon/features/notifications/containers/notification_container.js
@@ -1,6 +1,7 @@
 import { connect } from 'react-redux';
 import { makeGetNotification } from '../../../selectors';
 import Notification from '../components/notification';
+import { mentionCompose } from '../../../actions/compose';
 
 const makeMapStateToProps = () => {
   const getNotification = makeGetNotification();
@@ -12,4 +13,10 @@ const makeMapStateToProps = () => {
   return mapStateToProps;
 };
 
-export default connect(makeMapStateToProps)(Notification);
+const mapDispatchToProps = dispatch => ({
+  onMention: (account, router) => {
+    dispatch(mentionCompose(account, router));
+  },
+});
+
+export default connect(makeMapStateToProps, mapDispatchToProps)(Notification);
diff --git a/app/javascript/mastodon/features/notifications/index.js b/app/javascript/mastodon/features/notifications/index.js
index b74473b9f..35b430bfb 100644
--- a/app/javascript/mastodon/features/notifications/index.js
+++ b/app/javascript/mastodon/features/notifications/index.js
@@ -86,6 +86,24 @@ export default class Notifications extends React.PureComponent {
     this.column = c;
   }
 
+  handleMoveUp = id => {
+    const elementIndex = this.props.notifications.findIndex(item => item.get('id') === id) - 1;
+    this._selectChild(elementIndex);
+  }
+
+  handleMoveDown = id => {
+    const elementIndex = this.props.notifications.findIndex(item => item.get('id') === id) + 1;
+    this._selectChild(elementIndex);
+  }
+
+  _selectChild (index) {
+    const element = this.column.node.querySelector(`article:nth-of-type(${index + 1}) .focusable`);
+
+    if (element) {
+      element.focus();
+    }
+  }
+
   render () {
     const { intl, notifications, shouldUpdateScroll, isLoading, isUnread, columnId, multiColumn, hasMore } = this.props;
     const pinned = !!columnId;
@@ -96,7 +114,15 @@ export default class Notifications extends React.PureComponent {
     if (isLoading && this.scrollableContent) {
       scrollableContent = this.scrollableContent;
     } else if (notifications.size > 0 || hasMore) {
-      scrollableContent = notifications.map((item) => <NotificationContainer key={item.get('id')} notification={item} accountId={item.get('account')} />);
+      scrollableContent = notifications.map((item) => (
+        <NotificationContainer
+          key={item.get('id')}
+          notification={item}
+          accountId={item.get('account')}
+          onMoveUp={this.handleMoveUp}
+          onMoveDown={this.handleMoveDown}
+        />
+      ));
     } else {
       scrollableContent = null;
     }
diff --git a/app/javascript/mastodon/features/status/index.js b/app/javascript/mastodon/features/status/index.js
index 8da6e743c..83e83540a 100644
--- a/app/javascript/mastodon/features/status/index.js
+++ b/app/javascript/mastodon/features/status/index.js
@@ -28,6 +28,7 @@ import StatusContainer from '../../containers/status_container';
 import { openModal } from '../../actions/modal';
 import { defineMessages, injectIntl } from 'react-intl';
 import ImmutablePureComponent from 'react-immutable-pure-component';
+import { HotKeys } from 'react-hotkeys';
 
 const messages = defineMessages({
   deleteConfirm: { id: 'confirmations.delete.confirm', defaultMessage: 'Delete' },
@@ -151,8 +152,100 @@ export default class Status extends ImmutablePureComponent {
     this.props.dispatch(openModal('EMBED', { url: status.get('url') }));
   }
 
+  handleHotkeyMoveUp = () => {
+    this.handleMoveUp(this.props.status.get('id'));
+  }
+
+  handleHotkeyMoveDown = () => {
+    this.handleMoveDown(this.props.status.get('id'));
+  }
+
+  handleHotkeyReply = e => {
+    e.preventDefault();
+    this.handleReplyClick(this.props.status);
+  }
+
+  handleHotkeyFavourite = () => {
+    this.handleFavouriteClick(this.props.status);
+  }
+
+  handleHotkeyBoost = () => {
+    this.handleReblogClick(this.props.status);
+  }
+
+  handleHotkeyMention = e => {
+    e.preventDefault();
+    this.handleMentionClick(this.props.status);
+  }
+
+  handleHotkeyOpenProfile = () => {
+    this.context.router.history.push(`/accounts/${this.props.status.getIn(['account', 'id'])}`);
+  }
+
+  handleMoveUp = id => {
+    const { status, ancestorsIds, descendantsIds } = this.props;
+
+    if (id === status.get('id')) {
+      this._selectChild(ancestorsIds.size - 1);
+    } else {
+      let index = ancestorsIds.indexOf(id);
+
+      if (index === -1) {
+        index = descendantsIds.indexOf(id);
+        this._selectChild(ancestorsIds.size + index);
+      } else {
+        this._selectChild(index - 1);
+      }
+    }
+  }
+
+  handleMoveDown = id => {
+    const { status, ancestorsIds, descendantsIds } = this.props;
+
+    if (id === status.get('id')) {
+      this._selectChild(ancestorsIds.size + 1);
+    } else {
+      let index = ancestorsIds.indexOf(id);
+
+      if (index === -1) {
+        index = descendantsIds.indexOf(id);
+        this._selectChild(ancestorsIds.size + index + 2);
+      } else {
+        this._selectChild(index + 1);
+      }
+    }
+  }
+
+  _selectChild (index) {
+    const element = this.node.querySelectorAll('.focusable')[index];
+
+    if (element) {
+      element.focus();
+    }
+  }
+
   renderChildren (list) {
-    return list.map(id => <StatusContainer key={id} id={id} />);
+    return list.map(id => (
+      <StatusContainer
+        key={id}
+        id={id}
+        onMoveUp={this.handleMoveUp}
+        onMoveDown={this.handleMoveDown}
+      />
+    ));
+  }
+
+  setRef = c => {
+    this.node = c;
+  }
+
+  componentDidUpdate () {
+    const { ancestorsIds } = this.props;
+
+    if (ancestorsIds) {
+      const element = this.node.querySelectorAll('.focusable')[this.props.ancestorsIds.size];
+      element.scrollIntoView();
+    }
   }
 
   render () {
@@ -176,34 +269,48 @@ export default class Status extends ImmutablePureComponent {
       descendants = <div>{this.renderChildren(descendantsIds)}</div>;
     }
 
+    const handlers = {
+      moveUp: this.handleHotkeyMoveUp,
+      moveDown: this.handleHotkeyMoveDown,
+      reply: this.handleHotkeyReply,
+      favourite: this.handleHotkeyFavourite,
+      boost: this.handleHotkeyBoost,
+      mention: this.handleHotkeyMention,
+      openProfile: this.handleHotkeyOpenProfile,
+    };
+
     return (
       <Column>
         <ColumnBackButton />
 
         <ScrollContainer scrollKey='thread'>
-          <div className='scrollable detailed-status__wrapper'>
+          <div className='scrollable detailed-status__wrapper' ref={this.setRef}>
             {ancestors}
 
-            <DetailedStatus
-              status={status}
-              autoPlayGif={autoPlayGif}
-              me={me}
-              onOpenVideo={this.handleOpenVideo}
-              onOpenMedia={this.handleOpenMedia}
-            />
-
-            <ActionBar
-              status={status}
-              me={me}
-              onReply={this.handleReplyClick}
-              onFavourite={this.handleFavouriteClick}
-              onReblog={this.handleReblogClick}
-              onDelete={this.handleDeleteClick}
-              onMention={this.handleMentionClick}
-              onReport={this.handleReport}
-              onPin={this.handlePin}
-              onEmbed={this.handleEmbed}
-            />
+            <HotKeys handlers={handlers}>
+              <div className='focusable' tabIndex='0'>
+                <DetailedStatus
+                  status={status}
+                  autoPlayGif={autoPlayGif}
+                  me={me}
+                  onOpenVideo={this.handleOpenVideo}
+                  onOpenMedia={this.handleOpenMedia}
+                />
+
+                <ActionBar
+                  status={status}
+                  me={me}
+                  onReply={this.handleReplyClick}
+                  onFavourite={this.handleFavouriteClick}
+                  onReblog={this.handleReblogClick}
+                  onDelete={this.handleDeleteClick}
+                  onMention={this.handleMentionClick}
+                  onReport={this.handleReport}
+                  onPin={this.handlePin}
+                  onEmbed={this.handleEmbed}
+                />
+              </div>
+            </HotKeys>
 
             {descendants}
           </div>
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>
     );
   }