about summary refs log tree commit diff
path: root/app/assets/javascripts/components/features
diff options
context:
space:
mode:
Diffstat (limited to 'app/assets/javascripts/components/features')
-rw-r--r--app/assets/javascripts/components/features/status/index.jsx4
-rw-r--r--app/assets/javascripts/components/features/ui/components/character_counter.jsx21
-rw-r--r--app/assets/javascripts/components/features/ui/components/column.jsx76
-rw-r--r--app/assets/javascripts/components/features/ui/components/column_header.jsx34
-rw-r--r--app/assets/javascripts/components/features/ui/components/columns_area.jsx17
-rw-r--r--app/assets/javascripts/components/features/ui/components/compose_form.jsx58
-rw-r--r--app/assets/javascripts/components/features/ui/components/drawer.jsx17
-rw-r--r--app/assets/javascripts/components/features/ui/components/follow_form.jsx40
-rw-r--r--app/assets/javascripts/components/features/ui/components/media_gallery.jsx75
-rw-r--r--app/assets/javascripts/components/features/ui/components/navigation_bar.jsx30
-rw-r--r--app/assets/javascripts/components/features/ui/components/reply_indicator.jsx41
-rw-r--r--app/assets/javascripts/components/features/ui/components/upload_button.jsx37
-rw-r--r--app/assets/javascripts/components/features/ui/components/upload_form.jsx43
-rw-r--r--app/assets/javascripts/components/features/ui/containers/compose_form_container.jsx42
-rw-r--r--app/assets/javascripts/components/features/ui/containers/follow_form_container.jsx24
-rw-r--r--app/assets/javascripts/components/features/ui/containers/navigation_container.jsx8
-rw-r--r--app/assets/javascripts/components/features/ui/containers/notifications_container.jsx25
-rw-r--r--app/assets/javascripts/components/features/ui/containers/status_list_container.jsx29
-rw-r--r--app/assets/javascripts/components/features/ui/containers/upload_form_container.jsx25
-rw-r--r--app/assets/javascripts/components/features/ui/index.jsx56
20 files changed, 700 insertions, 2 deletions
diff --git a/app/assets/javascripts/components/features/status/index.jsx b/app/assets/javascripts/components/features/status/index.jsx
index 72ff6a944..7a810d474 100644
--- a/app/assets/javascripts/components/features/status/index.jsx
+++ b/app/assets/javascripts/components/features/status/index.jsx
@@ -31,12 +31,12 @@ const Status = React.createClass({
   mixins: [PureRenderMixin],
 
   componentWillMount () {
-    this.props.dispatch(fetchStatus(this.props.params.statusId));
+    this.props.dispatch(fetchStatus(Number(this.props.params.statusId)));
   },
 
   componentWillReceiveProps (nextProps) {
     if (nextProps.params.statusId !== this.props.params.statusId && nextProps.params.statusId) {
-      this.props.dispatch(fetchStatus(nextProps.params.statusId));
+      this.props.dispatch(fetchStatus(Number(nextProps.params.statusId)));
     }
   },
 
diff --git a/app/assets/javascripts/components/features/ui/components/character_counter.jsx b/app/assets/javascripts/components/features/ui/components/character_counter.jsx
new file mode 100644
index 000000000..dd9218844
--- /dev/null
+++ b/app/assets/javascripts/components/features/ui/components/character_counter.jsx
@@ -0,0 +1,21 @@
+import PureRenderMixin from 'react-addons-pure-render-mixin';
+
+const CharacterCounter = React.createClass({
+
+  propTypes: {
+    text: React.PropTypes.string.isRequired
+  },
+
+  mixins: [PureRenderMixin],
+
+  render () {
+    return (
+      <span style={{ fontSize: '16px', cursor: 'default' }}>
+        {this.props.text.length}
+      </span>
+    );
+  }
+
+});
+
+export default CharacterCounter;
diff --git a/app/assets/javascripts/components/features/ui/components/column.jsx b/app/assets/javascripts/components/features/ui/components/column.jsx
new file mode 100644
index 000000000..7109bcc19
--- /dev/null
+++ b/app/assets/javascripts/components/features/ui/components/column.jsx
@@ -0,0 +1,76 @@
+import ColumnHeader    from './column_header';
+import PureRenderMixin from 'react-addons-pure-render-mixin';
+
+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.scrollTo(0, easingOutQuint(0, elapsed, offset, targetY, duration));
+    requestAnimationFrame(step);
+  };
+
+  step();
+
+  return () => {
+    interrupt = true;
+  };
+};
+
+
+const Column = React.createClass({
+
+  propTypes: {
+    heading: React.PropTypes.string,
+    icon: React.PropTypes.string
+  },
+
+  mixins: [PureRenderMixin],
+
+  handleHeaderClick () {
+    let node = ReactDOM.findDOMNode(this);
+    this._interruptScrollAnimation = scrollTop(node.querySelector('.scrollable'));
+  },
+
+  handleWheel () {
+    if (typeof this._interruptScrollAnimation !== 'undefined') {
+      this._interruptScrollAnimation();
+    }
+  },
+
+  handleScroll () {
+    // todo
+  },
+
+  render () {
+    let header = '';
+
+    if (this.props.heading) {
+      header = <ColumnHeader icon={this.props.icon} type={this.props.heading} onClick={this.handleHeaderClick} />;
+    }
+
+    const style = { width: '350px', flex: '0 0 auto', background: '#282c37', margin: '10px', marginRight: '0', display: 'flex', flexDirection: 'column' };
+
+    return (
+      <div style={style} onWheel={this.handleWheel} onScroll={this.handleScroll}>
+        {header}
+        {this.props.children}
+      </div>
+    );
+  }
+
+});
+
+export default Column;
diff --git a/app/assets/javascripts/components/features/ui/components/column_header.jsx b/app/assets/javascripts/components/features/ui/components/column_header.jsx
new file mode 100644
index 000000000..21def69c7
--- /dev/null
+++ b/app/assets/javascripts/components/features/ui/components/column_header.jsx
@@ -0,0 +1,34 @@
+import PureRenderMixin from 'react-addons-pure-render-mixin';
+
+const ColumnHeader = React.createClass({
+
+  propTypes: {
+    icon: React.PropTypes.string,
+    type: React.PropTypes.string,
+    onClick: React.PropTypes.func
+  },
+
+  mixins: [PureRenderMixin],
+
+  handleClick () {
+    this.props.onClick();
+  },
+
+  render () {
+    let icon = '';
+
+    if (this.props.icon) {
+      icon = <i className={`fa fa-fw fa-${this.props.icon}`} style={{ display: 'inline-block', marginRight: '5px' }} />;
+    }
+
+    return (
+      <div onClick={this.handleClick} style={{ padding: '15px', fontSize: '16px', background: '#2f3441', flex: '0 0 auto', cursor: 'pointer' }}>
+        {icon}
+        {this.props.type}
+      </div>
+    );
+  }
+
+});
+
+export default ColumnHeader;
diff --git a/app/assets/javascripts/components/features/ui/components/columns_area.jsx b/app/assets/javascripts/components/features/ui/components/columns_area.jsx
new file mode 100644
index 000000000..e45a6466d
--- /dev/null
+++ b/app/assets/javascripts/components/features/ui/components/columns_area.jsx
@@ -0,0 +1,17 @@
+import PureRenderMixin from 'react-addons-pure-render-mixin';
+
+const ColumnsArea = React.createClass({
+
+  mixins: [PureRenderMixin],
+
+  render () {
+    return (
+      <div style={{ display: 'flex', flexDirection: 'row', flex: '1', marginRight: '10px' }}>
+        {this.props.children}
+      </div>
+    );
+  }
+
+});
+
+export default ColumnsArea;
diff --git a/app/assets/javascripts/components/features/ui/components/compose_form.jsx b/app/assets/javascripts/components/features/ui/components/compose_form.jsx
new file mode 100644
index 000000000..1aa0b447f
--- /dev/null
+++ b/app/assets/javascripts/components/features/ui/components/compose_form.jsx
@@ -0,0 +1,58 @@
+import CharacterCounter   from './character_counter';
+import Button             from '../../../components/button';
+import PureRenderMixin    from 'react-addons-pure-render-mixin';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import ReplyIndicator     from './reply_indicator';
+import UploadButton       from './upload_button';
+
+const ComposeForm = React.createClass({
+
+  propTypes: {
+    text: React.PropTypes.string.isRequired,
+    is_submitting: React.PropTypes.bool,
+    in_reply_to: ImmutablePropTypes.map,
+    onChange: React.PropTypes.func.isRequired,
+    onSubmit: React.PropTypes.func.isRequired,
+    onCancelReply: React.PropTypes.func.isRequired
+  },
+
+  mixins: [PureRenderMixin],
+
+  handleChange (e) {
+    this.props.onChange(e.target.value);
+  },
+
+  handleKeyUp (e) {
+    if (e.keyCode === 13 && e.ctrlKey) {
+      this.props.onSubmit();
+    }
+  },
+
+  handleSubmit () {
+    this.props.onSubmit();
+  },
+
+  render () {
+    let replyArea = '';
+
+    if (this.props.in_reply_to) {
+      replyArea = <ReplyIndicator status={this.props.in_reply_to} onCancel={this.props.onCancelReply} />;
+    }
+
+    return (
+      <div style={{ padding: '10px' }}>
+        {replyArea}
+
+        <textarea disabled={this.props.is_submitting} placeholder='What is on your mind?' value={this.props.text} onKeyUp={this.handleKeyUp} onChange={this.handleChange} className='compose-form__textarea' style={{ display: 'block', boxSizing: 'border-box', width: '100%', height: '100px', resize: 'none', border: 'none', color: '#282c37', padding: '10px', fontFamily: 'Roboto', fontSize: '14px', margin: '0' }} />
+
+        <div style={{ marginTop: '10px', overflow: 'hidden' }}>
+          <div style={{ float: 'right' }}><Button text='Publish' onClick={this.handleSubmit} disabled={this.props.is_submitting} /></div>
+          <div style={{ float: 'right', marginRight: '16px', lineHeight: '36px' }}><CharacterCounter text={this.props.text} /></div>
+        </div>
+      </div>
+    );
+  }
+
+});
+
+export default ComposeForm;
diff --git a/app/assets/javascripts/components/features/ui/components/drawer.jsx b/app/assets/javascripts/components/features/ui/components/drawer.jsx
new file mode 100644
index 000000000..dfba11ad2
--- /dev/null
+++ b/app/assets/javascripts/components/features/ui/components/drawer.jsx
@@ -0,0 +1,17 @@
+import PureRenderMixin from 'react-addons-pure-render-mixin';
+
+const Drawer = React.createClass({
+
+  mixins: [PureRenderMixin],
+
+  render () {
+    return (
+      <div style={{ width: '280px', flex: '0 0 auto', boxSizing: 'border-box', background: '#454b5e', margin: '10px', marginRight: '0', padding: '0', display: 'flex', flexDirection: 'column' }}>
+        {this.props.children}
+      </div>
+    );
+  }
+
+});
+
+export default Drawer;
diff --git a/app/assets/javascripts/components/features/ui/components/follow_form.jsx b/app/assets/javascripts/components/features/ui/components/follow_form.jsx
new file mode 100644
index 000000000..a9d73a9a1
--- /dev/null
+++ b/app/assets/javascripts/components/features/ui/components/follow_form.jsx
@@ -0,0 +1,40 @@
+import IconButton      from '../../../components/icon_button';
+import PureRenderMixin from 'react-addons-pure-render-mixin';
+
+const FollowForm = React.createClass({
+
+  propTypes: {
+    text: React.PropTypes.string.isRequired,
+    is_submitting: React.PropTypes.bool,
+    onChange: React.PropTypes.func.isRequired,
+    onSubmit: React.PropTypes.func.isRequired
+  },
+
+  mixins: [PureRenderMixin],
+
+  handleChange (e) {
+    this.props.onChange(e.target.value);
+  },
+
+  handleKeyUp (e) {
+    if (e.keyCode === 13) {
+      this.props.onSubmit();
+    }
+  },
+
+  handleSubmit () {
+    this.props.onSubmit();
+  },
+
+  render () {
+    return (
+      <div style={{ display: 'flex', lineHeight: '20px', padding: '10px', background: '#373b4a' }}>
+        <input type='text' disabled={this.props.is_submitting} placeholder='username@domain' value={this.props.text} onKeyUp={this.handleKeyUp} onChange={this.handleChange} className='follow-form__input' style={{ flex: '1 1 auto', boxSizing: 'border-box', display: 'block', border: 'none', padding: '10px', fontFamily: 'Roboto', color: '#282c37', fontSize: '14px', margin: '0' }} />
+        <div style={{ padding: '10px', paddingRight: '0' }}><IconButton title='Follow' size={20} icon='user-plus' onClick={this.handleSubmit} disabled={this.props.is_submitting} /></div>
+      </div>
+    );
+  }
+
+});
+
+export default FollowForm;
diff --git a/app/assets/javascripts/components/features/ui/components/media_gallery.jsx b/app/assets/javascripts/components/features/ui/components/media_gallery.jsx
new file mode 100644
index 000000000..20f9a3d87
--- /dev/null
+++ b/app/assets/javascripts/components/features/ui/components/media_gallery.jsx
@@ -0,0 +1,75 @@
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import PureRenderMixin    from 'react-addons-pure-render-mixin';
+
+const MediaGallery = React.createClass({
+
+  propTypes: {
+    media: ImmutablePropTypes.list.isRequired,
+    height: React.PropTypes.number.isRequired
+  },
+
+  mixins: [PureRenderMixin],
+
+  render () {
+    var children = this.props.media.take(4);
+    var size     = children.size;
+
+    children = children.map((attachment, i) => {
+      let width  = 50;
+      let height = 100;
+      let top    = 'auto';
+      let left   = 'auto';
+      let bottom = 'auto';
+      let right  = 'auto';
+
+      if (size === 4 || (size === 3 && i > 0)) {
+        height = 50;
+      }
+
+      if (size === 2) {
+        if (i === 0) {
+          right = '2px';
+        } else {
+          left = '2px';
+        }
+      } else if (size === 3) {
+        if (i === 0) {
+          right = '2px';
+        } else if (i > 0) {
+          left = '2px';
+        }
+
+        if (i === 1) {
+          bottom = '2px';
+        } else if (i > 1) {
+          top = '2px';
+        }
+      } else if (size === 4) {
+        if (i === 0 || i === 2) {
+          right = '2px';
+        }
+
+        if (i === 1 || i === 3) {
+          left = '2px';
+        }
+
+        if (i < 2) {
+          bottom = '2px';
+        } else {
+          top = '2px';
+        }
+      }
+
+      return <a key={attachment.get('id')} href={attachment.get('url')} target='_blank' style={{ boxSizing: 'border-box', position: 'relative', left: left, top: top, right: right, bottom: bottom, float: 'left', textDecoration: 'none', border: 'none', display: 'block', width: `${width}%`, height: `${height}%`, background: `url(${attachment.get('preview_url')}) no-repeat center`, backgroundSize: 'cover', cursor: 'zoom-in' }} />;
+    });
+
+    return (
+      <div style={{ marginTop: '8px', overflow: 'hidden', width: '100%', height: `${this.props.height}px`, boxSizing: 'border-box' }}>
+        {children}
+      </div>
+    );
+  }
+
+});
+
+export default MediaGallery;
diff --git a/app/assets/javascripts/components/features/ui/components/navigation_bar.jsx b/app/assets/javascripts/components/features/ui/components/navigation_bar.jsx
new file mode 100644
index 000000000..b5d374a88
--- /dev/null
+++ b/app/assets/javascripts/components/features/ui/components/navigation_bar.jsx
@@ -0,0 +1,30 @@
+import PureRenderMixin    from 'react-addons-pure-render-mixin';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import Avatar             from '../../../components/avatar';
+import IconButton         from '../../../components/icon_button';
+import DisplayName        from '../../../components/display_name';
+import { Link }           from 'react-router';
+
+const NavigationBar = React.createClass({
+  propTypes: {
+    account: ImmutablePropTypes.map.isRequired
+  },
+
+  mixins: [PureRenderMixin],
+
+  render () {
+    return (
+      <div style={{ padding: '10px', display: 'flex', cursor: 'default' }}>
+        <Link to={`/accounts/${this.props.account.get('id')}`} style={{ textDecoration: 'none' }}><Avatar src={this.props.account.get('avatar')} size={40} /></Link>
+
+        <div style={{ flex: '1 1 auto', marginLeft: '8px' }}>
+          <strong style={{ fontWeight: '500', display: 'block' }}>{this.props.account.get('acct')}</strong>
+          <Link to='/settings' style={{ color: '#9baec8', textDecoration: 'none' }}>Settings <i className='fa fa fa-cog' /></Link>
+        </div>
+      </div>
+    );
+  }
+
+});
+
+export default NavigationBar;
diff --git a/app/assets/javascripts/components/features/ui/components/reply_indicator.jsx b/app/assets/javascripts/components/features/ui/components/reply_indicator.jsx
new file mode 100644
index 000000000..316a23b54
--- /dev/null
+++ b/app/assets/javascripts/components/features/ui/components/reply_indicator.jsx
@@ -0,0 +1,41 @@
+import PureRenderMixin    from 'react-addons-pure-render-mixin';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import Avatar             from '../../../components/avatar';
+import IconButton         from '../../../components/icon_button';
+import DisplayName        from '../../../components/display_name';
+
+const ReplyIndicator = React.createClass({
+
+  propTypes: {
+    status: ImmutablePropTypes.map.isRequired,
+    onCancel: React.PropTypes.func.isRequired
+  },
+
+  mixins: [PureRenderMixin],
+
+  handleClick () {
+    this.props.onCancel();
+  },
+
+  render () {
+    let content = { __html: this.props.status.get('content') };
+
+    return (
+      <div style={{ background: '#9baec8', padding: '10px' }}>
+        <div style={{ overflow: 'hidden', marginBottom: '5px' }}>
+          <div style={{ float: 'right', lineHeight: '24px' }}><IconButton title='Cancel' icon='times' onClick={this.handleClick} /></div>
+
+          <a href={this.props.status.getIn(['account', 'url'])} className='reply-indicator__display-name' style={{ display: 'block', maxWidth: '100%', paddingRight: '25px', color: '#282c37', textDecoration: 'none', overflow: 'hidden', lineHeight: '24px' }}>
+            <div style={{ float: 'left', marginRight: '5px' }}><Avatar size={24} src={this.props.status.getIn(['account', 'avatar'])} /></div>
+            <DisplayName account={this.props.status.get('account')} />
+          </a>
+        </div>
+
+        <div className='reply-indicator__content' dangerouslySetInnerHTML={content} />
+      </div>
+    );
+  }
+
+});
+
+export default ReplyIndicator;
diff --git a/app/assets/javascripts/components/features/ui/components/upload_button.jsx b/app/assets/javascripts/components/features/ui/components/upload_button.jsx
new file mode 100644
index 000000000..d1b093242
--- /dev/null
+++ b/app/assets/javascripts/components/features/ui/components/upload_button.jsx
@@ -0,0 +1,37 @@
+import PureRenderMixin from 'react-addons-pure-render-mixin';
+import Button          from '../../../components/button';
+
+const UploadButton = React.createClass({
+
+  propTypes: {
+    disabled: React.PropTypes.bool,
+    onSelectFile: React.PropTypes.func.isRequired
+  },
+
+  mixins: [PureRenderMixin],
+
+  handleChange (e) {
+    if (e.target.files.length > 0) {
+      this.props.onSelectFile(e.target.files);
+    }
+  },
+
+  handleClick () {
+    this.refs.fileElement.click();
+  },
+
+  render () {
+    return (
+      <div>
+        <Button disabled={this.props.disabled} onClick={this.handleClick} block={true}>
+          <i className='fa fa-fw fa-photo' /> Add images
+        </Button>
+
+        <input ref='fileElement' type='file' multiple={false} onChange={this.handleChange} disabled={this.props.disabled} style={{ display: 'none' }} />
+      </div>
+    );
+  }
+
+});
+
+export default UploadButton;
diff --git a/app/assets/javascripts/components/features/ui/components/upload_form.jsx b/app/assets/javascripts/components/features/ui/components/upload_form.jsx
new file mode 100644
index 000000000..d584e9ab7
--- /dev/null
+++ b/app/assets/javascripts/components/features/ui/components/upload_form.jsx
@@ -0,0 +1,43 @@
+import PureRenderMixin    from 'react-addons-pure-render-mixin';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import UploadButton       from './upload_button';
+import IconButton         from '../../../components/icon_button';
+
+const UploadForm = React.createClass({
+
+  propTypes: {
+    media: ImmutablePropTypes.list.isRequired,
+    is_uploading: React.PropTypes.bool,
+    onSelectFile: React.PropTypes.func.isRequired,
+    onRemoveFile: React.PropTypes.func.isRequired
+  },
+
+  mixins: [PureRenderMixin],
+
+  render () {
+    let uploads = this.props.media.map(function (attachment) {
+      return (
+        <div key={attachment.get('id')} style={{ borderRadius: '4px', marginBottom: '10px' }} className='transparent-background'>
+          <div style={{ width: '100%', height: '100px', borderRadius: '4px', background: `url(${attachment.get('preview_url')}) no-repeat center`, backgroundSize: 'cover' }}>
+            <IconButton icon='times' title='Undo' size={36} onClick={() => this.props.onRemoveFile(attachment.get('id'))} />
+          </div>
+        </div>
+      );
+    }.bind(this));
+
+    const noMoreAllowed = (this.props.media.some(m => m.get('type') === 'video')) || (this.props.media.size > 3);
+
+    return (
+      <div style={{ marginBottom: '20px', padding: '10px', paddingTop: '0' }}>
+        <UploadButton onSelectFile={this.props.onSelectFile} disabled={this.props.is_uploading || noMoreAllowed } />
+
+        <div style={{ marginTop: '10px', overflow: 'hidden' }}>
+          {uploads}
+        </div>
+      </div>
+    );
+  }
+
+});
+
+export default UploadForm;
diff --git a/app/assets/javascripts/components/features/ui/containers/compose_form_container.jsx b/app/assets/javascripts/components/features/ui/containers/compose_form_container.jsx
new file mode 100644
index 000000000..a092a1e8e
--- /dev/null
+++ b/app/assets/javascripts/components/features/ui/containers/compose_form_container.jsx
@@ -0,0 +1,42 @@
+import { connect }                                          from 'react-redux';
+import ComposeForm                                          from '../components/compose_form';
+import { changeCompose, submitCompose, cancelReplyCompose } from '../../../actions/compose';
+
+function selectStatus(state) {
+  let statusId = state.getIn(['compose', 'in_reply_to'], null);
+
+  if (statusId === null) {
+    return null;
+  }
+
+  let status = state.getIn(['timelines', 'statuses', statusId]);
+  status = status.set('account', state.getIn(['timelines', 'accounts', status.get('account')]));
+
+  return status;
+};
+
+const mapStateToProps = function (state, props) {
+  return {
+    text: state.getIn(['compose', 'text']),
+    is_submitting: state.getIn(['compose', 'is_submitting']),
+    in_reply_to: selectStatus(state)
+  };
+};
+
+const mapDispatchToProps = function (dispatch) {
+  return {
+    onChange: function (text) {
+      dispatch(changeCompose(text));
+    },
+
+    onSubmit: function () {
+      dispatch(submitCompose());
+    },
+
+    onCancelReply: function () {
+      dispatch(cancelReplyCompose());
+    }
+  }
+};
+
+export default connect(mapStateToProps, mapDispatchToProps)(ComposeForm);
diff --git a/app/assets/javascripts/components/features/ui/containers/follow_form_container.jsx b/app/assets/javascripts/components/features/ui/containers/follow_form_container.jsx
new file mode 100644
index 000000000..a21c1291b
--- /dev/null
+++ b/app/assets/javascripts/components/features/ui/containers/follow_form_container.jsx
@@ -0,0 +1,24 @@
+import { connect }                    from 'react-redux';
+import FollowForm                     from '../components/follow_form';
+import { changeFollow, submitFollow } from '../../../actions/follow';
+
+const mapStateToProps = function (state, props) {
+  return {
+    text: state.getIn(['follow', 'text']),
+    is_submitting: state.getIn(['follow', 'is_submitting'])
+  };
+};
+
+const mapDispatchToProps = function (dispatch) {
+  return {
+    onChange: function (text) {
+      dispatch(changeFollow(text));
+    },
+
+    onSubmit: function () {
+      dispatch(submitFollow());
+    }
+  }
+};
+
+export default connect(mapStateToProps, mapDispatchToProps)(FollowForm);
diff --git a/app/assets/javascripts/components/features/ui/containers/navigation_container.jsx b/app/assets/javascripts/components/features/ui/containers/navigation_container.jsx
new file mode 100644
index 000000000..4aeea4c37
--- /dev/null
+++ b/app/assets/javascripts/components/features/ui/containers/navigation_container.jsx
@@ -0,0 +1,8 @@
+import { connect }   from 'react-redux';
+import NavigationBar from '../components/navigation_bar';
+
+const mapStateToProps = (state, props) => ({
+  account: state.getIn(['timelines', 'accounts', state.getIn(['timelines', 'me'])])
+});
+
+export default connect(mapStateToProps)(NavigationBar);
diff --git a/app/assets/javascripts/components/features/ui/containers/notifications_container.jsx b/app/assets/javascripts/components/features/ui/containers/notifications_container.jsx
new file mode 100644
index 000000000..2db1603fc
--- /dev/null
+++ b/app/assets/javascripts/components/features/ui/containers/notifications_container.jsx
@@ -0,0 +1,25 @@
+import { connect }             from 'react-redux';
+import { NotificationStack }   from 'react-notification';
+import { dismissNotification } from '../../../actions/notifications';
+
+const mapStateToProps = (state, props) => {
+  return {
+    notifications: state.get('notifications').map((item, i) => ({
+      message: item.get('message'),
+      title: item.get('title'),
+      key: i,
+      action: 'Dismiss',
+      dismissAfter: 5000
+    })).toJS()
+  };
+};
+
+const mapDispatchToProps = (dispatch) => {
+  return {
+    onDismiss: notifiction => {
+      dispatch(dismissNotification(notifiction));
+    }
+  };
+};
+
+export default connect(mapStateToProps, mapDispatchToProps)(NotificationStack);
diff --git a/app/assets/javascripts/components/features/ui/containers/status_list_container.jsx b/app/assets/javascripts/components/features/ui/containers/status_list_container.jsx
new file mode 100644
index 000000000..4ea599fc0
--- /dev/null
+++ b/app/assets/javascripts/components/features/ui/containers/status_list_container.jsx
@@ -0,0 +1,29 @@
+import { connect }           from 'react-redux';
+import StatusList            from '../../../components/status_list';
+import { replyCompose }      from '../../../actions/compose';
+import { reblog, favourite } from '../../../actions/interactions';
+import { selectStatus }      from '../../../reducers/timelines';
+
+const mapStateToProps = function (state, props) {
+  return {
+    statuses: state.getIn(['timelines', props.type]).map(id => selectStatus(state, id))
+  };
+};
+
+const mapDispatchToProps = function (dispatch) {
+  return {
+    onReply: function (status) {
+      dispatch(replyCompose(status));
+    },
+
+    onFavourite: function (status) {
+      dispatch(favourite(status));
+    },
+
+    onReblog: function (status) {
+      dispatch(reblog(status));
+    }
+  };
+};
+
+export default connect(mapStateToProps, mapDispatchToProps)(StatusList);
diff --git a/app/assets/javascripts/components/features/ui/containers/upload_form_container.jsx b/app/assets/javascripts/components/features/ui/containers/upload_form_container.jsx
new file mode 100644
index 000000000..6554f944f
--- /dev/null
+++ b/app/assets/javascripts/components/features/ui/containers/upload_form_container.jsx
@@ -0,0 +1,25 @@
+import { connect }                          from 'react-redux';
+import UploadForm                           from '../components/upload_form';
+import { uploadCompose, undoUploadCompose } from '../../../actions/compose';
+
+const mapStateToProps = function (state, props) {
+  return {
+    media: state.getIn(['compose', 'media_attachments']),
+    progress: state.getIn(['compose', 'progress']),
+    is_uploading: state.getIn(['compose', 'is_uploading'])
+  };
+};
+
+const mapDispatchToProps = function (dispatch) {
+  return {
+    onSelectFile: function (files) {
+      dispatch(uploadCompose(files));
+    },
+
+    onRemoveFile: function (media_id) {
+      dispatch(undoUploadCompose(media_id));
+    }
+  }
+};
+
+export default connect(mapStateToProps, mapDispatchToProps)(UploadForm);
diff --git a/app/assets/javascripts/components/features/ui/index.jsx b/app/assets/javascripts/components/features/ui/index.jsx
new file mode 100644
index 000000000..fbfa361f2
--- /dev/null
+++ b/app/assets/javascripts/components/features/ui/index.jsx
@@ -0,0 +1,56 @@
+import ColumnsArea            from './components/columns_area';
+import Column                 from './components/column';
+import Drawer                 from './components/drawer';
+import ComposeFormContainer   from './containers/compose_form_container';
+import FollowFormContainer    from './containers/follow_form_container';
+import UploadFormContainer    from './containers/upload_form_container';
+import StatusListContainer    from './containers/status_list_container';
+import NotificationsContainer from './containers/notifications_container';
+import NavigationContainer    from './containers/navigation_container';
+import PureRenderMixin        from 'react-addons-pure-render-mixin';
+import LoadingBar             from 'react-redux-loading-bar';
+
+const UI = React.createClass({
+
+  propTypes: {
+    router: React.PropTypes.object
+  },
+
+  mixins: [PureRenderMixin],
+
+  render () {
+    return (
+      <div style={{ flex: '0 0 auto', display: 'flex', width: '100%', height: '100%', background: '#1a1c23' }}>
+        <Drawer>
+          <div style={{ flex: '1 1 auto' }}>
+            <NavigationContainer />
+            <ComposeFormContainer />
+            <UploadFormContainer />
+          </div>
+
+          <FollowFormContainer />
+        </Drawer>
+
+        <ColumnsArea>
+          <Column icon='home' heading='Home'>
+            <StatusListContainer type='home' />
+          </Column>
+
+          <Column icon='at' heading='Mentions'>
+            <StatusListContainer type='mentions' />
+          </Column>
+
+          <Column>
+            {this.props.children}
+          </Column>
+        </ColumnsArea>
+
+        <NotificationsContainer />
+        <LoadingBar style={{ backgroundColor: '#2b90d9', left: '0', top: '0' }} />
+      </div>
+    );
+  }
+
+});
+
+export default UI;