about summary refs log tree commit diff
path: root/app/javascript/mastodon/features/ui
diff options
context:
space:
mode:
Diffstat (limited to 'app/javascript/mastodon/features/ui')
-rw-r--r--app/javascript/mastodon/features/ui/components/boost_modal.js84
-rw-r--r--app/javascript/mastodon/features/ui/components/column.js93
-rw-r--r--app/javascript/mastodon/features/ui/components/column_header.js43
-rw-r--r--app/javascript/mastodon/features/ui/components/column_link.js32
-rw-r--r--app/javascript/mastodon/features/ui/components/column_subheading.js16
-rw-r--r--app/javascript/mastodon/features/ui/components/columns_area.js20
-rw-r--r--app/javascript/mastodon/features/ui/components/confirmation_modal.js51
-rw-r--r--app/javascript/mastodon/features/ui/components/media_modal.js103
-rw-r--r--app/javascript/mastodon/features/ui/components/modal_root.js93
-rw-r--r--app/javascript/mastodon/features/ui/components/onboarding_modal.js264
-rw-r--r--app/javascript/mastodon/features/ui/components/tabs_bar.js24
-rw-r--r--app/javascript/mastodon/features/ui/components/upload_area.js60
-rw-r--r--app/javascript/mastodon/features/ui/components/video_modal.js40
-rw-r--r--app/javascript/mastodon/features/ui/containers/loading_bar_container.js8
-rw-r--r--app/javascript/mastodon/features/ui/containers/modal_container.js16
-rw-r--r--app/javascript/mastodon/features/ui/containers/notifications_container.js21
-rw-r--r--app/javascript/mastodon/features/ui/containers/status_list_container.js74
-rw-r--r--app/javascript/mastodon/features/ui/index.js169
18 files changed, 1211 insertions, 0 deletions
diff --git a/app/javascript/mastodon/features/ui/components/boost_modal.js b/app/javascript/mastodon/features/ui/components/boost_modal.js
new file mode 100644
index 000000000..d6000fe4e
--- /dev/null
+++ b/app/javascript/mastodon/features/ui/components/boost_modal.js
@@ -0,0 +1,84 @@
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import IconButton from '../../../components/icon_button';
+import Button from '../../../components/button';
+import StatusContent from '../../../components/status_content';
+import Avatar from '../../../components/avatar';
+import RelativeTimestamp from '../../../components/relative_timestamp';
+import DisplayName from '../../../components/display_name';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+
+const messages = defineMessages({
+  reblog: { id: 'status.reblog', defaultMessage: 'Boost' }
+});
+
+class BoostModal extends ImmutablePureComponent {
+
+  constructor (props, context) {
+    super(props, context);
+    this.handleReblog = this.handleReblog.bind(this);
+    this.handleAccountClick = this.handleAccountClick.bind(this);
+  }
+
+  handleReblog() {
+    this.props.onReblog(this.props.status);
+    this.props.onClose();
+  }
+
+  handleAccountClick (e) {
+    if (e.button === 0) {
+      e.preventDefault();
+      this.props.onClose();
+      this.context.router.push(`/accounts/${this.props.status.getIn(['account', 'id'])}`);
+    }
+  }
+
+  render () {
+    const { status, intl, onClose } = this.props;
+
+    return (
+      <div className='modal-root__modal boost-modal'>
+        <div className='boost-modal__container'>
+          <div className='status light'>
+            <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'><RelativeTimestamp timestamp={status.get('created_at')} /></a>
+              </div>
+
+              <a onClick={this.handleAccountClick} href={status.getIn(['account', 'url'])} className='status__display-name'>
+                <div className='status__avatar'>
+                  <Avatar src={status.getIn(['account', 'avatar'])} staticSrc={status.getIn(['account', 'avatar_static'])} size={48} />
+                </div>
+
+                <DisplayName account={status.get('account')} />
+              </a>
+            </div>
+
+            <StatusContent status={status} />
+          </div>
+        </div>
+
+        <div className='boost-modal__action-bar'>
+          <div><FormattedMessage id='boost_modal.combo' defaultMessage='You can press {combo} to skip this next time' values={{ combo: <span>Shift + <i className='fa fa-retweet' /></span> }} /></div>
+          <Button text={intl.formatMessage(messages.reblog)} onClick={this.handleReblog} />
+        </div>
+      </div>
+    );
+  }
+
+}
+
+BoostModal.contextTypes = {
+  router: PropTypes.object
+};
+
+BoostModal.propTypes = {
+  status: ImmutablePropTypes.map.isRequired,
+  onReblog: PropTypes.func.isRequired,
+  onClose: PropTypes.func.isRequired,
+  intl: PropTypes.object.isRequired
+};
+
+export default injectIntl(BoostModal);
diff --git a/app/javascript/mastodon/features/ui/components/column.js b/app/javascript/mastodon/features/ui/components/column.js
new file mode 100644
index 000000000..fcb197573
--- /dev/null
+++ b/app/javascript/mastodon/features/ui/components/column.js
@@ -0,0 +1,93 @@
+import React from 'react';
+import ColumnHeader from './column_header';
+import PropTypes from 'prop-types';
+
+const easingOutQuint = (x, t, b, c, d) => c*((t=t/d-1)*t*t*t*t + 1) + b;
+
+const scrollTop = (node) => {
+  const startTime = Date.now();
+  const offset    = node.scrollTop;
+  const targetY   = -offset;
+  const duration  = 1000;
+  let interrupt   = false;
+
+  const step = () => {
+    const elapsed    = Date.now() - startTime;
+    const percentage = elapsed / duration;
+
+    if (percentage > 1 || interrupt) {
+      return;
+    }
+
+    node.scrollTop = easingOutQuint(0, elapsed, offset, targetY, duration);
+    requestAnimationFrame(step);
+  };
+
+  step();
+
+  return () => {
+    interrupt = true;
+  };
+};
+
+class Column extends React.PureComponent {
+
+  constructor (props, context) {
+    super(props, context);
+    this.handleHeaderClick = this.handleHeaderClick.bind(this);
+    this.handleWheel = this.handleWheel.bind(this);
+    this.setRef = this.setRef.bind(this);
+  }
+
+  handleHeaderClick () {
+    const scrollable = this.node.querySelector('.scrollable');
+    if (!scrollable) {
+      return;
+    }
+    this._interruptScrollAnimation = scrollTop(scrollable);
+  }
+
+  handleWheel () {
+    if (typeof this._interruptScrollAnimation !== 'undefined') {
+      this._interruptScrollAnimation();
+    }
+  }
+
+  setRef (c) {
+    this.node = c;
+  }
+
+  render () {
+    const { heading, icon, children, active, hideHeadingOnMobile } = this.props;
+
+    let columnHeaderId = null
+    let header = '';
+
+    if (heading) {
+      columnHeaderId = heading.replace(/ /g, '-')
+      header = <ColumnHeader icon={icon} active={active} type={heading} onClick={this.handleHeaderClick} hideOnMobile={hideHeadingOnMobile} columnHeaderId={columnHeaderId}/>;
+    }
+    return (
+      <div
+        ref={this.setRef}
+        role='region'
+        aria-labelledby={columnHeaderId}
+        className='column'
+        onWheel={this.handleWheel}>
+        {header}
+        {children}
+      </div>
+    );
+  }
+
+}
+
+Column.propTypes = {
+  heading: PropTypes.string,
+  icon: PropTypes.string,
+  children: PropTypes.node,
+  active: PropTypes.bool,
+  hideHeadingOnMobile: PropTypes.bool
+};
+
+export default Column;
diff --git a/app/javascript/mastodon/features/ui/components/column_header.js b/app/javascript/mastodon/features/ui/components/column_header.js
new file mode 100644
index 000000000..2701cd57d
--- /dev/null
+++ b/app/javascript/mastodon/features/ui/components/column_header.js
@@ -0,0 +1,43 @@
+import React from 'react';
+import PropTypes from 'prop-types'
+
+class ColumnHeader extends React.PureComponent {
+
+  constructor (props, context) {
+    super(props, context);
+    this.handleClick = this.handleClick.bind(this);
+  }
+
+  handleClick () {
+    this.props.onClick();
+  }
+
+  render () {
+    const { type, active, hideOnMobile, columnHeaderId } = this.props;
+
+    let icon = '';
+
+    if (this.props.icon) {
+      icon = <i className={`fa fa-fw fa-${this.props.icon} column-header__icon`} />;
+    }
+
+    return (
+      <div role='button heading' tabIndex='0' className={`column-header ${active ? 'active' : ''} ${hideOnMobile ? 'hidden-on-mobile' : ''}`} onClick={this.handleClick} id={columnHeaderId || null}>
+        {icon}
+        {type}
+      </div>
+    );
+  }
+
+}
+
+ColumnHeader.propTypes = {
+  icon: PropTypes.string,
+  type: PropTypes.string,
+  active: PropTypes.bool,
+  onClick: PropTypes.func,
+  hideOnMobile: PropTypes.bool,
+  columnHeaderId: PropTypes.string
+};
+
+export default ColumnHeader;
diff --git a/app/javascript/mastodon/features/ui/components/column_link.js b/app/javascript/mastodon/features/ui/components/column_link.js
new file mode 100644
index 000000000..cffe796ba
--- /dev/null
+++ b/app/javascript/mastodon/features/ui/components/column_link.js
@@ -0,0 +1,32 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { Link } from 'react-router';
+
+const ColumnLink = ({ icon, text, to, href, method, hideOnMobile }) => {
+  if (href) {
+    return (
+      <a href={href} className={`column-link ${hideOnMobile ? 'hidden-on-mobile' : ''}`} data-method={method}>
+        <i className={`fa fa-fw fa-${icon} column-link__icon`} />
+        {text}
+      </a>
+    );
+  } else {
+    return (
+      <Link to={to} className={`column-link ${hideOnMobile ? 'hidden-on-mobile' : ''}`}>
+        <i className={`fa fa-fw fa-${icon} column-link__icon`} />
+        {text}
+      </Link>
+    );
+  }
+};
+
+ColumnLink.propTypes = {
+  icon: PropTypes.string.isRequired,
+  text: PropTypes.string.isRequired,
+  to: PropTypes.string,
+  href: PropTypes.string,
+  method: PropTypes.string,
+  hideOnMobile: PropTypes.bool
+};
+
+export default ColumnLink;
diff --git a/app/javascript/mastodon/features/ui/components/column_subheading.js b/app/javascript/mastodon/features/ui/components/column_subheading.js
new file mode 100644
index 000000000..8160c4aa3
--- /dev/null
+++ b/app/javascript/mastodon/features/ui/components/column_subheading.js
@@ -0,0 +1,16 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+const ColumnSubheading = ({ text }) => {
+  return (
+    <div className='column-subheading'>
+      {text}
+    </div>
+  );
+};
+
+ColumnSubheading.propTypes = {
+  text: PropTypes.string.isRequired,
+};
+
+export default ColumnSubheading;
diff --git a/app/javascript/mastodon/features/ui/components/columns_area.js b/app/javascript/mastodon/features/ui/components/columns_area.js
new file mode 100644
index 000000000..05f9f3fb5
--- /dev/null
+++ b/app/javascript/mastodon/features/ui/components/columns_area.js
@@ -0,0 +1,20 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+class ColumnsArea extends React.PureComponent {
+
+  render () {
+    return (
+      <div className='columns-area'>
+        {this.props.children}
+      </div>
+    );
+  }
+
+}
+
+ColumnsArea.propTypes = {
+  children: PropTypes.node
+};
+
+export default ColumnsArea;
diff --git a/app/javascript/mastodon/features/ui/components/confirmation_modal.js b/app/javascript/mastodon/features/ui/components/confirmation_modal.js
new file mode 100644
index 000000000..499993207
--- /dev/null
+++ b/app/javascript/mastodon/features/ui/components/confirmation_modal.js
@@ -0,0 +1,51 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import Button from '../../../components/button';
+
+class ConfirmationModal extends React.PureComponent {
+
+  constructor (props, context) {
+    super(props, context);
+    this.handleClick = this.handleClick.bind(this);
+    this.handleCancel = this.handleCancel.bind(this);
+  }
+
+  handleClick () {
+    this.props.onClose();
+    this.props.onConfirm();
+  }
+
+  handleCancel (e) {
+    e.preventDefault();
+    this.props.onClose();
+  }
+
+  render () {
+    const { intl, message, confirm, onConfirm, onClose } = this.props;
+
+    return (
+      <div className='modal-root__modal confirmation-modal'>
+        <div className='confirmation-modal__container'>
+          {message}
+        </div>
+
+        <div className='confirmation-modal__action-bar'>
+          <div><a href='#' onClick={this.handleCancel}><FormattedMessage id='confirmation_modal.cancel' defaultMessage='Cancel' /></a></div>
+          <Button text={confirm} onClick={this.handleClick} />
+        </div>
+      </div>
+    );
+  }
+
+}
+
+ConfirmationModal.propTypes = {
+  message: PropTypes.node.isRequired,
+  confirm: PropTypes.string.isRequired,
+  onClose: PropTypes.func.isRequired,
+  onConfirm: PropTypes.func.isRequired,
+  intl: PropTypes.object.isRequired
+};
+
+export default injectIntl(ConfirmationModal);
diff --git a/app/javascript/mastodon/features/ui/components/media_modal.js b/app/javascript/mastodon/features/ui/components/media_modal.js
new file mode 100644
index 000000000..a8fb3858a
--- /dev/null
+++ b/app/javascript/mastodon/features/ui/components/media_modal.js
@@ -0,0 +1,103 @@
+import React from 'react';
+import LoadingIndicator from '../../../components/loading_indicator';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
+import ExtendedVideoPlayer from '../../../components/extended_video_player';
+import ImageLoader from 'react-imageloader';
+import { defineMessages, injectIntl } from 'react-intl';
+import IconButton from '../../../components/icon_button';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+
+const messages = defineMessages({
+  close: { id: 'lightbox.close', defaultMessage: 'Close' }
+});
+
+class MediaModal extends ImmutablePureComponent {
+
+  constructor (props, context) {
+    super(props, context);
+    this.state = {
+      index: null
+    };
+    this.handleNextClick = this.handleNextClick.bind(this);
+    this.handlePrevClick = this.handlePrevClick.bind(this);
+    this.handleKeyUp = this.handleKeyUp.bind(this);
+  }
+
+  handleNextClick () {
+    this.setState({ index: (this.getIndex() + 1) % this.props.media.size});
+  }
+
+  handlePrevClick () {
+    this.setState({ index: (this.getIndex() - 1) % this.props.media.size});
+  }
+
+  handleKeyUp (e) {
+    switch(e.key) {
+    case 'ArrowLeft':
+      this.handlePrevClick();
+      break;
+    case 'ArrowRight':
+      this.handleNextClick();
+      break;
+    }
+  }
+
+  componentDidMount () {
+    window.addEventListener('keyup', this.handleKeyUp, false);
+  }
+
+  componentWillUnmount () {
+    window.removeEventListener('keyup', this.handleKeyUp);
+  }
+
+  getIndex () {
+    return this.state.index !== null ? this.state.index : this.props.index;
+  }
+
+  render () {
+    const { media, intl, onClose } = this.props;
+
+    const index = this.getIndex();
+    const attachment = media.get(index);
+    const url = attachment.get('url');
+
+    let leftNav, rightNav, content;
+
+    leftNav = rightNav = content = '';
+
+    if (media.size > 1) {
+      leftNav  = <div role='button' tabIndex='0' className='modal-container__nav modal-container__nav--left' onClick={this.handlePrevClick}><i className='fa fa-fw fa-chevron-left' /></div>;
+      rightNav = <div role='button' tabIndex='0' className='modal-container__nav  modal-container__nav--right' onClick={this.handleNextClick}><i className='fa fa-fw fa-chevron-right' /></div>;
+    }
+
+    if (attachment.get('type') === 'image') {
+      content = <ImageLoader src={url} imgProps={{ style: { display: 'block' } }} />;
+    } else if (attachment.get('type') === 'gifv') {
+      content = <ExtendedVideoPlayer src={url} muted={true} controls={false} />;
+    }
+
+    return (
+      <div className='modal-root__modal media-modal'>
+        {leftNav}
+
+        <div className='media-modal__content'>
+          <IconButton className='media-modal__close' title={intl.formatMessage(messages.close)} icon='times' onClick={onClose} size={16} />
+          {content}
+        </div>
+
+        {rightNav}
+      </div>
+    );
+  }
+
+}
+
+MediaModal.propTypes = {
+  media: ImmutablePropTypes.list.isRequired,
+  index: PropTypes.number.isRequired,
+  onClose: PropTypes.func.isRequired,
+  intl: PropTypes.object.isRequired
+};
+
+export default injectIntl(MediaModal);
diff --git a/app/javascript/mastodon/features/ui/components/modal_root.js b/app/javascript/mastodon/features/ui/components/modal_root.js
new file mode 100644
index 000000000..5cde65907
--- /dev/null
+++ b/app/javascript/mastodon/features/ui/components/modal_root.js
@@ -0,0 +1,93 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import MediaModal from './media_modal';
+import OnboardingModal from './onboarding_modal';
+import VideoModal from './video_modal';
+import BoostModal from './boost_modal';
+import ConfirmationModal from './confirmation_modal';
+import { TransitionMotion, spring } from 'react-motion';
+
+const MODAL_COMPONENTS = {
+  'MEDIA': MediaModal,
+  'ONBOARDING': OnboardingModal,
+  'VIDEO': VideoModal,
+  'BOOST': BoostModal,
+  'CONFIRM': ConfirmationModal
+};
+
+class ModalRoot extends React.PureComponent {
+
+  constructor (props, context) {
+    super(props, context);
+    this.handleKeyUp = this.handleKeyUp.bind(this);
+  }
+
+  handleKeyUp (e) {
+    if ((e.key === 'Escape' || e.key === 'Esc' || e.keyCode === 27)
+         && !!this.props.type) {
+      this.props.onClose();
+    }
+  }
+
+  componentDidMount () {
+    window.addEventListener('keyup', this.handleKeyUp, false);
+  }
+
+  componentWillUnmount () {
+    window.removeEventListener('keyup', this.handleKeyUp);
+  }
+
+  willEnter () {
+    return { opacity: 0, scale: 0.98 };
+  }
+
+  willLeave () {
+    return { opacity: spring(0), scale: spring(0.98) };
+  }
+
+  render () {
+    const { type, props, onClose } = this.props;
+    const items = [];
+
+    if (!!type) {
+      items.push({
+        key: type,
+        data: { type, props },
+        style: { opacity: spring(1), scale: spring(1, { stiffness: 120, damping: 14 }) }
+      });
+    }
+
+    return (
+      <TransitionMotion
+        styles={items}
+        willEnter={this.willEnter}
+        willLeave={this.willLeave}>
+        {interpolatedStyles =>
+          <div className='modal-root'>
+            {interpolatedStyles.map(({ key, data: { type, props }, style }) => {
+              const SpecificComponent = MODAL_COMPONENTS[type];
+
+              return (
+                <div key={key}>
+                  <div role='presentation' className='modal-root__overlay' style={{ opacity: style.opacity }} onClick={onClose} />
+                  <div className='modal-root__container' style={{ opacity: style.opacity, transform: `translateZ(0px) scale(${style.scale})` }}>
+                    <SpecificComponent {...props} onClose={onClose} />
+                  </div>
+                </div>
+              );
+            })}
+          </div>
+        }
+      </TransitionMotion>
+    );
+  }
+
+}
+
+ModalRoot.propTypes = {
+  type: PropTypes.string,
+  props: PropTypes.object,
+  onClose: PropTypes.func.isRequired
+};
+
+export default ModalRoot;
diff --git a/app/javascript/mastodon/features/ui/components/onboarding_modal.js b/app/javascript/mastodon/features/ui/components/onboarding_modal.js
new file mode 100644
index 000000000..7cdd3527a
--- /dev/null
+++ b/app/javascript/mastodon/features/ui/components/onboarding_modal.js
@@ -0,0 +1,264 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import Permalink from '../../../components/permalink';
+import { TransitionMotion, spring } from 'react-motion';
+import ComposeForm from '../../compose/components/compose_form';
+import Search from '../../compose/components/search';
+import NavigationBar from '../../compose/components/navigation_bar';
+import ColumnHeader from './column_header';
+import Immutable from 'immutable';
+
+const messages = defineMessages({
+  home_title: { id: 'column.home', defaultMessage: 'Home' },
+  notifications_title: { id: 'column.notifications', defaultMessage: 'Notifications' },
+  local_title: { id: 'column.community', defaultMessage: 'Local timeline' },
+  federated_title: { id: 'column.public', defaultMessage: 'Federated timeline' }
+});
+
+const PageOne = ({ acct, domain }) => (
+  <div className='onboarding-modal__page onboarding-modal__page-one'>
+    <div style={{ flex: '0 0 auto' }}>
+      <div className='onboarding-modal__page-one__elephant-friend' />
+    </div>
+
+    <div>
+      <h1><FormattedMessage id='onboarding.page_one.welcome' defaultMessage='Welcome to Mastodon!' /></h1>
+      <p><FormattedMessage id='onboarding.page_one.federation' defaultMessage='Mastodon is a network of independent servers joining up to make one larger social network. We call these servers instances.' /></p>
+      <p><FormattedMessage id='onboarding.page_one.handle' defaultMessage='You are on {domain}, so your full handle is {handle}' values={{ domain, handle: <strong>{acct}@{domain}</strong> }}/></p>
+    </div>
+  </div>
+);
+
+PageOne.propTypes = {
+  acct: PropTypes.string.isRequired,
+  domain: PropTypes.string.isRequired
+};
+
+const PageTwo = ({ me }) => (
+  <div className='onboarding-modal__page onboarding-modal__page-two'>
+    <div className='figure non-interactive'>
+      <div className='pseudo-drawer'>
+        <NavigationBar account={me} />
+      </div>
+      <ComposeForm
+        text='Awoo! #introductions'
+        suggestions={Immutable.List()}
+        mentionedDomains={[]}
+        spoiler={false}
+        onChange={() => {}}
+        onSubmit={() => {}}
+        onPaste={() => {}}
+        onPickEmoji={() => {}}
+        onChangeSpoilerText={() => {}}
+        onClearSuggestions={() => {}}
+        onFetchSuggestions={() => {}}
+        onSuggestionSelected={() => {}}
+      />
+    </div>
+
+    <p><FormattedMessage id='onboarding.page_two.compose' defaultMessage='Write posts from the compose column. You can upload images, change privacy settings, and add content warnings with the icons below.' /></p>
+  </div>
+);
+
+PageTwo.propTypes = {
+  me: ImmutablePropTypes.map.isRequired,
+};
+
+const PageThree = ({ me, domain }) => (
+  <div className='onboarding-modal__page onboarding-modal__page-three'>
+    <div className='figure non-interactive'>
+      <Search
+        value=''
+        onChange={() => {}}
+        onSubmit={() => {}}
+        onClear={() => {}}
+        onShow={() => {}}
+      />
+
+      <div className='pseudo-drawer'>
+        <NavigationBar account={me} />
+      </div>
+    </div>
+
+    <p><FormattedMessage id='onboarding.page_three.search' defaultMessage='Use the search bar to find people and look at hashtags, such as {illustration} and {introductions}. To look for a person who is not on this instance, use their full handle.' values={{ illustration: <Permalink to='/timelines/tag/illustration' href='/tags/illustration'>#illustration</Permalink>, introductions: <Permalink to='/timelines/tag/introductions' href='/tags/introductions'>#introductions</Permalink> }}/></p>
+    <p><FormattedMessage id='onboarding.page_three.profile' defaultMessage='Edit your profile to change your avatar, bio, and display name. There, you will also find other preferences.' /></p>
+  </div>
+);
+
+PageThree.propTypes = {
+  me: ImmutablePropTypes.map.isRequired,
+  domain: PropTypes.string.isRequired
+};
+
+const PageFour = ({ domain, intl }) => (
+  <div className='onboarding-modal__page onboarding-modal__page-four'>
+    <div className='onboarding-modal__page-four__columns'>
+      <div className='row'>
+        <div>
+          <div className='figure non-interactive'><ColumnHeader icon='home' type={intl.formatMessage(messages.home_title)} /></div>
+          <p><FormattedMessage id='onboarding.page_four.home' defaultMessage='The home timeline shows posts from people you follow.'/></p>
+        </div>
+
+        <div>
+          <div className='figure non-interactive'><ColumnHeader icon='bell' type={intl.formatMessage(messages.notifications_title)} /></div>
+          <p><FormattedMessage id='onboarding.page_four.notifications' defaultMessage='The notifications column shows when someone interacts with you.' /></p>
+        </div>
+      </div>
+
+      <div className='row'>
+        <div>
+          <div className='figure non-interactive' style={{ marginBottom: 0 }}><ColumnHeader icon='users' type={intl.formatMessage(messages.local_title)} /></div>
+        </div>
+
+        <div>
+          <div className='figure non-interactive' style={{ marginBottom: 0 }}><ColumnHeader icon='globe' type={intl.formatMessage(messages.federated_title)} /></div>
+        </div>
+      </div>
+
+      <p><FormattedMessage id='onboarding.page_five.public_timelines' defaultMessage='The local timeline shows public posts from everyone on {domain}. The federated timeline shows public posts from everyone who people on {domain} follow. These are the Public Timelines, a great way to discover new people.' values={{ domain }} /></p>
+    </div>
+  </div>
+);
+
+PageFour.propTypes = {
+  domain: PropTypes.string.isRequired,
+  intl: PropTypes.object.isRequired
+};
+
+const PageSix = ({ admin, domain }) => {
+  let adminSection = '';
+
+  if (admin) {
+    adminSection = (
+      <p>
+        <FormattedMessage id='onboarding.page_six.admin' defaultMessage="Your instance's admin is {admin}." values={{ admin: <Permalink href={admin.get('url')} to={`/accounts/${admin.get('id')}`}>@{admin.get('acct')}</Permalink> }} />
+        <br />
+        <FormattedMessage id='onboarding.page_six.read_guidelines' defaultMessage="Please read {domain}'s {guidelines}!" values={{domain, guidelines: <a href='/about/more' target='_blank'><FormattedMessage id='onboarding.page_six.guidelines' defaultMessage='community guidelines' /></a> }}/>
+      </p>
+    );
+  }
+
+  return (
+    <div className='onboarding-modal__page onboarding-modal__page-six'>
+      <h1><FormattedMessage id='onboarding.page_six.almost_done' defaultMessage='Almost done...' /></h1>
+      {adminSection}
+      <p><FormattedMessage id='onboarding.page_six.github' defaultMessage='Mastodon is free open-source software. You can report bugs, request features, or contribute to the code on {github}.' values={{ github: <a href='https://github.com/tootsuite/mastodon' target='_blank' rel='noopener'>GitHub</a> }} /></p>
+      <p><FormattedMessage id='onboarding.page_six.apps_available' defaultMessage='There are {apps} available for iOS, Android and other platforms.' values={{ apps: <a href='https://github.com/tootsuite/documentation/blob/master/Using-Mastodon/Apps.md' target='_blank' rel='noopener'><FormattedMessage id='onboarding.page_six.various_app' defaultMessage='mobile apps' /></a> }} /></p>
+      <p><em><FormattedMessage id='onboarding.page_six.appetoot' defaultMessage='Bon Appetoot!' /></em></p>
+    </div>
+  );
+};
+
+PageSix.propTypes = {
+  admin: ImmutablePropTypes.map,
+  domain: PropTypes.string.isRequired
+};
+
+const mapStateToProps = state => ({
+  me: state.getIn(['accounts', state.getIn(['meta', 'me'])]),
+  admin: state.getIn(['accounts', state.getIn(['meta', 'admin'])]),
+  domain: state.getIn(['meta', 'domain'])
+});
+
+class OnboardingModal extends React.PureComponent {
+
+  constructor (props, context) {
+    super(props, context);
+    this.state = {
+      currentIndex: 0
+    };
+    this.handleSkip = this.handleSkip.bind(this);
+    this.handleDot = this.handleDot.bind(this);
+    this.handleNext = this.handleNext.bind(this);
+  }
+
+  handleSkip (e) {
+    e.preventDefault();
+    this.props.onClose();
+  }
+
+  handleDot (i, e) {
+    e.preventDefault();
+    this.setState({ currentIndex: i });
+  }
+
+  handleNext (maxNum, e) {
+    e.preventDefault();
+
+    if (this.state.currentIndex < maxNum - 1) {
+      this.setState({ currentIndex: this.state.currentIndex + 1 });
+    } else {
+      this.props.onClose();
+    }
+  }
+
+  render () {
+    const { me, admin, domain, intl } = this.props;
+
+    const pages = [
+      <PageOne acct={me.get('acct')} domain={domain} />,
+      <PageTwo me={me} />,
+      <PageThree me={me} domain={domain} />,
+      <PageFour domain={domain} intl={intl} />,
+      <PageSix admin={admin} domain={domain} />
+    ];
+
+    const { currentIndex } = this.state;
+    const hasMore = currentIndex < pages.length - 1;
+
+    let nextOrDoneBtn;
+
+    if(hasMore) {
+      nextOrDoneBtn = <a href='#' onClick={this.handleNext.bind(null, pages.length)} className='onboarding-modal__nav onboarding-modal__next'><FormattedMessage id='onboarding.next' defaultMessage='Next' /></a>;
+    } else {
+      nextOrDoneBtn = <a href='#' onClick={this.handleNext.bind(null, pages.length)} className='onboarding-modal__nav onboarding-modal__done'><FormattedMessage id='onboarding.done' defaultMessage='Done' /></a>;
+    }
+
+    const styles = pages.map((page, i) => ({
+      key: `page-${i}`,
+      style: { opacity: spring(i === currentIndex ? 1 : 0) }
+    }));
+
+    return (
+      <div className='modal-root__modal onboarding-modal'>
+        <TransitionMotion styles={styles}>
+          {interpolatedStyles =>
+            <div className='onboarding-modal__pager'>
+              {pages.map((page, i) =>
+                <div key={`page-${i}`} style={{ opacity: interpolatedStyles[i].style.opacity, pointerEvents: i === currentIndex ? 'auto' : 'none' }}>{page}</div>
+              )}
+            </div>
+          }
+        </TransitionMotion>
+
+        <div className='onboarding-modal__paginator'>
+          <div>
+            <a href='#' className='onboarding-modal__skip' onClick={this.handleSkip}><FormattedMessage id='onboarding.skip' defaultMessage='Skip' /></a>
+          </div>
+
+          <div className='onboarding-modal__dots'>
+            {pages.map((_, i) => <div key={i} onClick={this.handleDot.bind(null, i)} className={`onboarding-modal__dot ${i === currentIndex ? 'active' : ''}`} />)}
+          </div>
+
+          <div>
+            {nextOrDoneBtn}
+          </div>
+        </div>
+      </div>
+    );
+  }
+
+}
+
+OnboardingModal.propTypes = {
+  onClose: PropTypes.func.isRequired,
+  intl: PropTypes.object.isRequired,
+  me: ImmutablePropTypes.map.isRequired,
+  domain: PropTypes.string.isRequired,
+  admin: ImmutablePropTypes.map
+}
+
+export default connect(mapStateToProps)(injectIntl(OnboardingModal));
diff --git a/app/javascript/mastodon/features/ui/components/tabs_bar.js b/app/javascript/mastodon/features/ui/components/tabs_bar.js
new file mode 100644
index 000000000..b6a30bc11
--- /dev/null
+++ b/app/javascript/mastodon/features/ui/components/tabs_bar.js
@@ -0,0 +1,24 @@
+import React from 'react';
+import { Link } from 'react-router';
+import { FormattedMessage } from 'react-intl';
+
+class TabsBar extends React.Component {
+
+  render () {
+    return (
+      <div className='tabs-bar'>
+        <Link className='tabs-bar__link primary' activeClassName='active' to='/statuses/new'><i className='fa fa-fw fa-pencil' /><FormattedMessage id='tabs_bar.compose' defaultMessage='Compose' /></Link>
+        <Link className='tabs-bar__link primary' activeClassName='active' to='/timelines/home'><i className='fa fa-fw fa-home' /><FormattedMessage id='tabs_bar.home' defaultMessage='Home' /></Link>
+        <Link className='tabs-bar__link primary' activeClassName='active' to='/notifications'><i className='fa fa-fw fa-bell' /><FormattedMessage id='tabs_bar.notifications' defaultMessage='Notifications' /></Link>
+
+        <Link className='tabs-bar__link secondary' activeClassName='active' to='/timelines/public/local'><i className='fa fa-fw fa-users' /><FormattedMessage id='tabs_bar.local_timeline' defaultMessage='Local' /></Link>
+        <Link className='tabs-bar__link secondary' activeClassName='active' to='/timelines/public'><i className='fa fa-fw fa-globe' /><FormattedMessage id='tabs_bar.federated_timeline' defaultMessage='Federated' /></Link>
+
+        <Link className='tabs-bar__link primary' activeClassName='active' style={{ flexGrow: '0', flexBasis: '30px' }} to='/getting-started'><i className='fa fa-fw fa-asterisk' /></Link>
+      </div>
+    );
+  }
+
+}
+
+export default TabsBar;
diff --git a/app/javascript/mastodon/features/ui/components/upload_area.js b/app/javascript/mastodon/features/ui/components/upload_area.js
new file mode 100644
index 000000000..c5710ee69
--- /dev/null
+++ b/app/javascript/mastodon/features/ui/components/upload_area.js
@@ -0,0 +1,60 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { Motion, spring } from 'react-motion';
+import { FormattedMessage } from 'react-intl';
+
+class UploadArea extends React.PureComponent {
+
+  constructor (props, context) {
+    super(props, context);
+
+    this.handleKeyUp = this.handleKeyUp.bind(this);
+  }
+
+  handleKeyUp (e) {
+    e.preventDefault();
+    e.stopPropagation();
+
+    const keyCode = e.keyCode
+    if (this.props.active) {
+      switch(keyCode) {
+      case 27:
+        this.props.onClose();
+        break;
+      }
+    }
+  }
+
+  componentDidMount () {
+    window.addEventListener('keyup', this.handleKeyUp, false);
+  }
+
+  componentWillUnmount () {
+    window.removeEventListener('keyup', this.handleKeyUp);
+  }
+
+  render () {
+    const { active } = this.props;
+
+    return (
+      <Motion defaultStyle={{ backgroundOpacity: 0, backgroundScale: 0.95 }} style={{ backgroundOpacity: spring(active ? 1 : 0, { stiffness: 150, damping: 15 }), backgroundScale: spring(active ? 1 : 0.95, { stiffness: 200, damping: 3 }) }}>
+        {({ backgroundOpacity, backgroundScale }) =>
+          <div className='upload-area' style={{ visibility: active ? 'visible' : 'hidden', opacity: backgroundOpacity }}>
+            <div className='upload-area__drop'>
+              <div className='upload-area__background' style={{ transform: `translateZ(0) scale(${backgroundScale})` }} />
+              <div className='upload-area__content'><FormattedMessage id='upload_area.title' defaultMessage='Drag & drop to upload' /></div>
+            </div>
+          </div>
+        }
+      </Motion>
+    );
+  }
+
+}
+
+UploadArea.propTypes = {
+  active: PropTypes.bool,
+  onClose: PropTypes.func
+};
+
+export default UploadArea;
diff --git a/app/javascript/mastodon/features/ui/components/video_modal.js b/app/javascript/mastodon/features/ui/components/video_modal.js
new file mode 100644
index 000000000..8e2e4a533
--- /dev/null
+++ b/app/javascript/mastodon/features/ui/components/video_modal.js
@@ -0,0 +1,40 @@
+import React from 'react';
+import LoadingIndicator from '../../../components/loading_indicator';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
+import ExtendedVideoPlayer from '../../../components/extended_video_player';
+import { defineMessages, injectIntl } from 'react-intl';
+import IconButton from '../../../components/icon_button';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+
+const messages = defineMessages({
+  close: { id: 'lightbox.close', defaultMessage: 'Close' }
+});
+
+class VideoModal extends ImmutablePureComponent {
+
+  render () {
+    const { media, intl, time, onClose } = this.props;
+
+    const url = media.get('url');
+
+    return (
+      <div className='modal-root__modal media-modal'>
+        <div>
+          <div className='media-modal__close'><IconButton title={intl.formatMessage(messages.close)} icon='times' overlay onClick={onClose} /></div>
+          <ExtendedVideoPlayer src={url} muted={false} controls={true} time={time} />
+        </div>
+      </div>
+    );
+  }
+
+}
+
+VideoModal.propTypes = {
+  media: ImmutablePropTypes.map.isRequired,
+  time: PropTypes.number,
+  onClose: PropTypes.func.isRequired,
+  intl: PropTypes.object.isRequired
+};
+
+export default injectIntl(VideoModal);
diff --git a/app/javascript/mastodon/features/ui/containers/loading_bar_container.js b/app/javascript/mastodon/features/ui/containers/loading_bar_container.js
new file mode 100644
index 000000000..6c4e73e38
--- /dev/null
+++ b/app/javascript/mastodon/features/ui/containers/loading_bar_container.js
@@ -0,0 +1,8 @@
+import { connect }    from 'react-redux';
+import LoadingBar from 'react-redux-loading-bar';
+
+const mapStateToProps = (state) => ({
+  loading: state.get('loadingBar')
+});
+
+export default connect(mapStateToProps)(LoadingBar.WrappedComponent);
diff --git a/app/javascript/mastodon/features/ui/containers/modal_container.js b/app/javascript/mastodon/features/ui/containers/modal_container.js
new file mode 100644
index 000000000..26d77818c
--- /dev/null
+++ b/app/javascript/mastodon/features/ui/containers/modal_container.js
@@ -0,0 +1,16 @@
+import { connect } from 'react-redux';
+import { closeModal } from '../../../actions/modal';
+import ModalRoot from '../components/modal_root';
+
+const mapStateToProps = state => ({
+  type: state.get('modal').modalType,
+  props: state.get('modal').modalProps
+});
+
+const mapDispatchToProps = dispatch => ({
+  onClose () {
+    dispatch(closeModal());
+  },
+});
+
+export default connect(mapStateToProps, mapDispatchToProps)(ModalRoot);
diff --git a/app/javascript/mastodon/features/ui/containers/notifications_container.js b/app/javascript/mastodon/features/ui/containers/notifications_container.js
new file mode 100644
index 000000000..529ebf6c8
--- /dev/null
+++ b/app/javascript/mastodon/features/ui/containers/notifications_container.js
@@ -0,0 +1,21 @@
+import { connect } from 'react-redux';
+import { NotificationStack } from 'react-notification';
+import {
+  dismissAlert,
+  clearAlerts
+} from '../../../actions/alerts';
+import { getAlerts } from '../../../selectors';
+
+const mapStateToProps = (state, props) => ({
+  notifications: getAlerts(state)
+});
+
+const mapDispatchToProps = (dispatch) => {
+  return {
+    onDismiss: alert => {
+      dispatch(dismissAlert(alert));
+    }
+  };
+};
+
+export default connect(mapStateToProps, mapDispatchToProps)(NotificationStack);
diff --git a/app/javascript/mastodon/features/ui/containers/status_list_container.js b/app/javascript/mastodon/features/ui/containers/status_list_container.js
new file mode 100644
index 000000000..1599000b5
--- /dev/null
+++ b/app/javascript/mastodon/features/ui/containers/status_list_container.js
@@ -0,0 +1,74 @@
+import { connect } from 'react-redux';
+import StatusList from '../../../components/status_list';
+import { expandTimeline, scrollTopTimeline } from '../../../actions/timelines';
+import Immutable from 'immutable';
+import { createSelector } from 'reselect';
+import { debounce } from 'react-decoration';
+
+const makeGetStatusIds = () => createSelector([
+  (state, { type }) => state.getIn(['settings', type], Immutable.Map()),
+  (state, { type }) => state.getIn(['timelines', type, 'items'], Immutable.List()),
+  (state)           => state.get('statuses'),
+  (state)           => state.getIn(['meta', 'me'])
+], (columnSettings, statusIds, statuses, me) => statusIds.filter(id => {
+  const statusForId = statuses.get(id);
+  let showStatus    = true;
+
+  if (columnSettings.getIn(['shows', 'reblog']) === false) {
+    showStatus = showStatus && statusForId.get('reblog') === null;
+  }
+
+  if (columnSettings.getIn(['shows', 'reply']) === false) {
+    showStatus = showStatus && (statusForId.get('in_reply_to_id') === null || statusForId.get('in_reply_to_account_id') === me);
+  }
+
+  if (columnSettings.getIn(['regex', 'body'], '').trim().length > 0) {
+    try {
+      if (showStatus) {
+        const regex = new RegExp(columnSettings.getIn(['regex', 'body']).trim(), 'i');
+        showStatus = !regex.test(statusForId.get('reblog') ? statuses.getIn([statusForId.get('reblog'), 'unescaped_content']) : statusForId.get('unescaped_content'));
+      }
+    } catch(e) {
+      // Bad regex, don't affect filters
+    }
+  }
+
+  return showStatus;
+}));
+
+const makeMapStateToProps = () => {
+  const getStatusIds = makeGetStatusIds();
+
+  const mapStateToProps = (state, props) => ({
+    scrollKey: props.scrollKey,
+    shouldUpdateScroll: props.shouldUpdateScroll,
+    statusIds: getStatusIds(state, props),
+    isLoading: state.getIn(['timelines', props.type, 'isLoading'], true),
+    isUnread: state.getIn(['timelines', props.type, 'unread']) > 0,
+    hasMore: !!state.getIn(['timelines', props.type, 'next'])
+  });
+
+  return mapStateToProps;
+};
+
+const mapDispatchToProps = (dispatch, { type, id }) => ({
+
+  @debounce(300, true)
+  onScrollToBottom () {
+    dispatch(scrollTopTimeline(type, false));
+    dispatch(expandTimeline(type, id));
+  },
+
+  @debounce(100)
+  onScrollToTop () {
+    dispatch(scrollTopTimeline(type, true));
+  },
+
+  @debounce(100)
+  onScroll () {
+    dispatch(scrollTopTimeline(type, false));
+  }
+
+});
+
+export default connect(makeMapStateToProps, mapDispatchToProps)(StatusList);
diff --git a/app/javascript/mastodon/features/ui/index.js b/app/javascript/mastodon/features/ui/index.js
new file mode 100644
index 000000000..d096cb882
--- /dev/null
+++ b/app/javascript/mastodon/features/ui/index.js
@@ -0,0 +1,169 @@
+import React from 'react';
+import ColumnsArea from './components/columns_area';
+import NotificationsContainer from './containers/notifications_container';
+import PropTypes from 'prop-types';
+import LoadingBarContainer from './containers/loading_bar_container';
+import HomeTimeline from '../home_timeline';
+import Compose from '../compose';
+import TabsBar from './components/tabs_bar';
+import ModalContainer from './containers/modal_container';
+import Notifications from '../notifications';
+import { connect } from 'react-redux';
+import { isMobile } from '../../is_mobile';
+import { debounce } from 'react-decoration';
+import { uploadCompose } from '../../actions/compose';
+import { refreshTimeline } from '../../actions/timelines';
+import { refreshNotifications } from '../../actions/notifications';
+import UploadArea from './components/upload_area';
+
+const noOp = () => false;
+
+class UI extends React.PureComponent {
+
+  constructor (props, context) {
+    super(props, context);
+    this.state = {
+      width: window.innerWidth,
+      draggingOver: false
+    };
+    this.handleResize = this.handleResize.bind(this);
+    this.handleDragEnter = this.handleDragEnter.bind(this);
+    this.handleDragOver = this.handleDragOver.bind(this);
+    this.handleDrop = this.handleDrop.bind(this);
+    this.handleDragLeave = this.handleDragLeave.bind(this);
+    this.handleDragEnd = this.handleDragLeave.bind(this)
+    this.closeUploadModal = this.closeUploadModal.bind(this)
+    this.setRef = this.setRef.bind(this);
+  }
+
+  @debounce(500)
+  handleResize () {
+    this.setState({ width: window.innerWidth });
+  }
+
+  handleDragEnter (e) {
+    e.preventDefault();
+
+    if (!this.dragTargets) {
+      this.dragTargets = [];
+    }
+
+    if (this.dragTargets.indexOf(e.target) === -1) {
+      this.dragTargets.push(e.target);
+    }
+
+    if (e.dataTransfer && e.dataTransfer.types.includes('Files')) {
+      this.setState({ draggingOver: true });
+    }
+  }
+
+  handleDragOver (e) {
+    e.preventDefault();
+    e.stopPropagation();
+
+    try {
+      e.dataTransfer.dropEffect = 'copy';
+    } catch (err) {
+
+    }
+
+    return false;
+  }
+
+  handleDrop (e) {
+    e.preventDefault();
+
+    this.setState({ draggingOver: false });
+
+    if (e.dataTransfer && e.dataTransfer.files.length === 1) {
+      this.props.dispatch(uploadCompose(e.dataTransfer.files));
+    }
+  }
+
+  handleDragLeave (e) {
+    e.preventDefault();
+    e.stopPropagation();
+
+    this.dragTargets = this.dragTargets.filter(el => el !== e.target && this.node.contains(el));
+
+    if (this.dragTargets.length > 0) {
+      return;
+    }
+
+    this.setState({ draggingOver: false });
+  }
+
+  closeUploadModal() {
+    this.setState({ draggingOver: false });
+  }
+
+  componentWillMount () {
+    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);
+    document.addEventListener('dragleave', this.handleDragLeave, false);
+    document.addEventListener('dragend', this.handleDragEnd, false);
+
+    this.props.dispatch(refreshTimeline('home'));
+    this.props.dispatch(refreshNotifications());
+  }
+
+  componentWillUnmount () {
+    window.removeEventListener('resize', this.handleResize);
+    document.removeEventListener('dragenter', this.handleDragEnter);
+    document.removeEventListener('dragover', this.handleDragOver);
+    document.removeEventListener('drop', this.handleDrop);
+    document.removeEventListener('dragleave', this.handleDragLeave);
+    document.removeEventListener('dragend', this.handleDragEnd);
+  }
+
+  setRef (c) {
+    this.node = c;
+  }
+
+  render () {
+    const { width, draggingOver } = this.state;
+    const { children } = this.props;
+
+    let mountedColumns;
+
+    if (isMobile(width)) {
+      mountedColumns = (
+        <ColumnsArea>
+          {children}
+        </ColumnsArea>
+      );
+    } else {
+      mountedColumns = (
+        <ColumnsArea>
+          <Compose withHeader={true} />
+          <HomeTimeline shouldUpdateScroll={noOp} />
+          <Notifications shouldUpdateScroll={noOp} />
+          <div style={{display: 'flex', flex: '1 1 auto', position: 'relative'}}>{children}</div>
+        </ColumnsArea>
+      );
+    }
+
+    return (
+      <div className='ui' ref={this.setRef}>
+        <TabsBar />
+
+        {mountedColumns}
+
+        <NotificationsContainer />
+        <LoadingBarContainer className="loading-bar" />
+        <ModalContainer />
+        <UploadArea active={draggingOver} onClose={this.closeUploadModal} />
+      </div>
+    );
+  }
+
+}
+
+UI.propTypes = {
+  dispatch: PropTypes.func.isRequired,
+  children: PropTypes.node
+};
+
+export default connect()(UI);