about summary refs log tree commit diff
path: root/app/assets
diff options
context:
space:
mode:
Diffstat (limited to 'app/assets')
-rw-r--r--app/assets/javascripts/application.js1
-rw-r--r--app/assets/javascripts/cable.js12
-rw-r--r--app/assets/javascripts/components/actions/blocks.jsx82
-rw-r--r--app/assets/javascripts/components/actions/compose.jsx2
-rw-r--r--app/assets/javascripts/components/actions/modal.jsx20
-rw-r--r--app/assets/javascripts/components/actions/notifications.jsx12
-rw-r--r--app/assets/javascripts/components/components/account.jsx2
-rw-r--r--app/assets/javascripts/components/components/button.jsx4
-rw-r--r--app/assets/javascripts/components/components/column_collapsable.jsx2
-rw-r--r--app/assets/javascripts/components/components/lightbox.jsx10
-rw-r--r--app/assets/javascripts/components/components/media_gallery.jsx11
-rw-r--r--app/assets/javascripts/components/components/status_content.jsx22
-rw-r--r--app/assets/javascripts/components/containers/mastodon.jsx46
-rw-r--r--app/assets/javascripts/components/containers/status_container.jsx4
-rw-r--r--app/assets/javascripts/components/features/account/components/header.jsx2
-rw-r--r--app/assets/javascripts/components/features/blocks/index.jsx68
-rw-r--r--app/assets/javascripts/components/features/compose/components/compose_form.jsx25
-rw-r--r--app/assets/javascripts/components/features/compose/containers/compose_form_container.jsx4
-rw-r--r--app/assets/javascripts/components/features/getting_started/index.jsx6
-rw-r--r--app/assets/javascripts/components/features/hashtag_timeline/index.jsx46
-rw-r--r--app/assets/javascripts/components/features/notifications/components/clear_column_button.jsx21
-rw-r--r--app/assets/javascripts/components/features/notifications/index.jsx9
-rw-r--r--app/assets/javascripts/components/features/public_timeline/index.jsx41
-rw-r--r--app/assets/javascripts/components/features/status/index.jsx4
-rw-r--r--app/assets/javascripts/components/features/ui/components/column.jsx10
-rw-r--r--app/assets/javascripts/components/features/ui/containers/modal_container.jsx112
-rw-r--r--app/assets/javascripts/components/features/ui/containers/status_list_container.jsx7
-rw-r--r--app/assets/javascripts/components/reducers/accounts.jsx8
-rw-r--r--app/assets/javascripts/components/reducers/compose.jsx5
-rw-r--r--app/assets/javascripts/components/reducers/modal.jsx19
-rw-r--r--app/assets/javascripts/components/reducers/notifications.jsx5
-rw-r--r--app/assets/javascripts/components/reducers/user_lists.jsx14
-rw-r--r--app/assets/javascripts/components/stream.jsx21
-rw-r--r--app/assets/stylesheets/components.scss22
-rw-r--r--app/assets/stylesheets/variables.scss4
35 files changed, 547 insertions, 136 deletions
diff --git a/app/assets/javascripts/application.js b/app/assets/javascripts/application.js
index c442ded61..e2fffd932 100644
--- a/app/assets/javascripts/application.js
+++ b/app/assets/javascripts/application.js
@@ -13,4 +13,3 @@
 //= require jquery
 //= require jquery_ujs
 //= require components
