about summary refs log tree commit diff
path: root/app/javascript/flavours
diff options
context:
space:
mode:
authorReverite <github@reverite.sh>2019-06-13 23:05:19 -0700
committerReverite <github@reverite.sh>2019-06-13 23:05:19 -0700
commit7ce2a4e95331cc9ef9b782a5c4d8046d8a835a05 (patch)
treebc4e5e39ee96ae74cbf9c09570b2e545da6587e0 /app/javascript/flavours
parent3614718bc91f90a6dc19dd80ecf3bc191283c24e (diff)
parentc0e5f32d13dfd696728dc1fa2ad9a93a27aa405f (diff)
Merge branch 'glitch' into production
Diffstat (limited to 'app/javascript/flavours')
-rw-r--r--app/javascript/flavours/glitch/actions/alerts.js3
-rw-r--r--app/javascript/flavours/glitch/actions/compose.js26
-rw-r--r--app/javascript/flavours/glitch/actions/conversations.js84
-rw-r--r--app/javascript/flavours/glitch/actions/statuses.js7
-rw-r--r--app/javascript/flavours/glitch/actions/streaming.js4
-rw-r--r--app/javascript/flavours/glitch/components/autosuggest_textarea.js55
-rw-r--r--app/javascript/flavours/glitch/components/avatar_composite.js104
-rw-r--r--app/javascript/flavours/glitch/components/display_name.js44
-rw-r--r--app/javascript/flavours/glitch/components/icon_with_badge.js20
-rw-r--r--app/javascript/flavours/glitch/components/status.js25
-rw-r--r--app/javascript/flavours/glitch/components/status_action_bar.js20
-rw-r--r--app/javascript/flavours/glitch/components/status_header.js82
-rw-r--r--app/javascript/flavours/glitch/components/status_icons.js6
-rw-r--r--app/javascript/flavours/glitch/containers/status_container.js15
-rw-r--r--app/javascript/flavours/glitch/features/compose/components/compose_form.js58
-rw-r--r--app/javascript/flavours/glitch/features/compose/components/navigation_bar.js2
-rw-r--r--app/javascript/flavours/glitch/features/compose/components/search.js13
-rw-r--r--app/javascript/flavours/glitch/features/compose/index.js12
-rw-r--r--app/javascript/flavours/glitch/features/direct_timeline/components/conversation.js64
-rw-r--r--app/javascript/flavours/glitch/features/direct_timeline/components/conversations_list.js73
-rw-r--r--app/javascript/flavours/glitch/features/direct_timeline/containers/conversation_container.js19
-rw-r--r--app/javascript/flavours/glitch/features/direct_timeline/containers/conversations_list_container.js15
-rw-r--r--app/javascript/flavours/glitch/features/direct_timeline/index.js96
-rw-r--r--app/javascript/flavours/glitch/features/follow_requests/index.js2
-rw-r--r--app/javascript/flavours/glitch/features/getting_started/index.js47
-rw-r--r--app/javascript/flavours/glitch/features/local_settings/page/index.js17
-rw-r--r--app/javascript/flavours/glitch/features/search/index.js17
-rw-r--r--app/javascript/flavours/glitch/features/status/index.js22
-rw-r--r--app/javascript/flavours/glitch/features/ui/components/boost_modal.js19
-rw-r--r--app/javascript/flavours/glitch/features/ui/components/columns_area.js48
-rw-r--r--app/javascript/flavours/glitch/features/ui/components/compose_panel.js16
-rw-r--r--app/javascript/flavours/glitch/features/ui/components/follow_requests_nav_link.js44
-rw-r--r--app/javascript/flavours/glitch/features/ui/components/link_footer.js36
-rw-r--r--app/javascript/flavours/glitch/features/ui/components/list_panel.js55
-rw-r--r--app/javascript/flavours/glitch/features/ui/components/navigation_panel.js32
-rw-r--r--app/javascript/flavours/glitch/features/ui/components/notifications_counter_icon.js9
-rw-r--r--app/javascript/flavours/glitch/features/ui/components/tabs_bar.js38
-rw-r--r--app/javascript/flavours/glitch/features/ui/containers/columns_area_container.js11
-rw-r--r--app/javascript/flavours/glitch/features/ui/index.js28
-rw-r--r--app/javascript/flavours/glitch/reducers/conversations.js102
-rw-r--r--app/javascript/flavours/glitch/reducers/index.js2
-rw-r--r--app/javascript/flavours/glitch/reducers/local_settings.js1
-rw-r--r--app/javascript/flavours/glitch/reducers/notifications.js2
-rw-r--r--app/javascript/flavours/glitch/reducers/settings.js1
-rw-r--r--app/javascript/flavours/glitch/styles/accounts.scss3
-rw-r--r--app/javascript/flavours/glitch/styles/components/accounts.scss12
-rw-r--r--app/javascript/flavours/glitch/styles/components/columns.scss155
-rw-r--r--app/javascript/flavours/glitch/styles/components/composer.scss15
-rw-r--r--app/javascript/flavours/glitch/styles/components/drawer.scss8
-rw-r--r--app/javascript/flavours/glitch/styles/components/index.scss133
-rw-r--r--app/javascript/flavours/glitch/styles/components/search.scss4
-rw-r--r--app/javascript/flavours/glitch/styles/components/single_column.scss232
-rw-r--r--app/javascript/flavours/glitch/styles/components/status.scss9
-rw-r--r--app/javascript/flavours/glitch/styles/forms.scss10
-rw-r--r--app/javascript/flavours/glitch/styles/mastodon-light/diff.scss7
-rw-r--r--app/javascript/flavours/glitch/styles/rtl.scss4
-rw-r--r--app/javascript/flavours/glitch/util/async-components.js4
-rw-r--r--app/javascript/flavours/glitch/util/initial_state.js1
-rw-r--r--app/javascript/flavours/glitch/util/is_mobile.js3
59 files changed, 1624 insertions, 372 deletions
diff --git a/app/javascript/flavours/glitch/actions/alerts.js b/app/javascript/flavours/glitch/actions/alerts.js
index b2c7ab76a..ef2500e7b 100644
--- a/app/javascript/flavours/glitch/actions/alerts.js
+++ b/app/javascript/flavours/glitch/actions/alerts.js
@@ -8,6 +8,7 @@ const messages = defineMessages({
 export const ALERT_SHOW    = 'ALERT_SHOW';
 export const ALERT_DISMISS = 'ALERT_DISMISS';
 export const ALERT_CLEAR   = 'ALERT_CLEAR';
+export const ALERT_NOOP    = 'ALERT_NOOP';
 
 export function dismissAlert(alert) {
   return {
@@ -36,7 +37,7 @@ export function showAlertForError(error) {
 
     if (status === 404 || status === 410) {
       // Skip these errors as they are reflected in the UI
-      return {};
+      return { type: ALERT_NOOP };
     }
 
     let message = statusText;
diff --git a/app/javascript/flavours/glitch/actions/compose.js b/app/javascript/flavours/glitch/actions/compose.js
index 2fb97fa17..69cc6827f 100644
--- a/app/javascript/flavours/glitch/actions/compose.js
+++ b/app/javascript/flavours/glitch/actions/compose.js
@@ -68,6 +68,14 @@ const messages = defineMessages({
   uploadErrorPoll:  { id: 'upload_error.poll', defaultMessage: 'File upload not allowed with polls.' },
 });
 
+const COMPOSE_PANEL_BREAKPOINT = 600 + (285 * 1) + (10 * 1);
+
+export const ensureComposeIsVisible = (getState, routerHistory) => {
+  if (!getState().getIn(['compose', 'mounted']) && window.innerWidth < COMPOSE_PANEL_BREAKPOINT) {
+    routerHistory.push('/statuses/new');
+  }
+};
+
 export function changeCompose(text) {
   return {
     type: COMPOSE_CHANGE,
@@ -81,16 +89,14 @@ export function cycleElefriendCompose() {
   };
 };
 
-export function replyCompose(status, router) {
+export function replyCompose(status, routerHistory) {
   return (dispatch, getState) => {
     dispatch({
       type: COMPOSE_REPLY,
       status: status,
     });
 
-    if (router && !getState().getIn(['compose', 'mounted'])) {
-      router.push('/statuses/new');
-    }
+    ensureComposeIsVisible(getState, routerHistory);
   };
 };
 
@@ -106,29 +112,25 @@ export function resetCompose() {
   };
 };
 
-export function mentionCompose(account, router) {
+export function mentionCompose(account, routerHistory) {
   return (dispatch, getState) => {
     dispatch({
       type: COMPOSE_MENTION,
       account: account,
     });
 
-    if (!getState().getIn(['compose', 'mounted'])) {
-      router.push('/statuses/new');
-    }
+    ensureComposeIsVisible(getState, routerHistory);
   };
 };
 
-export function directCompose(account, router) {
+export function directCompose(account, routerHistory) {
   return (dispatch, getState) => {
     dispatch({
       type: COMPOSE_DIRECT,
       account: account,
     });
 
-    if (!getState().getIn(['compose', 'mounted'])) {
-      router.push('/statuses/new');
-    }
+    ensureComposeIsVisible(getState, routerHistory);
   };
 };
 
diff --git a/app/javascript/flavours/glitch/actions/conversations.js b/app/javascript/flavours/glitch/actions/conversations.js
new file mode 100644
index 000000000..856f8f10f
--- /dev/null
+++ b/app/javascript/flavours/glitch/actions/conversations.js
@@ -0,0 +1,84 @@
+import api, { getLinks } from 'flavours/glitch/util/api';
+import {
+  importFetchedAccounts,
+  importFetchedStatuses,
+  importFetchedStatus,
+} from './importer';
+
+export const CONVERSATIONS_MOUNT   = 'CONVERSATIONS_MOUNT';
+export const CONVERSATIONS_UNMOUNT = 'CONVERSATIONS_UNMOUNT';
+
+export const CONVERSATIONS_FETCH_REQUEST = 'CONVERSATIONS_FETCH_REQUEST';
+export const CONVERSATIONS_FETCH_SUCCESS = 'CONVERSATIONS_FETCH_SUCCESS';
+export const CONVERSATIONS_FETCH_FAIL    = 'CONVERSATIONS_FETCH_FAIL';
+export const CONVERSATIONS_UPDATE        = 'CONVERSATIONS_UPDATE';
+
+export const CONVERSATIONS_READ = 'CONVERSATIONS_READ';
+
+export const mountConversations = () => ({
+  type: CONVERSATIONS_MOUNT,
+});
+
+export const unmountConversations = () => ({
+  type: CONVERSATIONS_UNMOUNT,
+});
+
+export const markConversationRead = conversationId => (dispatch, getState) => {
+  dispatch({
+    type: CONVERSATIONS_READ,
+    id: conversationId,
+  });
+
+  api(getState).post(`/api/v1/conversations/${conversationId}/read`);
+};
+
+export const expandConversations = ({ maxId } = {}) => (dispatch, getState) => {
+  dispatch(expandConversationsRequest());
+
+  const params = { max_id: maxId };
+
+  if (!maxId) {
+    params.since_id = getState().getIn(['conversations', 'items', 0, 'last_status']);
+  }
+
+  const isLoadingRecent = !!params.since_id;
+
+  api(getState).get('/api/v1/conversations', { params })
+    .then(response => {
+      const next = getLinks(response).refs.find(link => link.rel === 'next');
+
+      dispatch(importFetchedAccounts(response.data.reduce((aggr, item) => aggr.concat(item.accounts), [])));
+      dispatch(importFetchedStatuses(response.data.map(item => item.last_status).filter(x => !!x)));
+      dispatch(expandConversationsSuccess(response.data, next ? next.uri : null, isLoadingRecent));
+    })
+    .catch(err => dispatch(expandConversationsFail(err)));
+};
+
+export const expandConversationsRequest = () => ({
+  type: CONVERSATIONS_FETCH_REQUEST,
+});
+
+export const expandConversationsSuccess = (conversations, next, isLoadingRecent) => ({
+  type: CONVERSATIONS_FETCH_SUCCESS,
+  conversations,
+  next,
+  isLoadingRecent,
+});
+
+export const expandConversationsFail = error => ({
+  type: CONVERSATIONS_FETCH_FAIL,
+  error,
+});
+
+export const updateConversations = conversation => dispatch => {
+  dispatch(importFetchedAccounts(conversation.accounts));
+
+  if (conversation.last_status) {
+    dispatch(importFetchedStatus(conversation.last_status));
+  }
+
+  dispatch({
+    type: CONVERSATIONS_UPDATE,
+    conversation,
+  });
+};
diff --git a/app/javascript/flavours/glitch/actions/statuses.js b/app/javascript/flavours/glitch/actions/statuses.js
index 7e22a7f98..4d2bda78b 100644
--- a/app/javascript/flavours/glitch/actions/statuses.js
+++ b/app/javascript/flavours/glitch/actions/statuses.js
@@ -2,6 +2,7 @@ import api from 'flavours/glitch/util/api';
 
 import { deleteFromTimelines } from './timelines';
 import { importFetchedStatus, importFetchedStatuses } from './importer';
+import { ensureComposeIsVisible } from './compose';
 
 export const STATUS_FETCH_REQUEST = 'STATUS_FETCH_REQUEST';
 export const STATUS_FETCH_SUCCESS = 'STATUS_FETCH_SUCCESS';
@@ -80,7 +81,7 @@ export function redraft(status, raw_text, content_type) {
   };
 };
 
-export function deleteStatus(id, router, withRedraft = false) {
+export function deleteStatus(id, routerHistory, withRedraft = false) {
   return (dispatch, getState) => {
     let status = getState().getIn(['statuses', id]);
 
@@ -97,9 +98,7 @@ export function deleteStatus(id, router, withRedraft = false) {
       if (withRedraft) {
         dispatch(redraft(status, response.data.text, response.data.content_type));
 
-        if (!getState().getIn(['compose', 'mounted'])) {
-          router.push('/statuses/new');
-        }
+        ensureComposeIsVisible(getState, routerHistory);
       }
     }).catch(error => {
       dispatch(deleteStatusFail(id, error));
diff --git a/app/javascript/flavours/glitch/actions/streaming.js b/app/javascript/flavours/glitch/actions/streaming.js
index b5dd70989..21379f492 100644
--- a/app/javascript/flavours/glitch/actions/streaming.js
+++ b/app/javascript/flavours/glitch/actions/streaming.js
@@ -7,6 +7,7 @@ import {
   disconnectTimeline,
 } from './timelines';
 import { updateNotifications, expandNotifications } from './notifications';
+import { updateConversations } from './conversations';
 import { fetchFilters } from './filters';
 import { getLocale } from 'mastodon/locales';
 
@@ -37,6 +38,9 @@ export function connectTimelineStream (timelineId, path, pollingRefresh = null,
         case 'notification':
           dispatch(updateNotifications(JSON.parse(data.payload), messages, locale));
           break;
+        case 'conversation':
+          dispatch(updateConversations(JSON.parse(data.payload)));
+          break;
         case 'filters_changed':
           dispatch(fetchFilters());
           break;
diff --git a/app/javascript/flavours/glitch/components/autosuggest_textarea.js b/app/javascript/flavours/glitch/components/autosuggest_textarea.js
index cf3907fbf..bbe0ffcbe 100644
--- a/app/javascript/flavours/glitch/components/autosuggest_textarea.js
+++ b/app/javascript/flavours/glitch/components/autosuggest_textarea.js
@@ -192,7 +192,7 @@ export default class AutosuggestTextarea extends ImmutablePureComponent {
   }
 
   render () {
-    const { value, suggestions, disabled, placeholder, onKeyUp, autoFocus } = this.props;
+    const { value, suggestions, disabled, placeholder, onKeyUp, autoFocus, children } = this.props;
     const { suggestionsHidden } = this.state;
     const style = { direction: 'ltr' };
 
@@ -200,34 +200,39 @@ export default class AutosuggestTextarea extends ImmutablePureComponent {
       style.direction = 'rtl';
     }
 
-    return (
-      <div className='autosuggest-textarea'>
-        <label>
-          <span style={{ display: 'none' }}>{placeholder}</span>
-
-          <Textarea
-            inputRef={this.setTextarea}
-            className='autosuggest-textarea__textarea'
-            disabled={disabled}
-            placeholder={placeholder}
-            autoFocus={autoFocus}
-            value={value}
-            onChange={this.onChange}
-            onKeyDown={this.onKeyDown}
-            onKeyUp={onKeyUp}
-            onFocus={this.onFocus}
-            onBlur={this.onBlur}
-            onPaste={this.onPaste}
-            style={style}
-            aria-autocomplete='list'
-          />
-        </label>
+    return [
+      <div className='compose-form__autosuggest-wrapper' key='autosuggest-wrapper'>
+        <div className='autosuggest-textarea'>
+          <label>
+            <span style={{ display: 'none' }}>{placeholder}</span>
+
+            <Textarea
+              inputRef={this.setTextarea}
+              className='autosuggest-textarea__textarea'
+              disabled={disabled}
+              placeholder={placeholder}
+              autoFocus={autoFocus}
+              value={value}
+              onChange={this.onChange}
+              onKeyDown={this.onKeyDown}
+              onKeyUp={onKeyUp}
+              onFocus={this.onFocus}
+              onBlur={this.onBlur}
+              onPaste={this.onPaste}
+              style={style}
+              aria-autocomplete='list'
+            />
+          </label>
+        </div>
+        {children}
+      </div>,
 
+      <div className='autosuggest-textarea__suggestions-wrapper' key='suggestions-wrapper'>
         <div className={`autosuggest-textarea__suggestions ${suggestionsHidden || suggestions.isEmpty() ? '' : 'autosuggest-textarea__suggestions--visible'}`}>
           {suggestions.map(this.renderSuggestion)}
         </div>
-      </div>
-    );
+      </div>,
+    ];
   }
 
 }
diff --git a/app/javascript/flavours/glitch/components/avatar_composite.js b/app/javascript/flavours/glitch/components/avatar_composite.js
new file mode 100644
index 000000000..c52df043a
--- /dev/null
+++ b/app/javascript/flavours/glitch/components/avatar_composite.js
@@ -0,0 +1,104 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { autoPlayGif } from 'flavours/glitch/util/initial_state';
+
+export default class AvatarComposite extends React.PureComponent {
+
+  static propTypes = {
+    accounts: ImmutablePropTypes.list.isRequired,
+    animate: PropTypes.bool,
+    size: PropTypes.number.isRequired,
+  };
+
+  static defaultProps = {
+    animate: autoPlayGif,
+  };
+
+  renderItem (account, size, index) {
+    const { animate } = this.props;
+
+    let width  = 50;
+    let height = 100;
+    let top    = 'auto';
+    let left   = 'auto';
+    let bottom = 'auto';
+    let right  = 'auto';
+
+    if (size === 1) {
+      width = 100;
+    }
+
+    if (size === 4 || (size === 3 && index > 0)) {
+      height = 50;
+    }
+
+    if (size === 2) {
+      if (index === 0) {
+        right = '2px';
+      } else {
+        left = '2px';
+      }
+    } else if (size === 3) {
+      if (index === 0) {
+        right = '2px';
+      } else if (index > 0) {
+        left = '2px';
+      }
+
+      if (index === 1) {
+        bottom = '2px';
+      } else if (index > 1) {
+        top = '2px';
+      }
+    } else if (size === 4) {
+      if (index === 0 || index === 2) {
+        right = '2px';
+      }
+
+      if (index === 1 || index === 3) {
+        left = '2px';
+      }
+
+      if (index < 2) {
+        bottom = '2px';
+      } else {
+        top = '2px';
+      }
+    }
+
+    const style = {
+      left: left,
+      top: top,
+      right: right,
+      bottom: bottom,
+      width: `${width}%`,
+      height: `${height}%`,
+      backgroundSize: 'cover',
+      backgroundImage: `url(${account.get(animate ? 'avatar' : 'avatar_static')})`,
+    };
+
+    return (
+      <a
+        href={account.get('url')}
+        target='_blank'
+        onClick={(e) => this.props.onAccountClick(account.get('id'), e)}
+        title={`@${account.get('acct')}`}
+        key={account.get('id')}
+      >
+        <div style={style} data-avatar-of={`@${account.get('acct')}`} />
+      </a>
+    );
+  }
+
+  render() {
+    const { accounts, size } = this.props;
+
+    return (
+      <div className='account__avatar-composite' style={{ width: `${size}px`, height: `${size}px` }}>
+        {accounts.take(4).map((account, i) => this.renderItem(account, accounts.size, i))}
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/components/display_name.js b/app/javascript/flavours/glitch/components/display_name.js
index a26cff049..7f6ef5a5d 100644
--- a/app/javascript/flavours/glitch/components/display_name.js
+++ b/app/javascript/flavours/glitch/components/display_name.js
@@ -10,24 +10,56 @@ export default function DisplayName ({
   className,
   inline,
   localDomain,
+  others,
+  onAccountClick,
 }) {
   const computedClass = classNames('display-name', { inline }, className);
 
   if (!account) return null;
 
+  let displayName, suffix;
+
   let acct = account.get('acct');
+
   if (acct.indexOf('@') === -1 && localDomain) {
     acct = `${acct}@${localDomain}`;
   }
 
-  //  The result.
-  return account ? (
+  if (others && others.size > 0) {
+    displayName = others.take(2).map(a => (
+      <a
+        href={a.get('url')}
+        target='_blank'
+        onClick={(e) => onAccountClick(a.get('id'), e)}
+        title={`@${a.get('acct')}`}
+      >
+        <bdi key={a.get('id')}>
+          <strong className='display-name__html' dangerouslySetInnerHTML={{ __html: a.get('display_name_html') }} />
+        </bdi>
+      </a>
+    )).reduce((prev, cur) => [prev, ', ', cur]);
+
+    if (others.size - 2 > 0) {
+     displayName.push(` +${others.size - 2}`);
+    }
+
+    suffix = (
+      <a href={account.get('url')} target='_blank' onClick={(e) => onAccountClick(account.get('id'), e)}>
+        <span className='display-name__account'>@{acct}</span>
+      </a>
+    );
+  } else {
+    displayName = <bdi><strong className='display-name__html' dangerouslySetInnerHTML={{ __html: account.get('display_name_html') }} /></bdi>;
+    suffix      = <span className='display-name__account'>@{acct}</span>;
+  }
+
+  return (
     <span className={computedClass}>
-      <bdi><strong className='display-name__html' dangerouslySetInnerHTML={{ __html: account.get('display_name_html') }} /></bdi>
+      {displayName}
       {inline ? ' ' : null}
-      <span className='display-name__account'>@{acct}</span>
+      {suffix}
     </span>
-  ) : null;
+  );
 }
 
 //  Props.
@@ -36,4 +68,6 @@ DisplayName.propTypes = {
   className: PropTypes.string,
   inline: PropTypes.bool,
   localDomain: PropTypes.string,
+  others: ImmutablePropTypes.list,
+  handleClick: PropTypes.func,
 };
diff --git a/app/javascript/flavours/glitch/components/icon_with_badge.js b/app/javascript/flavours/glitch/components/icon_with_badge.js
new file mode 100644
index 000000000..4a15ee5b4
--- /dev/null
+++ b/app/javascript/flavours/glitch/components/icon_with_badge.js
@@ -0,0 +1,20 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import Icon from 'flavours/glitch/components/icon';
+
+const formatNumber = num => num > 40 ? '40+' : num;
+
+const IconWithBadge = ({ id, count, className }) => (
+  <i className='icon-with-badge'>
+    <Icon icon={id} fixedWidth className={className} />
+    {count > 0 && <i className='icon-with-badge__badge'>{formatNumber(count)}</i>}
+  </i>
+);
+
+IconWithBadge.propTypes = {
+  id: PropTypes.string.isRequired,
+  count: PropTypes.number.isRequired,
+  className: PropTypes.string,
+};
+
+export default IconWithBadge;
diff --git a/app/javascript/flavours/glitch/components/status.js b/app/javascript/flavours/glitch/components/status.js
index 7014cab17..f6d73475a 100644
--- a/app/javascript/flavours/glitch/components/status.js
+++ b/app/javascript/flavours/glitch/components/status.js
@@ -66,6 +66,7 @@ export default class Status extends ImmutablePureComponent {
     containerId: PropTypes.string,
     id: PropTypes.string,
     status: ImmutablePropTypes.map,
+    otherAccounts: ImmutablePropTypes.list,
     account: ImmutablePropTypes.map,
     onReply: PropTypes.func,
     onFavourite: PropTypes.func,
@@ -83,6 +84,7 @@ export default class Status extends ImmutablePureComponent {
     muted: PropTypes.bool,
     collapse: PropTypes.bool,
     hidden: PropTypes.bool,
+    unread: PropTypes.bool,
     prepend: PropTypes.string,
     withDismiss: PropTypes.bool,
     onMoveUp: PropTypes.func,
@@ -93,6 +95,7 @@ export default class Status extends ImmutablePureComponent {
     intl: PropTypes.object.isRequired,
     cacheMediaWidth: PropTypes.func,
     cachedMediaWidth: PropTypes.number,
+    onClick: PropTypes.func,
   };
 
   state = {
@@ -111,8 +114,6 @@ export default class Status extends ImmutablePureComponent {
     'account',
     'settings',
     'prepend',
-    'boostModal',
-    'favouriteModal',
     'muted',
     'collapse',
     'notification',
@@ -321,17 +322,21 @@ export default class Status extends ImmutablePureComponent {
     const { status } = this.props;
     const { isCollapsed } = this.state;
     if (!router) return;
-    if (destination === undefined) {
-      destination = `/statuses/${
-        status.getIn(['reblog', 'id'], status.get('id'))
-      }`;
-    }
+
     if (e.button === 0 && !(e.ctrlKey || e.altKey || e.metaKey)) {
       if (isCollapsed) this.setCollapsed(false);
       else if (e.shiftKey) {
         this.setCollapsed(true);
         document.getSelection().removeAllRanges();
+      } else if (this.props.onClick) {
+        this.props.onClick();
+        return;
       } else {
+        if (destination === undefined) {
+          destination = `/statuses/${
+            status.getIn(['reblog', 'id'], status.get('id'))
+          }`;
+        }
         let state = {...router.history.location.state};
         state.mastodonBackSteps = (state.mastodonBackSteps || 0) + 1;
         router.history.push(destination, state);
@@ -441,6 +446,7 @@ export default class Status extends ImmutablePureComponent {
       intl,
       status,
       account,
+      otherAccounts,
       settings,
       collapsed,
       muted,
@@ -450,6 +456,7 @@ export default class Status extends ImmutablePureComponent {
       onOpenMedia,
       notification,
       hidden,
+      unread,
       featured,
       ...other
     } = this.props;
@@ -617,6 +624,7 @@ export default class Status extends ImmutablePureComponent {
       collapsed: isCollapsed,
       'has-background': isCollapsed && background,
       'status__wrapper-reply': !!status.get('in_reply_to_id'),
+      read: unread === false,
       muted,
     }, 'focusable');
 
@@ -647,6 +655,7 @@ export default class Status extends ImmutablePureComponent {
                   friend={account}
                   collapsed={isCollapsed}
                   parseClick={parseClick}
+                  otherAccounts={otherAccounts}
                 />
               ) : null}
             </span>
@@ -656,6 +665,7 @@ export default class Status extends ImmutablePureComponent {
               collapsible={settings.getIn(['collapsed', 'enabled'])}
               collapsed={isCollapsed}
               setCollapsed={setCollapsed}
+              directMessage={!!otherAccounts}
             />
           </header>
           <StatusContent
@@ -673,6 +683,7 @@ export default class Status extends ImmutablePureComponent {
               status={status}
               account={status.get('account')}
               showReplyCount={settings.get('show_reply_count')}
+              directMessage={!!otherAccounts}
             />
           ) : null}
           {notification ? (
diff --git a/app/javascript/flavours/glitch/components/status_action_bar.js b/app/javascript/flavours/glitch/components/status_action_bar.js
index 4c398fd19..85bc4a976 100644
--- a/app/javascript/flavours/glitch/components/status_action_bar.js
+++ b/app/javascript/flavours/glitch/components/status_action_bar.js
@@ -71,6 +71,7 @@ export default class StatusActionBar extends ImmutablePureComponent {
     onBookmark: PropTypes.func,
     withDismiss: PropTypes.bool,
     showReplyCount: PropTypes.bool,
+    directMessage: PropTypes.bool,
     intl: PropTypes.object.isRequired,
   };
 
@@ -191,7 +192,7 @@ export default class StatusActionBar extends ImmutablePureComponent {
   }
 
   render () {
-    const { status, intl, withDismiss, showReplyCount } = this.props;
+    const { status, intl, withDismiss, showReplyCount, directMessage } = this.props;
 
     const mutingConversation = status.get('muted');
     const anonymousAccess    = !me;
@@ -282,14 +283,15 @@ export default class StatusActionBar extends ImmutablePureComponent {
     return (
       <div className='status__action-bar'>
         {replyButton}
-        <IconButton className='status__action-bar-button' disabled={reblogDisabled} active={status.get('reblogged')} pressed={status.get('reblogged')} title={reblogDisabled ? intl.formatMessage(messages.cannot_reblog) : intl.formatMessage(reblogMessage)} icon={reblogIcon} onClick={this.handleReblogClick} />
-        <IconButton className='status__action-bar-button star-icon' animate active={status.get('favourited')} pressed={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} />
-        {shareButton}
-        <IconButton className='status__action-bar-button bookmark-icon' disabled={anonymousAccess} active={status.get('bookmarked')} pressed={status.get('bookmarked')} title={intl.formatMessage(messages.bookmark)} icon='bookmark' onClick={this.handleBookmarkClick} />
-
-        <div className='status__action-bar-dropdown'>
-          <DropdownMenuContainer disabled={anonymousAccess} status={status} items={menu} icon='ellipsis-h' size={18} direction='right' ariaLabel={intl.formatMessage(messages.more)} />
-        </div>
+        {!directMessage && [
+          <IconButton className='status__action-bar-button' disabled={reblogDisabled} active={status.get('reblogged')} pressed={status.get('reblogged')} title={reblogDisabled ? intl.formatMessage(messages.cannot_reblog) : intl.formatMessage(reblogMessage)} icon={reblogIcon} onClick={this.handleReblogClick} />,
+          <IconButton className='status__action-bar-button star-icon' animate active={status.get('favourited')} pressed={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} />,
+          shareButton,
+          <IconButton className='status__action-bar-button bookmark-icon' disabled={anonymousAccess} active={status.get('bookmarked')} pressed={status.get('bookmarked')} title={intl.formatMessage(messages.bookmark)} icon='bookmark' onClick={this.handleBookmarkClick} />,
+          <div className='status__action-bar-dropdown'>
+            <DropdownMenuContainer disabled={anonymousAccess} status={status} items={menu} icon='ellipsis-h' size={18} direction='right' ariaLabel={intl.formatMessage(messages.more)} />
+          </div>,
+        ]}
 
         <a href={status.get('url')} className='status__relative-time' target='_blank' rel='noopener'><RelativeTimestamp timestamp={status.get('created_at')} /></a>
       </div>
diff --git a/app/javascript/flavours/glitch/components/status_header.js b/app/javascript/flavours/glitch/components/status_header.js
index f9321904c..23cff286a 100644
--- a/app/javascript/flavours/glitch/components/status_header.js
+++ b/app/javascript/flavours/glitch/components/status_header.js
@@ -6,6 +6,7 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
 //  Mastodon imports.
 import Avatar from './avatar';
 import AvatarOverlay from './avatar_overlay';
+import AvatarComposite from './avatar_composite';
 import DisplayName from './display_name';
 
 export default class StatusHeader extends React.PureComponent {
@@ -14,12 +15,18 @@ export default class StatusHeader extends React.PureComponent {
     status: ImmutablePropTypes.map.isRequired,
     friend: ImmutablePropTypes.map,
     parseClick: PropTypes.func.isRequired,
+    otherAccounts: ImmutablePropTypes.list,
   };
 
   //  Handles clicks on account name/image
+  handleClick = (id, e) => {
+    const { parseClick } = this.props;
+    parseClick(e, `/accounts/${id}`);
+  }
+
   handleAccountClick = (e) => {
-    const { status, parseClick } = this.props;
-    parseClick(e, `/accounts/${status.getIn(['account', 'id'])}`);
+    const { status } = this.props;
+    this.handleClick(status.getIn(['account', 'id']), e);
   }
 
   //  Rendering.
@@ -27,36 +34,55 @@ export default class StatusHeader extends React.PureComponent {
     const {
       status,
       friend,
+      otherAccounts,
     } = this.props;
 
     const account = status.get('account');
 
-    return (
-      <div className='status__info__account' >
-        <a
-          href={account.get('url')}
-          target='_blank'
-          className='status__avatar'
-          onClick={this.handleAccountClick}
-        >
-          {
-            friend ? (
-              <AvatarOverlay account={account} friend={friend} />
-            ) : (
-              <Avatar account={account} size={48} />
-            )
-          }
-        </a>
-        <a
-          href={account.get('url')}
-          target='_blank'
-          className='status__display-name'
-          onClick={this.handleAccountClick}
-        >
-          <DisplayName account={account} />
-        </a>
-      </div>
-    );
+    let statusAvatar;
+    if (otherAccounts && otherAccounts.size > 0) {
+      statusAvatar = <AvatarComposite accounts={otherAccounts} size={48} onAccountClick={this.handleClick} />;
+    } else if (friend === undefined || friend === null) {
+      statusAvatar = <Avatar account={account} size={48} />;
+    } else {
+      statusAvatar = <AvatarOverlay account={account} friend={friend} />;
+    }
+
+    if (!otherAccounts) {
+      return (
+        <div className='status__info__account'>
+          <a
+            href={account.get('url')}
+            target='_blank'
+            className='status__avatar'
+            onClick={this.handleAccountClick}
+          >
+            {statusAvatar}
+          </a>
+          <a
+            href={account.get('url')}
+            target='_blank'
+            className='status__display-name'
+            onClick={this.handleAccountClick}
+          >
+            <DisplayName account={account} others={otherAccounts} />
+          </a>
+        </div>
+      );
+    } else {
+      // This is a DM conversation
+      return (
+        <div className='status__info__account'>
+          <span className='status__avatar'>
+            {statusAvatar}
+          </span>
+
+          <span className='status__display-name'>
+            <DisplayName account={account} others={otherAccounts} onAccountClick={this.handleClick} />
+          </span>
+        </div>
+      );
+    }
   }
 
 }
diff --git a/app/javascript/flavours/glitch/components/status_icons.js b/app/javascript/flavours/glitch/components/status_icons.js
index c9747650f..4a2c62881 100644
--- a/app/javascript/flavours/glitch/components/status_icons.js
+++ b/app/javascript/flavours/glitch/components/status_icons.js
@@ -22,6 +22,7 @@ export default class StatusIcons extends React.PureComponent {
     mediaIcon: PropTypes.string,
     collapsible: PropTypes.bool,
     collapsed: PropTypes.bool,
+    directMessage: PropTypes.bool,
     setCollapsed: PropTypes.func.isRequired,
     intl: PropTypes.object.isRequired,
   };
@@ -42,6 +43,7 @@ export default class StatusIcons extends React.PureComponent {
       mediaIcon,
       collapsible,
       collapsed,
+      directMessage,
       intl,
     } = this.props;
 
@@ -59,9 +61,7 @@ export default class StatusIcons extends React.PureComponent {
             aria-hidden='true'
           />
         ) : null}
-        {(
-          <VisibilityIcon visibility={status.get('visibility')} />
-        )}
+        {!directMessage && <VisibilityIcon visibility={status.get('visibility')} />}
         {collapsible ? (
           <IconButton
             className='status__collapse-button'
diff --git a/app/javascript/flavours/glitch/containers/status_container.js b/app/javascript/flavours/glitch/containers/status_container.js
index 98dc5bb87..a6069cb90 100644
--- a/app/javascript/flavours/glitch/containers/status_container.js
+++ b/app/javascript/flavours/glitch/containers/status_container.js
@@ -96,11 +96,16 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
   },
 
   onReblog (status, e) {
-    if (e.shiftKey || !boostModal) {
-      this.onModalReblog(status);
-    } else {
-      dispatch(openModal('BOOST', { status, onReblog: this.onModalReblog }));
-    }
+    dispatch((_, getState) => {
+      let state = getState();
+      if (state.getIn(['local_settings', 'confirm_boost_missing_media_description']) && status.get('media_attachments').some(item => !item.get('description')) && !status.get('reblogged')) {
+        dispatch(openModal('BOOST', { status, onReblog: this.handleModalReblog, missingMediaDescription: true }));
+      } else if (e.shiftKey || !boostModal) {
+        this.onModalReblog(status);
+      } else {
+        dispatch(openModal('BOOST', { status, onReblog: this.onModalReblog }));
+      }
+    });
   },
 
   onBookmark (status) {
diff --git a/app/javascript/flavours/glitch/features/compose/components/compose_form.js b/app/javascript/flavours/glitch/features/compose/components/compose_form.js
index 0120be28f..b4b43785a 100644
--- a/app/javascript/flavours/glitch/features/compose/components/compose_form.js
+++ b/app/javascript/flavours/glitch/features/compose/components/compose_form.js
@@ -28,10 +28,6 @@ const messages = defineMessages({
 export default @injectIntl
 class ComposeForm extends ImmutablePureComponent {
 
-  setRef = c => {
-    this.composeForm = c;
-  };
-
   static contextTypes = {
     router: PropTypes.object,
   };
@@ -145,6 +141,10 @@ class ComposeForm extends ImmutablePureComponent {
     }
   }
 
+  setRef = c => {
+    this.composeForm = c;
+  };
+
   //  Inserts an emoji at the caret.
   handleEmoji = (data) => {
     const { textarea: { selectionStart } } = this;
@@ -213,7 +213,9 @@ class ComposeForm extends ImmutablePureComponent {
   }
 
   handleFocus = () => {
-    this.composeForm.scrollIntoView();
+    if (this.composeForm) {
+      this.composeForm.scrollIntoView();
+    }
   }
 
   //  This statement does several things:
@@ -335,32 +337,28 @@ class ComposeForm extends ImmutablePureComponent {
           />
         </div>
 
-        <div className='composer--textarea'>
-          <TextareaIcons advancedOptions={advancedOptions} />
-
-          <AutosuggestTextarea
-            ref={this.setAutosuggestTextarea}
-            placeholder={intl.formatMessage(messages.placeholder)}
-            disabled={isSubmitting}
-            value={this.props.text}
-            onChange={this.handleChange}
-            suggestions={this.props.suggestions}
-            onFocus={this.handleFocus}
-            onKeyDown={this.handleKeyDown}
-            onSuggestionsFetchRequested={onFetchSuggestions}
-            onSuggestionsClearRequested={onClearSuggestions}
-            onSuggestionSelected={this.onSuggestionSelected}
-            onPaste={onPaste}
-            autoFocus={!showSearch && !isMobile(window.innerWidth, layout)}
-          />
-
+        <AutosuggestTextarea
+          ref={this.setAutosuggestTextarea}
+          placeholder={intl.formatMessage(messages.placeholder)}
+          disabled={isSubmitting}
+          value={this.props.text}
+          onChange={this.handleChange}
+          suggestions={this.props.suggestions}
+          onFocus={this.handleFocus}
+          onKeyDown={this.handleKeyDown}
+          onSuggestionsFetchRequested={onFetchSuggestions}
+          onSuggestionsClearRequested={onClearSuggestions}
+          onSuggestionSelected={this.onSuggestionSelected}
+          onPaste={onPaste}
+          autoFocus={!showSearch && !isMobile(window.innerWidth, layout)}
+        >
           <EmojiPicker onPickEmoji={handleEmoji} />
-        </div>
-
-        <div className='compose-form__modifiers'>
-          <UploadFormContainer />
-          <PollFormContainer />
-        </div>
+          <TextareaIcons advancedOptions={advancedOptions} />
+          <div className='compose-form__modifiers'>
+            <UploadFormContainer />
+            <PollFormContainer />
+          </div>
+        </AutosuggestTextarea>
 
         <OptionsContainer
           advancedOptions={advancedOptions}
diff --git a/app/javascript/flavours/glitch/features/compose/components/navigation_bar.js b/app/javascript/flavours/glitch/features/compose/components/navigation_bar.js
index 59172bb23..3148434f1 100644
--- a/app/javascript/flavours/glitch/features/compose/components/navigation_bar.js
+++ b/app/javascript/flavours/glitch/features/compose/components/navigation_bar.js
@@ -17,7 +17,7 @@ export default class NavigationBar extends ImmutablePureComponent {
       <div className='drawer--account'>
         <Permalink className='avatar' href={this.props.account.get('url')} to={`/accounts/${this.props.account.get('id')}`}>
           <span style={{ display: 'none' }}>{this.props.account.get('acct')}</span>
-          <Avatar account={this.props.account} size={40} />
+          <Avatar account={this.props.account} size={48} />
         </Permalink>
 
         <Permalink className='acct' href={this.props.account.get('url')} to={`/accounts/${this.props.account.get('id')}`}>
diff --git a/app/javascript/flavours/glitch/features/compose/components/search.js b/app/javascript/flavours/glitch/features/compose/components/search.js
index 5fed1567a..1d96933ea 100644
--- a/app/javascript/flavours/glitch/features/compose/components/search.js
+++ b/app/javascript/flavours/glitch/features/compose/components/search.js
@@ -33,7 +33,7 @@ class SearchPopout extends React.PureComponent {
     const { style } = this.props;
     const extraInformation = searchEnabled ? <FormattedMessage id='search_popout.tips.full_text' defaultMessage='Simple text returns statuses you have written, favourited, boosted, or have been mentioned in, as well as matching usernames, display names, and hashtags.' /> : <FormattedMessage id='search_popout.tips.text' defaultMessage='Simple text returns matching display names, usernames and hashtags' />;
     return (
-      <div style={{ ...style, position: 'absolute', width: 285 }}>
+      <div style={{ ...style, position: 'absolute', width: 285, zIndex: 2 }}>
         <Motion defaultStyle={{ opacity: 0, scaleX: 0.85, scaleY: 0.75 }} style={{ opacity: spring(1, { damping: 35, stiffness: 400 }), scaleX: spring(1, { damping: 35, stiffness: 400 }), scaleY: spring(1, { damping: 35, stiffness: 400 }) }}>
           {({ opacity, scaleX, scaleY }) => (
             <div className='drawer--search--popout' style={{ opacity: opacity, transform: `scale(${scaleX}, ${scaleY})` }}>
@@ -60,6 +60,10 @@ class SearchPopout extends React.PureComponent {
 export default @injectIntl
 class Search extends React.PureComponent {
 
+  static contextTypes = {
+    router: PropTypes.object.isRequired,
+  };
+
   static propTypes = {
     value: PropTypes.string.isRequired,
     submitted: PropTypes.bool,
@@ -67,6 +71,7 @@ class Search extends React.PureComponent {
     onSubmit: PropTypes.func.isRequired,
     onClear: PropTypes.func.isRequired,
     onShow: PropTypes.func.isRequired,
+    openInRoute: PropTypes.bool,
     intl: PropTypes.object.isRequired,
   };
 
@@ -109,8 +114,10 @@ class Search extends React.PureComponent {
     const { onSubmit } = this.props;
     switch (e.key) {
     case 'Enter':
-      if (onSubmit) {
-        onSubmit();
+      onSubmit();
+
+      if (this.props.openInRoute) {
+        this.context.router.history.push('/search');
       }
       break;
     case 'Escape':
diff --git a/app/javascript/flavours/glitch/features/compose/index.js b/app/javascript/flavours/glitch/features/compose/index.js
index e60eedfd9..d3070a199 100644
--- a/app/javascript/flavours/glitch/features/compose/index.js
+++ b/app/javascript/flavours/glitch/features/compose/index.js
@@ -29,7 +29,7 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
   },
 });
 
-export default @connect(mapStateToProps)
+export default @connect(mapStateToProps, mapDispatchToProps)
 @injectIntl
 class Compose extends React.PureComponent {
   static propTypes = {
@@ -61,12 +61,12 @@ class Compose extends React.PureComponent {
         <div className='drawer__pager'>
           {!isSearchPage && <div className='drawer__inner'>
             <NavigationContainer />
+
             <ComposeFormContainer />
-            {multiColumn && (
-              <div className='drawer__inner__mastodon'>
-                {mascot ? <img alt='' draggable='false' src={mascot} /> : <button className='mastodon' onClick={onClickElefriend} />}
-              </div>
-            )}
+
+            <div className='drawer__inner__mastodon'>
+              {mascot ? <img alt='' draggable='false' src={mascot} /> : <button className='mastodon' onClick={onClickElefriend} />}
+            </div>
           </div>}
 
           <Motion defaultStyle={{ x: isSearchPage ? 0 : -100 }} style={{ x: spring(showSearch || isSearchPage ? 0 : -100, { stiffness: 210, damping: 20 }) }}>
diff --git a/app/javascript/flavours/glitch/features/direct_timeline/components/conversation.js b/app/javascript/flavours/glitch/features/direct_timeline/components/conversation.js
new file mode 100644
index 000000000..9ddeabe75
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/direct_timeline/components/conversation.js
@@ -0,0 +1,64 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import StatusContainer from 'flavours/glitch/containers/status_container';
+
+export default class Conversation extends ImmutablePureComponent {
+
+  static contextTypes = {
+    router: PropTypes.object,
+  };
+
+  static propTypes = {
+    conversationId: PropTypes.string.isRequired,
+    accounts: ImmutablePropTypes.list.isRequired,
+    lastStatusId: PropTypes.string,
+    unread:PropTypes.bool.isRequired,
+    onMoveUp: PropTypes.func,
+    onMoveDown: PropTypes.func,
+    markRead: PropTypes.func.isRequired,
+  };
+
+  handleClick = () => {
+    if (!this.context.router) {
+      return;
+    }
+
+    const { lastStatusId, unread, markRead } = this.props;
+
+    if (unread) {
+      markRead();
+    }
+
+    this.context.router.history.push(`/statuses/${lastStatusId}`);
+  }
+
+  handleHotkeyMoveUp = () => {
+    this.props.onMoveUp(this.props.conversationId);
+  }
+
+  handleHotkeyMoveDown = () => {
+    this.props.onMoveDown(this.props.conversationId);
+  }
+
+  render () {
+    const { accounts, lastStatusId, unread } = this.props;
+
+    if (lastStatusId === null) {
+      return null;
+    }
+
+    return (
+      <StatusContainer
+        id={lastStatusId}
+        unread={unread}
+        otherAccounts={accounts}
+        onMoveUp={this.handleHotkeyMoveUp}
+        onMoveDown={this.handleHotkeyMoveDown}
+        onClick={this.handleClick}
+      />
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/direct_timeline/components/conversations_list.js b/app/javascript/flavours/glitch/features/direct_timeline/components/conversations_list.js
new file mode 100644
index 000000000..4fa76fd6d
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/direct_timeline/components/conversations_list.js
@@ -0,0 +1,73 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import ConversationContainer from '../containers/conversation_container';
+import ScrollableList from 'flavours/glitch/components/scrollable_list';
+import { debounce } from 'lodash';
+
+export default class ConversationsList extends ImmutablePureComponent {
+
+  static propTypes = {
+    conversations: ImmutablePropTypes.list.isRequired,
+    hasMore: PropTypes.bool,
+    isLoading: PropTypes.bool,
+    onLoadMore: PropTypes.func,
+  };
+
+  getCurrentIndex = id => this.props.conversations.findIndex(x => x.get('id') === id)
+
+  handleMoveUp = id => {
+    const elementIndex = this.getCurrentIndex(id) - 1;
+    this._selectChild(elementIndex, true);
+  }
+
+  handleMoveDown = id => {
+    const elementIndex = this.getCurrentIndex(id) + 1;
+    this._selectChild(elementIndex, false);
+  }
+
+  _selectChild (index, align_top) {
+    const container = this.node.node;
+    const element = container.querySelector(`article:nth-of-type(${index + 1}) .focusable`);
+
+    if (element) {
+      if (align_top && container.scrollTop > element.offsetTop) {
+        element.scrollIntoView(true);
+      } else if (!align_top && container.scrollTop + container.clientHeight < element.offsetTop + element.offsetHeight) {
+        element.scrollIntoView(false);
+      }
+      element.focus();
+    }
+  }
+
+  setRef = c => {
+    this.node = c;
+  }
+
+  handleLoadOlder = debounce(() => {
+    const last = this.props.conversations.last();
+
+    if (last && last.get('last_status')) {
+      this.props.onLoadMore(last.get('last_status'));
+    }
+  }, 300, { leading: true })
+
+  render () {
+    const { conversations, onLoadMore, ...other } = this.props;
+
+    return (
+      <ScrollableList {...other} onLoadMore={onLoadMore && this.handleLoadOlder} scrollKey='direct' ref={this.setRef}>
+        {conversations.map(item => (
+          <ConversationContainer
+            key={item.get('id')}
+            conversationId={item.get('id')}
+            onMoveUp={this.handleMoveUp}
+            onMoveDown={this.handleMoveDown}
+          />
+        ))}
+      </ScrollableList>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/direct_timeline/containers/conversation_container.js b/app/javascript/flavours/glitch/features/direct_timeline/containers/conversation_container.js
new file mode 100644
index 000000000..bd6f6bfb0
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/direct_timeline/containers/conversation_container.js
@@ -0,0 +1,19 @@
+import { connect } from 'react-redux';
+import Conversation from '../components/conversation';
+import { markConversationRead } from '../../../actions/conversations';
+
+const mapStateToProps = (state, { conversationId }) => {
+  const conversation = state.getIn(['conversations', 'items']).find(x => x.get('id') === conversationId);
+
+  return {
+    accounts: conversation.get('accounts').map(accountId => state.getIn(['accounts', accountId], null)),
+    unread: conversation.get('unread'),
+    lastStatusId: conversation.get('last_status', null),
+  };
+};
+
+const mapDispatchToProps = (dispatch, { conversationId }) => ({
+  markRead: () => dispatch(markConversationRead(conversationId)),
+});
+
+export default connect(mapStateToProps, mapDispatchToProps)(Conversation);
diff --git a/app/javascript/flavours/glitch/features/direct_timeline/containers/conversations_list_container.js b/app/javascript/flavours/glitch/features/direct_timeline/containers/conversations_list_container.js
new file mode 100644
index 000000000..e10558f3a
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/direct_timeline/containers/conversations_list_container.js
@@ -0,0 +1,15 @@
+import { connect } from 'react-redux';
+import ConversationsList from '../components/conversations_list';
+import { expandConversations } from 'flavours/glitch/actions/conversations';
+
+const mapStateToProps = state => ({
+  conversations: state.getIn(['conversations', 'items']),
+  isLoading: state.getIn(['conversations', 'isLoading'], true),
+  hasMore: state.getIn(['conversations', 'hasMore'], false),
+});
+
+const mapDispatchToProps = dispatch => ({
+  onLoadMore: maxId => dispatch(expandConversations({ maxId })),
+});
+
+export default connect(mapStateToProps, mapDispatchToProps)(ConversationsList);
diff --git a/app/javascript/flavours/glitch/features/direct_timeline/index.js b/app/javascript/flavours/glitch/features/direct_timeline/index.js
index dc7e0534d..6fe8a1ce8 100644
--- a/app/javascript/flavours/glitch/features/direct_timeline/index.js
+++ b/app/javascript/flavours/glitch/features/direct_timeline/index.js
@@ -5,10 +5,13 @@ import StatusListContainer from 'flavours/glitch/features/ui/containers/status_l
 import Column from 'flavours/glitch/components/column';
 import ColumnHeader from 'flavours/glitch/components/column_header';
 import { expandDirectTimeline } from 'flavours/glitch/actions/timelines';
+import { mountConversations, unmountConversations, expandConversations } from 'flavours/glitch/actions/conversations';
 import { addColumn, removeColumn, moveColumn } from 'flavours/glitch/actions/columns';
 import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
 import ColumnSettingsContainer from './containers/column_settings_container';
 import { connectDirectStream } from 'flavours/glitch/actions/streaming';
+import { changeSetting } from 'flavours/glitch/actions/settings';
+import ConversationsListContainer from './containers/conversations_list_container';
 
 const messages = defineMessages({
   title: { id: 'column.direct', defaultMessage: 'Direct messages' },
@@ -16,6 +19,7 @@ const messages = defineMessages({
 
 const mapStateToProps = state => ({
   hasUnread: state.getIn(['timelines', 'direct', 'unread']) > 0,
+  conversationsMode: state.getIn(['settings', 'direct', 'conversations']),
 });
 
 @connect(mapStateToProps)
@@ -28,6 +32,7 @@ export default class DirectTimeline extends React.PureComponent {
     intl: PropTypes.object.isRequired,
     hasUnread: PropTypes.bool,
     multiColumn: PropTypes.bool,
+    conversationsMode: PropTypes.bool,
   };
 
   handlePin = () => {
@@ -50,13 +55,32 @@ export default class DirectTimeline extends React.PureComponent {
   }
 
   componentDidMount () {
-    const { dispatch } = this.props;
+    const { dispatch, conversationsMode } = this.props;
+
+    dispatch(mountConversations());
+
+    if (conversationsMode) {
+      dispatch(expandConversations());
+    } else {
+      dispatch(expandDirectTimeline());
+    }
 
-    dispatch(expandDirectTimeline());
     this.disconnect = dispatch(connectDirectStream());
   }
 
+  componentDidUpdate(prevProps) {
+    const { dispatch, conversationsMode } = this.props;
+
+    if (prevProps.conversationsMode && !conversationsMode) {
+      dispatch(expandDirectTimeline());
+    } else if (!prevProps.conversationsMode && conversationsMode) {
+      dispatch(expandConversations());
+    }
+  }
+
   componentWillUnmount () {
+    this.props.dispatch(unmountConversations());
+
     if (this.disconnect) {
       this.disconnect();
       this.disconnect = null;
@@ -67,14 +91,49 @@ export default class DirectTimeline extends React.PureComponent {
     this.column = c;
   }
 
-  handleLoadMore = maxId => {
+  handleLoadMoreTimeline = maxId => {
     this.props.dispatch(expandDirectTimeline({ maxId }));
   }
 
+  handleLoadMoreConversations = maxId => {
+    this.props.dispatch(expandConversations({ maxId }));
+  }
+
+  handleTimelineClick = () => {
+    this.props.dispatch(changeSetting(['direct', 'conversations'], false));
+  }
+
+  handleConversationsClick = () => {
+    this.props.dispatch(changeSetting(['direct', 'conversations'], true));
+  }
+
   render () {
-    const { intl, hasUnread, columnId, multiColumn } = this.props;
+    const { intl, hasUnread, columnId, multiColumn, conversationsMode } = this.props;
     const pinned = !!columnId;
 
+    let contents;
+    if (conversationsMode) {
+      contents = (
+        <ConversationsListContainer
+          trackScroll={!pinned}
+          scrollKey={`direct_timeline-${columnId}`}
+          timelineId='direct'
+          onLoadMore={this.handleLoadMore}
+          emptyMessage={<FormattedMessage id='empty_column.direct' defaultMessage="You don't have any direct messages yet. When you send or receive one, it will show up here." />}
+        />
+      );
+    } else {
+      contents = (
+        <StatusListContainer
+          trackScroll={!pinned}
+          scrollKey={`direct_timeline-${columnId}`}
+          timelineId='direct'
+          onLoadMore={this.handleLoadMoreTimeline}
+          emptyMessage={<FormattedMessage id='empty_column.direct' defaultMessage="You don't have any direct messages yet. When you send or receive one, it will show up here." />}
+        />
+      );
+    }
+
     return (
       <Column ref={this.setRef} label={intl.formatMessage(messages.title)}>
         <ColumnHeader
@@ -90,13 +149,28 @@ export default class DirectTimeline extends React.PureComponent {
           <ColumnSettingsContainer />
         </ColumnHeader>
 
-        <StatusListContainer
-          trackScroll={!pinned}
-          scrollKey={`direct_timeline-${columnId}`}
-          timelineId='direct'
-          onLoadMore={this.handleLoadMore}
-          emptyMessage={<FormattedMessage id='empty_column.direct' defaultMessage="You don't have any direct messages yet. When you send or receive one, it will show up here." />}
-        />
+        <div className='notification__filter-bar'>
+          <button
+            className={conversationsMode ? 'active' : ''}
+            onClick={this.handleConversationsClick}
+          >
+            <FormattedMessage
+              id='direct.conversations_mode'
+              defaultMessage='Conversations'
+            />
+          </button>
+          <button
+            className={conversationsMode ? '' : 'active'}
+            onClick={this.handleTimelineClick}
+          >
+            <FormattedMessage
+              id='direct.timeline_mode'
+              defaultMessage='Timeline'
+            />
+          </button>
+        </div>
+
+        {contents}
       </Column>
     );
   }
diff --git a/app/javascript/flavours/glitch/features/follow_requests/index.js b/app/javascript/flavours/glitch/features/follow_requests/index.js
index bce6338ea..d0845769e 100644
--- a/app/javascript/flavours/glitch/features/follow_requests/index.js
+++ b/app/javascript/flavours/glitch/features/follow_requests/index.js
@@ -59,7 +59,7 @@ export default class FollowRequests extends ImmutablePureComponent {
     }
 
     return (
-      <Column name='follow-requests' icon='users' heading={intl.formatMessage(messages.heading)}>
+      <Column name='follow-requests' icon='user-plus' heading={intl.formatMessage(messages.heading)}>
         <ColumnBackButtonSlim />
 
         <ScrollContainer scrollKey='follow_requests' shouldUpdateScroll={this.shouldUpdateScroll}>
diff --git a/app/javascript/flavours/glitch/features/getting_started/index.js b/app/javascript/flavours/glitch/features/getting_started/index.js
index d0c72c087..f669220e3 100644
--- a/app/javascript/flavours/glitch/features/getting_started/index.js
+++ b/app/javascript/flavours/glitch/features/getting_started/index.js
@@ -8,12 +8,13 @@ import { openModal } from 'flavours/glitch/actions/modal';
 import PropTypes from 'prop-types';
 import ImmutablePropTypes from 'react-immutable-proptypes';
 import ImmutablePureComponent from 'react-immutable-pure-component';
-import { me, invitesEnabled, version } from 'flavours/glitch/util/initial_state';
+import { me } from 'flavours/glitch/util/initial_state';
 import { fetchFollowRequests } from 'flavours/glitch/actions/accounts';
 import { List as ImmutableList } from 'immutable';
 import { createSelector } from 'reselect';
 import { fetchLists } from 'flavours/glitch/actions/lists';
-import { preferencesLink, profileLink, signOutLink } from 'flavours/glitch/util/backend_links';
+import { preferencesLink, signOutLink } from 'flavours/glitch/util/backend_links';
+import LinkFooter from 'flavours/glitch/features/ui/components/link_footer';
 
 const messages = defineMessages({
   heading: { id: 'getting_started.heading', defaultMessage: 'Getting started' },
@@ -73,9 +74,15 @@ const badgeDisplay = (number, limit) => {
   }
 };
 
-@connect(makeMapStateToProps, mapDispatchToProps)
-@injectIntl
-export default class GettingStarted extends ImmutablePureComponent {
+const NAVIGATION_PANEL_BREAKPOINT = 600 + (285 * 2) + (10 * 2);
+
+ export default @connect(makeMapStateToProps, mapDispatchToProps)
+ @injectIntl
+ class GettingStarted extends ImmutablePureComponent {
+
+  static contextTypes = {
+    router: PropTypes.object.isRequired,
+  };
 
   static propTypes = {
     intl: PropTypes.object.isRequired,
@@ -95,7 +102,12 @@ export default class GettingStarted extends ImmutablePureComponent {
   }
 
   componentDidMount () {
-    const { myAccount, fetchFollowRequests } = this.props;
+    const { myAccount, fetchFollowRequests, multiColumn } = this.props;
+
+    if (!multiColumn && window.innerWidth >= NAVIGATION_PANEL_BREAKPOINT) {
+      this.context.router.history.replace('/timelines/home');
+      return;
+    }
 
     if (myAccount.get('locked')) {
       fetchFollowRequests();
@@ -135,7 +147,7 @@ export default class GettingStarted extends ImmutablePureComponent {
     }
 
     if (myAccount.get('locked')) {
-      navItems.push(<ColumnLink key='6' icon='users' text={intl.formatMessage(messages.follow_requests)} badge={badgeDisplay(unreadFollowRequests, 40)} to='/follow_requests' />);
+      navItems.push(<ColumnLink key='6' icon='user-plus' text={intl.formatMessage(messages.follow_requests)} badge={badgeDisplay(unreadFollowRequests, 40)} to='/follow_requests' />);
     }
 
     navItems.push(<ColumnLink key='7' icon='ellipsis-h' text={intl.formatMessage(messages.misc)} to='/getting-started-misc' />);
@@ -163,26 +175,7 @@ export default class GettingStarted extends ImmutablePureComponent {
             <ColumnLink icon='sign-out' text={intl.formatMessage(messages.sign_out)} href={signOutLink} method='delete' />
           </div>
 
-          <div className='getting-started__footer'>
-            <ul>
-              {invitesEnabled && <li><a href='/invites' target='_blank'><FormattedMessage id='getting_started.invite' defaultMessage='Invite people' /></a> · </li>}
-              <li><a href='/about/more' target='_blank'><FormattedMessage id='navigation_bar.info' defaultMessage='About this server' /></a> · </li>
-              <li><a href='https://joinmastodon.org/apps' target='_blank'><FormattedMessage id='navigation_bar.apps' defaultMessage='Mobile apps' /></a> · </li>
-              <li><a href='/terms' target='_blank'><FormattedMessage id='getting_started.terms' defaultMessage='Terms of service' /></a> · </li>
-              <li><a href='https://docs.joinmastodon.org' target='_blank'><FormattedMessage id='getting_started.documentation' defaultMessage='Documentation' /></a></li>
-            </ul>
-
-            <p>
-              <FormattedMessage
-                id='getting_started.open_source_notice'
-                defaultMessage='GlitchCafe is open source software, based on {Glitchsoc} which is a friendly fork of {Mastodon}. You can contribute or report issues on GitHub at {github}.'
-                values={{
-                  github: <span><a href='https://github.com/pluralcafe/mastodon' rel='noopener' target='_blank'>pluralcafe/mastodon</a> (v{version})</span>,
-		  Glitchsoc: <a href='https://github.com/glitch-soc/mastodon' rel='noopener' target='_blank'>glitch-soc/mastodon</a>,
-                  Mastodon: <a href='https://github.com/tootsuite/mastodon' rel='noopener' target='_blank'>Mastodon</a> }}
-              />
-            </p>
-          </div>
+          <LinkFooter />
         </div>
       </Column>
     );
diff --git a/app/javascript/flavours/glitch/features/local_settings/page/index.js b/app/javascript/flavours/glitch/features/local_settings/page/index.js
index cd2d86713..23499455b 100644
--- a/app/javascript/flavours/glitch/features/local_settings/page/index.js
+++ b/app/javascript/flavours/glitch/features/local_settings/page/index.js
@@ -11,8 +11,11 @@ import LocalSettingsPageItem from './item';
 
 const messages = defineMessages({
   layout_auto: {  id: 'layout.auto', defaultMessage: 'Auto' },
+  layout_auto_hint: {  id: 'layout.hint.auto', defaultMessage: 'Automatically chose layout based on “Enable advanced web interface” setting and screen size.' },
   layout_desktop: { id: 'layout.desktop', defaultMessage: 'Desktop' },
+  layout_desktop_hint: { id: 'layout.hint.desktop', defaultMessage: 'Use multiple-column layout regardless of the “Enable advanced web interface” setting or screen size.' },
   layout_mobile: { id: 'layout.single', defaultMessage: 'Mobile' },
+  layout_mobile_hint: { id: 'layout.hint.single', defaultMessage: 'Use single-column layout regardless of the “Enable advanced web interface” setting or screen size.' },
   side_arm_none: { id: 'settings.side_arm.none', defaultMessage: 'None' },
   side_arm_keep: { id: 'settings.side_arm_reply_mode.keep', defaultMessage: 'Keep secondary toot button to set privacy' },
   side_arm_copy: { id: 'settings.side_arm_reply_mode.copy', defaultMessage: 'Copy privacy setting of the toot being replied to' },
@@ -51,6 +54,14 @@ export default class LocalSettingsPage extends React.PureComponent {
           <FormattedMessage id='settings.hicolor_privacy_icons' defaultMessage='High color privacy icons' />
           <span className='hint'><FormattedMessage id='settings.hicolor_privacy_icons.hint' defaultMessage="Display privacy icons in bright and easily distinguishable colors" /></span>
         </LocalSettingsPageItem>
+        <LocalSettingsPageItem
+          settings={settings}
+          item={['confirm_boost_missing_media_description']}
+          id='mastodon-settings--confirm_boost_missing_media_description'
+          onChange={onChange}
+        >
+          <FormattedMessage id='settings.confirm_boost_missing_media_description' defaultMessage='Show confirmation dialog before boosting toots lacking media descriptions' />
+        </LocalSettingsPageItem>
         <section>
           <h2><FormattedMessage id='settings.notifications_opts' defaultMessage='Notifications options' /></h2>
           <LocalSettingsPageItem
@@ -79,9 +90,9 @@ export default class LocalSettingsPage extends React.PureComponent {
             item={['layout']}
             id='mastodon-settings--layout'
             options={[
-              { value: 'auto', message: intl.formatMessage(messages.layout_auto) },
-              { value: 'multiple', message: intl.formatMessage(messages.layout_desktop) },
-              { value: 'single', message: intl.formatMessage(messages.layout_mobile) },
+              { value: 'auto', message: intl.formatMessage(messages.layout_auto), hint: intl.formatMessage(messages.layout_auto_hint) },
+              { value: 'multiple', message: intl.formatMessage(messages.layout_desktop), hint: intl.formatMessage(messages.layout_desktop_hint) },
+              { value: 'single', message: intl.formatMessage(messages.layout_mobile), hint: intl.formatMessage(messages.layout_mobile_hint) },
             ]}
             onChange={onChange}
           >
diff --git a/app/javascript/flavours/glitch/features/search/index.js b/app/javascript/flavours/glitch/features/search/index.js
new file mode 100644
index 000000000..b35c8ed49
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/search/index.js
@@ -0,0 +1,17 @@
+import React from 'react';
+import SearchContainer from 'flavours/glitch/features/compose/containers/search_container';
+import SearchResultsContainer from 'flavours/glitch/features/compose/containers/search_results_container';
+
+const Search = () => (
+  <div className='column search-page'>
+    <SearchContainer />
+
+    <div className='drawer__pager'>
+      <div className='drawer__inner darker'>
+        <SearchResultsContainer />
+      </div>
+    </div>
+  </div>
+);
+
+export default Search;
diff --git a/app/javascript/flavours/glitch/features/status/index.js b/app/javascript/flavours/glitch/features/status/index.js
index 145a33fff..76bfaaffa 100644
--- a/app/javascript/flavours/glitch/features/status/index.js
+++ b/app/javascript/flavours/glitch/features/status/index.js
@@ -231,18 +231,24 @@ export default class Status extends ImmutablePureComponent {
   }
 
   handleModalReblog = (status) => {
-    this.props.dispatch(reblog(status));
+    const { dispatch } = this.props;
+
+    if (status.get('reblogged')) {
+      dispatch(unreblog(status));
+    } else {
+      dispatch(reblog(status));
+    }
   }
 
   handleReblogClick = (status, e) => {
-    if (status.get('reblogged')) {
-      this.props.dispatch(unreblog(status));
+    const { settings, dispatch } = this.props;
+
+    if (settings.get('confirm_boost_missing_media_description') && status.get('media_attachments').some(item => !item.get('description')) && !status.get('reblogged')) {
+      dispatch(openModal('BOOST', { status, onReblog: this.handleModalReblog, missingMediaDescription: true }));
+    } else if ((e && e.shiftKey) || !boostModal) {
+      this.handleModalReblog(status);
     } else {
-      if ((e && e.shiftKey) || !boostModal) {
-        this.handleModalReblog(status);
-      } else {
-        this.props.dispatch(openModal('BOOST', { status, onReblog: this.handleModalReblog }));
-      }
+      dispatch(openModal('BOOST', { status, onReblog: this.handleModalReblog }));
     }
   }
 
diff --git a/app/javascript/flavours/glitch/features/ui/components/boost_modal.js b/app/javascript/flavours/glitch/features/ui/components/boost_modal.js
index ce7ec2479..600e4422f 100644
--- a/app/javascript/flavours/glitch/features/ui/components/boost_modal.js
+++ b/app/javascript/flavours/glitch/features/ui/components/boost_modal.js
@@ -7,6 +7,7 @@ import StatusContent from 'flavours/glitch/components/status_content';
 import Avatar from 'flavours/glitch/components/avatar';
 import RelativeTimestamp from 'flavours/glitch/components/relative_timestamp';
 import DisplayName from 'flavours/glitch/components/display_name';
+import AttachmentList from 'flavours/glitch/components/attachment_list';
 import ImmutablePureComponent from 'react-immutable-pure-component';
 
 const messages = defineMessages({
@@ -25,6 +26,7 @@ export default class BoostModal extends ImmutablePureComponent {
     status: ImmutablePropTypes.map.isRequired,
     onReblog: PropTypes.func.isRequired,
     onClose: PropTypes.func.isRequired,
+    missingMediaDescription: PropTypes.bool,
     intl: PropTypes.object.isRequired,
   };
 
@@ -52,7 +54,7 @@ export default class BoostModal extends ImmutablePureComponent {
   }
 
   render () {
-    const { status, intl } = this.props;
+    const { status, missingMediaDescription, intl } = this.props;
     const buttonText = status.get('reblogged') ? messages.cancel_reblog : messages.reblog;
 
     return (
@@ -74,11 +76,24 @@ export default class BoostModal extends ImmutablePureComponent {
             </div>
 
             <StatusContent status={status} />
+
+            {status.get('media_attachments').size > 0 && (
+              <AttachmentList
+                compact
+                media={status.get('media_attachments')}
+              />
+            )}
           </div>
         </div>
 
         <div className='boost-modal__action-bar'>
-          <div><FormattedMessage id='boost_modal.combo' defaultMessage='You can press {combo} to skip this next time' values={{ combo: <span>Shift + <i className='fa fa-retweet' /></span> }} /></div>
+          <div>
+            { missingMediaDescription ?
+                <FormattedMessage id='boost_modal.missing_description' defaultMessage='This toot contains some media without description' />
+              :
+                <FormattedMessage id='boost_modal.combo' defaultMessage='You can press {combo} to skip this next time' values={{ combo: <span>Shift + <i className='fa fa-retweet' /></span> }} />
+            }
+          </div>
           <Button text={intl.formatMessage(buttonText)} onClick={this.handleReblog} ref={this.setRef} />
         </div>
       </div>
diff --git a/app/javascript/flavours/glitch/features/ui/components/columns_area.js b/app/javascript/flavours/glitch/features/ui/components/columns_area.js
index 0fe580b9b..3a188ca87 100644
--- a/app/javascript/flavours/glitch/features/ui/components/columns_area.js
+++ b/app/javascript/flavours/glitch/features/ui/components/columns_area.js
@@ -5,7 +5,7 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
 import ImmutablePureComponent from 'react-immutable-pure-component';
 
 import ReactSwipeableViews from 'react-swipeable-views';
-import { links, getIndex, getLink } from './tabs_bar';
+import TabsBar, { links, getIndex, getLink } from './tabs_bar';
 import { Link } from 'react-router-dom';
 
 import BundleContainer from '../containers/bundle_container';
@@ -13,6 +13,8 @@ import ColumnLoading from './column_loading';
 import DrawerLoading from './drawer_loading';
 import BundleColumnError from './bundle_column_error';
 import { Compose, Notifications, HomeTimeline, CommunityTimeline, PublicTimeline, HashtagTimeline, DirectTimeline, FavouritedStatuses, BookmarkedStatuses, ListTimeline } from 'flavours/glitch/util/async-components';
+import ComposePanel from './compose_panel';
+import NavigationPanel from './navigation_panel';
 
 import detectPassiveEvents from 'detect-passive-events';
 import { scrollRight } from 'flavours/glitch/util/scroll';
@@ -49,6 +51,8 @@ export default class ColumnsArea extends ImmutablePureComponent {
     swipeToChangeColumns: PropTypes.bool,
     singleColumn: PropTypes.bool,
     children: PropTypes.node,
+    navbarUnder: PropTypes.bool,
+    openSettings: PropTypes.func,
   };
 
   state = {
@@ -139,7 +143,7 @@ export default class ColumnsArea extends ImmutablePureComponent {
       <ColumnLoading title={title} icon={icon} />;
 
     return (
-      <div className='columns-area' key={index}>
+      <div className='columns-area columns-area--mobile' key={index}>
         {view}
       </div>
     );
@@ -154,7 +158,7 @@ export default class ColumnsArea extends ImmutablePureComponent {
   }
 
   render () {
-    const { columns, children, singleColumn, swipeToChangeColumns, intl } = this.props;
+    const { columns, children, singleColumn, swipeToChangeColumns, intl, navbarUnder, openSettings } = this.props;
     const { shouldAnimate } = this.state;
 
     const columnIndex = getIndex(this.context.router.history.location.pathname);
@@ -163,17 +167,37 @@ export default class ColumnsArea extends ImmutablePureComponent {
     if (singleColumn) {
       const floatingActionButton = shouldHideFAB(this.context.router.history.location.pathname) ? null : <Link key='floating-action-button' to='/statuses/new' className='floating-action-button' aria-label={intl.formatMessage(messages.publish)}><i className='fa fa-pencil' /></Link>;
 
-      return columnIndex !== -1 ? [
+      const content = columnIndex !== -1 ? (
         <ReactSwipeableViews key='content' index={columnIndex} onChangeIndex={this.handleSwipe} onTransitionEnd={this.handleAnimationEnd} animateTransitions={shouldAnimate} springConfig={{ duration: '400ms', delay: '0s', easeFunction: 'ease' }} style={{ height: '100%' }} disabled={!swipeToChangeColumns}>
           {links.map(this.renderView)}
-        </ReactSwipeableViews>,
-
-        floatingActionButton,
-      ] : [
-        <div className='columns-area'>{children}</div>,
-
-        floatingActionButton,
-      ];
+        </ReactSwipeableViews>
+      ) : (
+        <div key='content' className='columns-area columns-area--mobile'>{children}</div>
+      );
+
+      return (
+        <div className='columns-area__panels'>
+          <div className='columns-area__panels__pane columns-area__panels__pane--compositional'>
+            <div className='columns-area__panels__pane__inner'>
+              <ComposePanel />
+            </div>
+          </div>
+
+          <div className='columns-area__panels__main'>
+            {!navbarUnder && <TabsBar key='tabs' />}
+            {content}
+            {navbarUnder && <TabsBar key='tabs' />}
+          </div>
+
+          <div className='columns-area__panels__pane columns-area__panels__pane--start columns-area__panels__pane--navigational'>
+            <div className='columns-area__panels__pane__inner'>
+              <NavigationPanel onOpenSettings={openSettings} />
+            </div>
+          </div>
+
+          {floatingActionButton}
+        </div>
+      );
     }
 
     return (
diff --git a/app/javascript/flavours/glitch/features/ui/components/compose_panel.js b/app/javascript/flavours/glitch/features/ui/components/compose_panel.js
new file mode 100644
index 000000000..f5eefee0d
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/ui/components/compose_panel.js
@@ -0,0 +1,16 @@
+import React from 'react';
+import SearchContainer from 'flavours/glitch/features/compose/containers/search_container';
+import ComposeFormContainer from 'flavours/glitch/features/compose/containers/compose_form_container';
+import NavigationContainer from 'flavours/glitch/features/compose/containers/navigation_container';
+import LinkFooter from './link_footer';
+
+const ComposePanel = () => (
+  <div className='compose-panel'>
+    <SearchContainer openInRoute />
+    <NavigationContainer />
+    <ComposeFormContainer />
+    <LinkFooter withHotkeys />
+  </div>
+);
+
+export default ComposePanel;
diff --git a/app/javascript/flavours/glitch/features/ui/components/follow_requests_nav_link.js b/app/javascript/flavours/glitch/features/ui/components/follow_requests_nav_link.js
new file mode 100644
index 000000000..189f403bd
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/ui/components/follow_requests_nav_link.js
@@ -0,0 +1,44 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { fetchFollowRequests } from 'flavours/glitch/actions/accounts';
+import { connect } from 'react-redux';
+import { NavLink, withRouter } from 'react-router-dom';
+import IconWithBadge from 'flavours/glitch/components/icon_with_badge';
+import { me } from 'flavours/glitch/util/initial_state';
+import { List as ImmutableList } from 'immutable';
+import { FormattedMessage } from 'react-intl';
+
+const mapStateToProps = state => ({
+  locked: state.getIn(['accounts', me, 'locked']),
+  count: state.getIn(['user_lists', 'follow_requests', 'items'], ImmutableList()).size,
+});
+
+export default @withRouter
+@connect(mapStateToProps)
+class FollowRequestsNavLink extends React.Component {
+
+  static propTypes = {
+    dispatch: PropTypes.func.isRequired,
+    locked: PropTypes.bool,
+    count: PropTypes.number.isRequired,
+  };
+
+  componentDidMount () {
+    const { dispatch, locked } = this.props;
+
+    if (locked) {
+      dispatch(fetchFollowRequests());
+    }
+  }
+
+  render () {
+    const { locked, count } = this.props;
+
+    if (!locked || count === 0) {
+      return null;
+    }
+
+    return <NavLink className='column-link column-link--transparent' to='/follow_requests'><IconWithBadge className='column-link__icon' id='user-plus' count={count} /><FormattedMessage id='navigation_bar.follow_requests' defaultMessage='Follow requests' /></NavLink>;
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/ui/components/link_footer.js b/app/javascript/flavours/glitch/features/ui/components/link_footer.js
new file mode 100644
index 000000000..3e724fffb
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/ui/components/link_footer.js
@@ -0,0 +1,36 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { FormattedMessage } from 'react-intl';
+import { Link } from 'react-router-dom';
+import { invitesEnabled, version, repository, source_url } from 'flavours/glitch/util/initial_state';
+import { signOutLink } from 'flavours/glitch/util/backend_links';
+
+const LinkFooter = () => (
+  <div className='getting-started__footer'>
+    <ul>
+      {invitesEnabled && <li><a href='/invites' target='_blank'><FormattedMessage id='getting_started.invite' defaultMessage='Invite people' /></a> · </li>}
+      <li><a href='/auth/edit'><FormattedMessage id='getting_started.security' defaultMessage='Security' /></a> · </li>
+      <li><a href='/about/more' target='_blank'><FormattedMessage id='navigation_bar.info' defaultMessage='About this server' /></a> · </li>
+      <li><a href='https://joinmastodon.org/apps' target='_blank'><FormattedMessage id='navigation_bar.apps' defaultMessage='Mobile apps' /></a> · </li>
+      <li><a href='/terms' target='_blank'><FormattedMessage id='getting_started.terms' defaultMessage='Terms of service' /></a> · </li>
+      <li><a href='/settings/applications' target='_blank'><FormattedMessage id='getting_started.developers' defaultMessage='Developers' /></a> · </li>
+      <li><a href='https://docs.joinmastodon.org' target='_blank'><FormattedMessage id='getting_started.documentation' defaultMessage='Documentation' /></a> · </li>
+      <li><a href={signOutLink} data-method='delete'><FormattedMessage id='navigation_bar.logout' defaultMessage='Logout' /></a></li>
+    </ul>
+
+    <p>
+      <FormattedMessage
+        id='getting_started.open_source_notice'
+        defaultMessage='Glitchsoc is open source software, a friendly fork of {Mastodon}. You can contribute or report issues on GitHub at {github}.'
+        values={{
+          github: <span><a href='https://github.com/glitch-soc/mastodon' rel='noopener' target='_blank'>glitch-soc/mastodon</a> (v{version})</span>,
+          Mastodon: <a href='https://github.com/tootsuite/mastodon' rel='noopener' target='_blank'>Mastodon</a> }}
+      />
+    </p>
+  </div>
+);
+
+LinkFooter.propTypes = {
+};
+
+export default LinkFooter;
diff --git a/app/javascript/flavours/glitch/features/ui/components/list_panel.js b/app/javascript/flavours/glitch/features/ui/components/list_panel.js
new file mode 100644
index 000000000..b2e6925b7
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/ui/components/list_panel.js
@@ -0,0 +1,55 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import { fetchLists } from 'flavours/glitch/actions/lists';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { NavLink, withRouter } from 'react-router-dom';
+import Icon from 'flavours/glitch/components/icon';
+
+const getOrderedLists = createSelector([state => state.get('lists')], lists => {
+  if (!lists) {
+    return lists;
+  }
+
+  return lists.toList().filter(item => !!item).sort((a, b) => a.get('title').localeCompare(b.get('title'))).take(4);
+});
+
+const mapStateToProps = state => ({
+  lists: getOrderedLists(state),
+});
+
+export default @withRouter
+@connect(mapStateToProps)
+class ListPanel extends ImmutablePureComponent {
+
+  static propTypes = {
+    dispatch: PropTypes.func.isRequired,
+    lists: ImmutablePropTypes.list,
+  };
+
+  componentDidMount () {
+    const { dispatch } = this.props;
+    dispatch(fetchLists());
+  }
+
+  render () {
+    const { lists } = this.props;
+
+    if (!lists || lists.isEmpty()) {
+      return null;
+    }
+
+    return (
+      <div>
+        <hr />
+
+        {lists.map(list => (
+          <NavLink key={list.get('id')} className='column-link column-link--transparent' strict to={`/timelines/list/${list.get('id')}`}><Icon className='column-link__icon' icon='list-ul' fixedWidth />{list.get('title')}</NavLink>
+        ))}
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/ui/components/navigation_panel.js b/app/javascript/flavours/glitch/features/ui/components/navigation_panel.js
new file mode 100644
index 000000000..4688c7766
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/ui/components/navigation_panel.js
@@ -0,0 +1,32 @@
+import React from 'react';
+import { NavLink, withRouter } from 'react-router-dom';
+import { FormattedMessage } from 'react-intl';
+import Icon from 'flavours/glitch/components/icon';
+import { profile_directory } from 'flavours/glitch/util/initial_state';
+import NotificationsCounterIcon from './notifications_counter_icon';
+import FollowRequestsNavLink from './follow_requests_nav_link';
+import ListPanel from './list_panel';
+
+const NavigationPanel = ({ onOpenSettings }) => (
+  <div className='navigation-panel'>
+    <NavLink className='column-link column-link--transparent' to='/timelines/home' data-preview-title-id='column.home' data-preview-icon='home' ><Icon className='column-link__icon' icon='home' fixedWidth /><FormattedMessage id='tabs_bar.home' defaultMessage='Home' /></NavLink>
+    <NavLink className='column-link column-link--transparent' to='/notifications' data-preview-title-id='column.notifications' data-preview-icon='bell' ><NotificationsCounterIcon className='column-link__icon' /><FormattedMessage id='tabs_bar.notifications' defaultMessage='Notifications' /></NavLink>
+    <FollowRequestsNavLink />
+    <NavLink className='column-link column-link--transparent' to='/timelines/public/local' data-preview-title-id='column.community' data-preview-icon='users' ><Icon className='column-link__icon' icon='users' fixedWidth /><FormattedMessage id='tabs_bar.local_timeline' defaultMessage='Local' /></NavLink>
+    <NavLink className='column-link column-link--transparent' exact to='/timelines/public' data-preview-title-id='column.public' data-preview-icon='globe' ><Icon className='column-link__icon' icon='globe' fixedWidth /><FormattedMessage id='tabs_bar.federated_timeline' defaultMessage='Federated' /></NavLink>
+    <NavLink className='column-link column-link--transparent' to='/timelines/direct'><Icon className='column-link__icon' icon='envelope' fixedWidth /><FormattedMessage id='navigation_bar.direct' defaultMessage='Direct messages' /></NavLink>
+    <NavLink className='column-link column-link--transparent' to='/bookmarks'><Icon className='column-link__icon' icon='bookmark' fixedWidth /><FormattedMessage id='navigation_bar.bookmarks' defaultMessage='Bookmarks' /></NavLink>
+    <NavLink className='column-link column-link--transparent' to='/lists'><Icon className='column-link__icon' icon='list-ul' fixedWidth /><FormattedMessage id='navigation_bar.lists' defaultMessage='Lists' /></NavLink>
+
+    <ListPanel />
+
+    <hr />
+
+    <a className='column-link column-link--transparent' href='/settings/preferences' target='_blank'><Icon className='column-link__icon' icon='cog' fixedWidth /><FormattedMessage id='navigation_bar.preferences' defaultMessage='Preferences' /></a>
+    <a className='column-link column-link--transparent' href='#' onClick={onOpenSettings}><Icon className='column-link__icon' icon='cogs' fixedWidth /><FormattedMessage id='navigation_bar.app_settings' defaultMessage='App settings' /></a>
+    <a className='column-link column-link--transparent' href='/relationships' target='_blank'><Icon className='column-link__icon' icon='users' fixedWidth /><FormattedMessage id='navigation_bar.follows_and_followers' defaultMessage='Follows and followers' /></a>
+    {!!profile_directory && <a className='column-link column-link--transparent' href='/explore'><Icon className='column-link__icon' id='address-book-o' fixedWidth /><FormattedMessage id='navigation_bar.profile_directory' defaultMessage='Profile directory' /></a>}
+  </div>
+);
+
+export default withRouter(NavigationPanel);
diff --git a/app/javascript/flavours/glitch/features/ui/components/notifications_counter_icon.js b/app/javascript/flavours/glitch/features/ui/components/notifications_counter_icon.js
new file mode 100644
index 000000000..6b52ef9b4
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/ui/components/notifications_counter_icon.js
@@ -0,0 +1,9 @@
+import { connect } from 'react-redux';
+import IconWithBadge from 'flavours/glitch/components/icon_with_badge';
+
+const mapStateToProps = state => ({
+  count: state.getIn(['local_settings', 'notifications', 'tab_badge']) ? state.getIn(['notifications', 'unread']) : 0,
+  id: 'bell',
+});
+
+export default connect(mapStateToProps)(IconWithBadge);
diff --git a/app/javascript/flavours/glitch/features/ui/components/tabs_bar.js b/app/javascript/flavours/glitch/features/ui/components/tabs_bar.js
index b44a21a42..dbd08aa2b 100644
--- a/app/javascript/flavours/glitch/features/ui/components/tabs_bar.js
+++ b/app/javascript/flavours/glitch/features/ui/components/tabs_bar.js
@@ -4,40 +4,16 @@ import { NavLink, withRouter } from 'react-router-dom';
 import { FormattedMessage, injectIntl } from 'react-intl';
 import { debounce } from 'lodash';
 import { isUserTouching } from 'flavours/glitch/util/is_mobile';
-import { connect } from 'react-redux';
-
-const mapStateToProps = state => ({
-  unreadNotifications: state.getIn(['notifications', 'unread']),
-  showBadge: state.getIn(['local_settings', 'notifications', 'tab_badge']),
-});
-
-@connect(mapStateToProps)
-class NotificationsIcon extends React.PureComponent {
-  static propTypes = {
-    unreadNotifications: PropTypes.number,
-    showBadge: PropTypes.bool,
-  };
-
-  render() {
-    const { unreadNotifications, showBadge } = this.props;
-    return (
-      <span className='icon-badge-wrapper'>
-        <i className='fa fa-fw fa-bell' />
-        { showBadge && unreadNotifications > 0 && <div className='icon-badge' />}
-      </span>
-    );
-  }
-}
+import NotificationsCounterIcon from './notifications_counter_icon';
 
 export const links = [
-  <NavLink className='tabs-bar__link primary' to='/timelines/home' data-preview-title-id='column.home' data-preview-icon='home' ><i className='fa fa-fw fa-home' /><FormattedMessage id='tabs_bar.home' defaultMessage='Home' /></NavLink>,
-  <NavLink className='tabs-bar__link primary' to='/notifications' data-preview-title-id='column.notifications' data-preview-icon='bell' ><NotificationsIcon /><FormattedMessage id='tabs_bar.notifications' defaultMessage='Notifications' /></NavLink>,
-
-  <NavLink className='tabs-bar__link secondary' to='/timelines/public/local' data-preview-title-id='column.community' data-preview-icon='users' ><i className='fa fa-fw fa-users' /><FormattedMessage id='tabs_bar.local_timeline' defaultMessage='Local' /></NavLink>,
-  <NavLink className='tabs-bar__link secondary' exact to='/timelines/public' data-preview-title-id='column.public' data-preview-icon='globe' ><i className='fa fa-fw fa-globe' /><FormattedMessage id='tabs_bar.federated_timeline' defaultMessage='Federated' /></NavLink>,
-  <NavLink className='tabs-bar__link primary' to='/search' data-preview-title-id='tabs_bar.search' data-preview-icon='bell' ><i className='fa fa-fw fa-search' /><FormattedMessage id='tabs_bar.search' defaultMessage='Search' /></NavLink>,
+  <NavLink className='tabs-bar__link' to='/timelines/home' data-preview-title-id='column.home' data-preview-icon='home' ><i className='fa fa-fw fa-home' /><FormattedMessage id='tabs_bar.home' defaultMessage='Home' /></NavLink>,
+  <NavLink className='tabs-bar__link' to='/notifications' data-preview-title-id='column.notifications' data-preview-icon='bell' ><NotificationsCounterIcon /><FormattedMessage id='tabs_bar.notifications' defaultMessage='Notifications' /></NavLink>,
 
-  <NavLink className='tabs-bar__link primary' style={{ flexGrow: '0', flexBasis: '30px' }} to='/getting-started' data-preview-title-id='getting_started.heading' data-preview-icon='bars' ><i className='fa fa-fw fa-bars' /></NavLink>,
+  <NavLink className='tabs-bar__link' to='/timelines/public/local' data-preview-title-id='column.community' data-preview-icon='users' ><i className='fa fa-fw fa-users' /><FormattedMessage id='tabs_bar.local_timeline' defaultMessage='Local' /></NavLink>,
+  <NavLink className='tabs-bar__link' exact to='/timelines/public' data-preview-title-id='column.public' data-preview-icon='globe' ><i className='fa fa-fw fa-globe' /><FormattedMessage id='tabs_bar.federated_timeline' defaultMessage='Federated' /></NavLink>,
+  <NavLink className='tabs-bar__link optional' to='/search' data-preview-title-id='tabs_bar.search' data-preview-icon='bell' ><i className='fa fa-fw fa-search' /><FormattedMessage id='tabs_bar.search' defaultMessage='Search' /></NavLink>,
+  <NavLink className='tabs-bar__link' style={{ flexGrow: '0', flexBasis: '30px' }} to='/getting-started' data-preview-title-id='getting_started.heading' data-preview-icon='bars' ><i className='fa fa-fw fa-bars' /></NavLink>,
 ];
 
 export function getIndex (path) {
diff --git a/app/javascript/flavours/glitch/features/ui/containers/columns_area_container.js b/app/javascript/flavours/glitch/features/ui/containers/columns_area_container.js
index ba194a002..b69842cd6 100644
--- a/app/javascript/flavours/glitch/features/ui/containers/columns_area_container.js
+++ b/app/javascript/flavours/glitch/features/ui/containers/columns_area_container.js
@@ -1,9 +1,18 @@
 import { connect } from 'react-redux';
 import ColumnsArea from '../components/columns_area';
+import { openModal } from 'flavours/glitch/actions/modal';
 
 const mapStateToProps = state => ({
   columns: state.getIn(['settings', 'columns']),
   swipeToChangeColumns: state.getIn(['local_settings', 'swipe_to_change_columns']),
 });
 
-export default connect(mapStateToProps, null, null, { forwardRef: true })(ColumnsArea);
+const mapDispatchToProps = dispatch => ({
+  openSettings (e) {
+    e.preventDefault();
+    e.stopPropagation();
+    dispatch(openModal('SETTINGS', {}));
+  },
+});
+
+export default connect(mapStateToProps, mapDispatchToProps, null, { forwardRef: true })(ColumnsArea);
diff --git a/app/javascript/flavours/glitch/features/ui/index.js b/app/javascript/flavours/glitch/features/ui/index.js
index f8fff934d..787488db4 100644
--- a/app/javascript/flavours/glitch/features/ui/index.js
+++ b/app/javascript/flavours/glitch/features/ui/index.js
@@ -2,7 +2,6 @@ import React from 'react';
 import NotificationsContainer from './containers/notifications_container';
 import PropTypes from 'prop-types';
 import LoadingBarContainer from './containers/loading_bar_container';
-import TabsBar from './components/tabs_bar';
 import ModalContainer from './containers/modal_container';
 import { connect } from 'react-redux';
 import { Redirect, withRouter } from 'react-router-dom';
@@ -45,6 +44,7 @@ import {
   Mutes,
   PinnedStatuses,
   Lists,
+  Search,
   GettingStartedMisc,
 } from 'flavours/glitch/util/async-components';
 import { HotKeys } from 'react-hotkeys';
@@ -270,19 +270,6 @@ export default class UI extends React.Component {
     };
   }
 
-  shouldComponentUpdate (nextProps) {
-    if (nextProps.navbarUnder !== this.props.navbarUnder) {
-      // Avoid expensive update just to toggle a class
-      this.node.classList.toggle('navbar-under', nextProps.navbarUnder);
-
-      return false;
-    }
-
-    // Why isn't this working?!?
-    // return super.shouldComponentUpdate(nextProps, nextState);
-    return true;
-  }
-
   componentDidUpdate (prevProps) {
     if (![this.props.location.pathname, '/'].includes(prevProps.location.pathname)) {
       this.columnsAreaNode.handleChildrenContentChange();
@@ -320,7 +307,7 @@ export default class UI extends React.Component {
   handleHotkeyNew = e => {
     e.preventDefault();
 
-    const element = this.node.querySelector('.composer--textarea textarea');
+    const element = this.node.querySelector('.compose-form__autosuggest-wrapper textarea');
 
     if (element) {
       element.focus();
@@ -432,6 +419,8 @@ export default class UI extends React.Component {
   render () {
     const { width, draggingOver } = this.state;
     const { children, layout, isWide, navbarUnder, dropdownMenuIsOpen } = this.props;
+    const singleColumn = isMobile(width, layout);
+    const redirect = singleColumn ? <Redirect from='/' to='/timelines/home' exact /> : <Redirect from='/' to='/getting-started' exact />;
 
     const columnsClass = layout => {
       switch (layout) {
@@ -475,11 +464,9 @@ export default class UI extends React.Component {
     return (
       <HotKeys keyMap={keyMap} handlers={handlers} ref={this.setHotkeysRef} attach={window} focused>
         <div className={className} ref={this.setRef} style={{ pointerEvents: dropdownMenuIsOpen ? 'none' : null }}>
-          {navbarUnder ? null : (<TabsBar />)}
-
-          <ColumnsAreaContainer ref={this.setColumnsAreaRef} singleColumn={isMobile(width, layout)}>
+          <ColumnsAreaContainer ref={this.setColumnsAreaRef} singleColumn={singleColumn} navbarUnder={navbarUnder}>
             <WrappedSwitch>
-              <Redirect from='/' to='/getting-started' exact />
+              {redirect}
               <WrappedRoute path='/getting-started' component={GettingStarted} content={children} />
               <WrappedRoute path='/keyboard-shortcuts' component={KeyboardShortcuts} content={children} />
               <WrappedRoute path='/timelines/home' component={HomeTimeline} content={children} />
@@ -493,7 +480,7 @@ export default class UI extends React.Component {
               <WrappedRoute path='/bookmarks' component={BookmarkedStatuses} content={children} />
               <WrappedRoute path='/pinned' component={PinnedStatuses} content={children} />
 
-              <WrappedRoute path='/search' component={Compose} content={children} componentParams={{ isSearchPage: true }} />
+              <WrappedRoute path='/search' component={Search} content={children} />
 
               <WrappedRoute path='/statuses/new' component={Compose} content={children} />
               <WrappedRoute path='/statuses/:statusId' exact component={Status} content={children} />
@@ -518,7 +505,6 @@ export default class UI extends React.Component {
           </ColumnsAreaContainer>
 
           <NotificationsContainer />
-          {navbarUnder ? (<TabsBar />) : null}
           <LoadingBarContainer className='loading-bar' />
           <ModalContainer />
           <UploadArea active={draggingOver} onClose={this.closeUploadModal} />
diff --git a/app/javascript/flavours/glitch/reducers/conversations.js b/app/javascript/flavours/glitch/reducers/conversations.js
new file mode 100644
index 000000000..c01659da5
--- /dev/null
+++ b/app/javascript/flavours/glitch/reducers/conversations.js
@@ -0,0 +1,102 @@
+import { Map as ImmutableMap, List as ImmutableList } from 'immutable';
+import {
+  CONVERSATIONS_MOUNT,
+  CONVERSATIONS_UNMOUNT,
+  CONVERSATIONS_FETCH_REQUEST,
+  CONVERSATIONS_FETCH_SUCCESS,
+  CONVERSATIONS_FETCH_FAIL,
+  CONVERSATIONS_UPDATE,
+  CONVERSATIONS_READ,
+} from '../actions/conversations';
+import compareId from 'flavours/glitch/util/compare_id';
+
+const initialState = ImmutableMap({
+  items: ImmutableList(),
+  isLoading: false,
+  hasMore: true,
+  mounted: 0,
+});
+
+const conversationToMap = item => ImmutableMap({
+  id: item.id,
+  unread: item.unread,
+  accounts: ImmutableList(item.accounts.map(a => a.id)),
+  last_status: item.last_status ? item.last_status.id : null,
+});
+
+const updateConversation = (state, item) => state.update('items', list => {
+  const index   = list.findIndex(x => x.get('id') === item.id);
+  const newItem = conversationToMap(item);
+
+  if (index === -1) {
+    return list.unshift(newItem);
+  } else {
+    return list.set(index, newItem);
+  }
+});
+
+const expandNormalizedConversations = (state, conversations, next, isLoadingRecent) => {
+  let items = ImmutableList(conversations.map(conversationToMap));
+
+  return state.withMutations(mutable => {
+    if (!items.isEmpty()) {
+      mutable.update('items', list => {
+        list = list.map(oldItem => {
+          const newItemIndex = items.findIndex(x => x.get('id') === oldItem.get('id'));
+
+          if (newItemIndex === -1) {
+            return oldItem;
+          }
+
+          const newItem = items.get(newItemIndex);
+          items = items.delete(newItemIndex);
+
+          return newItem;
+        });
+
+        list = list.concat(items);
+
+        return list.sortBy(x => x.get('last_status'), (a, b) => {
+          if(a === null || b === null) {
+            return -1;
+          }
+
+          return compareId(a, b) * -1;
+        });
+      });
+    }
+
+    if (!next && !isLoadingRecent) {
+      mutable.set('hasMore', false);
+    }
+
+    mutable.set('isLoading', false);
+  });
+};
+
+export default function conversations(state = initialState, action) {
+  switch (action.type) {
+  case CONVERSATIONS_FETCH_REQUEST:
+    return state.set('isLoading', true);
+  case CONVERSATIONS_FETCH_FAIL:
+    return state.set('isLoading', false);
+  case CONVERSATIONS_FETCH_SUCCESS:
+    return expandNormalizedConversations(state, action.conversations, action.next, action.isLoadingRecent);
+  case CONVERSATIONS_UPDATE:
+    return updateConversation(state, action.conversation);
+  case CONVERSATIONS_MOUNT:
+    return state.update('mounted', count => count + 1);
+  case CONVERSATIONS_UNMOUNT:
+    return state.update('mounted', count => count - 1);
+  case CONVERSATIONS_READ:
+    return state.update('items', list => list.map(item => {
+      if (item.get('id') === action.id) {
+        return item.set('unread', false);
+      }
+
+      return item;
+    }));
+  default:
+    return state;
+  }
+};
diff --git a/app/javascript/flavours/glitch/reducers/index.js b/app/javascript/flavours/glitch/reducers/index.js
index 45b93b92c..266d87dc1 100644
--- a/app/javascript/flavours/glitch/reducers/index.js
+++ b/app/javascript/flavours/glitch/reducers/index.js
@@ -28,6 +28,7 @@ import lists from './lists';
 import listEditor from './list_editor';
 import listAdder from './list_adder';
 import filters from './filters';
+import conversations from './conversations';
 import suggestions from './suggestions';
 import pinnedAccountsEditor from './pinned_accounts_editor';
 import polls from './polls';
@@ -64,6 +65,7 @@ const reducers = {
   listEditor,
   listAdder,
   filters,
+  conversations,
   suggestions,
   pinnedAccountsEditor,
   polls,
diff --git a/app/javascript/flavours/glitch/reducers/local_settings.js b/app/javascript/flavours/glitch/reducers/local_settings.js
index 5716c5982..68e1c8424 100644
--- a/app/javascript/flavours/glitch/reducers/local_settings.js
+++ b/app/javascript/flavours/glitch/reducers/local_settings.js
@@ -15,6 +15,7 @@ const initialState = ImmutableMap({
   show_reply_count : false,
   always_show_spoilers_field: false,
   confirm_missing_media_description: false,
+  confirm_boost_missing_media_description: false,
   confirm_before_clearing_draft: true,
   preselect_on_reply: true,
   inline_preview_cards: true,
diff --git a/app/javascript/flavours/glitch/reducers/notifications.js b/app/javascript/flavours/glitch/reducers/notifications.js
index 04ae3d406..5bbf9c822 100644
--- a/app/javascript/flavours/glitch/reducers/notifications.js
+++ b/app/javascript/flavours/glitch/reducers/notifications.js
@@ -27,7 +27,7 @@ import compareId from 'flavours/glitch/util/compare_id';
 const initialState = ImmutableMap({
   items: ImmutableList(),
   hasMore: true,
-  top: true,
+  top: false,
   mounted: 0,
   unread: 0,
   lastReadId: '0',
diff --git a/app/javascript/flavours/glitch/reducers/settings.js b/app/javascript/flavours/glitch/reducers/settings.js
index cc86a6b20..a37863a69 100644
--- a/app/javascript/flavours/glitch/reducers/settings.js
+++ b/app/javascript/flavours/glitch/reducers/settings.js
@@ -72,6 +72,7 @@ const initialState = ImmutableMap({
   }),
 
   direct: ImmutableMap({
+    conversations: true,
     regex: ImmutableMap({
       body: '',
     }),
diff --git a/app/javascript/flavours/glitch/styles/accounts.scss b/app/javascript/flavours/glitch/styles/accounts.scss
index 8b8c615ee..0fae137f0 100644
--- a/app/javascript/flavours/glitch/styles/accounts.scss
+++ b/app/javascript/flavours/glitch/styles/accounts.scss
@@ -199,7 +199,8 @@
   }
 }
 
-.account-role {
+.account-role,
+.simple_form .recommended {
   display: inline-block;
   padding: 4px 6px;
   cursor: default;
diff --git a/app/javascript/flavours/glitch/styles/components/accounts.scss b/app/javascript/flavours/glitch/styles/components/accounts.scss
index c0340e3f8..d2233207d 100644
--- a/app/javascript/flavours/glitch/styles/components/accounts.scss
+++ b/app/javascript/flavours/glitch/styles/components/accounts.scss
@@ -46,6 +46,18 @@
     vertical-align: middle;
     margin-right: 5px;
   }
+
+  &-composite {
+    @include avatar-radius;
+    overflow: hidden;
+
+    & div {
+      @include avatar-radius;
+      float: left;
+      position: relative;
+      box-sizing: border-box;
+    }
+  }
 }
 
 .account__avatar-overlay {
diff --git a/app/javascript/flavours/glitch/styles/components/columns.scss b/app/javascript/flavours/glitch/styles/components/columns.scss
index 7a8accc27..b354e7acf 100644
--- a/app/javascript/flavours/glitch/styles/components/columns.scss
+++ b/app/javascript/flavours/glitch/styles/components/columns.scss
@@ -11,15 +11,42 @@
   justify-content: flex-start;
   overflow-x: auto;
   position: relative;
-}
 
-@include limited-single-column('screen and (min-width: 360px)', $parent: null) {
-  .columns-area {
-    padding: 10px;
-  }
+  &__panels {
+    display: flex;
+    justify-content: center;
+    width: 100%;
+    height: 100%;
+
+    &__pane {
+      height: 100%;
+      overflow: hidden;
+      pointer-events: none;
+      display: flex;
+      justify-content: flex-end;
+
+      &--start {
+        justify-content: flex-start;
+      }
+
+      &__inner {
+        width: 285px;
+        pointer-events: auto;
+        height: 100%;
+      }
+    }
 
-  .react-swipeable-view-container .columns-area {
-    height: calc(100% - 20px) !important;
+    &__main {
+      box-sizing: border-box;
+      width: 100%;
+      max-width: 600px;
+      display: flex;
+      flex-direction: column;
+
+      @media screen and (min-width: 360px) {
+        padding: 0 10px;
+      }
+    }
   }
 }
 
@@ -63,62 +90,6 @@
   overflow: hidden;
 }
 
-@include limited-single-column('screen and (min-width: 360px)', $parent: null) {
-  .tabs-bar {
-    margin: 10px;
-    margin-bottom: 0;
-  }
-}
-
-:root {  //  Overrides .wide stylings for mobile view
-  @include single-column('screen and (max-width: 630px)', $parent: null) {
-    .column {
-      flex: auto;
-      width: 100%;
-      min-width: 0;
-      max-width: none;
-      padding: 0;
-    }
-
-    .columns-area {
-      flex-direction: column;
-    }
-
-    .search__input,
-    .autosuggest-textarea__textarea {
-      font-size: 16px;
-    }
-  }
-}
-
-@include multi-columns('screen and (min-width: 631px)', $parent: null) {
-  .columns-area {
-    padding: 0;
-  }
-
-  .column {
-    flex: 0 0 auto;
-    padding: 10px;
-    padding-left: 5px;
-    padding-right: 5px;
-
-    &:first-child {
-      padding-left: 10px;
-    }
-
-    &:last-child {
-      padding-right: 10px;
-    }
-  }
-
-  .columns-area > div {
-    .column {
-      padding-left: 5px;
-      padding-right: 5px;
-    }
-  }
-}
-
 .column-back-button {
   background: lighten($ui-base-color, 4%);
   color: $highlight-text-color;
@@ -183,9 +154,31 @@
   padding: 15px;
   text-decoration: none;
 
-  &:hover {
+  &:hover,
+  &:focus,
+  &:active {
     background: lighten($ui-base-color, 11%);
   }
+
+  &:focus {
+    outline: 0;
+  }
+
+  &--transparent {
+    background: transparent;
+    color: $ui-secondary-color;
+
+    &:hover,
+    &:focus,
+    &:active {
+      background: transparent;
+      color: $primary-text-color;
+    }
+
+    &.active {
+      color: $ui-highlight-color;
+    }
+  }
 }
 
 .column-link__icon {
@@ -277,7 +270,7 @@
   flex-direction: column;
   overflow: hidden;
 
-  .wide & {
+  .wide .columns-area:not(.columns-area--mobile) & {
     flex: auto;
     min-width: 330px;
     max-width: 400px;
@@ -294,6 +287,10 @@
   margin-left: 0;
 }
 
+.column-header__links {
+  margin-bottom: 14px;
+}
+
 .column-header__links .text-btn {
   margin-right: 10px;
 }
@@ -438,6 +435,10 @@
     contain: strict;
   }
 
+  & > span {
+    max-width: 400px;
+  }
+
   a {
     color: $highlight-text-color;
     text-decoration: none;
@@ -503,27 +504,3 @@
     margin: 0 5px;
   }
 }
-
-.floating-action-button {
-  position: fixed;
-  display: flex;
-  justify-content: center;
-  align-items: center;
-  width: 3.9375rem;
-  height: 3.9375rem;
-  bottom: 1.3125rem;
-  right: 1.3125rem;
-  background: darken($ui-highlight-color, 3%);
-  color: $white;
-  border-radius: 50%;
-  font-size: 21px;
-  line-height: 21px;
-  text-decoration: none;
-  box-shadow: 2px 3px 9px rgba($base-shadow-color, 0.4);
-
-  &:hover,
-  &:focus,
-  &:active {
-    background: lighten($ui-highlight-color, 7%);
-  }
-}
diff --git a/app/javascript/flavours/glitch/styles/components/composer.scss b/app/javascript/flavours/glitch/styles/components/composer.scss
index ba517a2ab..62eca49a1 100644
--- a/app/javascript/flavours/glitch/styles/components/composer.scss
+++ b/app/javascript/flavours/glitch/styles/components/composer.scss
@@ -12,7 +12,8 @@
   opacity: 0.0;
 
   &.composer--spoiler--visible {
-    height: 47px;
+    height: 36px;
+    margin-bottom: 11px;
     opacity: 1.0;
   }
 
@@ -98,6 +99,9 @@
   border-radius: 4px;
   padding: 10px;
   background: $ui-primary-color;
+  min-height: 23px;
+  overflow-y: auto;
+  flex: 0 2 auto;
 
   & > header {
     margin-bottom: 5px;
@@ -225,7 +229,7 @@
   }
 }
 
-.composer--textarea,
+.compose-form__autosuggest-wrapper,
 .autosuggest-input {
   position: relative;
 
@@ -284,6 +288,11 @@
   }
 }
 
+.autosuggest-textarea__suggestions-wrapper {
+  position: relative;
+  height: 0;
+}
+
 .autosuggest-textarea__suggestions {
   display: block;
   position: absolute;
@@ -485,6 +494,7 @@
   box-shadow: inset 0 5px 5px rgba($base-shadow-color, 0.05);
   border-radius: 0 0 4px 4px;
   height: 27px;
+  flex: 0 0 auto;
 
   & > * {
     display: inline-block;
@@ -575,6 +585,7 @@
   text-align: right;
   white-space: nowrap;
   overflow: hidden;
+  justify-content: flex-end;
 
   & > .count {
     display: inline-block;
diff --git a/app/javascript/flavours/glitch/styles/components/drawer.scss b/app/javascript/flavours/glitch/styles/components/drawer.scss
index 9f426448f..f054ddbc0 100644
--- a/app/javascript/flavours/glitch/styles/components/drawer.scss
+++ b/app/javascript/flavours/glitch/styles/components/drawer.scss
@@ -86,9 +86,8 @@
     box-sizing: border-box;
     margin: 0;
     border: none;
-    padding: 10px 30px 10px 10px;
+    padding: 15px 30px 15px 15px;
     width: 100%;
-    height: 36px;
     outline: 0;
     color: $darker-text-color;
     background: $ui-base-color;
@@ -276,6 +275,7 @@
   background: lighten($ui-base-color, 13%) url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 234.80078 31.757813" width="234.80078" height="31.757812"><path d="M19.599609 0c-1.05 0-2.10039.375-2.90039 1.125L0 16.925781v14.832031h234.80078V17.025391l-16.5-15.900391c-1.6-1.5-4.20078-1.5-5.80078 0l-13.80078 13.099609c-1.6 1.5-4.19883 1.5-5.79883 0L179.09961 1.125c-1.6-1.5-4.19883-1.5-5.79883 0L159.5 14.224609c-1.6 1.5-4.20078 1.5-5.80078 0L139.90039 1.125c-1.6-1.5-4.20078-1.5-5.80078 0l-13.79883 13.099609c-1.6 1.5-4.20078 1.5-5.80078 0L100.69922 1.125c-1.600001-1.5-4.198829-1.5-5.798829 0l-13.59961 13.099609c-1.6 1.5-4.200781 1.5-5.800781 0L61.699219 1.125c-1.6-1.5-4.198828-1.5-5.798828 0L42.099609 14.224609c-1.6 1.5-4.198828 1.5-5.798828 0L22.5 1.125C21.7.375 20.649609 0 19.599609 0z" fill="#{hex-color($ui-base-color)}"/></svg>') no-repeat bottom / 100% auto;
   flex: 1;
   min-height: 47px;
+  display: none;
 
   > img {
     display: block;
@@ -295,6 +295,10 @@
     border: none;
     cursor: inherit;
   }
+
+  @media screen and (min-height: 640px) {
+    display: block;
+  }
 }
 
 .pseudo-drawer {
diff --git a/app/javascript/flavours/glitch/styles/components/index.scss b/app/javascript/flavours/glitch/styles/components/index.scss
index 63211392e..9f96a3154 100644
--- a/app/javascript/flavours/glitch/styles/components/index.scss
+++ b/app/javascript/flavours/glitch/styles/components/index.scss
@@ -287,8 +287,12 @@
   text-overflow: ellipsis;
   white-space: nowrap;
 
+  a {
+    color: inherit;
+    text-decoration: inherit;
+  }
+
   strong {
-    display: block;
     height: 18px;
     font-size: 16px;
     font-weight: 500;
@@ -308,7 +312,7 @@
     white-space: nowrap;
   }
 
-  &:hover {
+  > a:hover {
     strong {
       text-decoration: underline;
     }
@@ -548,7 +552,44 @@
   }
 }
 
+.column,
+.drawer {
+  flex: 1 1 100%;
+  overflow: hidden;
+}
+
+@media screen and (min-width: 631px) {
+  .columns-area {
+    padding: 0;
+  }
+
+  .column,
+  .drawer {
+    flex: 0 0 auto;
+    padding: 10px;
+    padding-left: 5px;
+    padding-right: 5px;
+
+    &:first-child {
+      padding-left: 10px;
+    }
+
+    &:last-child {
+      padding-right: 10px;
+    }
+  }
+
+  .columns-area > div {
+    .column,
+    .drawer {
+      padding-left: 5px;
+      padding-right: 5px;
+    }
+  }
+}
+
 .tabs-bar {
+  box-sizing: border-box;
   display: flex;
   background: lighten($ui-base-color, 8%);
   flex: 0 0 auto;
@@ -559,6 +600,7 @@
   display: block;
   flex: 1 1 auto;
   padding: 15px 10px;
+  padding-bottom: 13px;
   color: $primary-text-color;
   text-decoration: none;
   text-align: center;
@@ -573,31 +615,53 @@
     font-size: 16px;
   }
 
-  &.active {
-    border-bottom: 2px solid $ui-highlight-color;
-    color: $highlight-text-color;
-  }
-
   &:hover,
   &:focus,
   &:active {
     @include multi-columns('screen and (min-width: 631px)') {
       background: lighten($ui-base-color, 14%);
+      border-bottom-color: lighten($ui-base-color, 14%);
     }
   }
 
-  span:last-child {
+  &.active {
+    border-bottom: 2px solid $ui-highlight-color;
+    color: $highlight-text-color;
+  }
+
+  span {
     margin-left: 5px;
     display: none;
   }
+
+  span.icon {
+    margin-left: 0;
+    display: inline;
+  }
 }
 
-@include multi-columns('screen and (min-width: 631px)', $parent: null) {
-  .tabs-bar {
-    display: none;
+.icon-with-badge {
+  position: relative;
+
+  &__badge {
+    position: absolute;
+    left: 9px;
+    top: -13px;
+    background: $ui-highlight-color;
+    border: 2px solid lighten($ui-base-color, 8%);
+    padding: 1px 6px;
+    border-radius: 6px;
+    font-size: 10px;
+    font-weight: 500;
+    line-height: 14px;
+    color: $primary-text-color;
   }
 }
 
+.column-link--transparent .icon-with-badge__badge {
+  border-color: darken($ui-base-color, 8%);
+}
+
 .scrollable {
   overflow-y: scroll;
   overflow-x: hidden;
@@ -1268,6 +1332,52 @@
   height: 1em;
 }
 
+.layout-toggle {
+  display: flex;
+  padding: 5px;
+
+  button {
+    box-sizing: border-box;
+    flex: 0 0 50%;
+    background: transparent;
+    padding: 5px;
+    border: 0;
+    position: relative;
+
+    &:hover,
+    &:focus,
+    &:active {
+      svg path:first-child {
+        fill: lighten($ui-base-color, 16%);
+      }
+    }
+  }
+
+  svg {
+    width: 100%;
+    height: auto;
+
+    path:first-child {
+      fill: lighten($ui-base-color, 12%);
+    }
+
+    path:last-child {
+      fill: darken($ui-base-color, 14%);
+    }
+  }
+
+  &__active {
+    color: $ui-highlight-color;
+    position: absolute;
+    top: 50%;
+    left: 50%;
+    transform: translate(-50%, -50%);
+    background: lighten($ui-base-color, 12%);
+    border-radius: 50%;
+    padding: 0.35rem;
+  }
+}
+
 ::-webkit-scrollbar-thumb {
   border-radius: 0;
 }
@@ -1322,3 +1432,4 @@ noscript {
 @import 'emoji_picker';
 @import 'local_settings';
 @import 'error_boundary';
+@import 'single_column';
diff --git a/app/javascript/flavours/glitch/styles/components/search.scss b/app/javascript/flavours/glitch/styles/components/search.scss
index f59ef019e..3ef141133 100644
--- a/app/javascript/flavours/glitch/styles/components/search.scss
+++ b/app/javascript/flavours/glitch/styles/components/search.scss
@@ -12,7 +12,7 @@
 .search__icon {
   .fa {
     position: absolute;
-    top: 10px;
+    top: 16px;
     right: 10px;
     z-index: 2;
     display: inline-block;
@@ -42,7 +42,7 @@
   }
 
   .fa-times-circle {
-    top: 11px;
+    top: 17px;
     transform: rotate(0deg);
     cursor: pointer;
 
diff --git a/app/javascript/flavours/glitch/styles/components/single_column.scss b/app/javascript/flavours/glitch/styles/components/single_column.scss
new file mode 100644
index 000000000..ca962abd2
--- /dev/null
+++ b/app/javascript/flavours/glitch/styles/components/single_column.scss
@@ -0,0 +1,232 @@
+.compose-panel {
+  width: 285px;
+  margin-top: 10px;
+  display: flex;
+  flex-direction: column;
+  height: calc(100% - 10px);
+  overflow-y: hidden;
+
+  .drawer--search input {
+    line-height: 18px;
+    font-size: 16px;
+    padding: 15px;
+    padding-right: 30px;
+  }
+
+  .search__icon .fa {
+    top: 15px;
+  }
+
+  .drawer--account {
+    flex: 0 1 48px;
+  }
+
+  .flex-spacer {
+    background: transparent;
+  }
+
+  .composer {
+    flex: 1;
+    overflow-y: hidden;
+    display: flex;
+    flex-direction: column;
+    min-height: 310px;
+  }
+
+  .compose-form__autosuggest-wrapper {
+    overflow-y: auto;
+    background-color: $white;
+    border-radius: 4px 4px 0 0;
+    flex: 0 1 auto;
+  }
+
+  .autosuggest-textarea__textarea {
+    overflow-y: hidden;
+  }
+
+  .compose-form__upload-thumbnail {
+    height: 80px;
+  }
+}
+
+.navigation-panel {
+  margin-top: 10px;
+  margin-bottom: 10px;
+  height: calc(100% - 20px);
+  overflow-y: auto;
+
+  hr {
+    border: 0;
+    background: transparent;
+    border-top: 1px solid lighten($ui-base-color, 4%);
+    margin: 10px 0;
+  }
+}
+
+@media screen and (min-width: 600px) {
+  .tabs-bar__link {
+    span {
+      display: inline;
+    }
+  }
+}
+
+.columns-area--mobile {
+  flex-direction: column;
+  width: 100%;
+  margin: 0 auto;
+
+  .column,
+  .drawer {
+    width: 100%;
+    height: 100%;
+    padding: 0;
+  }
+
+  .autosuggest-textarea__textarea {
+    font-size: 16px;
+  }
+
+  .search__input {
+    line-height: 18px;
+    font-size: 16px;
+    padding: 15px;
+    padding-right: 30px;
+  }
+
+  .search__icon .fa {
+    top: 15px;
+  }
+
+  @media screen and (min-width: 360px) {
+    padding: 10px 0;
+  }
+
+  @media screen and (min-width: 630px) {
+    .detailed-status {
+      padding: 15px;
+
+      .media-gallery,
+      .video-player {
+        margin-top: 15px;
+      }
+    }
+
+    .account__header__bar {
+      padding: 5px 10px;
+    }
+
+    .navigation-bar,
+    .compose-form {
+      padding: 15px;
+    }
+
+    .compose-form .compose-form__publish .compose-form__publish-button-wrapper {
+      padding-top: 15px;
+    }
+
+    .status {
+      padding: 15px;
+      min-height: 48px + 2px;
+
+      .media-gallery,
+      &__action-bar,
+      .video-player {
+        margin-top: 10px;
+      }
+    }
+
+    .account {
+      padding: 15px 10px;
+
+      &__header__bio {
+        margin: 0 -10px;
+      }
+    }
+
+    .notification {
+      &__message {
+        padding-top: 15px;
+      }
+
+      .status {
+        padding-top: 8px;
+      }
+
+      .account {
+        padding-top: 8px;
+      }
+    }
+  }
+}
+
+.floating-action-button {
+  position: fixed;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  width: 3.9375rem;
+  height: 3.9375rem;
+  bottom: 1.3125rem;
+  right: 1.3125rem;
+  background: darken($ui-highlight-color, 3%);
+  color: $white;
+  border-radius: 50%;
+  font-size: 21px;
+  line-height: 21px;
+  text-decoration: none;
+  box-shadow: 2px 3px 9px rgba($base-shadow-color, 0.4);
+
+  &:hover,
+  &:focus,
+  &:active {
+    background: lighten($ui-highlight-color, 7%);
+  }
+}
+
+@media screen and (min-width: 360px) {
+  .tabs-bar {
+    margin: 10px auto;
+    margin-bottom: 0;
+    width: 100%;
+  }
+
+  .react-swipeable-view-container .columns-area--mobile {
+    height: calc(100% - 20px) !important;
+  }
+
+  .getting-started__wrapper,
+  .getting-started__trends,
+  .search {
+    margin-bottom: 10px;
+  }
+}
+
+@media screen and (max-width: 600px + (285px * 1) + (10px * 1)) {
+  .columns-area__panels__pane--compositional {
+    display: none;
+  }
+}
+
+@media screen and (min-width: 600px + (285px * 1) + (10px * 1)) {
+  .floating-action-button,
+  .tabs-bar__link.optional {
+    display: none;
+  }
+
+  .search-page .search {
+    display: none;
+  }
+}
+
+@media screen and (max-width: 600px + (285px * 2) + (10px * 2)) {
+  .columns-area__panels__pane--navigational {
+    display: none;
+  }
+}
+
+@media screen and (min-width: 600px + (285px * 2) + (10px * 2)) {
+  .tabs-bar {
+    display: none;
+  }
+}
diff --git a/app/javascript/flavours/glitch/styles/components/status.scss b/app/javascript/flavours/glitch/styles/components/status.scss
index 327694a7e..ee4440e89 100644
--- a/app/javascript/flavours/glitch/styles/components/status.scss
+++ b/app/javascript/flavours/glitch/styles/components/status.scss
@@ -209,7 +209,7 @@
     outline: 0;
     background: lighten($ui-base-color, 4%);
 
-    .status.status-direct {
+    &.status.status-direct:not(.read) {
       background: lighten($ui-base-color, 12%);
 
       &.muted {
@@ -249,8 +249,9 @@
     margin-top: 8px;
   }
 
-  &.status-direct {
+  &.status-direct:not(.read) {
     background: lighten($ui-base-color, 8%);
+    border-bottom-color: lighten($ui-base-color, 12%);
   }
 
   &.light {
@@ -333,7 +334,7 @@
     &:focus > .status__content:after {
       background: linear-gradient(rgba(lighten($ui-base-color, 4%), 0), rgba(lighten($ui-base-color, 4%), 1));
     }
-    &.status-direct> .status__content:after {
+    &.status-direct:not(.read)> .status__content:after {
       background: linear-gradient(rgba(lighten($ui-base-color, 8%), 0), rgba(lighten($ui-base-color, 8%), 1));
     }
 
@@ -599,7 +600,7 @@
   }
 }
 
-.status__display-name,
+a.status__display-name,
 .reply-indicator__display-name,
 .detailed-status__display-name,
 .account__display-name {
diff --git a/app/javascript/flavours/glitch/styles/forms.scss b/app/javascript/flavours/glitch/styles/forms.scss
index 2b8d7a682..dae29a003 100644
--- a/app/javascript/flavours/glitch/styles/forms.scss
+++ b/app/javascript/flavours/glitch/styles/forms.scss
@@ -79,6 +79,12 @@ code {
           text-decoration: none;
         }
       }
+
+      .recommended {
+        position: absolute;
+        margin: 0 4px;
+        margin-top: -2px;
+      }
     }
   }
 
@@ -443,6 +449,10 @@ code {
     height: 41px;
   }
 
+  h4 {
+    margin-bottom: 15px !important;
+  }
+
   .label_input {
     &__wrapper {
       position: relative;
diff --git a/app/javascript/flavours/glitch/styles/mastodon-light/diff.scss b/app/javascript/flavours/glitch/styles/mastodon-light/diff.scss
index ce2a2eeb5..7da8edbde 100644
--- a/app/javascript/flavours/glitch/styles/mastodon-light/diff.scss
+++ b/app/javascript/flavours/glitch/styles/mastodon-light/diff.scss
@@ -27,15 +27,16 @@
   }
 }
 
-.status.status-direct {
+.status.status-direct:not(.read) {
   background: darken($ui-base-color, 8%);
+  border-bottom-color: darken($ui-base-color, 12%);
 
   &.collapsed> .status__content:after {
     background: linear-gradient(rgba(darken($ui-base-color, 8%), 0), rgba(darken($ui-base-color, 8%), 1));
   }
 }
 
-.focusable:focus.status.status-direct {
+.focusable:focus.status.status-direct:not(.read) {
   background: darken($ui-base-color, 4%);
 
   &.collapsed> .status__content:after {
@@ -124,7 +125,7 @@
 // Change the default color of several parts of the compose form
 .composer {
 
-  .composer--spoiler input, .composer--textarea textarea {
+  .composer--spoiler input, .compose-form__autosuggest-wrapper textarea {
     color: lighten($ui-base-color, 80%);
 
     &:disabled { background: lighten($simple-background-color, 10%) }
diff --git a/app/javascript/flavours/glitch/styles/rtl.scss b/app/javascript/flavours/glitch/styles/rtl.scss
index 9e0a93ec2..11fae3121 100644
--- a/app/javascript/flavours/glitch/styles/rtl.scss
+++ b/app/javascript/flavours/glitch/styles/rtl.scss
@@ -43,6 +43,10 @@ body.rtl {
     left: 10px;
   }
 
+  .columns-area {
+    direction: rtl;
+  }
+
   .column-header__buttons {
     left: 0;
     right: auto;
diff --git a/app/javascript/flavours/glitch/util/async-components.js b/app/javascript/flavours/glitch/util/async-components.js
index 094952204..f2aeda834 100644
--- a/app/javascript/flavours/glitch/util/async-components.js
+++ b/app/javascript/flavours/glitch/util/async-components.js
@@ -149,3 +149,7 @@ export function GettingStartedMisc () {
 export function ListAdder () {
   return import(/* webpackChunkName: "features/glitch/async/list_adder" */'flavours/glitch/features/list_adder');
 }
+
+export function Search () {
+  return import(/*webpackChunkName: "features/glitch/async/search" */'flavours/glitch/features/search');
+}
diff --git a/app/javascript/flavours/glitch/util/initial_state.js b/app/javascript/flavours/glitch/util/initial_state.js
index 99d8a4dbc..f42c06a3a 100644
--- a/app/javascript/flavours/glitch/util/initial_state.js
+++ b/app/javascript/flavours/glitch/util/initial_state.js
@@ -28,5 +28,6 @@ export const version = getMeta('version');
 export const mascot = getMeta('mascot');
 export const isStaff = getMeta('is_staff');
 export const defaultContentType = getMeta('default_content_type');
+export const forceSingleColumn = getMeta('advanced_layout') === false;
 
 export default initialState;
diff --git a/app/javascript/flavours/glitch/util/is_mobile.js b/app/javascript/flavours/glitch/util/is_mobile.js
index 80e8e0a8a..db3c8bd80 100644
--- a/app/javascript/flavours/glitch/util/is_mobile.js
+++ b/app/javascript/flavours/glitch/util/is_mobile.js
@@ -1,4 +1,5 @@
 import detectPassiveEvents from 'detect-passive-events';
+import { forceSingleColumn } from 'flavours/glitch/util/initial_state';
 
 const LAYOUT_BREAKPOINT = 630;
 
@@ -9,7 +10,7 @@ export function isMobile(width, columns) {
   case 'single':
     return true;
   default:
-    return width <= LAYOUT_BREAKPOINT;
+    return forceSingleColumn || width <= LAYOUT_BREAKPOINT;
   }
 };