about summary refs log tree commit diff
path: root/app/javascript/flavours/glitch/features/ui
diff options
context:
space:
mode:
authorStarfall <us@starfall.systems>2022-11-10 08:50:11 -0600
committerStarfall <us@starfall.systems>2022-11-10 08:50:11 -0600
commit67d1a0476d77e2ed0ca15dd2981c54c2b90b0742 (patch)
tree152f8c13a341d76738e8e2c09b24711936e6af68 /app/javascript/flavours/glitch/features/ui
parentb581e6b6d4a5ba9ed4ae17427b7f2d5d158be4e5 (diff)
parentee7e49d1b1323618e16026bc8db8ab7f9459cc2d (diff)
Merge remote-tracking branch 'glitch/main'
- Remove Helm charts
- Lots of conflicts with our removal of recommended settings and custom
  icons
Diffstat (limited to 'app/javascript/flavours/glitch/features/ui')
-rw-r--r--app/javascript/flavours/glitch/features/ui/components/bundle_column_error.js162
-rw-r--r--app/javascript/flavours/glitch/features/ui/components/column.js9
-rw-r--r--app/javascript/flavours/glitch/features/ui/components/column_link.js30
-rw-r--r--app/javascript/flavours/glitch/features/ui/components/column_loading.js6
-rw-r--r--app/javascript/flavours/glitch/features/ui/components/columns_area.js114
-rw-r--r--app/javascript/flavours/glitch/features/ui/components/compare_history_modal.js2
-rw-r--r--app/javascript/flavours/glitch/features/ui/components/compose_panel.js60
-rw-r--r--app/javascript/flavours/glitch/features/ui/components/deprecated_settings_modal.js2
-rw-r--r--app/javascript/flavours/glitch/features/ui/components/disabled_account_banner.js92
-rw-r--r--app/javascript/flavours/glitch/features/ui/components/embed_modal.js2
-rw-r--r--app/javascript/flavours/glitch/features/ui/components/favourite_modal.js10
-rw-r--r--app/javascript/flavours/glitch/features/ui/components/focal_point_modal.js6
-rw-r--r--app/javascript/flavours/glitch/features/ui/components/follow_requests_column_link.js (renamed from app/javascript/flavours/glitch/features/ui/components/follow_requests_nav_link.js)24
-rw-r--r--app/javascript/flavours/glitch/features/ui/components/header.js63
-rw-r--r--app/javascript/flavours/glitch/features/ui/components/image_modal.js59
-rw-r--r--app/javascript/flavours/glitch/features/ui/components/link_footer.js61
-rw-r--r--app/javascript/flavours/glitch/features/ui/components/list_panel.js12
-rw-r--r--app/javascript/flavours/glitch/features/ui/components/media_modal.js2
-rw-r--r--app/javascript/flavours/glitch/features/ui/components/modal_root.js25
-rw-r--r--app/javascript/flavours/glitch/features/ui/components/navigation_panel.js137
-rw-r--r--app/javascript/flavours/glitch/features/ui/components/onboarding_modal.js8
-rw-r--r--app/javascript/flavours/glitch/features/ui/components/report_modal.js4
-rw-r--r--app/javascript/flavours/glitch/features/ui/components/sign_in_banner.js40
-rw-r--r--app/javascript/flavours/glitch/features/ui/components/tabs_bar.js86
-rw-r--r--app/javascript/flavours/glitch/features/ui/components/upload_area.js2
-rw-r--r--app/javascript/flavours/glitch/features/ui/containers/status_list_container.js10
-rw-r--r--app/javascript/flavours/glitch/features/ui/index.js187
-rw-r--r--app/javascript/flavours/glitch/features/ui/util/async-components.js203
-rw-r--r--app/javascript/flavours/glitch/features/ui/util/fullscreen.js46
-rw-r--r--app/javascript/flavours/glitch/features/ui/util/get_rect_from_entry.js21
-rw-r--r--app/javascript/flavours/glitch/features/ui/util/intersection_observer_wrapper.js57
-rw-r--r--app/javascript/flavours/glitch/features/ui/util/optional_motion.js5
-rw-r--r--app/javascript/flavours/glitch/features/ui/util/react_router_helpers.js101
-rw-r--r--app/javascript/flavours/glitch/features/ui/util/reduced_motion.js44
-rw-r--r--app/javascript/flavours/glitch/features/ui/util/schedule_idle_task.js29
35 files changed, 1305 insertions, 416 deletions
diff --git a/app/javascript/flavours/glitch/features/ui/components/bundle_column_error.js b/app/javascript/flavours/glitch/features/ui/components/bundle_column_error.js
index 3e979a250..7cbe1413d 100644
--- a/app/javascript/flavours/glitch/features/ui/components/bundle_column_error.js
+++ b/app/javascript/flavours/glitch/features/ui/components/bundle_column_error.js
@@ -1,44 +1,162 @@
 import React from 'react';
 import PropTypes from 'prop-types';
-import { defineMessages, injectIntl } from 'react-intl';
+import { injectIntl, FormattedMessage } from 'react-intl';
+import Column from 'flavours/glitch/components/column';
+import Button from 'flavours/glitch/components/button';
+import { Helmet } from 'react-helmet';
+import { Link } from 'react-router-dom';
+import classNames from 'classnames';
+import { autoPlayGif } from 'flavours/glitch/initial_state';
 