-//= require cable
diff --git a/app/assets/javascripts/cable.js b/app/assets/javascripts/cable.js
deleted file mode 100644
index 03258761c..000000000
--- a/app/assets/javascripts/cable.js
+++ /dev/null
@@ -1,12 +0,0 @@
-// Action Cable provides the framework to deal with WebSockets in Rails.
-// You can generate new channels where WebSocket features live using the rails generate channel command.
-//
-//= require action_cable
-//= require_self
-
-(function() {
-  this.App || (this.App = {});
-
-  App.cable = ActionCable.createConsumer();
-
-}).call(this);
diff --git a/app/assets/javascripts/components/actions/blocks.jsx b/app/assets/javascripts/components/actions/blocks.jsx
new file mode 100644
index 000000000..79e316497
--- /dev/null
+++ b/app/assets/javascripts/components/actions/blocks.jsx
@@ -0,0 +1,82 @@
+import api, { getLinks } from '../api'
+import { fetchRelationships } from './accounts';
+
+export const BLOCKS_FETCH_REQUEST = 'BLOCKS_FETCH_REQUEST';
+export const BLOCKS_FETCH_SUCCESS = 'BLOCKS_FETCH_SUCCESS';
+export const BLOCKS_FETCH_FAIL    = 'BLOCKS_FETCH_FAIL';
+
+export const BLOCKS_EXPAND_REQUEST = 'BLOCKS_EXPAND_REQUEST';
+export const BLOCKS_EXPAND_SUCCESS = 'BLOCKS_EXPAND_SUCCESS';
+export const BLOCKS_EXPAND_FAIL    = 'BLOCKS_EXPAND_FAIL';
+
+export function fetchBlocks() {
+  return (dispatch, getState) => {
+    dispatch(fetchBlocksRequest());
+
+    api(getState).get('/api/v1/blocks').then(response => {
+      const next = getLinks(response).refs.find(link => link.rel === 'next');
+      dispatch(fetchBlocksSuccess(response.data, next ? next.uri : null));
+      dispatch(fetchRelationships(response.data.map(item => item.id)));
+    }).catch(error => dispatch(fetchBlocksFail(error)));
+  };
+};
+
+export function fetchBlocksRequest() {
+  return {
+    type: BLOCKS_FETCH_REQUEST
+  };
+};
+
+export function fetchBlocksSuccess(accounts, next) {
+  return {
+    type: BLOCKS_FETCH_SUCCESS,
+    accounts,
+    next
+  };
+};
+
+export function fetchBlocksFail(error) {
+  return {
+    type: BLOCKS_FETCH_FAIL,
+    error
+  };
+};
+
+export function expandBlocks() {
+  return (dispatch, getState) => {
+    const url = getState().getIn(['user_lists', 'blocks', 'next']);
+
+    if (url === null) {
+      return;
+    }
+
+    dispatch(expandBlocksRequest());
+
+    api(getState).get(url).then(response => {
+      const next = getLinks(response).refs.find(link => link.rel === 'next');
+      dispatch(expandBlocksSuccess(response.data, next ? next.uri : null));
+      dispatch(fetchRelationships(response.data.map(item => item.id)));
+    }).catch(error => dispatch(expandBlocksFail(error)));
+  };
+};
+
+export function expandBlocksRequest() {
+  return {
+    type: BLOCKS_EXPAND_REQUEST
+  };
+};
+
+export function expandBlocksSuccess(accounts, next) {
+  return {
+    type: BLOCKS_EXPAND_SUCCESS,
+    accounts,
+    next
+  };
+};
+
+export function expandBlocksFail(error) {
+  return {
+    type: BLOCKS_EXPAND_FAIL,
+    error
+  };
+};
diff --git a/app/assets/javascripts/components/actions/compose.jsx b/app/assets/javascripts/components/actions/compose.jsx
index e11d1e537..f87518751 100644
--- a/app/assets/javascripts/components/actions/compose.jsx
+++ b/app/assets/javascripts/components/actions/compose.jsx
@@ -84,7 +84,7 @@ export function submitCompose() {
       // To make the app more responsive, immediately get the status into the columns
       dispatch(updateTimeline('home', { ...response.data }));
 
-      if (response.data.in_reply_to_id === null && !getState().getIn(['compose', 'private']) && !getState().getIn(['compose', 'unlisted'])) {
+      if (response.data.in_reply_to_id === null && response.data.visibility === 'public') {
         dispatch(updateTimeline('public', { ...response.data }));
       }
     }).catch(function (error) {
diff --git a/app/assets/javascripts/components/actions/modal.jsx b/app/assets/javascripts/components/actions/modal.jsx
index 89dbc7947..d19218c48 100644
--- a/app/assets/javascripts/components/actions/modal.jsx
+++ b/app/assets/javascripts/components/actions/modal.jsx
@@ -1,10 +1,14 @@
 export const MEDIA_OPEN  = 'MEDIA_OPEN';
 export const MODAL_CLOSE = 'MODAL_CLOSE';
 
-export function openMedia(url) {
+export const MODAL_INDEX_DECREASE = 'MODAL_INDEX_DECREASE';
+export const MODAL_INDEX_INCREASE = 'MODAL_INDEX_INCREASE';
+
+export function openMedia(media, index) {
   return {
     type: MEDIA_OPEN,
-    url: url
+    media,
+    index
   };
 };
 
@@ -13,3 +17,15 @@ export function closeModal() {
     type: MODAL_CLOSE
   };
 };
+
+export function decreaseIndexInModal() {
+  return {
+    type: MODAL_INDEX_DECREASE
+  };
+};
+
+export function increaseIndexInModal() {
+  return {
+    type: MODAL_INDEX_INCREASE
+  };
+};
diff --git a/app/assets/javascripts/components/actions/notifications.jsx b/app/assets/javascripts/components/actions/notifications.jsx
index 4caf9c75b..df82e73fc 100644
--- a/app/assets/javascripts/components/actions/notifications.jsx
+++ b/app/assets/javascripts/components/actions/notifications.jsx
@@ -14,6 +14,8 @@ export const NOTIFICATIONS_EXPAND_REQUEST = 'NOTIFICATIONS_EXPAND_REQUEST';
 export const NOTIFICATIONS_EXPAND_SUCCESS = 'NOTIFICATIONS_EXPAND_SUCCESS';
 export const NOTIFICATIONS_EXPAND_FAIL    = 'NOTIFICATIONS_EXPAND_FAIL';
 
+export const NOTIFICATIONS_CLEAR = 'NOTIFICATIONS_CLEAR';
+
 const fetchRelatedRelationships = (dispatch, notifications) => {
   const accountIds = notifications.filter(item => item.type === 'follow').map(item => item.account.id);
 
@@ -139,3 +141,13 @@ export function expandNotificationsFail(error) {
     error
   };
 };
+
+export function clearNotifications() {
+  return (dispatch, getState) => {
+    dispatch({
+      type: NOTIFICATIONS_CLEAR
+    });
+
+    api(getState).post('/api/v1/notifications/clear');
+  };
+};
diff --git a/app/assets/javascripts/components/components/account.jsx b/app/assets/javascripts/components/components/account.jsx
index 108401b2f..13ffab49b 100644
--- a/app/assets/javascripts/components/components/account.jsx
+++ b/app/assets/javascripts/components/components/account.jsx
@@ -51,7 +51,7 @@ const Account = React.createClass({
 
   getDefaultProps () {
     return {
-      withNote: true
+      withNote: false
     };
   },
 
diff --git a/app/assets/javascripts/components/components/button.jsx b/app/assets/javascripts/components/components/button.jsx
index 19c52550a..fb70d5772 100644
--- a/app/assets/javascripts/components/components/button.jsx
+++ b/app/assets/javascripts/components/components/button.jsx
@@ -3,12 +3,13 @@ import PureRenderMixin from 'react-addons-pure-render-mixin';
 const Button = React.createClass({
 
   propTypes: {
-    text: React.PropTypes.string,
+    text: React.PropTypes.node,
     onClick: React.PropTypes.func,
     disabled: React.PropTypes.bool,
     block: React.PropTypes.bool,
     secondary: React.PropTypes.bool,
     size: React.PropTypes.number,
+    children: React.PropTypes.node
   },
 
   getDefaultProps () {
@@ -38,7 +39,6 @@ const Button = React.createClass({
       fontSize: '14px',
       fontWeight: '500',
       letterSpacing: '0',
-      textTransform: 'uppercase',
       padding: `0 ${this.props.size / 2.25}px`,
       height: `${this.props.size}px`,
       cursor: 'pointer',
diff --git a/app/assets/javascripts/components/components/column_collapsable.jsx b/app/assets/javascripts/components/components/column_collapsable.jsx
index 203dc5e0c..90c561bce 100644
--- a/app/assets/javascripts/components/components/column_collapsable.jsx
+++ b/app/assets/javascripts/components/components/column_collapsable.jsx
@@ -47,7 +47,7 @@ const ColumnCollapsable = React.createClass({
 
         <Motion defaultStyle={{ opacity: 0, height: 0 }} style={{ opacity: spring(collapsed ? 0 : 100), height: spring(collapsed ? 0 : fullHeight, collapsed ? undefined : { stiffness: 150, damping: 9 }) }}>
           {({ opacity, height }) =>
-            <div style={{ overflow: 'hidden', height: `${height}px`, opacity: opacity / 100 }}>
+            <div style={{ overflow: height === fullHeight ? 'auto' : 'hidden', height: `${height}px`, opacity: opacity / 100, maxHeight: '70vh' }}>
               {children}
             </div>
           }
diff --git a/app/assets/javascripts/components/components/lightbox.jsx b/app/assets/javascripts/components/components/lightbox.jsx
index 1e3a88955..f04ca47ba 100644
--- a/app/assets/javascripts/components/components/lightbox.jsx
+++ b/app/assets/javascripts/components/components/lightbox.jsx
@@ -44,7 +44,7 @@ const Lightbox = React.createClass({
 
   componentDidMount () {
     this._listener = e => {
-      if (e.key === 'Escape') {
+      if (this.props.isVisible && e.key === 'Escape') {
         this.props.onCloseClicked();
       }
     };
@@ -56,14 +56,18 @@ const Lightbox = React.createClass({
     window.removeEventListener('keyup', this._listener);
   },
 
+  stopPropagation (e) {
+    e.stopPropagation();
+  },
+
   render () {
     const { intl, isVisible, onOverlayClicked, onCloseClicked, children } = this.props;
 
     return (
       <Motion defaultStyle={{ backgroundOpacity: 0, opacity: 0, y: -400 }} style={{ backgroundOpacity: spring(isVisible ? 50 : 0), opacity: isVisible ? spring(200) : 0, y: spring(isVisible ? 0 : -400, { stiffness: 150, damping: 12 }) }}>
         {({ backgroundOpacity, opacity, y }) =>
-          <div className='lightbox' style={{...overlayStyle, background: `rgba(0, 0, 0, ${backgroundOpacity / 100})`, display: Math.floor(backgroundOpacity) === 0 ? 'none' : 'flex'}} onClick={onOverlayClicked}>
-            <div style={{...dialogStyle, transform: `translateY(${y}px)`, opacity: opacity / 100 }}>
+          <div className='lightbox' style={{...overlayStyle, background: `rgba(0, 0, 0, ${backgroundOpacity / 100})`, display: Math.floor(backgroundOpacity) === 0 ? 'none' : 'flex', pointerEvents: !isVisible ? 'none' : 'auto'}} onClick={onOverlayClicked}>
+            <div style={{...dialogStyle, transform: `translateY(${y}px)`, opacity: opacity / 100 }} onClick={this.stopPropagation}>
               <IconButton title={intl.formatMessage({ id: 'lightbox.close', defaultMessage: 'Close' })} icon='times' onClick={onCloseClicked} size={16} style={closeStyle} />
               {children}
             </div>
diff --git a/app/assets/javascripts/components/components/media_gallery.jsx b/app/assets/javascripts/components/components/media_gallery.jsx
index 7e92abe2d..a13448d0b 100644
--- a/app/assets/javascripts/components/components/media_gallery.jsx
+++ b/app/assets/javascripts/components/components/media_gallery.jsx
@@ -57,15 +57,16 @@ const MediaGallery = React.createClass({
     sensitive: React.PropTypes.bool,
     media: ImmutablePropTypes.list.isRequired,
     height: React.PropTypes.number.isRequired,
-    onOpenMedia: React.PropTypes.func.isRequired
+    onOpenMedia: React.PropTypes.func.isRequired,
+    intl: React.PropTypes.object.isRequired
   },
 
   mixins: [PureRenderMixin],
 
-  handleClick (url, e) {
+  handleClick (index, e) {
     if (e.button === 0) {
       e.preventDefault();
-      this.props.onOpenMedia(url);
+      this.props.onOpenMedia(this.props.media, index);
     }
 
     e.stopPropagation();
@@ -151,12 +152,12 @@ const MediaGallery = React.createClass({
 
         return (
           <div key={attachment.get('id')} style={{ boxSizing: 'border-box', position: 'relative', left: left, top: top, right: right, bottom: bottom, float: 'left', border: 'none', display: 'block', width: `${width}%`, height: `${height}%` }}>
-            <a href={attachment.get('remote_url') ? attachment.get('remote_url') : attachment.get('url')} onClick={this.handleClick.bind(this, attachment.get('url'))} target='_blank' style={{ display: 'block', width: '100%', height: '100%', background: `url(${attachment.get('preview_url')}) no-repeat center`, textDecoration: 'none', backgroundSize: 'cover', cursor: 'zoom-in' }} />
+            <a href={attachment.get('remote_url') ? attachment.get('remote_url') : attachment.get('url')} onClick={this.handleClick.bind(this, i)} target='_blank' style={{ display: 'block', width: '100%', height: '100%', background: `url(${attachment.get('preview_url')}) no-repeat center`, textDecoration: 'none', backgroundSize: 'cover', cursor: 'zoom-in' }} />
           </div>
         );
       });
     }
-    
+
     return (
       <div style={{ ...outerStyle, height: `${this.props.height}px` }}>
         <div style={spoilerButtonStyle} >
diff --git a/app/assets/javascripts/components/components/status_content.jsx b/app/assets/javascripts/components/components/status_content.jsx
index 44346fabc..9263a76f5 100644
--- a/app/assets/javascripts/components/components/status_content.jsx
+++ b/app/assets/javascripts/components/components/status_content.jsx
@@ -3,6 +3,7 @@ import PureRenderMixin from 'react-addons-pure-render-mixin';
 import escapeTextContentForBrowser from 'react/lib/escapeTextContentForBrowser';
 import emojify from '../emoji';
 import { FormattedMessage } from 'react-intl';
+import Permalink from './permalink';
 
 const spoilerStyle = {
   display: 'inline-block',
@@ -41,11 +42,14 @@ const StatusContent = React.createClass({
     for (var i = 0; i < links.length; ++i) {
       let link    = links[i];
       let mention = this.props.status.get('mentions').find(item => link.href === item.get('url'));
+      let media   = this.props.status.get('media_attachments').find(item => link.href === item.get('text_url') || link.href === item.get('remote_url'));
 
       if (mention) {
         link.addEventListener('click', this.onMentionClick.bind(this, mention), false);
       } else if (link.textContent[0] === '#' || (link.previousSibling && link.previousSibling.textContent && link.previousSibling.textContent[link.previousSibling.textContent.length - 1] === '#')) {
         link.addEventListener('click', this.onHashtagClick.bind(this, link.text), false);
+      } else if (media) {
+        link.innerHTML = '<i class="fa fa-fw fa-photo"></i>';
       } else {
         link.setAttribute('target', '_blank');
         link.setAttribute('rel', 'noopener');
@@ -100,14 +104,28 @@ const StatusContent = React.createClass({
     const spoilerContent = { __html: emojify(escapeTextContentForBrowser(status.get('spoiler_text', ''))) };
 
     if (status.get('spoiler_text').length > 0) {
+      let mentionsPlaceholder = '';
+
+      const mentionLinks = status.get('mentions').map(item => (
+        <Permalink to={`/accounts/${item.get('id')}`} href={item.get('url')} key={item.get('id')} className='mention'>
+          @<span>{item.get('username')}</span>
+        </Permalink>
+      )).reduce((aggregate, item) => [...aggregate, item, ' '], [])
+
       const toggleText = hidden ? <FormattedMessage id='status.show_more' defaultMessage='Show more' /> : <FormattedMessage id='status.show_less' defaultMessage='Show less' />;
 
+      if (hidden) {
+        mentionsPlaceholder = <div>{mentionLinks}</div>;
+      }
+
       return (
         <div className='status__content' style={{ cursor: 'pointer' }} onMouseDown={this.handleMouseDown} onMouseUp={this.handleMouseUp}>
-          <p style={{ marginBottom: hidden ? '0px' : '' }} >
-            <span dangerouslySetInnerHTML={spoilerContent} /> <a className='status__content__spoiler-link' style={spoilerStyle} onClick={this.handleSpoilerClick}>{toggleText}</a>
+          <p style={{ marginBottom: hidden && status.get('mentions').size === 0 ? '0px' : '' }} >
+            <span dangerouslySetInnerHTML={spoilerContent} />  <a className='status__content__spoiler-link' style={spoilerStyle} onClick={this.handleSpoilerClick}>{toggleText}</a>
           </p>
 
+          {mentionsPlaceholder}
+
           <div style={{ display: hidden ? 'none' : 'block' }} dangerouslySetInnerHTML={content} />
         </div>
       );
diff --git a/app/assets/javascripts/components/containers/mastodon.jsx b/app/assets/javascripts/components/containers/mastodon.jsx
index 5fd43fb2b..3b36ce3ef 100644
--- a/app/assets/javascripts/components/containers/mastodon.jsx
+++ b/app/assets/javascripts/components/containers/mastodon.jsx
@@ -33,6 +33,7 @@ import Notifications from '../features/notifications';
 import FollowRequests from '../features/follow_requests';
 import GenericNotFound from '../features/generic_not_found';
 import FavouritedStatuses from '../features/favourited_statuses';
+import Blocks from '../features/blocks';
 import { IntlProvider, addLocaleData } from 'react-intl';
 import en from 'react-intl/locale-data/en';
 import de from 'react-intl/locale-data/de';
@@ -43,6 +44,7 @@ import hu from 'react-intl/locale-data/hu';
 import uk from 'react-intl/locale-data/uk';
 import getMessagesForLocale from '../locales';
 import { hydrateStore } from '../actions/store';
+import createStream from '../stream';
 
 const store = configureStore();
 
@@ -60,28 +62,27 @@ const Mastodon = React.createClass({
     locale: React.PropTypes.string.isRequired
   },
 
-  componentWillMount() {
-    const { locale } = this.props;
-
-    if (typeof App !== 'undefined') {
-      this.subscription = App.cable.subscriptions.create('TimelineChannel', {
-
-        received (data) {
-          switch(data.event) {
-          case 'update':
-            store.dispatch(updateTimeline('home', JSON.parse(data.payload)));
-            break;
-          case 'delete':
-            store.dispatch(deleteFromTimelines(data.payload));
-            break;
-          case 'notification':
-            store.dispatch(updateNotifications(JSON.parse(data.payload), getMessagesForLocale(locale), locale));
-            break;
-          }
+  componentDidMount() {
+    const { locale }  = this.props;
+    const accessToken = store.getState().getIn(['meta', 'access_token']);
+
+    this.subscription = createStream(accessToken, 'user', {
+
+      received (data) {
+        switch(data.event) {
+        case 'update':
+          store.dispatch(updateTimeline('home', JSON.parse(data.payload)));
+          break;
+        case 'delete':
+          store.dispatch(deleteFromTimelines(data.payload));
+          break;
+        case 'notification':
+          store.dispatch(updateNotifications(JSON.parse(data.payload), getMessagesForLocale(locale), locale));
+          break;
         }
+      }
 
-      });
-    }
+    });
 
     // Desktop notifications
     if (typeof window.Notification !== 'undefined' && Notification.permission === 'default') {
@@ -91,7 +92,8 @@ const Mastodon = React.createClass({
 
   componentWillUnmount () {
     if (typeof this.subscription !== 'undefined') {
-      this.subscription.unsubscribe();
+      this.subscription.close();
+      this.subscription = null;
     }
   },
 
@@ -123,6 +125,8 @@ const Mastodon = React.createClass({
               <Route path='accounts/:accountId/following' component={Following} />
 
               <Route path='follow_requests' component={FollowRequests} />
+              <Route path='blocks' component={Blocks} />
+
               <Route path='*' component={GenericNotFound} />
             </Route>
           </Router>
diff --git a/app/assets/javascripts/components/containers/status_container.jsx b/app/assets/javascripts/components/containers/status_container.jsx
index 1704a8cc2..f5fb09d52 100644
--- a/app/assets/javascripts/components/containers/status_container.jsx
+++ b/app/assets/javascripts/components/containers/status_container.jsx
@@ -91,8 +91,8 @@ const mapDispatchToProps = (dispatch) => ({
     dispatch(mentionCompose(account, router));
   },
 
-  onOpenMedia (url) {
-    dispatch(openMedia(url));
+  onOpenMedia (media, index) {
+    dispatch(openMedia(media, index));
   },
 
   onBlock (account) {
diff --git a/app/assets/javascripts/components/features/account/components/header.jsx b/app/assets/javascripts/components/features/account/components/header.jsx
index dead11265..30e0449c5 100644
--- a/app/assets/javascripts/components/features/account/components/header.jsx
+++ b/app/assets/javascripts/components/features/account/components/header.jsx
@@ -44,7 +44,7 @@ const Header = React.createClass({
             <IconButton size={26} disabled={true} icon='hourglass' title={intl.formatMessage(messages.requested)} />
           </div>
         );
-      } else {
+      } else if (!account.getIn(['relationship', 'blocking'])) {
         actionBtn = (
           <div style={{ position: 'absolute', top: '10px', left: '20px' }}>
             <IconButton size={26} icon={account.getIn(['relationship', 'following']) ? 'user-times' : 'user-plus'} active={account.getIn(['relationship', 'following'])} title={intl.formatMessage(account.getIn(['relationship', 'following']) ? messages.unfollow : messages.follow)} onClick={this.props.onFollow} />
diff --git a/app/assets/javascripts/components/features/blocks/index.jsx b/app/assets/javascripts/components/features/blocks/index.jsx
new file mode 100644
index 000000000..e941b27f7
--- /dev/null
+++ b/app/assets/javascripts/components/features/blocks/index.jsx
@@ -0,0 +1,68 @@
+import { connect } from 'react-redux';
+import PureRenderMixin from 'react-addons-pure-render-mixin';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import LoadingIndicator from '../../components/loading_indicator';
+import { ScrollContainer } from 'react-router-scroll';
+import Column from '../ui/components/column';
+import ColumnBackButtonSlim from '../../components/column_back_button_slim';
+import AccountContainer from '../../containers/account_container';
+import { fetchBlocks, expandBlocks } from '../../actions/blocks';
+import { defineMessages, injectIntl } from 'react-intl';
+
+const messages = defineMessages({
+  heading: { id: 'column.blocks', defaultMessage: 'Blocked users' }
+});
+
+const mapStateToProps = state => ({
+  accountIds: state.getIn(['user_lists', 'blocks', 'items'])
+});
+
+const Blocks = React.createClass({
+  propTypes: {
+    params: React.PropTypes.object.isRequired,
+    dispatch: React.PropTypes.func.isRequired,
+    accountIds: ImmutablePropTypes.list,
+    intl: React.PropTypes.object.isRequired
+  },
+
+  mixins: [PureRenderMixin],
+
+  componentWillMount () {
+    this.props.dispatch(fetchBlocks());
+  },
+
+  handleScroll (e) {
+    const { scrollTop, scrollHeight, clientHeight } = e.target;
+
+    if (scrollTop === scrollHeight - clientHeight) {
+      this.props.dispatch(expandBlocks());
+    }
+  },
+
+  render () {
+    const { intl, accountIds } = this.props;
+
+    if (!accountIds) {
+      return (
+        <Column>
+          <LoadingIndicator />
+        </Column>
+      );
+    }
+
+    return (
+      <Column icon='users' heading={intl.formatMessage(messages.heading)}>
+        <ColumnBackButtonSlim />
+        <ScrollContainer scrollKey='blocks'>
+          <div className='scrollable' onScroll={this.handleScroll}>
+            {accountIds.map(id =>
+              <AccountContainer key={id} id={id} />
+            )}
+          </div>
+        </ScrollContainer>
+      </Column>
+    );
+  }
+});
+
+export default connect(mapStateToProps)(injectIntl(Blocks));
diff --git a/app/assets/javascripts/components/features/compose/components/compose_form.jsx b/app/assets/javascripts/components/features/compose/components/compose_form.jsx
index 5073c9d9e..48939054d 100644
--- a/app/assets/javascripts/components/features/compose/components/compose_form.jsx
+++ b/app/assets/javascripts/components/features/compose/components/compose_form.jsx
@@ -117,9 +117,10 @@ const ComposeForm = React.createClass({
   },
 
   render () {
-    const { intl } = this.props;
-    let replyArea  = '';
-    const disabled = this.props.is_submitting || this.props.is_uploading;
+    const { intl }  = this.props;
+    let replyArea   = '';
+    let publishText = '';
+    const disabled  = this.props.is_submitting || this.props.is_uploading;
 
     if (this.props.in_reply_to) {
       replyArea = <ReplyIndicator status={this.props.in_reply_to} onCancel={this.props.onCancelReply} />;
@@ -127,6 +128,12 @@ const ComposeForm = React.createClass({
 
     let reply_to_other = !!this.props.in_reply_to && (this.props.in_reply_to.getIn(['account', 'id']) !== this.props.me);
 
+    if (this.props.private) {
+      publishText = <span><i className='fa fa-lock' /> {intl.formatMessage(messages.publish)}</span>;
+    } else {
+      publishText = intl.formatMessage(messages.publish) + (!this.props.unlisted ? '!' : '');
+    }
+
     return (
       <div style={{ padding: '10px' }}>
         <Motion defaultStyle={{ opacity: !this.props.spoiler ? 0 : 100, height: !this.props.spoiler ? 50 : 0 }} style={{ opacity: spring(!this.props.spoiler ? 0 : 100), height: spring(!this.props.spoiler ? 0 : 50) }}>
@@ -154,19 +161,19 @@ const ComposeForm = React.createClass({
         />
 
         <div style={{ marginTop: '10px', overflow: 'hidden' }}>
-          <div style={{ float: 'right' }}><Button text={intl.formatMessage(messages.publish)} onClick={this.handleSubmit} disabled={disabled} /></div>
+          <div style={{ float: 'right' }}><Button text={publishText} onClick={this.handleSubmit} disabled={disabled} /></div>
           <div style={{ float: 'right', marginRight: '16px', lineHeight: '36px' }}><CharacterCounter max={500} text={[this.props.spoiler_text, this.props.text].join('')} /></div>
           <UploadButtonContainer style={{ paddingTop: '4px' }} />
         </div>
 
         <label style={{ display: 'block', lineHeight: '24px', verticalAlign: 'middle', marginTop: '10px', borderTop: '1px solid #282c37', paddingTop: '10px' }}>
-          <Toggle checked={this.props.private} onChange={this.handleChangeVisibility} />
-          <span style={{ display: 'inline-block', verticalAlign: 'middle', marginBottom: '14px', marginLeft: '8px', color: '#9baec8' }}><FormattedMessage id='compose_form.private' defaultMessage='Mark as private' /></span>
+          <Toggle checked={this.props.spoiler} onChange={this.handleChangeSpoilerness} />
+          <span style={{ display: 'inline-block', verticalAlign: 'top', marginLeft: '8px', color: '#9baec8' }}><FormattedMessage id='compose_form.spoiler' defaultMessage='Hide text behind warning' /></span>
         </label>
 
-        <label style={{ display: 'block', lineHeight: '24px', verticalAlign: 'middle' }}>
-          <Toggle checked={this.props.spoiler} onChange={this.handleChangeSpoilerness} />
-          <span style={{ display: 'inline-block', verticalAlign: 'middle', marginBottom: '14px', marginLeft: '8px', color: '#9baec8' }}><FormattedMessage id='compose_form.spoiler' defaultMessage='Hide text behind warning' /></span>
+        <label style={{ display: 'block', lineHeight: '24px', verticalAlign: 'middle', borderTop: '1px solid #282c37', paddingTop: '10px' }}>
+          <Toggle checked={this.props.private} onChange={this.handleChangeVisibility} />
+          <span style={{ display: 'inline-block', verticalAlign: 'middle', marginBottom: '14px', marginLeft: '8px', color: '#9baec8' }}><FormattedMessage id='compose_form.private' defaultMessage='Mark as private' /></span>
         </label>
 
         <Motion defaultStyle={{ opacity: (this.props.private || reply_to_other) ? 0 : 100, height: (this.props.private || reply_to_other) ? 39.5 : 0 }} style={{ opacity: spring((this.props.private || reply_to_other) ? 0 : 100), height: spring((this.props.private || reply_to_other) ? 0 : 39.5) }}>
diff --git a/app/assets/javascripts/components/features/compose/containers/compose_form_container.jsx b/app/assets/javascripts/components/features/compose/containers/compose_form_container.jsx
index 8ccfce059..c027875cd 100644
--- a/app/assets/javascripts/components/features/compose/containers/compose_form_container.jsx
+++ b/app/assets/javascripts/components/features/compose/containers/compose_form_container.jsx
@@ -26,14 +26,14 @@ const makeMapStateToProps = () => {
       sensitive: state.getIn(['compose', 'sensitive']),
       spoiler: state.getIn(['compose', 'spoiler']),
       spoiler_text: state.getIn(['compose', 'spoiler_text']),
-      unlisted: state.getIn(['compose', 'unlisted']),
+      unlisted: state.getIn(['compose', 'unlisted'], ),
       private: state.getIn(['compose', 'private']),
       fileDropDate: state.getIn(['compose', 'fileDropDate']),
       is_submitting: state.getIn(['compose', 'is_submitting']),
       is_uploading: state.getIn(['compose', 'is_uploading']),
       in_reply_to: getStatus(state, state.getIn(['compose', 'in_reply_to'])),
       media_count: state.getIn(['compose', 'media_attachments']).size,
-      me: state.getIn(['compose', 'me'])
+      me: state.getIn(['compose', 'me']),
     };
   };
 
diff --git a/app/assets/javascripts/components/features/getting_started/index.jsx b/app/assets/javascripts/components/features/getting_started/index.jsx
index 42e0a9e24..a0bf3a694 100644
--- a/app/assets/javascripts/components/features/getting_started/index.jsx
+++ b/app/assets/javascripts/components/features/getting_started/index.jsx
@@ -11,7 +11,9 @@ const messages = defineMessages({
   preferences: { id: 'navigation_bar.preferences', defaultMessage: 'Preferences' },
   follow_requests: { id: 'navigation_bar.follow_requests', defaultMessage: 'Follow requests' },
   sign_out: { id: 'navigation_bar.logout', defaultMessage: 'Sign out' },
-  favourites: { id: 'navigation_bar.favourites', defaultMessage: 'Favourites' }
+  favourites: { id: 'navigation_bar.favourites', defaultMessage: 'Favourites' },
+  blocks: { id: 'navigation_bar.blocks', defaultMessage: 'Blocked users' },
+  info: { id: 'navigation_bar.info', defaultMessage: 'Extended information' }
 });
 
 const mapStateToProps = state => ({
@@ -32,6 +34,8 @@ const GettingStarted = ({ intl, me }) => {
         <ColumnLink icon='cog' text={intl.formatMessage(messages.preferences)} href='/settings/preferences' />
         <ColumnLink icon='star' text={intl.formatMessage(messages.favourites)} to='/favourites' />
         {followRequests}
+        <ColumnLink icon='users' text={intl.formatMessage(messages.blocks)} to='/blocks' />
+        <ColumnLink icon='book' text={intl.formatMessage(messages.info)} href='/about/more' />
         <ColumnLink icon='sign-out' text={intl.formatMessage(messages.sign_out)} href='/auth/sign_out' method='delete' />
       </div>
 
diff --git a/app/assets/javascripts/components/features/hashtag_timeline/index.jsx b/app/assets/javascripts/components/features/hashtag_timeline/index.jsx
index 7548e6d56..4a0e7684d 100644
--- a/app/assets/javascripts/components/features/hashtag_timeline/index.jsx
+++ b/app/assets/javascripts/components/features/hashtag_timeline/index.jsx
@@ -8,45 +8,49 @@ import {
   deleteFromTimelines
 } from '../../actions/timelines';
 import ColumnBackButtonSlim from '../../components/column_back_button_slim';
+import createStream from '../../stream';
+
+const mapStateToProps = state => ({
+  accessToken: state.getIn(['meta', 'access_token'])
+});
 
 const HashtagTimeline = React.createClass({
 
   propTypes: {
     params: React.PropTypes.object.isRequired,
-    dispatch: React.PropTypes.func.isRequired
+    dispatch: React.PropTypes.func.isRequired,
+    accessToken: React.PropTypes.string.isRequired
   },
 
   mixins: [PureRenderMixin],
 
   _subscribe (dispatch, id) {
-    if (typeof App !== 'undefined') {
-      this.subscription = App.cable.subscriptions.create({
-        channel: 'HashtagChannel',
-        tag: id
-      }, {
-
-        received (data) {
-          switch(data.event) {
-          case 'update':
-            dispatch(updateTimeline('tag', JSON.parse(data.payload)));
-            break;
-          case 'delete':
-            dispatch(deleteFromTimelines(data.payload));
-            break;
-          }
+    const { accessToken } = this.props;
+
+    this.subscription = createStream(accessToken, `hashtag&tag=${id}`, {
+
+      received (data) {
+        switch(data.event) {
+        case 'update':
+          dispatch(updateTimeline('tag', JSON.parse(data.payload)));
+          break;
+        case 'delete':
+          dispatch(deleteFromTimelines(data.payload));
+          break;
         }
+      }
 
-      });
-    }
+    });
   },
 
   _unsubscribe () {
     if (typeof this.subscription !== 'undefined') {
-      this.subscription.unsubscribe();
+      this.subscription.close();
+      this.subscription = null;
     }
   },
 
-  componentWillMount () {
+  componentDidMount () {
     const { dispatch } = this.props;
     const { id } = this.props.params;
 
@@ -79,4 +83,4 @@ const HashtagTimeline = React.createClass({
 
 });
 
-export default connect()(HashtagTimeline);
+export default connect(mapStateToProps)(HashtagTimeline);
diff --git a/app/assets/javascripts/components/features/notifications/components/clear_column_button.jsx b/app/assets/javascripts/components/features/notifications/components/clear_column_button.jsx
new file mode 100644
index 000000000..d20a4d170
--- /dev/null
+++ b/app/assets/javascripts/components/features/notifications/components/clear_column_button.jsx
@@ -0,0 +1,21 @@
+const iconStyle = {
+  fontSize: '16px',
+  padding: '15px',
+  position: 'absolute',
+  right: '48px',
+  top: '0',
+  cursor: 'pointer',
+  background: '#2f3441'
+};
+
+const ClearColumnButton = ({ onClick }) => (
+  <div className='column-icon' style={iconStyle} onClick={onClick}>
+    <i className='fa fa-trash' />
+  </div>
+);
+
+ClearColumnButton.propTypes = {
+  onClick: React.PropTypes.func.isRequired
+};
+
+export default ClearColumnButton;
diff --git a/app/assets/javascripts/components/features/notifications/index.jsx b/app/assets/javascripts/components/features/notifications/index.jsx
index d3300acd5..6d10768de 100644
--- a/app/assets/javascripts/components/features/notifications/index.jsx
+++ b/app/assets/javascripts/components/features/notifications/index.jsx
@@ -2,7 +2,7 @@ import { connect } from 'react-redux';
 import PureRenderMixin from 'react-addons-pure-render-mixin';
 import ImmutablePropTypes from 'react-immutable-proptypes';
 import Column from '../ui/components/column';
-import { expandNotifications } from '../../actions/notifications';
+import { expandNotifications, clearNotifications } from '../../actions/notifications';
 import NotificationContainer from './containers/notification_container';
 import { ScrollContainer } from 'react-router-scroll';
 import { defineMessages, injectIntl } from 'react-intl';
@@ -10,6 +10,7 @@ import ColumnSettingsContainer from './containers/column_settings_container';
 import { createSelector } from 'reselect';
 import Immutable from 'immutable';
 import LoadMore from '../../components/load_more';
+import ClearColumnButton from './components/clear_column_button';
 
 const messages = defineMessages({
   title: { id: 'column.notifications', defaultMessage: 'Notifications' }
@@ -64,6 +65,10 @@ const Notifications = React.createClass({
     this.props.dispatch(expandNotifications());
   },
 
+  handleClear () {
+    this.props.dispatch(clearNotifications());
+  },
+
   setRef (c) {
     this.node = c;
   },
@@ -90,6 +95,7 @@ const Notifications = React.createClass({
       return (
         <Column icon='bell' heading={intl.formatMessage(messages.title)}>
           <ColumnSettingsContainer />
+          <ClearColumnButton onClick={this.handleClear} />
           <ScrollContainer scrollKey='notifications'>
             {scrollableArea}
           </ScrollContainer>
@@ -99,6 +105,7 @@ const Notifications = React.createClass({
       return (
         <Column icon='bell' heading={intl.formatMessage(messages.title)}>
           <ColumnSettingsContainer />
+          <ClearColumnButton onClick={this.handleClear} />
           {scrollableArea}
         </Column>
       );
diff --git a/app/assets/javascripts/components/features/public_timeline/index.jsx b/app/assets/javascripts/components/features/public_timeline/index.jsx
index 42970061c..36d68dbbb 100644
--- a/app/assets/javascripts/components/features/public_timeline/index.jsx
+++ b/app/assets/javascripts/components/features/public_timeline/index.jsx
@@ -9,46 +9,51 @@ import {
 } from '../../actions/timelines';
 import { defineMessages, injectIntl } from 'react-intl';
 import ColumnBackButtonSlim from '../../components/column_back_button_slim';
+import createStream from '../../stream';
 
 const messages = defineMessages({
   title: { id: 'column.public', defaultMessage: 'Public' }
 });
 
+const mapStateToProps = state => ({
+  accessToken: state.getIn(['meta', 'access_token'])
+});
+
 const PublicTimeline = React.createClass({
 
   propTypes: {
     dispatch: React.PropTypes.func.isRequired,
-    intl: React.PropTypes.object.isRequired
+    intl: React.PropTypes.object.isRequired,
+    accessToken: React.PropTypes.string.isRequired
   },
 
   mixins: [PureRenderMixin],
 
-  componentWillMount () {
-    const { dispatch } = this.props;
+  componentDidMount () {
+    const { dispatch, accessToken } = this.props;
 
     dispatch(refreshTimeline('public'));
 
-    if (typeof App !== 'undefined') {
-      this.subscription = App.cable.subscriptions.create('PublicChannel', {
+    this.subscription = createStream(accessToken, 'public', {
 
-        received (data) {
-          switch(data.event) {
-          case 'update':
-            dispatch(updateTimeline('public', JSON.parse(data.payload)));
-            break;
-          case 'delete':
-            dispatch(deleteFromTimelines(data.payload));
-            break;
-          }
+      received (data) {
+        switch(data.event) {
+        case 'update':
+          dispatch(updateTimeline('public', JSON.parse(data.payload)));
+          break;
+        case 'delete':
+          dispatch(deleteFromTimelines(data.payload));
+          break;
         }
+      }
 
-      });
-    }
+    });
   },
 
   componentWillUnmount () {
     if (typeof this.subscription !== 'undefined') {
-      this.subscription.unsubscribe();
+      this.subscription.close();
+      this.subscription = null;
     }
   },
 
@@ -65,4 +70,4 @@ const PublicTimeline = React.createClass({
 
 });
 
-export default connect()(injectIntl(PublicTimeline));
+export default connect(mapStateToProps)(injectIntl(PublicTimeline));
diff --git a/app/assets/javascripts/components/features/status/index.jsx b/app/assets/javascripts/components/features/status/index.jsx
index 993c649d2..894fa3176 100644
--- a/app/assets/javascripts/components/features/status/index.jsx
+++ b/app/assets/javascripts/components/features/status/index.jsx
@@ -84,8 +84,8 @@ const Status = React.createClass({
     this.props.dispatch(mentionCompose(account, router));
   },
 
-  handleOpenMedia (url) {
-    this.props.dispatch(openMedia(url));
+  handleOpenMedia (media, index) {
+    this.props.dispatch(openMedia(media, index));
   },
 
   renderChildren (list) {
diff --git a/app/assets/javascripts/components/features/ui/components/column.jsx b/app/assets/javascripts/components/features/ui/components/column.jsx
index c382e108d..2eafe5a8f 100644
--- a/app/assets/javascripts/components/features/ui/components/column.jsx
+++ b/app/assets/javascripts/components/features/ui/components/column.jsx
@@ -1,4 +1,4 @@
-import ColumnHeader    from './column_header';
+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;
@@ -58,16 +58,18 @@ const Column = React.createClass({
   },
 
   render () {
+    const { heading, icon, children } = this.props;
+
     let header = '';
 
-    if (this.props.heading) {
-      header = <ColumnHeader icon={this.props.icon} type={this.props.heading} onClick={this.handleHeaderClick} />;
+    if (heading) {
+      header = <ColumnHeader icon={icon} type={heading} onClick={this.handleHeaderClick} />;
     }
 
     return (
       <div className='column' style={style} onWheel={this.handleWheel}>
         {header}
-        {this.props.children}
+        {children}
       </div>
     );
   }
diff --git a/app/assets/javascripts/components/features/ui/containers/modal_container.jsx b/app/assets/javascripts/components/features/ui/containers/modal_container.jsx
index 53d162462..334e5c199 100644
--- a/app/assets/javascripts/components/features/ui/containers/modal_container.jsx
+++ b/app/assets/javascripts/components/features/ui/containers/modal_container.jsx
@@ -1,12 +1,18 @@
 import { connect } from 'react-redux';
-import { closeModal } from '../../../actions/modal';
+import {
+  closeModal,
+  decreaseIndexInModal,
+  increaseIndexInModal
+} from '../../../actions/modal';
 import Lightbox from '../../../components/lightbox';
 import ImageLoader from 'react-imageloader';
 import LoadingIndicator from '../../../components/loading_indicator';
 import PureRenderMixin from 'react-addons-pure-render-mixin';
+import ImmutablePropTypes from 'react-immutable-proptypes';
 
 const mapStateToProps = state => ({
-  url: state.getIn(['modal', 'url']),
+  media: state.getIn(['modal', 'media']),
+  index: state.getIn(['modal', 'index']),
   isVisible: state.getIn(['modal', 'open'])
 });
 
@@ -17,6 +23,14 @@ const mapDispatchToProps = dispatch => ({
 
   onOverlayClicked () {
     dispatch(closeModal());
+  },
+
+  onNextClicked () {
+    dispatch(increaseIndexInModal());
+  },
+
+  onPrevClicked () {
+    dispatch(decreaseIndexInModal());
   }
 });
 
@@ -38,27 +52,115 @@ const preloader = () => (
   </div>
 );
 
+const leftNavStyle = {
+  position: 'absolute',
+  background: 'rgba(0, 0, 0, 0.5)',
+  padding: '30px 15px',
+  cursor: 'pointer',
+  color: '#fff',
+  fontSize: '24px',
+  top: '0',
+  left: '-61px',
+  boxSizing: 'border-box',
+  height: '100%',
+  display: 'flex',
+  alignItems: 'center'
+};
+
+const rightNavStyle = {
+  position: 'absolute',
+  background: 'rgba(0, 0, 0, 0.5)',
+  padding: '30px 15px',
+  cursor: 'pointer',
+  color: '#fff',
+  fontSize: '24px',
+  top: '0',
+  right: '-61px',
+  boxSizing: 'border-box',
+  height: '100%',
+  display: 'flex',
+  alignItems: 'center'
+};
+
 const Modal = React.createClass({
 
   propTypes: {
-    url: React.PropTypes.string,
+    media: ImmutablePropTypes.list,
+    index: React.PropTypes.number.isRequired,
     isVisible: React.PropTypes.bool,
     onCloseClicked: React.PropTypes.func,
-    onOverlayClicked: React.PropTypes.func
+    onOverlayClicked: React.PropTypes.func,
+    onNextClicked: React.PropTypes.func,
+    onPrevClicked: React.PropTypes.func
   },
 
   mixins: [PureRenderMixin],
 
+  handleNextClick () {
+    this.props.onNextClicked();
+  },
+
+  handlePrevClick () {
+    this.props.onPrevClicked();
+  },
+
+  componentDidMount () {
+    this._listener = e => {
+      if (!this.props.isVisible) {
+        return;
+      }
+
+      switch(e.key) {
+      case 'ArrowLeft':
+        this.props.onPrevClicked();
+        break;
+      case 'ArrowRight':
+        this.props.onNextClicked();
+        break;
+      }
+    };
+
+    window.addEventListener('keyup', this._listener);
+  },
+
+  componentWillUnmount () {
+    window.removeEventListener('keyup', this._listener);
+  },
+
   render () {
-    const { url, ...other } = this.props;
+    const { media, index, ...other } = this.props;
+
+    if (!media) {
+      return null;
+    }
+
+    const url      = media.get(index).get('url');
+    const hasLeft  = index > 0;
+    const hasRight = index + 1 < media.size;
+
+    let leftNav, rightNav;
+
+    leftNav = rightNav = '';
+
+    if (hasLeft) {
+      leftNav = <div style={leftNavStyle} onClick={this.handlePrevClick}><i className='fa fa-fw fa-chevron-left' /></div>;
+    }
+
+    if (hasRight) {
+      rightNav = <div style={rightNavStyle} onClick={this.handleNextClick}><i className='fa fa-fw fa-chevron-right' /></div>;
+    }
 
     return (
       <Lightbox {...other}>
+        {leftNav}
+
         <ImageLoader
           src={url}
           preloader={preloader}
           imgProps={{ style: imageStyle }}
         />
+
+        {rightNav}
       </Lightbox>
     );
   }
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
index 8af7b0c3c..100989d22 100644
--- a/app/assets/javascripts/components/features/ui/containers/status_list_container.jsx
+++ b/app/assets/javascripts/components/features/ui/containers/status_list_container.jsx
@@ -7,8 +7,9 @@ import { createSelector } from 'reselect';
 const getStatusIds = createSelector([
   (state, { type }) => state.getIn(['settings', type], Immutable.Map()),
   (state, { type }) => state.getIn(['timelines', type, 'items'], Immutable.List()),
-  (state)           => state.get('statuses')
-], (columnSettings, statusIds, statuses) => statusIds.filter(id => {
+  (state)           => state.get('statuses'),
+  (state)           => state.getIn(['meta', 'me'])
+], (columnSettings, statusIds, statuses, me) => statusIds.filter(id => {
   const statusForId = statuses.get(id);
   let showStatus    = true;
 
@@ -17,7 +18,7 @@ const getStatusIds = createSelector([
   }
 
   if (columnSettings.getIn(['shows', 'reply']) === false) {
-    showStatus = showStatus && statusForId.get('in_reply_to_id') === null;
+    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) {
diff --git a/app/assets/javascripts/components/reducers/accounts.jsx b/app/assets/javascripts/components/reducers/accounts.jsx
index 409dfd663..f3938cee1 100644
--- a/app/assets/javascripts/components/reducers/accounts.jsx
+++ b/app/assets/javascripts/components/reducers/accounts.jsx
@@ -7,9 +7,14 @@ import {
   ACCOUNT_TIMELINE_FETCH_SUCCESS,
   ACCOUNT_TIMELINE_EXPAND_SUCCESS,
   FOLLOW_REQUESTS_FETCH_SUCCESS,
+  FOLLOW_REQUESTS_EXPAND_SUCCESS,
   ACCOUNT_FOLLOW_SUCCESS,
   ACCOUNT_UNFOLLOW_SUCCESS
 } from '../actions/accounts';
+import {
+  BLOCKS_FETCH_SUCCESS,
+  BLOCKS_EXPAND_SUCCESS
+} from '../actions/blocks';
 import { COMPOSE_SUGGESTIONS_READY } from '../actions/compose';
 import {
   REBLOG_SUCCESS,
@@ -87,6 +92,9 @@ export default function accounts(state = initialState, action) {
   case COMPOSE_SUGGESTIONS_READY:
   case SEARCH_SUGGESTIONS_READY:
   case FOLLOW_REQUESTS_FETCH_SUCCESS:
+  case FOLLOW_REQUESTS_EXPAND_SUCCESS:
+  case BLOCKS_FETCH_SUCCESS:
+  case BLOCKS_EXPAND_SUCCESS:
     return normalizeAccounts(state, action.accounts);
   case NOTIFICATIONS_REFRESH_SUCCESS:
   case NOTIFICATIONS_EXPAND_SUCCESS:
diff --git a/app/assets/javascripts/components/reducers/compose.jsx b/app/assets/javascripts/components/reducers/compose.jsx
index d3a84842f..1b903ed44 100644
--- a/app/assets/javascripts/components/reducers/compose.jsx
+++ b/app/assets/javascripts/components/reducers/compose.jsx
@@ -43,6 +43,7 @@ const initialState = Immutable.Map({
   suggestion_token: null,
   suggestions: Immutable.List(),
   me: null,
+  default_privacy: 'public',
   resetFileKey: Math.floor((Math.random() * 0x10000))
 });
 
@@ -64,6 +65,8 @@ function clearAll(state) {
     map.set('spoiler_text', '');
     map.set('is_submitting', false);
     map.set('in_reply_to', null);
+    map.set('unlisted', state.get('default_privacy') === 'unlisted');
+    map.set('private', state.get('default_privacy') === 'private');
     map.update('media_attachments', list => list.clear());
   });
 };
@@ -97,7 +100,7 @@ const insertSuggestion = (state, position, token, completion) => {
 export default function compose(state = initialState, action) {
   switch(action.type) {
   case STORE_HYDRATE:
-    return state.merge(action.state.get('compose'));
+    return clearAll(state.merge(action.state.get('compose')));
   case COMPOSE_MOUNT:
     return state.set('mounted', true);
   case COMPOSE_UNMOUNT:
diff --git a/app/assets/javascripts/components/reducers/modal.jsx b/app/assets/javascripts/components/reducers/modal.jsx
index ac53ea210..07da65771 100644
--- a/app/assets/javascripts/components/reducers/modal.jsx
+++ b/app/assets/javascripts/components/reducers/modal.jsx
@@ -1,8 +1,14 @@
-import { MEDIA_OPEN, MODAL_CLOSE } from '../actions/modal';
-import Immutable                   from 'immutable';
+import {
+  MEDIA_OPEN,
+  MODAL_CLOSE,
+  MODAL_INDEX_DECREASE,
+  MODAL_INDEX_INCREASE
+} from '../actions/modal';
+import Immutable from 'immutable';
 
 const initialState = Immutable.Map({
-  url: '',
+  media: null,
+  index: 0,
   open: false
 });
 
@@ -10,11 +16,16 @@ export default function modal(state = initialState, action) {
   switch(action.type) {
   case MEDIA_OPEN:
     return state.withMutations(map => {
-      map.set('url', action.url);
+      map.set('media', action.media);
+      map.set('index', action.index);
       map.set('open', true);
     });
   case MODAL_CLOSE:
     return state.set('open', false);
+  case MODAL_INDEX_DECREASE:
+    return state.update('index', index => Math.max(index - 1, 0));
+  case MODAL_INDEX_INCREASE:
+    return state.update('index', index => Math.min(index + 1, state.get('media').size - 1));
   default:
     return state;
   }
diff --git a/app/assets/javascripts/components/reducers/notifications.jsx b/app/assets/javascripts/components/reducers/notifications.jsx
index 482093c33..4a7af8856 100644
--- a/app/assets/javascripts/components/reducers/notifications.jsx
+++ b/app/assets/javascripts/components/reducers/notifications.jsx
@@ -5,7 +5,8 @@ import {
   NOTIFICATIONS_REFRESH_REQUEST,
   NOTIFICATIONS_EXPAND_REQUEST,
   NOTIFICATIONS_REFRESH_FAIL,
-  NOTIFICATIONS_EXPAND_FAIL
+  NOTIFICATIONS_EXPAND_FAIL,
+  NOTIFICATIONS_CLEAR
 } from '../actions/notifications';
 import { ACCOUNT_BLOCK_SUCCESS } from '../actions/accounts';
 import Immutable from 'immutable';
@@ -75,6 +76,8 @@ export default function notifications(state = initialState, action) {
     return appendNormalizedNotifications(state, action.notifications, action.next);
   case ACCOUNT_BLOCK_SUCCESS:
     return filterNotifications(state, action.relationship);
+  case NOTIFICATIONS_CLEAR:
+    return state.set('items', Immutable.List()).set('next', null);
   default:
     return state;
   }
diff --git a/app/assets/javascripts/components/reducers/user_lists.jsx b/app/assets/javascripts/components/reducers/user_lists.jsx
index 72922f509..8c9a3d3aa 100644
--- a/app/assets/javascripts/components/reducers/user_lists.jsx
+++ b/app/assets/javascripts/components/reducers/user_lists.jsx
@@ -4,6 +4,7 @@ import {
   FOLLOWING_FETCH_SUCCESS,
   FOLLOWING_EXPAND_SUCCESS,
   FOLLOW_REQUESTS_FETCH_SUCCESS,
+  FOLLOW_REQUESTS_EXPAND_SUCCESS,
   FOLLOW_REQUEST_AUTHORIZE_SUCCESS,
   FOLLOW_REQUEST_REJECT_SUCCESS
 } from '../actions/accounts';
@@ -11,6 +12,10 @@ import {
   REBLOGS_FETCH_SUCCESS,
   FAVOURITES_FETCH_SUCCESS
 } from '../actions/interactions';
+import {
+  BLOCKS_FETCH_SUCCESS,
+  BLOCKS_EXPAND_SUCCESS
+} from '../actions/blocks';
 import Immutable from 'immutable';
 
 const initialState = Immutable.Map({
@@ -18,7 +23,8 @@ const initialState = Immutable.Map({
   following: Immutable.Map(),
   reblogged_by: Immutable.Map(),
   favourited_by: Immutable.Map(),
-  follow_requests: Immutable.Map()
+  follow_requests: Immutable.Map(),
+  blocks: Immutable.Map()
 });
 
 const normalizeList = (state, type, id, accounts, next) => {
@@ -50,9 +56,15 @@ export default function userLists(state = initialState, action) {
     return state.setIn(['favourited_by', action.id], Immutable.List(action.accounts.map(item => item.id)));
   case FOLLOW_REQUESTS_FETCH_SUCCESS:
     return state.setIn(['follow_requests', 'items'], Immutable.List(action.accounts.map(item => item.id))).setIn(['follow_requests', 'next'], action.next);
+  case FOLLOW_REQUESTS_EXPAND_SUCCESS:
+    return state.updateIn(['follow_requests', 'items'], list => list.push(...action.accounts.map(item => item.id))).setIn(['follow_requests', 'next'], action.next);
   case FOLLOW_REQUEST_AUTHORIZE_SUCCESS:
   case FOLLOW_REQUEST_REJECT_SUCCESS:
     return state.updateIn(['follow_requests', 'items'], list => list.filterNot(item => item === action.id));
+  case BLOCKS_FETCH_SUCCESS:
+    return state.setIn(['blocks', 'items'], Immutable.List(action.accounts.map(item => item.id))).setIn(['blocks', 'next'], action.next);
+  case BLOCKS_EXPAND_SUCCESS:
+    return state.updateIn(['blocks', 'items'], list => list.push(...action.accounts.map(item => item.id))).setIn(['blocks', 'next'], action.next);
   default:
     return state;
   }
diff --git a/app/assets/javascripts/components/stream.jsx b/app/assets/javascripts/components/stream.jsx
new file mode 100644
index 000000000..0787399f6
--- /dev/null
+++ b/app/assets/javascripts/components/stream.jsx
@@ -0,0 +1,21 @@
+import WebSocketClient from 'websocket.js';
+
+const createWebSocketURL = (url) => {
+  const a = document.createElement('a');
+
+  a.href     = url;
+  a.href     = a.href;
+  a.protocol = a.protocol.replace('http', 'ws');
+
+  return a.href;
+};
+
+export default function getStream(accessToken, stream, { connected, received, disconnected }) {
+  const ws = new WebSocketClient(`${createWebSocketURL(STREAMING_API_BASE_URL)}/api/v1/streaming/?access_token=${accessToken}&stream=${stream}`);
+
+  ws.onopen    = connected;
+  ws.onmessage = e => received(JSON.parse(e.data));
+  ws.onclose   = disconnected;
+
+  return ws;
+};
diff --git a/app/assets/stylesheets/components.scss b/app/assets/stylesheets/components.scss
index ca0ec0cec..13df099b1 100644
--- a/app/assets/stylesheets/components.scss
+++ b/app/assets/stylesheets/components.scss
@@ -28,15 +28,15 @@
   }
 
   &.button-secondary {
-    background-color: $color1;
+    //
+  }
+}
 
-    &:hover {
-      background-color: $color1;
-    }
+.column-icon {
+  color: $color3;
 
-    &:disabled {
-      background-color: $color3;
-    }
+  &:hover {
+    color: lighten($color3, 7%);
   }
 }
 
@@ -125,6 +125,10 @@
 
     &:hover {
       text-decoration: underline;
+
+      .fa {
+        color: lighten($color1, 40%);
+      }
     }
 
     &.mention {
@@ -136,6 +140,10 @@
         }
       }
     }
+
+    .fa {
+      color: lighten($color1, 30%);
+    }
   }
 
   .status__content__spoiler-link {
diff --git a/app/assets/stylesheets/variables.scss b/app/assets/stylesheets/variables.scss
index de4157af8..cdf81c818 100644
--- a/app/assets/stylesheets/variables.scss
+++ b/app/assets/stylesheets/variables.scss
@@ -2,7 +2,7 @@ $color1: #282c37; // darkest
 $color2: #d9e1e8; // lightest
 $color3: #9baec8; // lighter
 $color4: #2b90d9; // vibrant
-$color5: #fff; // white
+$color5: #ffffff; // white
 $color6: #df405a; // error red
 $color7: #79bd9a; // succ green
-$color8: #000; // black
+$color8: #000000; // black