-import Column from './column';
-import ColumnHeader from './column_header';
-import ColumnBackButtonSlim from 'flavours/glitch/components/column_back_button_slim';
-import IconButton from 'flavours/glitch/components/icon_button';
+class GIF extends React.PureComponent {
 
-const messages = defineMessages({
-  title: { id: 'bundle_column_error.title', defaultMessage: 'Network error' },
-  body: { id: 'bundle_column_error.body', defaultMessage: 'Something went wrong while loading this component.' },
-  retry: { id: 'bundle_column_error.retry', defaultMessage: 'Try again' },
-});
+  static propTypes = {
+    src: PropTypes.string.isRequired,
+    staticSrc: PropTypes.string.isRequired,
+    className: PropTypes.string,
+    animate: PropTypes.bool,
+  };
+
+  static defaultProps = {
+    animate: autoPlayGif,
+  };
+
+  state = {
+    hovering: false,
+  };
+
+  handleMouseEnter = () => {
+    const { animate } = this.props;
+
+    if (!animate) {
+      this.setState({ hovering: true });
+    }
+  }
 
-class BundleColumnError extends React.Component {
+  handleMouseLeave = () => {
+    const { animate } = this.props;
+
+    if (!animate) {
+      this.setState({ hovering: false });
+    }
+  }
+
+  render () {
+    const { src, staticSrc, className, animate } = this.props;
+    const { hovering } = this.state;
+
+    return (
+      <img
+        className={className}
+        src={(hovering || animate) ? src : staticSrc}
+        alt=''
+        role='presentation'
+        onMouseEnter={this.handleMouseEnter}
+        onMouseLeave={this.handleMouseLeave}
+      />
+    );
+  }
+
+}
+
+class CopyButton extends React.PureComponent {
 
   static propTypes = {
-    onRetry: PropTypes.func.isRequired,
-    intl: PropTypes.object.isRequired,
+    children: PropTypes.node.isRequired,
+    value: PropTypes.string.isRequired,
+  };
+
+  state = {
+    copied: false,
+  };
+
+  handleClick = () => {
+    const { value } = this.props;
+    navigator.clipboard.writeText(value);
+    this.setState({ copied: true });
+    this.timeout = setTimeout(() => this.setState({ copied: false }), 700);
+  }
+
+  componentWillUnmount () {
+    if (this.timeout) clearTimeout(this.timeout);
   }
 
+  render () {
+    const { children } = this.props;
+    const { copied } = this.state;
+
+    return (
+      <Button onClick={this.handleClick} className={copied ? 'copied' : 'copyable'}>{copied ? <FormattedMessage id='copypaste.copied' defaultMessage='Copied' /> : children}</Button>
+    );
+  }
+
+}
+
+export default @injectIntl
+class BundleColumnError extends React.PureComponent {
+
+  static propTypes = {
+    errorType: PropTypes.oneOf(['routing', 'network', 'error']),
+    onRetry: PropTypes.func,
+    intl: PropTypes.object.isRequired,
+    multiColumn: PropTypes.bool,
+    stacktrace: PropTypes.string,
+  };
+
+  static defaultProps = {
+    errorType: 'routing',
+  };
+
   handleRetry = () => {
-    this.props.onRetry();
+    const { onRetry } = this.props;
+
+    if (onRetry) {
+      onRetry();
+    }
   }
 
   render () {
-    const { intl: { formatMessage } } = this.props;
+    const { errorType, multiColumn, stacktrace } = this.props;
+
+    let title, body;
+
+    switch(errorType) {
+    case 'routing':
+      title = <FormattedMessage id='bundle_column_error.routing.title' defaultMessage='404' />;
+      body = <FormattedMessage id='bundle_column_error.routing.body' defaultMessage='The requested page could not be found. Are you sure the URL in the address bar is correct?' />;
+      break;
+    case 'network':
+      title = <FormattedMessage id='bundle_column_error.network.title' defaultMessage='Network error' />;
+      body = <FormattedMessage id='bundle_column_error.network.body' defaultMessage='There was an error when trying to load this page. This could be due to a temporary problem with your internet connection or this server.' />;
+      break;
+    case 'error':
+      title = <FormattedMessage id='bundle_column_error.error.title' defaultMessage='Oh, no!' />;
+      body = <FormattedMessage id='bundle_column_error.error.body' defaultMessage='The requested page could not be rendered. It could be due to a bug in our code, or a browser compatibility issue.' />;
+      break;
+    }
 
     return (
-      <Column>
-        <ColumnHeader icon='exclamation-circle' type={formatMessage(messages.title)} />
-        <ColumnBackButtonSlim />
+      <Column bindToDocument={!multiColumn}>
         <div className='error-column'>
-          <IconButton title={formatMessage(messages.retry)} icon='refresh' onClick={this.handleRetry} size={64} />
-          {formatMessage(messages.body)}
+          <GIF src='/oops.gif' staticSrc='/oops.png' className='error-column__image' />
+
+          <div className='error-column__message'>
+            <h1>{title}</h1>
+            <p>{body}</p>
+
+            <div className='error-column__message__actions'>
+              {errorType === 'network' && <Button onClick={this.handleRetry}><FormattedMessage id='bundle_column_error.retry' defaultMessage='Try again' /></Button>}
+              {errorType === 'error' && <CopyButton value={stacktrace}><FormattedMessage id='bundle_column_error.copy_stacktrace' defaultMessage='Copy error report' /></CopyButton>}
+              <Link to='/' className={classNames('button', { 'button-tertiary': errorType !== 'routing' })}><FormattedMessage id='bundle_column_error.return' defaultMessage='Go back home' /></Link>
+            </div>
+          </div>
         </div>
+
+        <Helmet>
+          <meta name='robots' content='noindex' />
+        </Helmet>
       </Column>
     );
   }
 
 }
-
-export default injectIntl(BundleColumnError);
diff --git a/app/javascript/flavours/glitch/features/ui/components/column.js b/app/javascript/flavours/glitch/features/ui/components/column.js
index ab78414e0..e9c1e2f87 100644
--- a/app/javascript/flavours/glitch/features/ui/components/column.js
+++ b/app/javascript/flavours/glitch/features/ui/components/column.js
@@ -2,8 +2,8 @@ import React from 'react';
 import ColumnHeader from './column_header';
 import PropTypes from 'prop-types';
 import { debounce } from 'lodash';
-import { scrollTop } from 'flavours/glitch/util/scroll';
-import { isMobile } from 'flavours/glitch/util/is_mobile';
+import { scrollTop } from 'flavours/glitch/scroll';
+import { isMobile } from 'flavours/glitch/is_mobile';
 
 export default class Column extends React.PureComponent {
 
@@ -14,10 +14,11 @@ export default class Column extends React.PureComponent {
     active: PropTypes.bool,
     hideHeadingOnMobile: PropTypes.bool,
     name: PropTypes.string,
+    bindToDocument: PropTypes.bool,
   };
 
   handleHeaderClick = () => {
-    const scrollable = this.node.querySelector('.scrollable');
+    const scrollable = this.props.bindToDocument ? document.scrollingElement : this.node.querySelector('.scrollable');
 
     if (!scrollable) {
       return;
@@ -27,7 +28,7 @@ export default class Column extends React.PureComponent {
   }
 
   scrollTop () {
-    const scrollable = this.node.querySelector('.scrollable');
+    const scrollable = this.props.bindToDocument ? document.scrollingElement : this.node.querySelector('.scrollable');
 
     if (!scrollable) {
       return;
diff --git a/app/javascript/flavours/glitch/features/ui/components/column_link.js b/app/javascript/flavours/glitch/features/ui/components/column_link.js
index d04b869b6..bd1c20b47 100644
--- a/app/javascript/flavours/glitch/features/ui/components/column_link.js
+++ b/app/javascript/flavours/glitch/features/ui/components/column_link.js
@@ -1,26 +1,29 @@
 import React from 'react';
 import PropTypes from 'prop-types';
-import { Link } from 'react-router-dom';
+import { NavLink } from 'react-router-dom';
 import Icon from 'flavours/glitch/components/icon';
+import classNames from 'classnames';
 
-const ColumnLink = ({ icon, text, to, onClick, href, method, badge }) => {
+const ColumnLink = ({ icon, text, to, onClick, href, method, badge, transparent, ...other }) => {
+  const className = classNames('column-link', { 'column-link--transparent': transparent });
   const badgeElement = typeof badge !== 'undefined' ? <span className='column-link__badge'>{badge}</span> : null;
+  const iconElement = typeof icon === 'string' ? <Icon id={icon} fixedWidth className='column-link__icon' /> : icon;
 
   if (href) {
     return (
-      <a href={href} className='column-link' data-method={method}>
-        <Icon id={icon} fixedWidth className='column-link__icon' />
-        {text}
+      <a href={href} className={className} data-method={method} title={text} {...other}>
+        {iconElement}
+        <span>{text}</span>
         {badgeElement}
       </a>
     );
   } else if (to) {
     return (
-      <Link to={to} className='column-link'>
-        <Icon id={icon} fixedWidth className='column-link__icon' />
-        {text}
+      <NavLink to={to} className={className} title={text} {...other}>
+        {iconElement}
+        <span>{text}</span>
         {badgeElement}
-      </Link>
+      </NavLink>
     );
   } else {
     const handleOnClick = (e) => {
@@ -29,9 +32,9 @@ const ColumnLink = ({ icon, text, to, onClick, href, method, badge }) => {
       return onClick(e);
     }
     return (
-      <a href='#' onClick={onClick && handleOnClick} className='column-link' tabIndex='0'>
-        <Icon id={icon} fixedWidth className='column-link__icon' />
-        {text}
+      <a href='#' onClick={onClick && handleOnClick} className={className} title={text} {...other} tabIndex='0'>
+        {iconElement}
+        <span>{text}</span>
         {badgeElement}
       </a>
     );
@@ -39,13 +42,14 @@ const ColumnLink = ({ icon, text, to, onClick, href, method, badge }) => {
 };
 
 ColumnLink.propTypes = {
-  icon: PropTypes.string.isRequired,
+  icon: PropTypes.oneOfType([PropTypes.string, PropTypes.node]).isRequired,
   text: PropTypes.string.isRequired,
   to: PropTypes.string,
   onClick: PropTypes.func,
   href: PropTypes.string,
   method: PropTypes.string,
   badge: PropTypes.node,
+  transparent: PropTypes.bool,
 };
 
 export default ColumnLink;
diff --git a/app/javascript/flavours/glitch/features/ui/components/column_loading.js b/app/javascript/flavours/glitch/features/ui/components/column_loading.js
index 22c00c915..b07385397 100644
--- a/app/javascript/flavours/glitch/features/ui/components/column_loading.js
+++ b/app/javascript/flavours/glitch/features/ui/components/column_loading.js
@@ -10,6 +10,7 @@ export default class ColumnLoading extends ImmutablePureComponent {
   static propTypes = {
     title: PropTypes.oneOfType([PropTypes.node, PropTypes.string]),
     icon: PropTypes.string,
+    multiColumn: PropTypes.bool,
   };
 
   static defaultProps = {
@@ -18,10 +19,11 @@ export default class ColumnLoading extends ImmutablePureComponent {
   };
 
   render() {
-    let { title, icon } = this.props;
+    let { title, icon, multiColumn } = this.props;
+
     return (
       <Column>
-        <ColumnHeader icon={icon} title={title} multiColumn={false} focusable={false} placeholder />
+        <ColumnHeader icon={icon} title={title} multiColumn={multiColumn} focusable={false} placeholder />
         <div className='scrollable' />
       </Column>
     );
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 bfb1ae405..bf3e79c24 100644
--- a/app/javascript/flavours/glitch/features/ui/components/columns_area.js
+++ b/app/javascript/flavours/glitch/features/ui/components/columns_area.js
@@ -1,15 +1,7 @@
 import React from 'react';
 import PropTypes from 'prop-types';
-import { defineMessages, injectIntl } from 'react-intl';
 import ImmutablePropTypes from 'react-immutable-proptypes';
 import ImmutablePureComponent from 'react-immutable-pure-component';
-
-import ReactSwipeableViews from 'react-swipeable-views';
-import TabsBar, { links, getIndex, getLink } from './tabs_bar';
-import { Link } from 'react-router-dom';
-
-import { disableSwiping } from 'flavours/glitch/util/initial_state';
-
 import BundleContainer from '../containers/bundle_container';
 import ColumnLoading from './column_loading';
 import DrawerLoading from './drawer_loading';
@@ -26,13 +18,12 @@ import {
   BookmarkedStatuses,
   ListTimeline,
   Directory,
-} from 'flavours/glitch/util/async-components';
-import Icon from 'flavours/glitch/components/icon';
+} from '../../ui/util/async-components';
 import ComposePanel from './compose_panel';
 import NavigationPanel from './navigation_panel';
 
 import { supportsPassiveEvents } from 'detect-passive-events';
-import { scrollRight } from 'flavours/glitch/util/scroll';
+import { scrollRight } from 'flavours/glitch/scroll';
 
 const componentMap = {
   'COMPOSE': Compose,
@@ -49,21 +40,13 @@ const componentMap = {
   'DIRECTORY': Directory,
 };
 
-const shouldHideFAB = path => path.match(/^\/statuses\/|^\/@[^/]+\/\d+|^\/publish|^\/search|^\/getting-started|^\/start/);
-
-const messages = defineMessages({
-  publish: { id: 'compose_form.publish', defaultMessage: 'Toot' },
-});
-
-export default @(component => injectIntl(component, { withRef: true }))
-class ColumnsArea extends ImmutablePureComponent {
+export default class ColumnsArea extends ImmutablePureComponent {
 
   static contextTypes = {
     router: PropTypes.object.isRequired,
   };
 
   static propTypes = {
-    intl: PropTypes.object.isRequired,
     columns: ImmutablePropTypes.list.isRequired,
     singleColumn: PropTypes.bool,
     children: PropTypes.node,
@@ -71,20 +54,13 @@ class ColumnsArea extends ImmutablePureComponent {
     openSettings: PropTypes.func,
   };
 
-   // Corresponds to (max-width: 600px + (285px * 1) + (10px * 1)) in SCSS
-   mediaQuery = 'matchMedia' in window && window.matchMedia('(max-width: 895px)');
+  // Corresponds to (max-width: $no-gap-breakpoint + 285px - 1px) in SCSS
+  mediaQuery = 'matchMedia' in window && window.matchMedia('(max-width: 1174px)');
 
   state = {
-    shouldAnimate: false,
     renderComposePanel: !(this.mediaQuery && this.mediaQuery.matches),
   }
 
-  componentWillReceiveProps() {
-    if (typeof this.pendingIndex !== 'number' && this.lastIndex !== getIndex(this.context.router.history.location.pathname)) {
-      this.setState({ shouldAnimate: false });
-    }
-  }
-
   componentDidMount() {
     if (!this.props.singleColumn) {
       this.node.addEventListener('wheel', this.handleWheel, supportsPassiveEvents ? { passive: true } : false);
@@ -99,10 +75,7 @@ class ColumnsArea extends ImmutablePureComponent {
       this.setState({ renderComposePanel: !this.mediaQuery.matches });
     }
 
-    this.lastIndex   = getIndex(this.context.router.history.location.pathname);
     this.isRtlLayout = document.getElementsByTagName('body')[0].classList.contains('rtl');
-
-    this.setState({ shouldAnimate: true });
   }
 
   componentWillUpdate(nextProps) {
@@ -115,13 +88,6 @@ class ColumnsArea extends ImmutablePureComponent {
     if (this.props.singleColumn !== prevProps.singleColumn && !this.props.singleColumn) {
       this.node.addEventListener('wheel', this.handleWheel, supportsPassiveEvents ? { passive: true } : false);
     }
-
-    const newIndex = getIndex(this.context.router.history.location.pathname);
-
-    if (this.lastIndex !== newIndex) {
-      this.lastIndex = newIndex;
-      this.setState({ shouldAnimate: true });
-    }
   }
 
   componentWillUnmount () {
@@ -149,31 +115,6 @@ class ColumnsArea extends ImmutablePureComponent {
     this.setState({ renderComposePanel: !e.matches });
   }
 
-  handleSwipe = (index) => {
-    this.pendingIndex = index;
-
-    const nextLinkTranslationId = links[index].props['data-preview-title-id'];
-    const currentLinkSelector = '.tabs-bar__link.active';
-    const nextLinkSelector = `.tabs-bar__link[data-preview-title-id="${nextLinkTranslationId}"]`;
-
-    // HACK: Remove the active class from the current link and set it to the next one
-    // React-router does this for us, but too late, feeling laggy.
-    document.querySelector(currentLinkSelector).classList.remove('active');
-    document.querySelector(nextLinkSelector).classList.add('active');
-
-    if (!this.state.shouldAnimate && typeof this.pendingIndex === 'number') {
-      this.context.router.history.push(getLink(this.pendingIndex));
-      this.pendingIndex = null;
-    }
-  }
-
-  handleAnimationEnd = () => {
-    if (typeof this.pendingIndex === 'number') {
-      this.context.router.history.push(getLink(this.pendingIndex));
-      this.pendingIndex = null;
-    }
-  }
-
   handleWheel = () => {
     if (typeof this._interruptScrollAnimation !== 'function') {
       return;
@@ -186,47 +127,19 @@ class ColumnsArea extends ImmutablePureComponent {
     this.node = node;
   }
 
-  renderView = (link, index) => {
-    const columnIndex = getIndex(this.context.router.history.location.pathname);
-    const title = this.props.intl.formatMessage({ id: link.props['data-preview-title-id'] });
-    const icon = link.props['data-preview-icon'];
-
-    const view = (index === columnIndex) ?
-      React.cloneElement(this.props.children) :
-      <ColumnLoading title={title} icon={icon} />;
-
-    return (
-      <div className='columns-area columns-area--mobile' key={index}>
-        {view}
-      </div>
-    );
-  }
-
   renderLoading = columnId => () => {
-    return columnId === 'COMPOSE' ? <DrawerLoading /> : <ColumnLoading />;
+    return columnId === 'COMPOSE' ? <DrawerLoading /> : <ColumnLoading multiColumn />;
   }
 
   renderError = (props) => {
-    return <BundleColumnError {...props} />;
+    return <BundleColumnError multiColumn errorType='network' {...props} />;
   }
 
   render () {
-    const { columns, children, singleColumn, intl, navbarUnder, openSettings } = this.props;
-    const { shouldAnimate, renderComposePanel } = this.state;
-
-    const columnIndex = getIndex(this.context.router.history.location.pathname);
+    const { columns, children, singleColumn, navbarUnder, openSettings } = this.props;
+    const { renderComposePanel } = this.state;
 
     if (singleColumn) {
-      const floatingActionButton = shouldHideFAB(this.context.router.history.location.pathname) ? null : <Link key='floating-action-button' to='/publish' className='floating-action-button' aria-label={intl.formatMessage(messages.publish)}><Icon id='pencil' /></Link>;
-
-      const content = columnIndex !== -1 ? (
-        <ReactSwipeableViews key='content' hysteresis={0.2} threshold={15} index={columnIndex} onChangeIndex={this.handleSwipe} onTransitionEnd={this.handleAnimationEnd} animateTransitions={shouldAnimate} springConfig={{ duration: '400ms', delay: '0s', easeFunction: 'ease' }} style={{ height: '100%' }} disabled={disableSwiping}>
-          {links.map(this.renderView)}
-        </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'>
@@ -235,10 +148,9 @@ class ColumnsArea extends ImmutablePureComponent {
             </div>
           </div>
 
-          <div className={`columns-area__panels__main ${floatingActionButton && 'with-fab'}`}>
-            {!navbarUnder && <TabsBar key='tabs' />}
-            {content}
-            {navbarUnder && <TabsBar key='tabs' />}
+          <div className='columns-area__panels__main'>
+            <div className='tabs-bar__wrapper'><div id='tabs-bar__portal' /></div>
+            <div className='columns-area columns-area--mobile'>{children}</div>
           </div>
 
           <div className='columns-area__panels__pane columns-area__panels__pane--start columns-area__panels__pane--navigational'>
@@ -246,8 +158,6 @@ class ColumnsArea extends ImmutablePureComponent {
               <NavigationPanel onOpenSettings={openSettings} />
             </div>
           </div>
-
-          {floatingActionButton}
         </div>
       );
     }
diff --git a/app/javascript/flavours/glitch/features/ui/components/compare_history_modal.js b/app/javascript/flavours/glitch/features/ui/components/compare_history_modal.js
index 8fd528da0..baf7f25be 100644
--- a/app/javascript/flavours/glitch/features/ui/components/compare_history_modal.js
+++ b/app/javascript/flavours/glitch/features/ui/components/compare_history_modal.js
@@ -4,7 +4,7 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
 import { connect } from 'react-redux';
 import { FormattedMessage } from 'react-intl';
 import { closeModal } from 'flavours/glitch/actions/modal';
-import emojify from 'flavours/glitch/util/emoji';
+import emojify from 'flavours/glitch/features/emoji/emoji';
 import escapeTextContentForBrowser from 'escape-html';
 import InlineAccount from 'flavours/glitch/components/inline_account';
 import IconButton from 'flavours/glitch/components/icon_button';
diff --git a/app/javascript/flavours/glitch/features/ui/components/compose_panel.js b/app/javascript/flavours/glitch/features/ui/components/compose_panel.js
index 498f09ab6..dde252a61 100644
--- a/app/javascript/flavours/glitch/features/ui/components/compose_panel.js
+++ b/app/javascript/flavours/glitch/features/ui/components/compose_panel.js
@@ -1,16 +1,58 @@
 import React from 'react';
+import { connect } from 'react-redux';
+import PropTypes from 'prop-types';
 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';
+import ServerBanner from 'flavours/glitch/components/server_banner';
+import { mountCompose, unmountCompose } from 'flavours/glitch/actions/compose';
 
-const ComposePanel = () => (
-  <div className='compose-panel'>
-    <SearchContainer openInRoute />
-    <NavigationContainer />
-    <ComposeFormContainer singleColumn />
-    <LinkFooter withHotkeys />
-  </div>
-);
+export default @connect()
+class ComposePanel extends React.PureComponent {
 
-export default ComposePanel;
+  static contextTypes = {
+    identity: PropTypes.object.isRequired,
+  };
+
+  static propTypes = {
+    dispatch: PropTypes.func.isRequired,
+  };
+
+  componentDidMount () {
+    const { dispatch } = this.props;
+    dispatch(mountCompose());
+  }
+
+  componentWillUnmount () {
+    const { dispatch } = this.props;
+    dispatch(unmountCompose());
+  }
+
+  render() {
+    const { signedIn } = this.context.identity;
+
+    return (
+      <div className='compose-panel'>
+        <SearchContainer openInRoute />
+
+        {!signedIn && (
+          <React.Fragment>
+            <ServerBanner />
+            <div className='flex-spacer' />
+          </React.Fragment>
+        )}
+
+        {signedIn && (
+          <React.Fragment>
+            <NavigationContainer />
+            <ComposeFormContainer singleColumn />
+          </React.Fragment>
+        )}
+
+        <LinkFooter />
+      </div>
+    );
+  }
+
+};
diff --git a/app/javascript/flavours/glitch/features/ui/components/deprecated_settings_modal.js b/app/javascript/flavours/glitch/features/ui/components/deprecated_settings_modal.js
index 9cb5a30b9..68f04cb21 100644
--- a/app/javascript/flavours/glitch/features/ui/components/deprecated_settings_modal.js
+++ b/app/javascript/flavours/glitch/features/ui/components/deprecated_settings_modal.js
@@ -2,7 +2,7 @@ import React from 'react';
 import PropTypes from 'prop-types';
 import ImmutablePropTypes from 'react-immutable-proptypes';
 import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
-import { preferenceLink } from 'flavours/glitch/util/backend_links';
+import { preferenceLink } from 'flavours/glitch/utils/backend_links';
 import Button from 'flavours/glitch/components/button';
 import Icon from 'flavours/glitch/components/icon';
 import illustration from 'flavours/glitch/images/logo_warn_glitch.svg';
diff --git a/app/javascript/flavours/glitch/features/ui/components/disabled_account_banner.js b/app/javascript/flavours/glitch/features/ui/components/disabled_account_banner.js
new file mode 100644
index 000000000..c861d4d81
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/ui/components/disabled_account_banner.js
@@ -0,0 +1,92 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { connect } from 'react-redux';
+import { Link } from 'react-router-dom';
+import { FormattedMessage, defineMessages, injectIntl } from 'react-intl';
+import { disabledAccountId, movedToAccountId, domain } from 'flavours/glitch/initial_state';
+import { openModal } from 'flavours/glitch/actions/modal';
+import { logOut } from 'flavours/glitch/utils/log_out';
+
+const messages = defineMessages({
+  logoutMessage: { id: 'confirmations.logout.message', defaultMessage: 'Are you sure you want to log out?' },
+  logoutConfirm: { id: 'confirmations.logout.confirm', defaultMessage: 'Log out' },
+});
+
+const mapStateToProps = (state) => ({
+  disabledAcct: state.getIn(['accounts', disabledAccountId, 'acct']),
+  movedToAcct: movedToAccountId ? state.getIn(['accounts', movedToAccountId, 'acct']) : undefined,
+});
+
+const mapDispatchToProps = (dispatch, { intl }) => ({
+  onLogout () {
+    dispatch(openModal('CONFIRM', {
+      message: intl.formatMessage(messages.logoutMessage),
+      confirm: intl.formatMessage(messages.logoutConfirm),
+      closeWhenConfirm: false,
+      onConfirm: () => logOut(),
+    }));
+  },
+});
+
+export default @injectIntl
+@connect(mapStateToProps, mapDispatchToProps)
+class DisabledAccountBanner extends React.PureComponent {
+
+  static propTypes = {
+    disabledAcct: PropTypes.string.isRequired,
+    movedToAcct: PropTypes.string,
+    onLogout: PropTypes.func.isRequired,
+    intl: PropTypes.object.isRequired,
+  };
+
+  handleLogOutClick = e => {
+    e.preventDefault();
+    e.stopPropagation();
+
+    this.props.onLogout();
+
+    return false;
+  }
+
+  render () {
+    const { disabledAcct, movedToAcct } = this.props;
+
+    const disabledAccountLink = (
+      <Link to={`/@${disabledAcct}`}>
+        {disabledAcct}@{domain}
+      </Link>
+    );
+
+    return (
+      <div className='sign-in-banner'>
+        <p>
+          {movedToAcct ? (
+            <FormattedMessage
+              id='moved_to_account_banner.text'
+              defaultMessage='Your account {disabledAccount} is currently disabled because you moved to {movedToAccount}.'
+              values={{
+                disabledAccount: disabledAccountLink,
+                movedToAccount: <Link to={`/@${movedToAcct}`}>{movedToAcct.includes('@') ? movedToAcct : `${movedToAcct}@${domain}`}</Link>,
+              }}
+            />
+          ) : (
+            <FormattedMessage
+              id='disabled_account_banner.text'
+              defaultMessage='Your account {disabledAccount} is currently disabled.'
+              values={{
+                disabledAccount: disabledAccountLink,
+              }}
+            />
+          )}
+        </p>
+        <a href='/auth/edit' className='button button--block'>
+          <FormattedMessage id='disabled_account_banner.account_settings' defaultMessage='Account settings' />
+        </a>
+        <button type='button' className='button button--block button-tertiary' onClick={this.handleLogOutClick}>
+          <FormattedMessage id='confirmations.logout.confirm' defaultMessage='Log out' />
+        </button>
+      </div>
+    );
+  }
+
+};
diff --git a/app/javascript/flavours/glitch/features/ui/components/embed_modal.js b/app/javascript/flavours/glitch/features/ui/components/embed_modal.js
index b6f5e628d..624b68f7e 100644
--- a/app/javascript/flavours/glitch/features/ui/components/embed_modal.js
+++ b/app/javascript/flavours/glitch/features/ui/components/embed_modal.js
@@ -2,7 +2,7 @@ import React from 'react';
 import PropTypes from 'prop-types';
 import ImmutablePureComponent from 'react-immutable-pure-component';
 import { defineMessages, FormattedMessage, injectIntl } from 'react-intl';
-import api from 'flavours/glitch/util/api';
+import api from 'flavours/glitch/api';
 import IconButton from 'flavours/glitch/components/icon_button';
 
 const messages = defineMessages({
diff --git a/app/javascript/flavours/glitch/features/ui/components/favourite_modal.js b/app/javascript/flavours/glitch/features/ui/components/favourite_modal.js
index 4d02be29b..d7f671d58 100644
--- a/app/javascript/flavours/glitch/features/ui/components/favourite_modal.js
+++ b/app/javascript/flavours/glitch/features/ui/components/favourite_modal.js
@@ -58,11 +58,11 @@ class FavouriteModal extends ImmutablePureComponent {
     const { status, intl } = this.props;
 
     return (
-      <div className='modal-root__modal favourite-modal'>
-        <div className='favourite-modal__container'>
+      <div className='modal-root__modal boost-modal'>
+        <div className='boost-modal__container'>
           <div className={classNames('status', `status-${status.get('visibility')}`, 'light')}>
-            <div className='favourite-modal__status-header'>
-              <div className='favourite-modal__status-time'>
+            <div className='boost-modal__status-header'>
+              <div className='boost-modal__status-time'>
                 <a href={status.get('url')} className='status__relative-time' target='_blank' rel='noopener noreferrer'>
                   <VisibilityIcon visibility={status.get('visibility')} />
                   <RelativeTimestamp timestamp={status.get('created_at')} />
@@ -90,7 +90,7 @@ class FavouriteModal extends ImmutablePureComponent {
           </div>
         </div>
 
-        <div className='favourite-modal__action-bar'>
+        <div className='boost-modal__action-bar'>
           <div><FormattedMessage id='favourite_modal.combo' defaultMessage='You can press {combo} to skip this next time' values={{ combo: <span>Shift + <Icon id='star' /></span> }} /></div>
           <Button text={intl.formatMessage(messages.favourite)} onClick={this.handleFavourite} ref={this.setRef} />
         </div>
diff --git a/app/javascript/flavours/glitch/features/ui/components/focal_point_modal.js b/app/javascript/flavours/glitch/features/ui/components/focal_point_modal.js
index 5a4baa5a1..de330b3a1 100644
--- a/app/javascript/flavours/glitch/features/ui/components/focal_point_modal.js
+++ b/app/javascript/flavours/glitch/features/ui/components/focal_point_modal.js
@@ -15,14 +15,14 @@ import Textarea from 'react-textarea-autosize';
 import UploadProgress from 'flavours/glitch/features/compose/components/upload_progress';
 import CharacterCounter from 'flavours/glitch/features/compose/components/character_counter';
 import { length } from 'stringz';
-import { Tesseract as fetchTesseract } from 'flavours/glitch/util/async-components';
+import { Tesseract as fetchTesseract } from 'flavours/glitch/features/ui/util/async-components';
 import GIFV from 'flavours/glitch/components/gifv';
-import { me } from 'flavours/glitch/util/initial_state';
+import { me } from 'flavours/glitch/initial_state';
 // eslint-disable-next-line import/no-extraneous-dependencies
 import tesseractCorePath from 'tesseract.js-core/tesseract-core.wasm.js';
 // eslint-disable-next-line import/extensions
 import tesseractWorkerPath from 'tesseract.js/dist/worker.min.js';
-import { assetHost } from 'flavours/glitch/util/config';
+import { assetHost } from 'flavours/glitch/utils/config';
 
 const messages = defineMessages({
   close: { id: 'lightbox.close', defaultMessage: 'Close' },
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_column_link.js
index c30427896..301392a52 100644
--- a/app/javascript/flavours/glitch/features/ui/components/follow_requests_nav_link.js
+++ b/app/javascript/flavours/glitch/features/ui/components/follow_requests_column_link.js
@@ -2,22 +2,27 @@ 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 ColumnLink from 'flavours/glitch/features/ui/components/column_link';
 import IconWithBadge from 'flavours/glitch/components/icon_with_badge';
 import { List as ImmutableList } from 'immutable';
-import { FormattedMessage } from 'react-intl';
+import { injectIntl, defineMessages } from 'react-intl';
+
+const messages = defineMessages({
+  text: { id: 'navigation_bar.follow_requests', defaultMessage: 'Follow requests' },
+});
 
 const mapStateToProps = state => ({
   count: state.getIn(['user_lists', 'follow_requests', 'items'], ImmutableList()).size,
 });
 
-export default @withRouter
+export default @injectIntl
 @connect(mapStateToProps)
-class FollowRequestsNavLink extends React.Component {
+class FollowRequestsColumnLink extends React.Component {
 
   static propTypes = {
     dispatch: PropTypes.func.isRequired,
     count: PropTypes.number.isRequired,
+    intl: PropTypes.object.isRequired,
   };
 
   componentDidMount () {
@@ -27,13 +32,20 @@ class FollowRequestsNavLink extends React.Component {
   }
 
   render () {
-    const { count } = this.props;
+    const { count, intl } = this.props;
 
     if (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>;
+    return (
+      <ColumnLink
+        transparent
+        to='/follow_requests'
+        icon={<IconWithBadge className='column-link__icon' id='user-plus' count={count} />}
+        text={intl.formatMessage(messages.text)}
+      />
+    );
   }
 
 }
diff --git a/app/javascript/flavours/glitch/features/ui/components/header.js b/app/javascript/flavours/glitch/features/ui/components/header.js
new file mode 100644
index 000000000..6c2fb40ba
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/ui/components/header.js
@@ -0,0 +1,63 @@
+import React from 'react';
+import Logo from 'flavours/glitch/components/logo';
+import { Link, withRouter } from 'react-router-dom';
+import { FormattedMessage } from 'react-intl';
+import { registrationsOpen, me } from 'flavours/glitch/initial_state';
+import Avatar from 'flavours/glitch/components/avatar';
+import Permalink from 'flavours/glitch/components/permalink';
+import PropTypes from 'prop-types';
+import { connect } from 'react-redux';
+
+const Account = connect(state => ({
+  account: state.getIn(['accounts', me]),
+}))(({ account }) => (
+  <Permalink href={account.get('url')} to={`/@${account.get('acct')}`} title={account.get('acct')}>
+    <Avatar account={account} size={35} />
+  </Permalink>
+));
+
+export default @withRouter
+class Header extends React.PureComponent {
+
+  static contextTypes = {
+    identity: PropTypes.object,
+  };
+
+  static propTypes = {
+    location: PropTypes.object,
+  };
+
+  render () {
+    const { signedIn } = this.context.identity;
+    const { location } = this.props;
+
+    let content;
+
+    if (signedIn) {
+      content = (
+        <>
+          {location.pathname !== '/publish' && <Link to='/publish' className='button'><FormattedMessage id='compose_form.publish' defaultMessage='Publish' /></Link>}
+          <Account />
+        </>
+      );
+    } else {
+      content = (
+        <>
+          <a href='/auth/sign_in' className='button'><FormattedMessage id='sign_in_banner.sign_in' defaultMessage='Sign in' /></a>
+          <a href={registrationsOpen ? '/auth/sign_up' : 'https://joinmastodon.org/servers'} className='button button-tertiary'><FormattedMessage id='sign_in_banner.create_account' defaultMessage='Create account' /></a>
+        </>
+      );
+    }
+
+    return (
+      <div className='ui__header'>
+        <Link to='/' className='ui__header__logo'><Logo /></Link>
+
+        <div className='ui__header__links'>
+          {content}
+        </div>
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/ui/components/image_modal.js b/app/javascript/flavours/glitch/features/ui/components/image_modal.js
new file mode 100644
index 000000000..a792b9be7
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/ui/components/image_modal.js
@@ -0,0 +1,59 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import classNames from 'classnames';
+import { defineMessages, injectIntl } from 'react-intl';
+import IconButton from 'flavours/glitch/components/icon_button';
+import ImageLoader from './image_loader';
+
+const messages = defineMessages({
+  close: { id: 'lightbox.close', defaultMessage: 'Close' },
+});
+
+export default @injectIntl
+class ImageModal extends React.PureComponent {
+
+  static propTypes = {
+    src: PropTypes.string.isRequired,
+    alt: PropTypes.string.isRequired,
+    onClose: PropTypes.func.isRequired,
+    intl: PropTypes.object.isRequired,
+  };
+
+  state = {
+    navigationHidden: false,
+  };
+
+  toggleNavigation = () => {
+    this.setState(prevState => ({
+      navigationHidden: !prevState.navigationHidden,
+    }));
+  };
+
+  render () {
+    const { intl, src, alt, onClose } = this.props;
+    const { navigationHidden } = this.state;
+
+    const navigationClassName = classNames('media-modal__navigation', {
+      'media-modal__navigation--hidden': navigationHidden,
+    });
+
+    return (
+      <div className='modal-root__modal media-modal'>
+        <div className='media-modal__closer' role='presentation' onClick={onClose} >
+          <ImageLoader
+            src={src}
+            width={400}
+            height={400}
+            alt={alt}
+            onClick={this.toggleNavigation}
+          />
+        </div>
+
+        <div className={navigationClassName}>
+          <IconButton className='media-modal__close' title={intl.formatMessage(messages.close)} icon='times' onClick={onClose} size={40} />
+        </div>
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/ui/components/link_footer.js b/app/javascript/flavours/glitch/features/ui/components/link_footer.js
index 040e967f2..075e04494 100644
--- a/app/javascript/flavours/glitch/features/ui/components/link_footer.js
+++ b/app/javascript/flavours/glitch/features/ui/components/link_footer.js
@@ -3,9 +3,8 @@ import React from 'react';
 import PropTypes from 'prop-types';
 import { FormattedMessage, defineMessages, injectIntl } from 'react-intl';
 import { Link } from 'react-router-dom';
-import { limitedFederationMode, version, repository, source_url } from 'flavours/glitch/util/initial_state';
-import { signOutLink, securityLink } from 'flavours/glitch/util/backend_links';
-import { logOut } from 'flavours/glitch/util/log_out';
+import { domain, version, source_url, profile_directory as profileDirectory } from 'flavours/glitch/initial_state';
+import { logOut } from 'flavours/glitch/utils/log_out';
 import { openModal } from 'flavours/glitch/actions/modal';
 import { PERMISSION_INVITE_USERS } from 'flavours/glitch/permissions';
 
@@ -43,34 +42,48 @@ class LinkFooter extends React.PureComponent {
     e.stopPropagation();
 
     this.props.onLogout();
- 
+
     return false;
   }
 
   render () {
+    const { signedIn, permissions } = this.context.identity;
+
+    const canInvite = signedIn && ((permissions & PERMISSION_INVITE_USERS) === PERMISSION_INVITE_USERS);
+    const canProfileDirectory = profileDirectory;
+
     return (
-      <div className='getting-started__footer'>
-        <ul>
-          {((this.context.identity.permissions & PERMISSION_INVITE_USERS) === PERMISSION_INVITE_USERS) && <li><a href='/invites' target='_blank'><FormattedMessage id='getting_started.invite' defaultMessage='Invite people' /></a> · </li>}
-          {!!securityLink && <li><a href='/auth/edit'><FormattedMessage id='getting_started.security' defaultMessage='Security' /></a> · </li>}
-          {!limitedFederationMode && <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} onClick={this.handleLogoutClick}><FormattedMessage id='navigation_bar.logout' defaultMessage='Logout' /></a></li>
-        </ul>
+      <div className='link-footer'>
+        <p>
+          <strong>{domain}</strong>:
+          {' '}
+          <Link key='about' to='/about'><FormattedMessage id='footer.about' defaultMessage='About' /></Link>
+          {canInvite && (
+            <>
+              {' · '}
+              <a key='invites' href='/invites' target='_blank'><FormattedMessage id='footer.invite' defaultMessage='Invite people' /></a>
+            </>
+          )}
+          {canProfileDirectory && (
+            <>
+              {' · '}
+              <Link key='directory' to='/directory'><FormattedMessage id='footer.directory' defaultMessage='Profiles directory' /></Link>
+            </>
+          )}
+          {' · '}
+          <Link key='privacy-policy' to='/privacy-policy'><FormattedMessage id='footer.privacy_policy' defaultMessage='Privacy policy' /></Link>
+        </p>
 
         <p>
-          <FormattedMessage
-            id='getting_started.open_source_notice'
-            defaultMessage='GlitchCafé is free open source software, based on {Glitchsoc} which is a friendly fork of {Mastodon}. You can see our source code on {github} and report bugs, request features, or contribute to the code by emailing {admin}.'
-            values={{
-              github: <span><a href='https://git.starfall.systems/pluralcafe/mastodon/' rel='noopener noreferrer' target='_blank'>our Git repository</a> (v{version})</span>,
-              Glitchsoc: <a href='https://github.com/glitch-soc/mastodon' rel='noopener noreferrer' target='_blank'>glitch-soc/mastodon</a>,
-              Mastodon: <a href='https://github.com/tootsuite/mastodon' rel='noopener noreferrer' target='_blank'>Mastodon</a>,
-			  admin: <a href='mailto://admin@plural.cafe' rel='noopener noreferrer'>admin@plural.cafe</a> }}
-          />
+          <strong>Mastodon</strong>:
+          {' '}
+          <a href='https://joinmastodon.org' target='_blank'><FormattedMessage id='footer.about' defaultMessage='About Mastodon' /></a>
+          {' · '}
+          <Link to='/keyboard-shortcuts'><FormattedMessage id='footer.keyboard_shortcuts' defaultMessage='Keyboard shortcuts' /></Link>
+          {' · '}
+          <a href={source_url} rel='noopener noreferrer' target='_blank'><FormattedMessage id='footer.source_code' defaultMessage='View source code' /></a>
+          {' · '}
+          v{version}
         </p>
       </div>
     );
diff --git a/app/javascript/flavours/glitch/features/ui/components/list_panel.js b/app/javascript/flavours/glitch/features/ui/components/list_panel.js
index e61234283..dff830065 100644
--- a/app/javascript/flavours/glitch/features/ui/components/list_panel.js
+++ b/app/javascript/flavours/glitch/features/ui/components/list_panel.js
@@ -1,12 +1,12 @@
 import React from 'react';
 import PropTypes from 'prop-types';
+import { createSelector } from 'reselect';
 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';
+import { withRouter } from 'react-router-dom';
+import { fetchLists } from 'flavours/glitch/actions/lists';
+import ColumnLink from './column_link';
 
 const getOrderedLists = createSelector([state => state.get('lists')], lists => {
   if (!lists) {
@@ -42,11 +42,11 @@ class ListPanel extends ImmutablePureComponent {
     }
 
     return (
-      <div>
+      <div className='list-panel'>
         <hr />
 
         {lists.map(list => (
-          <NavLink key={list.get('id')} className='column-link column-link--transparent' strict to={`/lists/${list.get('id')}`}><Icon className='column-link__icon' id='list-ul' fixedWidth />{list.get('title')}</NavLink>
+          <ColumnLink icon='list-ul' key={list.get('id')} strict text={list.get('title')} to={`/lists/${list.get('id')}`} transparent />
         ))}
       </div>
     );
diff --git a/app/javascript/flavours/glitch/features/ui/components/media_modal.js b/app/javascript/flavours/glitch/features/ui/components/media_modal.js
index baa5ff275..ec3af857d 100644
--- a/app/javascript/flavours/glitch/features/ui/components/media_modal.js
+++ b/app/javascript/flavours/glitch/features/ui/components/media_modal.js
@@ -12,7 +12,7 @@ import Icon from 'flavours/glitch/components/icon';
 import GIFV from 'flavours/glitch/components/gifv';
 import Footer from 'flavours/glitch/features/picture_in_picture/components/footer';
 import { getAverageFromBlurhash } from 'flavours/glitch/blurhash';
-import { disableSwiping } from 'flavours/glitch/util/initial_state';
+import { disableSwiping } from 'flavours/glitch/initial_state';
 
 const messages = defineMessages({
   close: { id: 'lightbox.close', defaultMessage: 'Close' },
diff --git a/app/javascript/flavours/glitch/features/ui/components/modal_root.js b/app/javascript/flavours/glitch/features/ui/components/modal_root.js
index 4df3a0dee..d2ee28948 100644
--- a/app/javascript/flavours/glitch/features/ui/components/modal_root.js
+++ b/app/javascript/flavours/glitch/features/ui/components/modal_root.js
@@ -1,6 +1,6 @@
 import React from 'react';
 import PropTypes from 'prop-types';
-import { getScrollbarWidth } from 'flavours/glitch/util/scrollbar';
+import { getScrollbarWidth } from 'flavours/glitch/utils/scrollbar';
 import Base from 'flavours/glitch/components/modal_root';
 import BundleContainer from '../containers/bundle_container';
 import BundleModalError from './bundle_modal_error';
@@ -15,6 +15,7 @@ import DoodleModal from './doodle_modal';
 import ConfirmationModal from './confirmation_modal';
 import FocalPointModal from './focal_point_modal';
 import DeprecatedSettingsModal from './deprecated_settings_modal';
+import ImageModal from './image_modal';
 import {
   OnboardingModal,
   MuteModal,
@@ -27,13 +28,18 @@ import {
   PinnedAccountsEditor,
   CompareHistoryModal,
   FilterModal,
-} from 'flavours/glitch/util/async-components';
+  InteractionModal,
+  SubscribedLanguagesModal,
+  ClosedRegistrationsModal,
+} from 'flavours/glitch/features/ui/util/async-components';
+import { Helmet } from 'react-helmet';
 
 const MODAL_COMPONENTS = {
   'MEDIA': () => Promise.resolve({ default: MediaModal }),
   'ONBOARDING': OnboardingModal,
   'VIDEO': () => Promise.resolve({ default: VideoModal }),
   'AUDIO': () => Promise.resolve({ default: AudioModal }),
+  'IMAGE': () => Promise.resolve({ default: ImageModal }),
   'BOOST': () => Promise.resolve({ default: BoostModal }),
   'FAVOURITE': () => Promise.resolve({ default: FavouriteModal }),
   'DOODLE': () => Promise.resolve({ default: DoodleModal }),
@@ -51,6 +57,9 @@ const MODAL_COMPONENTS = {
   'PINNED_ACCOUNTS_EDITOR': PinnedAccountsEditor,
   'COMPARE_HISTORY': CompareHistoryModal,
   'FILTER': FilterModal,
+  'SUBSCRIBED_LANGUAGES': SubscribedLanguagesModal,
+  'INTERACTION': InteractionModal,
+  'CLOSED_REGISTRATIONS': ClosedRegistrationsModal,
 };
 
 export default class ModalRoot extends React.PureComponent {
@@ -115,9 +124,15 @@ export default class ModalRoot extends React.PureComponent {
     return (
       <Base backgroundColor={backgroundColor} onClose={this.handleClose} noEsc={props ? props.noEsc : false} ignoreFocus={ignoreFocus}>
         {visible && (
-          <BundleContainer fetchComponent={MODAL_COMPONENTS[type]} loading={this.renderLoading(type)} error={this.renderError} renderDelay={200}>
-            {(SpecificComponent) => <SpecificComponent {...props} onChangeBackgroundColor={this.setBackgroundColor} onClose={this.handleClose} ref={this.setModalRef} />}
-          </BundleContainer>
+          <>
+            <BundleContainer fetchComponent={MODAL_COMPONENTS[type]} loading={this.renderLoading(type)} error={this.renderError} renderDelay={200}>
+              {(SpecificComponent) => <SpecificComponent {...props} onChangeBackgroundColor={this.setBackgroundColor} onClose={this.handleClose} ref={this.setModalRef} />}
+            </BundleContainer>
+
+            <Helmet>
+              <meta name='robots' content='noindex' />
+            </Helmet>
+          </>
         )}
       </Base>
     );
diff --git a/app/javascript/flavours/glitch/features/ui/components/navigation_panel.js b/app/javascript/flavours/glitch/features/ui/components/navigation_panel.js
index 2dcd535ca..3b46c6eec 100644
--- a/app/javascript/flavours/glitch/features/ui/components/navigation_panel.js
+++ b/app/javascript/flavours/glitch/features/ui/components/navigation_panel.js
@@ -1,37 +1,104 @@
 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, showTrends } from 'flavours/glitch/util/initial_state';
-import { preferencesLink, relationshipsLink } from 'flavours/glitch/util/backend_links';
-import NotificationsCounterIcon from './notifications_counter_icon';
-import FollowRequestsNavLink from './follow_requests_nav_link';
+import PropTypes from 'prop-types';
+import { defineMessages, injectIntl } from 'react-intl';
+import { Link } from 'react-router-dom';
+import { timelinePreview, showTrends } from 'flavours/glitch/initial_state';
+import ColumnLink from 'flavours/glitch/features/ui/components/column_link';
+import DisabledAccountBanner from './disabled_account_banner';
+import FollowRequestsColumnLink from './follow_requests_column_link';
 import ListPanel from './list_panel';
-import TrendsContainer from 'flavours/glitch/features/getting_started/containers/trends_container';
-
-const NavigationPanel = ({ onOpenSettings }) => (
-  <div className='navigation-panel'>
-    <NavLink className='column-link column-link--transparent' to='/home' data-preview-title-id='column.home' data-preview-icon='home' ><Icon className='column-link__icon' id='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='/public/local' data-preview-title-id='column.community' data-preview-icon='users' ><Icon className='column-link__icon' id='users' fixedWidth /><FormattedMessage id='tabs_bar.local_timeline' defaultMessage='Local' /></NavLink>
-    <NavLink className='column-link column-link--transparent' exact to='/public' data-preview-title-id='column.public' data-preview-icon='globe' ><Icon className='column-link__icon' id='globe' fixedWidth /><FormattedMessage id='tabs_bar.federated_timeline' defaultMessage='Federated' /></NavLink>
-    <NavLink className='column-link column-link--transparent' to='/conversations'><Icon className='column-link__icon' id='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' id='bookmark' fixedWidth /><FormattedMessage id='navigation_bar.bookmarks' defaultMessage='Bookmarks' /></NavLink>
-    {profile_directory && <NavLink className='column-link column-link--transparent' to='/directory'><Icon className='column-link__icon' id='address-book-o' fixedWidth /><FormattedMessage id='getting_started.directory' defaultMessage='Profile directory' /></NavLink>}
-    <NavLink className='column-link column-link--transparent' to='/lists'><Icon className='column-link__icon' id='list-ul' fixedWidth /><FormattedMessage id='navigation_bar.lists' defaultMessage='Lists' /></NavLink>
-
-    <ListPanel />
-
-    <hr />
-
-    {!!preferencesLink && <a className='column-link column-link--transparent' href={preferencesLink} target='_blank'><Icon className='column-link__icon' id='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' id='cogs' fixedWidth /><FormattedMessage id='navigation_bar.app_settings' defaultMessage='App settings' /></a>
-    {!!relationshipsLink && <a className='column-link column-link--transparent' href={relationshipsLink} target='_blank'><Icon className='column-link__icon' id='users' fixedWidth /><FormattedMessage id='navigation_bar.follows_and_followers' defaultMessage='Follows and followers' /></a>}
-
-    {showTrends && <div className='flex-spacer' />}
-    {showTrends && <TrendsContainer />}
-  </div>
-);
-
-export default withRouter(NavigationPanel);
+import NotificationsCounterIcon from './notifications_counter_icon';
+import SignInBanner from './sign_in_banner';
+import { preferencesLink, relationshipsLink } from 'flavours/glitch/utils/backend_links';
+import NavigationPortal from 'flavours/glitch/components/navigation_portal';
+
+const messages = defineMessages({
+  home: { id: 'tabs_bar.home', defaultMessage: 'Home' },
+  notifications: { id: 'tabs_bar.notifications', defaultMessage: 'Notifications' },
+  explore: { id: 'explore.title', defaultMessage: 'Explore' },
+  local: { id: 'tabs_bar.local_timeline', defaultMessage: 'Local' },
+  federated: { id: 'tabs_bar.federated_timeline', defaultMessage: 'Federated' },
+  direct: { id: 'navigation_bar.direct', defaultMessage: 'Direct messages' },
+  favourites: { id: 'navigation_bar.favourites', defaultMessage: 'Favourites' },
+  bookmarks: { id: 'navigation_bar.bookmarks', defaultMessage: 'Bookmarks' },
+  lists: { id: 'navigation_bar.lists', defaultMessage: 'Lists' },
+  preferences: { id: 'navigation_bar.preferences', defaultMessage: 'Preferences' },
+  followsAndFollowers: { id: 'navigation_bar.follows_and_followers', defaultMessage: 'Follows and followers' },
+  about: { id: 'navigation_bar.about', defaultMessage: 'About' },
+  search: { id: 'navigation_bar.search', defaultMessage: 'Search' },
+  app_settings: { id: 'navigation_bar.app_settings', defaultMessage: 'App settings' },
+});
+
+export default @injectIntl
+class NavigationPanel extends React.Component {
+
+  static contextTypes = {
+    router: PropTypes.object.isRequired,
+    identity: PropTypes.object.isRequired,
+  };
+
+  static propTypes = {
+    onOpenSettings: PropTypes.func,
+  };
+
+  render() {
+    const { intl, onOpenSettings } = this.props;
+    const { signedIn, disabledAccountId } = this.context.identity;
+
+    return (
+      <div className='navigation-panel'>
+        {signedIn && (
+          <React.Fragment>
+            <ColumnLink transparent to='/home' icon='home' text={intl.formatMessage(messages.home)} />
+            <ColumnLink transparent to='/notifications' icon={<NotificationsCounterIcon className='column-link__icon' />} text={intl.formatMessage(messages.notifications)} />
+            <FollowRequestsColumnLink />
+          </React.Fragment>
+        )}
+
+        {showTrends ? (
+          <ColumnLink transparent to='/explore' icon='hashtag' text={intl.formatMessage(messages.explore)} />
+        ) : (
+          <ColumnLink transparent to='/search' icon='search' text={intl.formatMessage(messages.search)} />
+        )}
+
+        {(signedIn || timelinePreview) && (
+          <>
+            <ColumnLink transparent to='/public/local' icon='users' text={intl.formatMessage(messages.local)} />
+            <ColumnLink transparent exact to='/public' icon='globe' text={intl.formatMessage(messages.federated)} />
+          </>
+        )}
+
+        {!signedIn && (
+          <div className='navigation-panel__sign-in-banner'>
+            <hr />
+            { disabledAccountId ? <DisabledAccountBanner /> : <SignInBanner /> }
+          </div>
+        )}
+
+        {signedIn && (
+          <React.Fragment>
+            <ColumnLink transparent to='/conversations' icon='at' text={intl.formatMessage(messages.direct)} />
+            <ColumnLink transparent to='/favourites' icon='star' text={intl.formatMessage(messages.favourites)} />
+            <ColumnLink transparent to='/bookmarks' icon='bookmark' text={intl.formatMessage(messages.bookmarks)} />
+            <ColumnLink transparent to='/lists' icon='list-ul' text={intl.formatMessage(messages.lists)} />
+
+            <ListPanel />
+
+            <hr />
+
+            {!!preferencesLink && <ColumnLink transparent href={preferencesLink} icon='cog' text={intl.formatMessage(messages.preferences)} />}
+            <ColumnLink transparent onClick={onOpenSettings} icon='cogs' text={intl.formatMessage(messages.app_settings)} />
+          </React.Fragment>
+        )}
+
+        <div className='navigation-panel__legal'>
+          <hr />
+          <ColumnLink transparent to='/about' icon='ellipsis-h' text={intl.formatMessage(messages.about)} />
+        </div>
+
+        <NavigationPortal />
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/ui/components/onboarding_modal.js b/app/javascript/flavours/glitch/features/ui/components/onboarding_modal.js
index 82a1ee4a4..5ca003ee9 100644
--- a/app/javascript/flavours/glitch/features/ui/components/onboarding_modal.js
+++ b/app/javascript/flavours/glitch/features/ui/components/onboarding_modal.js
@@ -10,7 +10,7 @@ import ComposeForm from 'flavours/glitch/features/compose/components/compose_for
 import DrawerAccount from 'flavours/glitch/features/compose/components/navigation_bar';
 import Search from 'flavours/glitch/features/compose/components/search';
 import ColumnHeader from './column_header';
-import { me, source_url } from 'flavours/glitch/util/initial_state';
+import { me, source_url } from 'flavours/glitch/initial_state';
 
 const noop = () => { };
 
@@ -144,13 +144,13 @@ const PageSix = ({ admin, domain }) => {
       <p>
 		  <FormattedMessage
 		    id='onboarding.page_six.github'
-		    defaultMessage='{domain} runs on GlitchCafé, which is based on {Glitchsoc}, a friendly {fork} of {Mastodon}. Glitchsoc is fully compatible with all Mastodon apps and instances. GlitchCafé is free open-source software. You can view the source code on {github} and report bugs, request features, or contribute to the code by emailing {admin}.'
+		    defaultMessage='{domain} runs on GlitchCafé, which is based on {Glitchsoc}, a friendly {fork} of {Mastodon}. GlitchCafé is fully compatible with all Mastodon apps and instances. GlitchCafé is free open-source software. You can view the source code on {git} and report bugs, request features, or contribute to the code by emailing {admin}.'
 		    values={{
 		      domain,
 		      fork: <a href='https://en.wikipedia.org/wiki/Fork_(software_development)' target='_blank' rel='noopener'>fork</a>,
-	          Mastodon: <a href='https://github.com/tootsuite/mastodon' target='_blank' rel='noopener'>Mastodon</a>,
+	          Mastodon: <a href='https://github.com/mastodon/mastodon' target='_blank' rel='noopener'>Mastodon</a>,
 		      Glitchsoc: <a href='https://github.com/glitch-soc/mastodon' target='_blank' rel='noopener'>GlitchSoc</a>,
-              github: <a href='https://git.starfall.systems/pluralcafe/mastodon/' rel='noopener noreferrer' target='_blank'>our Git repository</a>,
+              git: <a href='https://git.starfall.systems/pluralcafe/mastodon/' rel='noopener noreferrer' target='_blank'>our Git repository</a>,
 			  admin: <a href='mailto://admin@plural.cafe' rel='noopener noreferrer'>admin@plural.cafe</a> }}
 		  />
 	  </p>
diff --git a/app/javascript/flavours/glitch/features/ui/components/report_modal.js b/app/javascript/flavours/glitch/features/ui/components/report_modal.js
index dcbe94929..7b6a4a784 100644
--- a/app/javascript/flavours/glitch/features/ui/components/report_modal.js
+++ b/app/javascript/flavours/glitch/features/ui/components/report_modal.js
@@ -2,7 +2,7 @@ import React from 'react';
 import { connect } from 'react-redux';
 import { submitReport } from 'flavours/glitch/actions/reports';
 import { expandAccountTimeline } from 'flavours/glitch/actions/timelines';
-import { fetchRules } from 'flavours/glitch/actions/rules';
+import { fetchServer } from 'flavours/glitch/actions/server';
 import { fetchRelationships } from 'flavours/glitch/actions/accounts';
 import PropTypes from 'prop-types';
 import ImmutablePropTypes from 'react-immutable-proptypes';
@@ -119,7 +119,7 @@ class ReportModal extends ImmutablePureComponent {
 
     dispatch(fetchRelationships([accountId]));
     dispatch(expandAccountTimeline(accountId, { withReplies: true }));
-    dispatch(fetchRules());
+    dispatch(fetchServer());
   }
 
   render () {
diff --git a/app/javascript/flavours/glitch/features/ui/components/sign_in_banner.js b/app/javascript/flavours/glitch/features/ui/components/sign_in_banner.js
new file mode 100644
index 000000000..e8023803f
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/ui/components/sign_in_banner.js
@@ -0,0 +1,40 @@
+import React, { useCallback } from 'react';
+import { FormattedMessage } from 'react-intl';
+import { useDispatch } from 'react-redux';
+import { registrationsOpen } from 'flavours/glitch/initial_state';
+import { openModal } from 'flavours/glitch/actions/modal';
+
+const SignInBanner = () => {
+  const dispatch = useDispatch();
+
+  const openClosedRegistrationsModal = useCallback(
+    () => dispatch(openModal('CLOSED_REGISTRATIONS')),
+    [dispatch],
+  );
+
+  let signupButton;
+
+  if (registrationsOpen) {
+    signupButton = (
+      <a href='/auth/sign_up' className='button button--block button-tertiary'>
+        <FormattedMessage id='sign_in_banner.create_account' defaultMessage='Create account' />
+      </a>
+    );
+  } else {
+    signupButton = (
+      <button className='button button--block button-tertiary' onClick={openClosedRegistrationsModal}>
+        <FormattedMessage id='sign_in_banner.create_account' defaultMessage='Create account' />
+      </button>
+    );
+  }
+
+  return (
+    <div className='sign-in-banner'>
+      <p><FormattedMessage id='sign_in_banner.text' defaultMessage='Sign in to follow profiles or hashtags, favourite, share and reply to posts, or interact from your account on a different server.' /></p>
+      <a href='/auth/sign_in' className='button button--block'><FormattedMessage id='sign_in_banner.sign_in' defaultMessage='Sign in' /></a>
+      {signupButton}
+    </div>
+  );
+};
+
+export default SignInBanner;
diff --git a/app/javascript/flavours/glitch/features/ui/components/tabs_bar.js b/app/javascript/flavours/glitch/features/ui/components/tabs_bar.js
deleted file mode 100644
index 55cc84f5e..000000000
--- a/app/javascript/flavours/glitch/features/ui/components/tabs_bar.js
+++ /dev/null
@@ -1,86 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-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 Icon from 'flavours/glitch/components/icon';
-import NotificationsCounterIcon from './notifications_counter_icon';
-
-export const links = [
-  <NavLink className='tabs-bar__link' to='/home' data-preview-title-id='column.home' data-preview-icon='home' ><Icon id='home' fixedWidth /><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' to='/public/local' data-preview-title-id='column.community' data-preview-icon='users' ><Icon id='users' fixedWidth /><FormattedMessage id='tabs_bar.local_timeline' defaultMessage='Local' /></NavLink>,
-  <NavLink className='tabs-bar__link' exact to='/public' data-preview-title-id='column.public' data-preview-icon='globe' ><Icon id='globe' fixedWidth /><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' ><Icon id='search' fixedWidth /><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' ><Icon id='bars' fixedWidth /></NavLink>,
-];
-
-export function getIndex (path) {
-  return links.findIndex(link => link.props.to === path);
-}
-
-export function getLink (index) {
-  return links[index].props.to;
-}
-
-export default @injectIntl
-@withRouter
-class TabsBar extends React.PureComponent {
-
-  static propTypes = {
-    intl: PropTypes.object.isRequired,
-    history: PropTypes.object.isRequired,
-  }
-
-  setRef = ref => {
-    this.node = ref;
-  }
-
-  handleClick = (e) => {
-    // Only apply optimization for touch devices, which we assume are slower
-    // We thus avoid the 250ms delay for non-touch devices and the lag for touch devices
-    if (isUserTouching()) {
-      e.preventDefault();
-      e.persist();
-
-      requestAnimationFrame(() => {
-        const tabs = Array(...this.node.querySelectorAll('.tabs-bar__link'));
-        const currentTab = tabs.find(tab => tab.classList.contains('active'));
-        const nextTab = tabs.find(tab => tab.contains(e.target));
-        const { props: { to } } = links[Array(...this.node.childNodes).indexOf(nextTab)];
-
-
-        if (currentTab !== nextTab) {
-          if (currentTab) {
-            currentTab.classList.remove('active');
-          }
-
-          const listener = debounce(() => {
-            nextTab.removeEventListener('transitionend', listener);
-            this.props.history.push(to);
-          }, 50);
-
-          nextTab.addEventListener('transitionend', listener);
-          nextTab.classList.add('active');
-        }
-      });
-    }
-
-  }
-
-  render () {
-    const { intl: { formatMessage } } = this.props;
-
-    return (
-      <div className='tabs-bar__wrapper'>
-        <nav className='tabs-bar' ref={this.setRef}>
-          {links.map(link => React.cloneElement(link, { key: link.props.to, onClick: this.handleClick, 'aria-label': formatMessage({ id: link.props['data-preview-title-id'] }) }))}
-        </nav>
-
-        <div id='tabs-bar__portal' />
-      </div>
-    );
-  }
-
-}
diff --git a/app/javascript/flavours/glitch/features/ui/components/upload_area.js b/app/javascript/flavours/glitch/features/ui/components/upload_area.js
index 11a10baf1..6958ba9df 100644
--- a/app/javascript/flavours/glitch/features/ui/components/upload_area.js
+++ b/app/javascript/flavours/glitch/features/ui/components/upload_area.js
@@ -1,6 +1,6 @@
 import React from 'react';
 import PropTypes from 'prop-types';
-import Motion from 'flavours/glitch/util/optional_motion';
+import Motion from '../../ui/util/optional_motion';
 import spring from 'react-motion/lib/spring';
 import { FormattedMessage } from 'react-intl';
 
diff --git a/app/javascript/flavours/glitch/features/ui/containers/status_list_container.js b/app/javascript/flavours/glitch/features/ui/containers/status_list_container.js
index abcbf13db..ddb991d54 100644
--- a/app/javascript/flavours/glitch/features/ui/containers/status_list_container.js
+++ b/app/javascript/flavours/glitch/features/ui/containers/status_list_container.js
@@ -4,10 +4,10 @@ import { scrollTopTimeline, loadPending } from 'flavours/glitch/actions/timeline
 import { Map as ImmutableMap, List as ImmutableList } from 'immutable';
 import { createSelector } from 'reselect';
 import { debounce } from 'lodash';
-import { me } from 'flavours/glitch/util/initial_state';
+import { me } from 'flavours/glitch/initial_state';
 
 const getRegex = createSelector([
-  (state, { type }) => state.getIn(['settings', type, 'regex', 'body']),
+  (state, { regex }) => regex,
 ], (rawRegex) => {
   let regex = null;
 
@@ -25,8 +25,6 @@ const makeGetStatusIds = (pending = false) => createSelector([
   (state)           => state.get('statuses'),
   getRegex,
 ], (columnSettings, statusIds, statuses, regex) => {
-  const rawRegex = columnSettings.getIn(['regex', 'body'], '').trim();
-
   return statusIds.filter(id => {
     if (id === null) return true;
 
@@ -58,8 +56,8 @@ const makeMapStateToProps = () => {
   const getStatusIds = makeGetStatusIds();
   const getPendingStatusIds = makeGetStatusIds(true);
 
-  const mapStateToProps = (state, { timelineId }) => ({
-    statusIds: getStatusIds(state, { type: timelineId }),
+  const mapStateToProps = (state, { timelineId, regex }) => ({
+    statusIds: getStatusIds(state, { type: timelineId, regex }),
     isLoading: state.getIn(['timelines', timelineId, 'isLoading'], true),
     isPartial: state.getIn(['timelines', timelineId, 'isPartial'], false),
     hasMore:   state.getIn(['timelines', timelineId, 'hasMore']),
diff --git a/app/javascript/flavours/glitch/features/ui/index.js b/app/javascript/flavours/glitch/features/ui/index.js
index 2be6d9478..3d385eee2 100644
--- a/app/javascript/flavours/glitch/features/ui/index.js
+++ b/app/javascript/flavours/glitch/features/ui/index.js
@@ -4,16 +4,18 @@ import PropTypes from 'prop-types';
 import LoadingBarContainer from './containers/loading_bar_container';
 import ModalContainer from './containers/modal_container';
 import { connect } from 'react-redux';
-import { Redirect, withRouter } from 'react-router-dom';
-import { isMobile } from 'flavours/glitch/util/is_mobile';
+import { Redirect, Route, withRouter } from 'react-router-dom';
+import { layoutFromWindow } from 'flavours/glitch/is_mobile';
 import { debounce } from 'lodash';
 import { uploadCompose, resetCompose, changeComposeSpoilerness } from 'flavours/glitch/actions/compose';
 import { expandHomeTimeline } from 'flavours/glitch/actions/timelines';
 import { expandNotifications, notificationsSetVisibility } from 'flavours/glitch/actions/notifications';
-import { fetchRules } from 'flavours/glitch/actions/rules';
+import { fetchServer } from 'flavours/glitch/actions/server';
 import { clearHeight } from 'flavours/glitch/actions/height_cache';
+import { changeLayout } from 'flavours/glitch/actions/app';
 import { synchronouslySubmitMarkers, submitMarkers, fetchMarkers } from 'flavours/glitch/actions/markers';
-import { WrappedSwitch, WrappedRoute } from 'flavours/glitch/util/react_router_helpers';
+import { WrappedSwitch, WrappedRoute } from './util/react_router_helpers';
+import BundleColumnError from './components/bundle_column_error';
 import UploadArea from './components/upload_area';
 import PermaLink from 'flavours/glitch/components/permalink';
 import ColumnsAreaContainer from './containers/columns_area_container';
@@ -38,7 +40,6 @@ import {
   HashtagTimeline,
   Notifications,
   FollowRequests,
-  GenericNotFound,
   FavouritedStatuses,
   BookmarkedStatuses,
   ListTimeline,
@@ -47,15 +48,19 @@ import {
   Mutes,
   PinnedStatuses,
   Lists,
-  Search,
   GettingStartedMisc,
   Directory,
+  Explore,
   FollowRecommendations,
-} from 'flavours/glitch/util/async-components';
+  About,
+  PrivacyPolicy,
+} from './util/async-components';
 import { HotKeys } from 'react-hotkeys';
-import { me } from 'flavours/glitch/util/initial_state';
+import initialState, { me, owner, singleUserMode, showTrends } from '../../initial_state';
 import { closeOnboarding, INTRODUCTION_VERSION } from 'flavours/glitch/actions/onboarding';
 import { defineMessages, FormattedMessage, injectIntl } from 'react-intl';
+import { Helmet } from 'react-helmet';
+import Header from './components/header';
 
 // Dummy import, to make sure that <Status /> ends up in the application bundle.
 // Without this it ends up in ~8 very commonly used bundles.
@@ -66,10 +71,12 @@ const messages = defineMessages({
 });
 
 const mapStateToProps = state => ({
+  layout: state.getIn(['meta', 'layout']),
   hasComposingText: state.getIn(['compose', 'text']).trim().length !== 0,
   hasMediaAttachments: state.getIn(['compose', 'media_attachments']).size > 0,
   canUploadMore: !state.getIn(['compose', 'media_attachments']).some(x => ['audio', 'video'].includes(x.get('type'))) && state.getIn(['compose', 'media_attachments']).size < 4,
-  layout: state.getIn(['local_settings', 'layout']),
+  layout: state.getIn(['meta', 'layout']),
+  layout_local_setting: state.getIn(['local_settings', 'layout']),
   isWide: state.getIn(['local_settings', 'stretch']),
   navbarUnder: state.getIn(['local_settings', 'navbar_under']),
   dropdownMenuIsOpen: state.getIn(['dropdown_menu', 'openId']) !== null,
@@ -118,28 +125,19 @@ const keyMap = {
 
 class SwitchingColumnsArea extends React.PureComponent {
 
+  static contextTypes = {
+    identity: PropTypes.object,
+  };
+
   static propTypes = {
     children: PropTypes.node,
-    layout: PropTypes.string,
     location: PropTypes.object,
     navbarUnder: PropTypes.bool,
-    onLayoutChange: PropTypes.func.isRequired,
-  };
-
-  state = {
-    mobile: isMobile(window.innerWidth, this.props.layout),
+    mobile: PropTypes.bool,
   };
 
-  componentWillReceiveProps (nextProps) {
-    if (nextProps.layout !== this.props.layout) {
-      this.setState({ mobile: isMobile(window.innerWidth, nextProps.layout) });
-    }
-  }
-
   componentWillMount () {
-    window.addEventListener('resize', this.handleResize, { passive: true });
-
-    if (this.state.mobile) {
+    if (this.props.mobile) {
       document.body.classList.toggle('layout-single-column', true);
       document.body.classList.toggle('layout-multiple-columns', false);
     } else {
@@ -148,57 +146,52 @@ class SwitchingColumnsArea extends React.PureComponent {
     }
   }
 
-  componentDidUpdate (prevProps, prevState) {
+  componentDidUpdate (prevProps) {
     if (![this.props.location.pathname, '/'].includes(prevProps.location.pathname)) {
       this.node.handleChildrenContentChange();
     }
 
-    if (prevState.mobile !== this.state.mobile) {
-      document.body.classList.toggle('layout-single-column', this.state.mobile);
-      document.body.classList.toggle('layout-multiple-columns', !this.state.mobile);
-    }
-  }
-
-  componentWillUnmount () {
-    window.removeEventListener('resize', this.handleResize);
-  }
-
-  handleLayoutChange = debounce(() => {
-    // The cached heights are no longer accurate, invalidate
-    this.props.onLayoutChange();
-  }, 500, {
-    trailing: true,
-  })
-
-  handleResize = () => {
-    const mobile = isMobile(window.innerWidth, this.props.layout);
-
-    if (mobile !== this.state.mobile) {
-      this.handleLayoutChange.cancel();
-      this.props.onLayoutChange();
-      this.setState({ mobile });
-    } else {
-      this.handleLayoutChange();
+    if (prevProps.mobile !== this.props.mobile) {
+      document.body.classList.toggle('layout-single-column', this.props.mobile);
+      document.body.classList.toggle('layout-multiple-columns', !this.props.mobile);
     }
   }
 
   setRef = c => {
     if (c) {
-      this.node = c.getWrappedInstance();
+      this.node = c;
     }
   }
 
   render () {
-    const { children, navbarUnder } = this.props;
-    const singleColumn = this.state.mobile;
-    const redirect = singleColumn ? <Redirect from='/' to='/home' exact /> : <Redirect from='/' to='/getting-started' exact />;
+    const { children, mobile, navbarUnder } = this.props;
+    const { signedIn } = this.context.identity;
+
+    let redirect;
+
+    if (signedIn) {
+      if (mobile) {
+        redirect = <Redirect from='/' to='/home' exact />;
+      } else {
+        redirect = <Redirect from='/' to='/getting-started' exact />;
+      }
+    } else if (singleUserMode && owner && initialState?.accounts[owner]) {
+      redirect = <Redirect from='/' to={`/@${initialState.accounts[owner].username}`} exact />;
+    } else if (showTrends) {
+      redirect = <Redirect from='/' to='/explore' exact />;
+    } else {
+      redirect = <Redirect from='/' to='/about' exact />;
+    }
 
     return (
-      <ColumnsAreaContainer ref={this.setRef} singleColumn={singleColumn} navbarUnder={navbarUnder}>
+      <ColumnsAreaContainer ref={this.setRef} singleColumn={mobile} navbarUnder={navbarUnder}>
         <WrappedSwitch>
           {redirect}
+
           <WrappedRoute path='/getting-started' component={GettingStarted} content={children} />
           <WrappedRoute path='/keyboard-shortcuts' component={KeyboardShortcuts} content={children} />
+          <WrappedRoute path='/about' component={About} content={children} />
+          <WrappedRoute path='/privacy-policy' component={PrivacyPolicy} content={children} />
 
           <WrappedRoute path={['/home', '/timelines/home']} component={HomeTimeline} content={children} />
           <WrappedRoute path={['/public', '/timelines/public']} exact component={PublicTimeline} content={children} />
@@ -213,14 +206,15 @@ class SwitchingColumnsArea extends React.PureComponent {
           <WrappedRoute path='/pinned' component={PinnedStatuses} content={children} />
 
           <WrappedRoute path='/start' component={FollowRecommendations} content={children} />
-          <WrappedRoute path='/search' component={Search} content={children} />
           <WrappedRoute path='/directory' component={Directory} content={children} />
+          <WrappedRoute path={['/explore', '/search']} component={Explore} content={children} />
           <WrappedRoute path={['/publish', '/statuses/new']} component={Compose} content={children} />
 
           <WrappedRoute path={['/@:acct', '/accounts/:id']} exact component={AccountTimeline} content={children} />
+          <WrappedRoute path='/@:acct/tagged/:tagged?' exact component={AccountTimeline} content={children} />
           <WrappedRoute path={['/@:acct/with_replies', '/accounts/:id/with_replies']} component={AccountTimeline} content={children} componentParams={{ withReplies: true }} />
-          <WrappedRoute path={['/@:acct/followers', '/accounts/:id/followers']} component={Followers} content={children} />
-          <WrappedRoute path={['/@:acct/following', '/accounts/:id/following']} component={Following} content={children} />
+          <WrappedRoute path={['/accounts/:id/followers', '/users/:acct/followers', '/@:acct/followers']} component={Followers} content={children} />
+          <WrappedRoute path={['/accounts/:id/following', '/users/:acct/following', '/@:acct/following']} component={Following} content={children} />
           <WrappedRoute path={['/@:acct/media', '/accounts/:id/media']} component={AccountGallery} content={children} />
           <WrappedRoute path='/@:acct/:statusId' exact component={Status} content={children} />
           <WrappedRoute path='/@:acct/:statusId/reblogs' component={Reblogs} content={children} />
@@ -240,7 +234,7 @@ class SwitchingColumnsArea extends React.PureComponent {
           <WrappedRoute path='/lists' component={Lists} content={children} />
           <WrappedRoute path='/getting-started-misc' component={GettingStartedMisc} content={children} />
 
-          <WrappedRoute component={GenericNotFound} content={children} />
+          <Route component={BundleColumnError} />
         </WrappedSwitch>
       </ColumnsAreaContainer>
     );
@@ -253,10 +247,14 @@ export default @connect(mapStateToProps)
 @withRouter
 class UI extends React.Component {
 
+  static contextTypes = {
+    identity: PropTypes.object.isRequired,
+  };
+
   static propTypes = {
     dispatch: PropTypes.func.isRequired,
     children: PropTypes.node,
-    layout: PropTypes.string,
+    layout_local_setting: PropTypes.string,
     isWide: PropTypes.bool,
     systemFontUi: PropTypes.bool,
     navbarUnder: PropTypes.bool,
@@ -272,6 +270,7 @@ class UI extends React.Component {
     unreadNotifications: PropTypes.number,
     showFaviconBadge: PropTypes.bool,
     moved: PropTypes.map,
+    layout: PropTypes.string.isRequired,
     firstLaunch: PropTypes.bool,
     username: PropTypes.string,
   };
@@ -293,11 +292,6 @@ class UI extends React.Component {
     }
   }
 
-  handleLayoutChange = () => {
-    // The cached heights are no longer accurate, invalidate
-    this.props.dispatch(clearHeight());
-  }
-
   handleDragEnter = (e) => {
     e.preventDefault();
 
@@ -378,8 +372,29 @@ class UI extends React.Component {
     }
   }
 
-  componentWillMount () {
+  handleLayoutChange = debounce(() => {
+    this.props.dispatch(clearHeight()); // The cached heights are no longer accurate, invalidate
+  }, 500, {
+    trailing: true,
+  });
+
+  handleResize = () => {
+    const layout = layoutFromWindow(this.props.layout_local_setting);
+
+    if (layout !== this.props.layout) {
+      this.handleLayoutChange.cancel();
+      this.props.dispatch(changeLayout(layout));
+    } else {
+      this.handleLayoutChange();
+    }
+  }
+
+  componentDidMount () {
+    const { signedIn } = this.context.identity;
+
     window.addEventListener('beforeunload', this.handleBeforeUnload, false);
+    window.addEventListener('resize', this.handleResize, { passive: true });
+
     document.addEventListener('dragenter', this.handleDragEnter, false);
     document.addEventListener('dragover', this.handleDragOver, false);
     document.addEventListener('drop', this.handleDrop, false);
@@ -393,19 +408,19 @@ class UI extends React.Component {
     this.favicon = new Favico({ animation:"none" });
 
     // On first launch, redirect to the follow recommendations page
-    if (this.props.firstLaunch) {
+    if (signedIn && this.props.firstLaunch) {
       this.context.router.history.replace('/start');
       this.props.dispatch(closeOnboarding());
     }
 
-    this.props.dispatch(fetchMarkers());
-    this.props.dispatch(expandHomeTimeline());
-    this.props.dispatch(expandNotifications());
+    if (signedIn) {
+      this.props.dispatch(fetchMarkers());
+      this.props.dispatch(expandHomeTimeline());
+      this.props.dispatch(expandNotifications());
 
-    setTimeout(() => this.props.dispatch(fetchRules()), 3000);
-  }
+      setTimeout(() => this.props.dispatch(fetchServer()), 3000);
+    }
 
-  componentDidMount () {
     this.hotkeys.__mousetrap__.stopCallback = (e, element) => {
       return ['TEXTAREA', 'SELECT', 'INPUT'].includes(element.tagName);
     };
@@ -427,6 +442,19 @@ class UI extends React.Component {
     }
   }
 
+  componentWillReceiveProps (nextProps) {
+    if (nextProps.layout_local_setting !== this.props.layout_local_setting) {
+      const layout = layoutFromWindow(nextProps.layout_local_setting);
+
+      if (layout !== this.props.layout) {
+        this.handleLayoutChange.cancel();
+        this.props.dispatch(changeLayout(layout));
+      } else {
+        this.handleLayoutChange();
+      }
+    }
+  }
+
   componentDidUpdate (prevProps) {
     if (this.props.unreadNotifications != prevProps.unreadNotifications ||
         this.props.showFaviconBadge != prevProps.showFaviconBadge) {
@@ -446,6 +474,8 @@ class UI extends React.Component {
     }
 
     window.removeEventListener('beforeunload', this.handleBeforeUnload);
+    window.removeEventListener('resize', this.handleResize);
+
     document.removeEventListener('dragenter', this.handleDragEnter);
     document.removeEventListener('dragover', this.handleDragOver);
     document.removeEventListener('drop', this.handleDrop);
@@ -576,7 +606,7 @@ class UI extends React.Component {
 
   render () {
     const { draggingOver } = this.state;
-    const { children, layout, isWide, navbarUnder, location, dropdownMenuIsOpen, moved } = this.props;
+    const { children, isWide, navbarUnder, location, dropdownMenuIsOpen, layout, moved } = this.props;
 
     const columnsClass = layout => {
       switch (layout) {
@@ -632,11 +662,14 @@ class UI extends React.Component {
               )}}
             />
           </div>)}
-          <SwitchingColumnsArea location={location} layout={layout} navbarUnder={navbarUnder} onLayoutChange={this.handleLayoutChange}>
+
+          <Header />
+
+          <SwitchingColumnsArea location={location} mobile={layout === 'mobile' || layout === 'single-column'} navbarUnder={navbarUnder}>
             {children}
           </SwitchingColumnsArea>
 
-          <PictureInPicture />
+          {layout !== 'mobile' && <PictureInPicture />}
           <NotificationsContainer />
           <LoadingBarContainer className='loading-bar' />
           <ModalContainer />
diff --git a/app/javascript/flavours/glitch/features/ui/util/async-components.js b/app/javascript/flavours/glitch/features/ui/util/async-components.js
new file mode 100644
index 000000000..025b22e61
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/ui/util/async-components.js
@@ -0,0 +1,203 @@
+export function EmojiPicker () {
+  return import(/* webpackChunkName: "flavours/glitch/async/emoji_picker" */'flavours/glitch/features/emoji/emoji_picker');
+}
+
+export function Compose () {
+  return import(/* webpackChunkName: "flavours/glitch/async/compose" */'flavours/glitch/features/compose');
+}
+
+export function Notifications () {
+  return import(/* webpackChunkName: "flavours/glitch/async/notifications" */'flavours/glitch/features/notifications');
+}
+
+export function HomeTimeline () {
+  return import(/* webpackChunkName: "flavours/glitch/async/home_timeline" */'flavours/glitch/features/home_timeline');
+}
+
+export function PublicTimeline () {
+  return import(/* webpackChunkName: "flavours/glitch/async/public_timeline" */'flavours/glitch/features/public_timeline');
+}
+
+export function CommunityTimeline () {
+  return import(/* webpackChunkName: "flavours/glitch/async/community_timeline" */'flavours/glitch/features/community_timeline');
+}
+
+export function HashtagTimeline () {
+  return import(/* webpackChunkName: "flavours/glitch/async/hashtag_timeline" */'flavours/glitch/features/hashtag_timeline');
+}
+
+export function ListTimeline () {
+  return import(/* webpackChunkName: "flavours/glitch/async/list_timeline" */'flavours/glitch/features/list_timeline');
+}
+
+export function Lists () {
+  return import(/* webpackChunkName: "flavours/glitch/async/lists" */'flavours/glitch/features/lists');
+}
+
+export function ListEditor () {
+  return import(/* webpackChunkName: "flavours/glitch/async/list_editor" */'flavours/glitch/features/list_editor');
+}
+
+export function PinnedAccountsEditor () {
+  return import(/* webpackChunkName: "flavours/glitch/async/pinned_accounts_editor" */'flavours/glitch/features/pinned_accounts_editor');
+}
+
+export function DirectTimeline() {
+  return import(/* webpackChunkName: "flavours/glitch/async/direct_timeline" */'flavours/glitch/features/direct_timeline');
+}
+
+export function Status () {
+  return import(/* webpackChunkName: "flavours/glitch/async/status" */'flavours/glitch/features/status');
+}
+
+export function GettingStarted () {
+  return import(/* webpackChunkName: "flavours/glitch/async/getting_started" */'flavours/glitch/features/getting_started');
+}
+
+export function KeyboardShortcuts () {
+  return import(/* webpackChunkName: "flavours/glitch/async/keyboard_shortcuts" */'flavours/glitch/features/keyboard_shortcuts');
+}
+
+export function PinnedStatuses () {
+  return import(/* webpackChunkName: "flavours/glitch/async/pinned_statuses" */'flavours/glitch/features/pinned_statuses');
+}
+
+export function AccountTimeline () {
+  return import(/* webpackChunkName: "flavours/glitch/async/account_timeline" */'flavours/glitch/features/account_timeline');
+}
+
+export function AccountGallery () {
+  return import(/* webpackChunkName: "flavours/glitch/async/account_gallery" */'flavours/glitch/features/account_gallery');
+}
+
+export function Followers () {
+  return import(/* webpackChunkName: "flavours/glitch/async/followers" */'flavours/glitch/features/followers');
+}
+
+export function Following () {
+  return import(/* webpackChunkName: "flavours/glitch/async/following" */'flavours/glitch/features/following');
+}
+
+export function Reblogs () {
+  return import(/* webpackChunkName: "flavours/glitch/async/reblogs" */'flavours/glitch/features/reblogs');
+}
+
+export function Favourites () {
+  return import(/* webpackChunkName: "flavours/glitch/async/favourites" */'flavours/glitch/features/favourites');
+}
+
+export function FollowRequests () {
+  return import(/* webpackChunkName: "flavours/glitch/async/follow_requests" */'flavours/glitch/features/follow_requests');
+}
+
+export function GenericNotFound () {
+  return import(/* webpackChunkName: "flavours/glitch/async/generic_not_found" */'flavours/glitch/features/generic_not_found');
+}
+
+export function FavouritedStatuses () {
+  return import(/* webpackChunkName: "flavours/glitch/async/favourited_statuses" */'flavours/glitch/features/favourited_statuses');
+}
+
+export function BookmarkedStatuses () {
+  return import(/* webpackChunkName: "flavours/glitch/async/bookmarked_statuses" */'flavours/glitch/features/bookmarked_statuses');
+}
+
+export function Blocks () {
+  return import(/* webpackChunkName: "flavours/glitch/async/blocks" */'flavours/glitch/features/blocks');
+}
+
+export function DomainBlocks () {
+  return import(/* webpackChunkName: "flavours/glitch/async/domain_blocks" */'flavours/glitch/features/domain_blocks');
+}
+
+export function Mutes () {
+  return import(/* webpackChunkName: "flavours/glitch/async/mutes" */'flavours/glitch/features/mutes');
+}
+
+export function OnboardingModal () {
+  return import(/* webpackChunkName: "flavours/glitch/async/onboarding_modal" */'flavours/glitch/features/ui/components/onboarding_modal');
+}
+
+export function MuteModal () {
+  return import(/* webpackChunkName: "flavours/glitch/async/mute_modal" */'flavours/glitch/features/ui/components/mute_modal');
+}
+
+export function BlockModal () {
+  return import(/* webpackChunkName: "flavours/glitch/async/block_modal" */'flavours/glitch/features/ui/components/block_modal');
+}
+
+export function ReportModal () {
+  return import(/* webpackChunkName: "flavours/glitch/async/report_modal" */'flavours/glitch/features/ui/components/report_modal');
+}
+
+export function SettingsModal () {
+  return import(/* webpackChunkName: "flavours/glitch/async/settings_modal" */'flavours/glitch/features/local_settings');
+}
+
+export function MediaGallery () {
+  return import(/* webpackChunkName: "flavours/glitch/async/media_gallery" */'flavours/glitch/components/media_gallery');
+}
+
+export function Video () {
+  return import(/* webpackChunkName: "flavours/glitch/async/video" */'flavours/glitch/features/video');
+}
+
+export function Audio () {
+  return import(/* webpackChunkName: "features/glitch/async/audio" */'flavours/glitch/features/audio');
+}
+
+export function EmbedModal () {
+  return import(/* webpackChunkName: "flavours/glitch/async/embed_modal" */'flavours/glitch/features/ui/components/embed_modal');
+}
+
+export function GettingStartedMisc () {
+  return import(/* webpackChunkName: "flavours/glitch/async/getting_started_misc" */'flavours/glitch/features/getting_started_misc');
+}
+
+export function ListAdder () {
+  return import(/* webpackChunkName: "features/glitch/async/list_adder" */'flavours/glitch/features/list_adder');
+}
+
+export function Tesseract () {
+  return import(/*webpackChunkName: "tesseract" */'tesseract.js');
+}
+
+export function Directory () {
+  return import(/* webpackChunkName: "features/glitch/async/directory" */'flavours/glitch/features/directory');
+}
+
+export function FollowRecommendations () {
+  return import(/* webpackChunkName: "features/glitch/async/follow_recommendations" */'flavours/glitch/features/follow_recommendations');
+}
+
+export function CompareHistoryModal () {
+  return import(/*webpackChunkName: "flavours/glitch/async/compare_history_modal" */'flavours/glitch/features/ui/components/compare_history_modal');
+}
+
+export function FilterModal () {
+  return import(/*webpackChunkName: "flavours/glitch/async/filter_modal" */'flavours/glitch/features/ui/components/filter_modal');
+}
+
+export function Explore () {
+  return import(/* webpackChunkName: "flavours/glitch/async/explore" */'flavours/glitch/features/explore');
+}
+
+export function InteractionModal () {
+  return import(/*webpackChunkName: "flavours/glitch/async/modals/interaction_modal" */'flavours/glitch/features/interaction_modal');
+}
+
+export function SubscribedLanguagesModal () {
+  return import(/*webpackChunkName: "flavours/glitch/async/modals/subscribed_languages_modal" */'flavours/glitch/features/subscribed_languages_modal');
+}
+
+export function ClosedRegistrationsModal () {
+  return import(/*webpackChunkName: "flavours/glitch/async/modals/closed_registrations_modal" */'flavours/glitch/features/closed_registrations_modal');
+}
+
+export function About () {
+  return import(/*webpackChunkName: "features/glitch/async/about" */'flavours/glitch/features/about');
+}
+
+export function PrivacyPolicy () {
+  return import(/*webpackChunkName: "features/glitch/async/privacy_policy" */'flavours/glitch/features/privacy_policy');
+}
diff --git a/app/javascript/flavours/glitch/features/ui/util/fullscreen.js b/app/javascript/flavours/glitch/features/ui/util/fullscreen.js
new file mode 100644
index 000000000..cf5d0cf98
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/ui/util/fullscreen.js
@@ -0,0 +1,46 @@
+// APIs for normalizing fullscreen operations. Note that Edge uses
+// the WebKit-prefixed APIs currently (as of Edge 16).
+
+export const isFullscreen = () => document.fullscreenElement ||
+  document.webkitFullscreenElement ||
+  document.mozFullScreenElement;
+
+export const exitFullscreen = () => {
+  if (document.exitFullscreen) {
+    document.exitFullscreen();
+  } else if (document.webkitExitFullscreen) {
+    document.webkitExitFullscreen();
+  } else if (document.mozCancelFullScreen) {
+    document.mozCancelFullScreen();
+  }
+};
+
+export const requestFullscreen = el => {
+  if (el.requestFullscreen) {
+    el.requestFullscreen();
+  } else if (el.webkitRequestFullscreen) {
+    el.webkitRequestFullscreen();
+  } else if (el.mozRequestFullScreen) {
+    el.mozRequestFullScreen();
+  }
+};
+
+export const attachFullscreenListener = (listener) => {
+  if ('onfullscreenchange' in document) {
+    document.addEventListener('fullscreenchange', listener);
+  } else if ('onwebkitfullscreenchange' in document) {
+    document.addEventListener('webkitfullscreenchange', listener);
+  } else if ('onmozfullscreenchange' in document) {
+    document.addEventListener('mozfullscreenchange', listener);
+  }
+};
+
+export const detachFullscreenListener = (listener) => {
+  if ('onfullscreenchange' in document) {
+    document.removeEventListener('fullscreenchange', listener);
+  } else if ('onwebkitfullscreenchange' in document) {
+    document.removeEventListener('webkitfullscreenchange', listener);
+  } else if ('onmozfullscreenchange' in document) {
+    document.removeEventListener('mozfullscreenchange', listener);
+  }
+};
diff --git a/app/javascript/flavours/glitch/features/ui/util/get_rect_from_entry.js b/app/javascript/flavours/glitch/features/ui/util/get_rect_from_entry.js
new file mode 100644
index 000000000..c266cd7dc
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/ui/util/get_rect_from_entry.js
@@ -0,0 +1,21 @@
+
+// Get the bounding client rect from an IntersectionObserver entry.
+// This is to work around a bug in Chrome: https://crbug.com/737228
+
+let hasBoundingRectBug;
+
+function getRectFromEntry(entry) {
+  if (typeof hasBoundingRectBug !== 'boolean') {
+    const boundingRect = entry.target.getBoundingClientRect();
+    const observerRect = entry.boundingClientRect;
+    hasBoundingRectBug = boundingRect.height !== observerRect.height ||
+      boundingRect.top !== observerRect.top ||
+      boundingRect.width !== observerRect.width ||
+      boundingRect.bottom !== observerRect.bottom ||
+      boundingRect.left !== observerRect.left ||
+      boundingRect.right !== observerRect.right;
+  }
+  return hasBoundingRectBug ? entry.target.getBoundingClientRect() : entry.boundingClientRect;
+}
+
+export default getRectFromEntry;
diff --git a/app/javascript/flavours/glitch/features/ui/util/intersection_observer_wrapper.js b/app/javascript/flavours/glitch/features/ui/util/intersection_observer_wrapper.js
new file mode 100644
index 000000000..2b24c6583
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/ui/util/intersection_observer_wrapper.js
@@ -0,0 +1,57 @@
+// Wrapper for IntersectionObserver in order to make working with it
+// a bit easier. We also follow this performance advice:
+// "If you need to observe multiple elements, it is both possible and
+// advised to observe multiple elements using the same IntersectionObserver
+// instance by calling observe() multiple times."
+// https://developers.google.com/web/updates/2016/04/intersectionobserver
+
+class IntersectionObserverWrapper {
+
+  callbacks = {};
+  observerBacklog = [];
+  observer = null;
+
+  connect (options) {
+    const onIntersection = (entries) => {
+      entries.forEach(entry => {
+        const id = entry.target.getAttribute('data-id');
+        if (this.callbacks[id]) {
+          this.callbacks[id](entry);
+        }
+      });
+    };
+
+    this.observer = new IntersectionObserver(onIntersection, options);
+    this.observerBacklog.forEach(([ id, node, callback ]) => {
+      this.observe(id, node, callback);
+    });
+    this.observerBacklog = null;
+  }
+
+  observe (id, node, callback) {
+    if (!this.observer) {
+      this.observerBacklog.push([ id, node, callback ]);
+    } else {
+      this.callbacks[id] = callback;
+      this.observer.observe(node);
+    }
+  }
+
+  unobserve (id, node) {
+    if (this.observer) {
+      delete this.callbacks[id];
+      this.observer.unobserve(node);
+    }
+  }
+
+  disconnect () {
+    if (this.observer) {
+      this.callbacks = {};
+      this.observer.disconnect();
+      this.observer = null;
+    }
+  }
+
+}
+
+export default IntersectionObserverWrapper;
diff --git a/app/javascript/flavours/glitch/features/ui/util/optional_motion.js b/app/javascript/flavours/glitch/features/ui/util/optional_motion.js
new file mode 100644
index 000000000..a7fbe6310
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/ui/util/optional_motion.js
@@ -0,0 +1,5 @@
+import { reduceMotion } from 'flavours/glitch/initial_state';
+import ReducedMotion from './reduced_motion';
+import Motion from 'react-motion/lib/Motion';
+
+export default reduceMotion ? ReducedMotion : Motion;
diff --git a/app/javascript/flavours/glitch/features/ui/util/react_router_helpers.js b/app/javascript/flavours/glitch/features/ui/util/react_router_helpers.js
new file mode 100644
index 000000000..8946c8252
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/ui/util/react_router_helpers.js
@@ -0,0 +1,101 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { Switch, Route } from 'react-router-dom';
+import StackTrace from 'stacktrace-js';
+import ColumnLoading from 'flavours/glitch/features/ui/components/column_loading';
+import BundleColumnError from 'flavours/glitch/features/ui/components/bundle_column_error';
+import BundleContainer from 'flavours/glitch/features/ui/containers/bundle_container';
+
+// Small wrapper to pass multiColumn to the route components
+export class WrappedSwitch extends React.PureComponent {
+
+  render () {
+    const { multiColumn, children } = this.props;
+
+    return (
+      <Switch>
+        {React.Children.map(children, child => React.cloneElement(child, { multiColumn }))}
+      </Switch>
+    );
+  }
+
+}
+
+WrappedSwitch.propTypes = {
+  multiColumn: PropTypes.bool,
+  children: PropTypes.node,
+};
+
+// Small Wraper to extract the params from the route and pass
+// them to the rendered component, together with the content to
+// be rendered inside (the children)
+export class WrappedRoute extends React.Component {
+
+  static propTypes = {
+    component: PropTypes.func.isRequired,
+    content: PropTypes.node,
+    multiColumn: PropTypes.bool,
+    componentParams: PropTypes.object,
+  }
+
+  static defaultProps = {
+    componentParams: {},
+  };
+
+  static getDerivedStateFromError () {
+    return {
+      hasError: true,
+    };
+  };
+
+  state = {
+    hasError: false,
+    stacktrace: '',
+  };
+
+  componentDidCatch (error) {
+    StackTrace.fromError(error).then(stackframes => {
+      this.setState({ stacktrace: error.toString() + '\n' + stackframes.map(frame => frame.toString()).join('\n') });
+    }).catch(err => {
+      console.error(err);
+    });
+  }
+
+  renderComponent = ({ match }) => {
+    const { component, content, multiColumn, componentParams } = this.props;
+    const { hasError, stacktrace } = this.state;
+
+    if (hasError) {
+      return (
+        <BundleColumnError
+          stacktrace={stacktrace}
+          multiColumn={multiColumn}
+          errorType='error'
+        />
+      );
+    }
+
+    return (
+      <BundleContainer fetchComponent={component} loading={this.renderLoading} error={this.renderError}>
+        {Component => <Component params={match.params} multiColumn={multiColumn} {...componentParams}>{content}</Component>}
+      </BundleContainer>
+    );
+  }
+
+  renderLoading = () => {
+    const { multiColumn } = this.props;
+
+    return <ColumnLoading multiColumn={multiColumn} />;
+  }
+
+  renderError = (props) => {
+    return <BundleColumnError {...props} errorType='network' />;
+  }
+
+  render () {
+    const { component: Component, content, ...rest } = this.props;
+
+    return <Route {...rest} render={this.renderComponent} />;
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/ui/util/reduced_motion.js b/app/javascript/flavours/glitch/features/ui/util/reduced_motion.js
new file mode 100644
index 000000000..95519042b
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/ui/util/reduced_motion.js
@@ -0,0 +1,44 @@
+// Like react-motion's Motion, but reduces all animations to cross-fades
+// for the benefit of users with motion sickness.
+import React from 'react';
+import Motion from 'react-motion/lib/Motion';
+import PropTypes from 'prop-types';
+
+const stylesToKeep = ['opacity', 'backgroundOpacity'];
+
+const extractValue = (value) => {
+  // This is either an object with a "val" property or it's a number
+  return (typeof value === 'object' && value && 'val' in value) ? value.val : value;
+};
+
+class ReducedMotion extends React.Component {
+
+  static propTypes = {
+    defaultStyle: PropTypes.object,
+    style: PropTypes.object,
+    children: PropTypes.func,
+  }
+
+  render() {
+
+    const { style, defaultStyle, children } = this.props;
+
+    Object.keys(style).forEach(key => {
+      if (stylesToKeep.includes(key)) {
+        return;
+      }
+      // If it's setting an x or height or scale or some other value, we need
+      // to preserve the end-state value without actually animating it
+      style[key] = defaultStyle[key] = extractValue(style[key]);
+    });
+
+    return (
+      <Motion style={style} defaultStyle={defaultStyle}>
+        {children}
+      </Motion>
+    );
+  }
+
+}
+
+export default ReducedMotion;
diff --git a/app/javascript/flavours/glitch/features/ui/util/schedule_idle_task.js b/app/javascript/flavours/glitch/features/ui/util/schedule_idle_task.js
new file mode 100644
index 000000000..b04d4a8ee
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/ui/util/schedule_idle_task.js
@@ -0,0 +1,29 @@
+// Wrapper to call requestIdleCallback() to schedule low-priority work.
+// See https://developer.mozilla.org/en-US/docs/Web/API/Background_Tasks_API
+// for a good breakdown of the concepts behind this.
+
+import Queue from 'tiny-queue';
+
+const taskQueue = new Queue();
+let runningRequestIdleCallback = false;
+
+function runTasks(deadline) {
+  while (taskQueue.length && deadline.timeRemaining() > 0) {
+    taskQueue.shift()();
+  }
+  if (taskQueue.length) {
+    requestIdleCallback(runTasks);
+  } else {
+    runningRequestIdleCallback = false;
+  }
+}
+
+function scheduleIdleTask(task) {
+  taskQueue.push(task);
+  if (!runningRequestIdleCallback) {
+    runningRequestIdleCallback = true;
+    requestIdleCallback(runTasks);
+  }
+}
+
+export default scheduleIdleTask;