about summary refs log tree commit diff
diff options
context:
space:
mode:
-rw-r--r--.env.production.sample4
-rw-r--r--.travis.yml6
-rw-r--r--Gemfile1
-rw-r--r--Gemfile.lock7
-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
-rw-r--r--app/controllers/api/v1/blocks_controller.rb2
-rw-r--r--app/controllers/api/v1/devices_controller.rb18
-rw-r--r--app/controllers/api/v1/statuses_controller.rb7
-rw-r--r--app/controllers/api/v1/timelines_controller.rb4
-rw-r--r--app/controllers/settings/preferences_controller.rb6
-rw-r--r--app/controllers/stream_entries_controller.rb4
-rw-r--r--app/lib/feed_manager.rb1
-rw-r--r--app/lib/formatter.rb3
-rw-r--r--app/models/device.rb7
-rw-r--r--app/models/favourite.rb2
-rw-r--r--app/models/status.rb18
-rw-r--r--app/models/user.rb4
-rw-r--r--app/services/fan_out_on_write_service.rb12
-rw-r--r--app/services/follow_service.rb2
-rw-r--r--app/services/notify_service.rb5
-rw-r--r--app/services/search_service.rb2
-rw-r--r--app/services/send_push_notification_service.rb28
-rw-r--r--app/services/warm_cache_service.rb8
-rw-r--r--app/views/api/v1/statuses/_media.rabl1
-rw-r--r--app/views/api/v1/statuses/_mention.rabl7
-rw-r--r--app/views/api/v1/statuses/_show.rabl2
-rw-r--r--app/views/api/v1/statuses/show.rabl10
-rw-r--r--app/views/home/index.html.haml1
-rw-r--r--app/views/home/initial_state.json.rabl14
-rw-r--r--app/views/settings/preferences/show.html.haml2
-rw-r--r--app/workers/distribution_worker.rb5
-rw-r--r--config/environments/development.rb5
-rw-r--r--config/environments/production.rb2
-rw-r--r--config/initializers/ostatus.rb2
-rw-r--r--config/locales/en.yml6
-rw-r--r--config/locales/simple_form.en.yml1
-rw-r--r--config/routes.rb3
-rw-r--r--db/migrate/20170205175257_remove_devices.rb5
-rw-r--r--db/schema.rb11
-rw-r--r--docker-compose.yml10
-rw-r--r--docs/Running-Mastodon/Production-guide.md37
-rw-r--r--docs/Using-the-API/API.md16
-rw-r--r--docs/Using-the-API/Push-notifications.md15
-rw-r--r--package.json7
-rw-r--r--spec/controllers/api/v1/devices_controller_spec.rb38
-rw-r--r--spec/fabricators/device_fabricator.rb3
-rw-r--r--spec/lib/formatter_spec.rb2
-rw-r--r--spec/models/device_spec.rb5
-rw-r--r--streaming/index.js188
-rw-r--r--yarn.lock65
84 files changed, 911 insertions, 386 deletions
diff --git a/.env.production.sample b/.env.production.sample
index 1a96775de..ef0af9d5c 100644
--- a/.env.production.sample
+++ b/.env.production.sample
@@ -43,5 +43,5 @@ SMTP_FROM_ADDRESS=notifications@example.com
 # Optional alias for S3 if you want to use Cloudfront or Cloudflare in front
 # S3_CLOUDFRONT_HOST=
 
-# Optional Firebase Cloud Messaging API key
-FCM_API_KEY=
+# Streaming API integration
+# STREAMING_API_BASE_URL=
diff --git a/.travis.yml b/.travis.yml
index fe4549edd..b1b0c2bcd 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -11,7 +11,7 @@ env:
     - LOCAL_DOMAIN=cb6e6126.ngrok.io
     - LOCAL_HTTPS=true
     - RAILS_ENV=test
-
+    - CXX=g++-4.8
 addons:
   postgresql: 9.4
 
@@ -23,6 +23,10 @@ services:
 
 bundler_args: --without development production --retry=3 --jobs=3
 
+before_install:
+  - sudo add-apt-repository -y ppa:ubuntu-toolchain-r/test
+  - sudo apt-get -qq update
+  - sudo apt-get -qq install g++-4.8
 install:
   - nvm install $TRAVIS_NODE_VERSION
   - npm install -g npm@3
diff --git a/Gemfile b/Gemfile
index 6fd86ec48..423560bb6 100644
--- a/Gemfile
+++ b/Gemfile
@@ -50,7 +50,6 @@ gem 'pg_search'
 gem 'simple-navigation'
 gem 'statsd-instrument'
 gem 'ruby-oembed', require: 'oembed'
-gem 'fcm'
 
 gem 'react-rails'
 gem 'browserify-rails'
diff --git a/Gemfile.lock b/Gemfile.lock
index dd3105b38..4f54a621c 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -147,9 +147,6 @@ GEM
     execjs (2.7.0)
     fabrication (2.15.2)
     fast_blank (1.0.0)
-    fcm (0.0.2)
-      httparty
-      json
     font-awesome-rails (4.6.3.1)
       railties (>= 3.2, < 5.1)
     fuubar (2.1.1)
@@ -183,8 +180,6 @@ GEM
       domain_name (~> 0.5)
     http-form_data (1.0.1)
     http_parser.rb (0.6.0)
-    httparty (0.14.0)
-      multi_xml (>= 0.5.2)
     httplog (0.3.2)
       colorize
     i18n (0.7.0)
@@ -232,7 +227,6 @@ GEM
     mini_portile2 (2.1.0)
     minitest (5.10.1)
     multi_json (1.12.1)
-    multi_xml (0.6.0)
     net-scp (1.2.1)
       net-ssh (>= 2.6.5)
     net-ssh (4.0.1)
@@ -470,7 +464,6 @@ DEPENDENCIES
   dotenv-rails
   fabrication
   fast_blank
-  fcm
   font-awesome-rails
   fuubar
   goldfinger
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
diff --git a/app/controllers/api/v1/blocks_controller.rb b/app/controllers/api/v1/blocks_controller.rb
index b9816e052..08aefc175 100644
--- a/app/controllers/api/v1/blocks_controller.rb
+++ b/app/controllers/api/v1/blocks_controller.rb
@@ -9,7 +9,7 @@ class Api::V1::BlocksController < ApiController
   def index
     results   = Block.where(account: current_account).paginate_by_max_id(limit_param(DEFAULT_ACCOUNTS_LIMIT), params[:max_id], params[:since_id])
     accounts  = Account.where(id: results.map(&:target_account_id)).map { |a| [a.id, a] }.to_h
-    @accounts = results.map { |f| accounts[f.target_account_id] }
+    @accounts = results.map { |f| accounts[f.target_account_id] }.compact
 
     set_account_counters_maps(@accounts)
 
diff --git a/app/controllers/api/v1/devices_controller.rb b/app/controllers/api/v1/devices_controller.rb
deleted file mode 100644
index c565e972b..000000000
--- a/app/controllers/api/v1/devices_controller.rb
+++ /dev/null
@@ -1,18 +0,0 @@
-# frozen_string_literal: true
-
-class Api::V1::DevicesController < ApiController
-  before_action -> { doorkeeper_authorize! :read }
-  before_action :require_user!
-
-  respond_to :json
-
-  def register
-    Device.where(account: current_account, registration_id: params[:registration_id]).first_or_create!(account: current_account, registration_id: params[:registration_id])
-    render_empty
-  end
-
-  def unregister
-    Device.where(account: current_account, registration_id: params[:registration_id]).delete_all
-    render_empty
-  end
-end
diff --git a/app/controllers/api/v1/statuses_controller.rb b/app/controllers/api/v1/statuses_controller.rb
index 4b095a570..69cbdce5d 100644
--- a/app/controllers/api/v1/statuses_controller.rb
+++ b/app/controllers/api/v1/statuses_controller.rb
@@ -14,7 +14,12 @@ class Api::V1::StatusesController < ApiController
   end
 
   def context
-    @context = OpenStruct.new(ancestors: @status.in_reply_to_id.nil? ? [] : @status.ancestors(current_account), descendants: @status.descendants(current_account))
+    ancestors_results   = @status.in_reply_to_id.nil? ? [] : @status.ancestors(current_account)
+    descendants_results = @status.descendants(current_account)
+    loaded_ancestors    = cache_collection(ancestors_results, Status)
+    loaded_descendants  = cache_collection(descendants_results, Status)
+
+    @context = OpenStruct.new(ancestors: loaded_ancestors, descendants: loaded_descendants)
     statuses = [@status] + @context[:ancestors] + @context[:descendants]
 
     set_maps(statuses)
diff --git a/app/controllers/api/v1/timelines_controller.rb b/app/controllers/api/v1/timelines_controller.rb
index 854ca13e6..a8cc2b288 100644
--- a/app/controllers/api/v1/timelines_controller.rb
+++ b/app/controllers/api/v1/timelines_controller.rb
@@ -23,7 +23,7 @@ class Api::V1::TimelinesController < ApiController
   end
 
   def public
-    @statuses = Status.as_public_timeline(current_account).paginate_by_max_id(limit_param(DEFAULT_STATUSES_LIMIT), params[:max_id], params[:since_id])
+    @statuses = Status.as_public_timeline(current_account, params[:local]).paginate_by_max_id(limit_param(DEFAULT_STATUSES_LIMIT), params[:max_id], params[:since_id])
     @statuses = cache_collection(@statuses)
 
     set_maps(@statuses)
@@ -40,7 +40,7 @@ class Api::V1::TimelinesController < ApiController
 
   def tag
     @tag      = Tag.find_by(name: params[:id].downcase)
-    @statuses = @tag.nil? ? [] : Status.as_tag_timeline(@tag, current_account).paginate_by_max_id(limit_param(DEFAULT_STATUSES_LIMIT), params[:max_id], params[:since_id])
+    @statuses = @tag.nil? ? [] : Status.as_tag_timeline(@tag, current_account, params[:local]).paginate_by_max_id(limit_param(DEFAULT_STATUSES_LIMIT), params[:max_id], params[:since_id])
     @statuses = cache_collection(@statuses)
 
     set_maps(@statuses)
diff --git a/app/controllers/settings/preferences_controller.rb b/app/controllers/settings/preferences_controller.rb
index 5ad825675..b7479bf8c 100644
--- a/app/controllers/settings/preferences_controller.rb
+++ b/app/controllers/settings/preferences_controller.rb
@@ -21,7 +21,9 @@ class Settings::PreferencesController < ApplicationController
       must_be_following: user_params[:interactions][:must_be_following] == '1',
     }
 
-    if current_user.update(user_params.except(:notification_emails, :interactions))
+    current_user.settings['default_privacy'] = user_params[:setting_default_privacy]
+
+    if current_user.update(user_params.except(:notification_emails, :interactions, :setting_default_privacy))
       redirect_to settings_preferences_path, notice: I18n.t('generic.changes_saved_msg')
     else
       render action: :show
@@ -31,6 +33,6 @@ class Settings::PreferencesController < ApplicationController
   private
 
   def user_params
-    params.require(:user).permit(:locale, notification_emails: [:follow, :follow_request, :reblog, :favourite, :mention], interactions: [:must_be_follower, :must_be_following])
+    params.require(:user).permit(:locale, :setting_default_privacy, notification_emails: [:follow, :follow_request, :reblog, :favourite, :mention], interactions: [:must_be_follower, :must_be_following])
   end
 end
diff --git a/app/controllers/stream_entries_controller.rb b/app/controllers/stream_entries_controller.rb
index 5701b2efa..da284d80e 100644
--- a/app/controllers/stream_entries_controller.rb
+++ b/app/controllers/stream_entries_controller.rb
@@ -14,8 +14,8 @@ class StreamEntriesController < ApplicationController
         return gone if @stream_entry.activity.nil?
 
         if @stream_entry.activity_type == 'Status'
-          @ancestors   = @stream_entry.activity.ancestors(current_account)
-          @descendants = @stream_entry.activity.descendants(current_account)
+          @ancestors   = @stream_entry.activity.reply? ? cache_collection(@stream_entry.activity.ancestors(current_account), Status) : []
+          @descendants = cache_collection(@stream_entry.activity.descendants(current_account), Status)
         end
       end
 
diff --git a/app/lib/feed_manager.rb b/app/lib/feed_manager.rb
index 028fc5218..7069026e3 100644
--- a/app/lib/feed_manager.rb
+++ b/app/lib/feed_manager.rb
@@ -30,6 +30,7 @@ class FeedManager
   end
 
   def broadcast(timeline_id, options = {})
+    options[:queued_at] = (Time.now.to_f * 1000.0).to_i
     ActionCable.server.broadcast("timeline:#{timeline_id}", options)
   end
 
diff --git a/app/lib/formatter.rb b/app/lib/formatter.rb
index ff2a16f1b..044407a6c 100644
--- a/app/lib/formatter.rb
+++ b/app/lib/formatter.rb
@@ -68,8 +68,9 @@ class Formatter
     prefix = url.match(/\Ahttps?:\/\/(www\.)?/).to_s
     text   = url[prefix.length, 30]
     suffix = url[prefix.length + 30..-1]
+    cutoff = url[prefix.length..-1].length > 30
 
-    "<a rel=\"nofollow noopener\" target=\"_blank\" href=\"#{url}\"><span class=\"invisible\">#{prefix}</span><span class=\"ellipsis\">#{text}</span><span class=\"invisible\">#{suffix}</span></a>"
+    "<a rel=\"nofollow noopener\" target=\"_blank\" href=\"#{url}\"><span class=\"invisible\">#{prefix}</span><span class=\"#{cutoff ? 'ellipsis' : ''}\">#{text}</span><span class=\"invisible\">#{suffix}</span></a>"
   end
 
   def hashtag_html(match)
diff --git a/app/models/device.rb b/app/models/device.rb
deleted file mode 100644
index 2782a7f38..000000000
--- a/app/models/device.rb
+++ /dev/null
@@ -1,7 +0,0 @@
-# frozen_string_literal: true
-
-class Device < ApplicationRecord
-  belongs_to :account
-
-  validates :account, :registration_id, presence: true
-end
diff --git a/app/models/favourite.rb b/app/models/favourite.rb
index 147105e48..3f3616dce 100644
--- a/app/models/favourite.rb
+++ b/app/models/favourite.rb
@@ -5,7 +5,7 @@ class Favourite < ApplicationRecord
   include Streamable
 
   belongs_to :account, inverse_of: :favourites
-  belongs_to :status,  inverse_of: :favourites, touch: true
+  belongs_to :status,  inverse_of: :favourites
 
   has_one :notification, as: :activity, dependent: :destroy
 
diff --git a/app/models/status.rb b/app/models/status.rb
index 63f5d5fa4..6ef0b2bdd 100644
--- a/app/models/status.rb
+++ b/app/models/status.rb
@@ -14,7 +14,7 @@ class Status < ApplicationRecord
   belongs_to :in_reply_to_account, foreign_key: 'in_reply_to_account_id', class_name: 'Account'
 
   belongs_to :thread, foreign_key: 'in_reply_to_id', class_name: 'Status', inverse_of: :replies
-  belongs_to :reblog, foreign_key: 'reblog_of_id', class_name: 'Status', inverse_of: :reblogs, touch: true
+  belongs_to :reblog, foreign_key: 'reblog_of_id', class_name: 'Status', inverse_of: :reblogs
 
   has_many :favourites, inverse_of: :status, dependent: :destroy
   has_many :reblogs, foreign_key: 'reblog_of_id', class_name: 'Status', inverse_of: :reblog, dependent: :destroy
@@ -81,7 +81,7 @@ class Status < ApplicationRecord
 
   def ancestors(account = nil)
     ids      = (Status.find_by_sql(['WITH RECURSIVE search_tree(id, in_reply_to_id, path) AS (SELECT id, in_reply_to_id, ARRAY[id] FROM statuses WHERE id = ? UNION ALL SELECT statuses.id, statuses.in_reply_to_id, path || statuses.id FROM search_tree JOIN statuses ON statuses.id = search_tree.in_reply_to_id WHERE NOT statuses.id = ANY(path)) SELECT id FROM search_tree ORDER BY path DESC', id]) - [self]).pluck(:id)
-    statuses = Status.where(id: ids).with_includes.group_by(&:id)
+    statuses = Status.where(id: ids).group_by(&:id)
     results  = ids.map { |id| statuses[id].first }
     results  = results.reject { |status| filter_from_context?(status, account) }
 
@@ -90,7 +90,7 @@ class Status < ApplicationRecord
 
   def descendants(account = nil)
     ids      = (Status.find_by_sql(['WITH RECURSIVE search_tree(id, path) AS (SELECT id, ARRAY[id] FROM statuses WHERE id = ? UNION ALL SELECT statuses.id, path || statuses.id FROM search_tree JOIN statuses ON statuses.in_reply_to_id = search_tree.id WHERE NOT statuses.id = ANY(path)) SELECT id FROM search_tree ORDER BY path', id]) - [self]).pluck(:id)
-    statuses = Status.where(id: ids).with_includes.group_by(&:id)
+    statuses = Status.where(id: ids).group_by(&:id)
     results  = ids.map { |id| statuses[id].first }
     results  = results.reject { |status| filter_from_context?(status, account) }
 
@@ -102,21 +102,25 @@ class Status < ApplicationRecord
       where(account: [account] + account.following)
     end
 
-    def as_public_timeline(account = nil)
+    def as_public_timeline(account = nil, local_only = false)
       query = joins('LEFT OUTER JOIN accounts ON statuses.account_id = accounts.id')
               .where(visibility: :public)
               .where('(statuses.in_reply_to_id IS NULL OR statuses.in_reply_to_account_id = statuses.account_id)')
               .where('statuses.reblog_of_id IS NULL')
 
+      query = query.where('accounts.domain IS NULL') if local_only
+
       account.nil? ? filter_timeline_default(query) : filter_timeline_default(filter_timeline(query, account))
     end
 
-    def as_tag_timeline(tag, account = nil)
+    def as_tag_timeline(tag, account = nil, local_only = false)
       query = tag.statuses
                  .joins('LEFT OUTER JOIN accounts ON statuses.account_id = accounts.id')
                  .where(visibility: :public)
                  .where('statuses.reblog_of_id IS NULL')
 
+      query = query.where('accounts.domain IS NULL') if local_only
+
       account.nil? ? filter_timeline_default(query) : filter_timeline_default(filter_timeline(query, account))
     end
 
@@ -157,7 +161,7 @@ class Status < ApplicationRecord
     private
 
     def filter_timeline(query, account)
-      blocked = Block.where(account: account).pluck(:target_account_id)
+      blocked = Block.where(account: account).pluck(:target_account_id) + Block.where(target_account: account).pluck(:account_id)
       query   = query.where('statuses.account_id NOT IN (?)', blocked) unless blocked.empty?
       query   = query.where('accounts.silenced = TRUE') if account.silenced?
       query
@@ -180,6 +184,6 @@ class Status < ApplicationRecord
   private
 
   def filter_from_context?(status, account)
-    account&.blocking?(status.account) || !status.permitted?(account)
+    account&.blocking?(status.account_id) || !status.permitted?(account)
   end
 end
diff --git a/app/models/user.rb b/app/models/user.rb
index b34144f2c..08aac2679 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -21,4 +21,8 @@ class User < ApplicationRecord
   def send_devise_notification(notification, *args)
     devise_mailer.send(notification, self, *args).deliver_later
   end
+
+  def setting_default_privacy
+    settings.default_privacy || (account.locked? ? 'private' : 'public')
+  end
 end
diff --git a/app/services/fan_out_on_write_service.rb b/app/services/fan_out_on_write_service.rb
index 13aad4632..71f6cbca1 100644
--- a/app/services/fan_out_on_write_service.rb
+++ b/app/services/fan_out_on_write_service.rb
@@ -34,13 +34,21 @@ class FanOutOnWriteService < BaseService
 
   def deliver_to_hashtags(status)
     Rails.logger.debug "Delivering status #{status.id} to hashtags"
+
+    payload = FeedManager.instance.inline_render(nil, 'api/v1/statuses/show', status)
+
     status.tags.find_each do |tag|
-      FeedManager.instance.broadcast("hashtag:#{tag.name}", event: 'update', payload: FeedManager.instance.inline_render(nil, 'api/v1/statuses/show', status))
+      FeedManager.instance.broadcast("hashtag:#{tag.name}", event: 'update', payload: payload)
+      FeedManager.instance.broadcast("hashtag:#{tag.name}:local", event: 'update', payload: payload) if status.account.local?
     end
   end
 
   def deliver_to_public(status)
     Rails.logger.debug "Delivering status #{status.id} to public timeline"
-    FeedManager.instance.broadcast(:public, event: 'update', payload: FeedManager.instance.inline_render(nil, 'api/v1/statuses/show', status))
+
+    payload = FeedManager.instance.inline_render(nil, 'api/v1/statuses/show', status)
+
+    FeedManager.instance.broadcast(:public, event: 'update', payload: payload)
+    FeedManager.instance.broadcast('public:local', event: 'update', payload: payload) if status.account.local?
   end
 end
diff --git a/app/services/follow_service.rb b/app/services/follow_service.rb
index 87c16a621..9f34cb6ac 100644
--- a/app/services/follow_service.rb
+++ b/app/services/follow_service.rb
@@ -8,7 +8,7 @@ class FollowService < BaseService
     target_account = follow_remote_account_service.call(uri)
 
     raise ActiveRecord::RecordNotFound if target_account.nil? || target_account.id == source_account.id || target_account.suspended?
-    raise Mastodon::NotPermitted       if target_account.blocking?(source_account)
+    raise Mastodon::NotPermitted       if target_account.blocking?(source_account) || source_account.blocking?(target_account)
 
     if target_account.locked?
       request_follow(source_account, target_account)
diff --git a/app/services/notify_service.rb b/app/services/notify_service.rb
index 0cc3cd618..942cd9d21 100644
--- a/app/services/notify_service.rb
+++ b/app/services/notify_service.rb
@@ -10,7 +10,6 @@ class NotifyService < BaseService
 
     create_notification
     send_email if email_enabled?
-    send_push_notification
   rescue ActiveRecord::RecordInvalid
     return
   end
@@ -58,10 +57,6 @@ class NotifyService < BaseService
     NotificationMailer.send(@notification.type, @recipient, @notification).deliver_later
   end
 
-  def send_push_notification
-    PushNotificationWorker.perform_async(@notification.id)
-  end
-
   def email_enabled?
     @recipient.user.settings.notification_emails[@notification.type]
   end
diff --git a/app/services/search_service.rb b/app/services/search_service.rb
index e9a27f136..04de8a134 100644
--- a/app/services/search_service.rb
+++ b/app/services/search_service.rb
@@ -17,7 +17,7 @@ class SearchService < BaseService
     results = results.limit(limit).to_a
     results = [exact_match] + results.reject { |a| a.id == exact_match.id } if exact_match
 
-    if resolve && results.empty? && !domain.nil?
+    if resolve && !exact_match && !domain.nil?
       results = [FollowRemoteAccountService.new.call("#{username}@#{domain}")]
     end
 
diff --git a/app/services/send_push_notification_service.rb b/app/services/send_push_notification_service.rb
deleted file mode 100644
index 526ae20cb..000000000
--- a/app/services/send_push_notification_service.rb
+++ /dev/null
@@ -1,28 +0,0 @@
-# frozen_string_literal: true
-
-class SendPushNotificationService < BaseService
-  def call(notification)
-    return if ENV['FCM_API_KEY'].blank?
-
-    devices = Device.where(account: notification.account).pluck(:registration_id)
-    fcm     = FCM.new(ENV['FCM_API_KEY'])
-
-    response = fcm.send(devices, data: { notification_id: notification.id }, collapse_key: :notifications, priority: :high)
-    handle_response(response)
-  end
-
-  private
-
-  def handle_response(response)
-    update_canonical_ids(response[:canonical_ids]) if response[:canonical_ids]
-    remove_bad_ids(response[:not_registered_ids])  if response[:not_registered_ids]
-  end
-
-  def update_canonical_ids(ids)
-    ids.each { |pair| Device.find_by(registration_id: pair[:old]).update(registration_id: pair[:new]) }
-  end
-
-  def remove_bad_ids(bad_ids)
-    Device.where(registration_id: bad_ids).delete_all unless bad_ids.empty?
-  end
-end
diff --git a/app/services/warm_cache_service.rb b/app/services/warm_cache_service.rb
new file mode 100644
index 000000000..091a471ff
--- /dev/null
+++ b/app/services/warm_cache_service.rb
@@ -0,0 +1,8 @@
+# frozen_string_literal: true
+
+class WarmCacheService < BaseService
+  def call(cacheable)
+    full_item = cacheable.class.where(id: cacheable.id).with_includes.first
+    Rails.cache.write(cacheable.cache_key, full_item)
+  end
+end
diff --git a/app/views/api/v1/statuses/_media.rabl b/app/views/api/v1/statuses/_media.rabl
index 5c6be1ce7..2f56c6d07 100644
--- a/app/views/api/v1/statuses/_media.rabl
+++ b/app/views/api/v1/statuses/_media.rabl
@@ -2,3 +2,4 @@ attributes :id, :remote_url, :type
 
 node(:url)         { |media| full_asset_url(media.file.url(:original)) }
 node(:preview_url) { |media| full_asset_url(media.file.url(:small)) }
+node(:text_url)    { |media| media.local? ? medium_url(media) : nil }
diff --git a/app/views/api/v1/statuses/_mention.rabl b/app/views/api/v1/statuses/_mention.rabl
index 07b3d1f61..498cca275 100644
--- a/app/views/api/v1/statuses/_mention.rabl
+++ b/app/views/api/v1/statuses/_mention.rabl
@@ -1,3 +1,4 @@
-node(:url)  { |mention| TagManager.instance.url_for(mention.account) }
-node(:acct) { |mention| mention.account.acct }
-node(:id)   { |mention| mention.account_id }
+node(:url)      { |mention| TagManager.instance.url_for(mention.account) }
+node(:acct)     { |mention| mention.account.acct }
+node(:id)       { |mention| mention.account_id }
+node(:username) { |mention| mention.account.username }
diff --git a/app/views/api/v1/statuses/_show.rabl b/app/views/api/v1/statuses/_show.rabl
index 7309a78b8..059e0d13f 100644
--- a/app/views/api/v1/statuses/_show.rabl
+++ b/app/views/api/v1/statuses/_show.rabl
@@ -1,4 +1,4 @@
-attributes :id, :created_at, :in_reply_to_id, :sensitive, :spoiler_text, :visibility
+attributes :id, :created_at, :in_reply_to_id, :in_reply_to_account_id, :sensitive, :spoiler_text, :visibility
 
 node(:uri)              { |status| TagManager.instance.uri_for(status) }
 node(:content)          { |status| Formatter.instance.format(status) }
diff --git a/app/views/api/v1/statuses/show.rabl b/app/views/api/v1/statuses/show.rabl
index 1b4651cdd..41e8983ef 100644
--- a/app/views/api/v1/statuses/show.rabl
+++ b/app/views/api/v1/statuses/show.rabl
@@ -2,12 +2,12 @@ object @status
 
 extends 'api/v1/statuses/_show'
 
-node(:favourited, if: proc { !current_account.nil? }) { |status| defined?(@favourites_map) ? !!@favourites_map[status.id] : current_account.favourited?(status) }
-node(:reblogged,  if: proc { !current_account.nil? }) { |status| defined?(@reblogs_map)    ? !!@reblogs_map[status.id]    : current_account.reblogged?(status) }
+node(:favourited, if: proc { !current_account.nil? }) { |status| defined?(@favourites_map) ? @favourites_map[status.id] : current_account.favourited?(status) }
+node(:reblogged,  if: proc { !current_account.nil? }) { |status| defined?(@reblogs_map)    ? @reblogs_map[status.id]    : current_account.reblogged?(status) }
 
-child :reblog => :reblog do
+child reblog: :reblog do
   extends 'api/v1/statuses/_show'
 
-  node(:favourited, if: proc { !current_account.nil? }) { |status| defined?(@favourites_map) ? !!@favourites_map[status.id] : current_account.favourited?(status) }
-  node(:reblogged,  if: proc { !current_account.nil? }) { |status| defined?(@reblogs_map)    ? !!@reblogs_map[status.id]    : current_account.reblogged?(status) }
+  node(:favourited, if: proc { !current_account.nil? }) { |status| defined?(@favourites_map) ? @favourites_map[status.id] : current_account.favourited?(status) }
+  node(:reblogged,  if: proc { !current_account.nil? }) { |status| defined?(@reblogs_map)    ? @reblogs_map[status.id]    : current_account.reblogged?(status) }
 end
diff --git a/app/views/home/index.html.haml b/app/views/home/index.html.haml
index 0147f4064..9e3b94463 100644
--- a/app/views/home/index.html.haml
+++ b/app/views/home/index.html.haml
@@ -1,5 +1,6 @@
 - content_for :header_tags do
   :javascript
+    window.STREAMING_API_BASE_URL = '#{Rails.configuration.x.streaming_api_base_url}';
     window.INITIAL_STATE = #{json_escape(render(file: 'home/initial_state', formats: :json))}
 
   = javascript_include_tag 'application'
diff --git a/app/views/home/initial_state.json.rabl b/app/views/home/initial_state.json.rabl
index 0e9736f5f..71949ab0e 100644
--- a/app/views/home/initial_state.json.rabl
+++ b/app/views/home/initial_state.json.rabl
@@ -1,24 +1,24 @@
 object false
 
-node(:meta) {
+node(:meta) do
   {
     access_token: @token,
     locale: I18n.locale,
     me: current_account.id,
   }
-}
+end
 
-node(:compose) {
+node(:compose) do
   {
     me: current_account.id,
-    private: current_account.locked?,
+    default_privacy: current_account.user.setting_default_privacy,
   }
-}
+end
 
-node(:accounts) {
+node(:accounts) do
   {
     current_account.id => partial('api/v1/accounts/show', object: current_account),
   }
-}
+end
 
 node(:settings) { @web_settings }
diff --git a/app/views/settings/preferences/show.html.haml b/app/views/settings/preferences/show.html.haml
index 747977f9c..aee0540d2 100644
--- a/app/views/settings/preferences/show.html.haml
+++ b/app/views/settings/preferences/show.html.haml
@@ -7,6 +7,8 @@
   .fields-group
     = f.input :locale, collection: I18n.available_locales, wrapper: :with_label, include_blank: false, label_method: lambda { |locale| human_locale(locale) }
 
+    = f.input :setting_default_privacy, collection: Status.visibilities.keys, wrapper: :with_label, include_blank: false, label_method: lambda { |visibility| I18n.t("statuses.visibilities.#{visibility}") }, required: false
+
   .fields-group
     = f.simple_fields_for :notification_emails, hash_to_object(current_user.settings.notification_emails) do |ff|
       = ff.input :follow, as: :boolean, wrapper: :with_label
diff --git a/app/workers/distribution_worker.rb b/app/workers/distribution_worker.rb
index f423d43ae..f4e738d80 100644
--- a/app/workers/distribution_worker.rb
+++ b/app/workers/distribution_worker.rb
@@ -4,7 +4,10 @@ class DistributionWorker
   include Sidekiq::Worker
 
   def perform(status_id)
-    FanOutOnWriteService.new.call(Status.find(status_id))
+    status = Status.find(status_id)
+
+    FanOutOnWriteService.new.call(status)
+    WarmCacheService.new.call(status)
   rescue ActiveRecord::RecordNotFound
     true
   end
diff --git a/config/environments/development.rb b/config/environments/development.rb
index 51cb43e5d..6157f20d3 100644
--- a/config/environments/development.rb
+++ b/config/environments/development.rb
@@ -20,11 +20,12 @@ Rails.application.configure do
       host: ENV['REDIS_HOST'] || 'localhost',
       port: ENV['REDIS_PORT'] || 6379,
       db: 0,
-      namespace: 'cache'
+      namespace: 'cache',
+      expires_in: 1.minute,
     }
 
     config.public_file_server.headers = {
-      'Cache-Control' => 'public, max-age=172800'
+      'Cache-Control' => 'public, max-age=172800',
     }
   else
     config.action_controller.perform_caching = false
diff --git a/config/environments/production.rb b/config/environments/production.rb
index eaddba522..62ea217ef 100644
--- a/config/environments/production.rb
+++ b/config/environments/production.rb
@@ -64,7 +64,7 @@ Rails.application.configure do
     password: ENV.fetch('REDIS_PASSWORD') { false },
     db: 0,
     namespace: 'cache',
-    expires_in: 20.minutes
+    expires_in: 20.minutes,
   }
 
   # Enable serving of images, stylesheets, and JavaScripts from an asset server.
diff --git a/config/initializers/ostatus.rb b/config/initializers/ostatus.rb
index faa9940b0..fb0b8b7fe 100644
--- a/config/initializers/ostatus.rb
+++ b/config/initializers/ostatus.rb
@@ -10,8 +10,10 @@ Rails.application.configure do
   config.x.use_s3       = ENV['S3_ENABLED'] == 'true'
 
   config.action_mailer.default_url_options = { host: host, protocol: https ? 'https://' : 'http://', trailing_slash: false }
+  config.x.streaming_api_base_url          = 'http://localhost:4000'
 
   if Rails.env.production?
     config.action_cable.allowed_request_origins = ["http#{https ? 's' : ''}://#{host}"]
+    config.x.streaming_api_base_url             = ENV.fetch('STREAMING_API_BASE_URL') { "http#{https ? 's' : ''}://#{host}" }
   end
 end
diff --git a/config/locales/en.yml b/config/locales/en.yml
index 16b406745..b1b1e7995 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -97,8 +97,12 @@ en:
     settings: Settings
     two_factor_auth: Two-factor Authentication
   statuses:
-    over_character_limit: character limit of %{max} exceeded
     open_in_web: Open in web
+    over_character_limit: character limit of %{max} exceeded
+    visibilities:
+      private: Only show to followers
+      public: Public
+      unlisted: Public, but do not display on the public timeline
   stream_entries:
     click_to_show: Click to show
     favourited: favourited a post by
diff --git a/config/locales/simple_form.en.yml b/config/locales/simple_form.en.yml
index 09957c914..4d1758f82 100644
--- a/config/locales/simple_form.en.yml
+++ b/config/locales/simple_form.en.yml
@@ -23,6 +23,7 @@ en:
         note: Bio
         otp_attempt: Two-factor code
         password: Password
+        setting_default_privacy: Post privacy
         username: Username
       interactions:
         must_be_follower: Block notifications from non-followers
diff --git a/config/routes.rb b/config/routes.rb
index 699f56833..e17d54995 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -117,9 +117,6 @@ Rails.application.routes.draw do
       resources :blocks,     only: [:index]
       resources :favourites, only: [:index]
 
-      post '/devices/register',   to: 'devices#register', as: :register_device
-      post '/devices/unregister', to: 'devices#unregister', as: :unregister_device
-
       resources :follow_requests, only: [:index] do
         member do
           post :authorize
diff --git a/db/migrate/20170205175257_remove_devices.rb b/db/migrate/20170205175257_remove_devices.rb
new file mode 100644
index 000000000..e96ffed4d
--- /dev/null
+++ b/db/migrate/20170205175257_remove_devices.rb
@@ -0,0 +1,5 @@
+class RemoveDevices < ActiveRecord::Migration[5.0]
+  def change
+    drop_table :devices
+  end
+end
diff --git a/db/schema.rb b/db/schema.rb
index 448a7b861..28a578aa2 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -10,7 +10,7 @@
 #
 # It's strongly recommended that you check this file into your version control system.
 
-ActiveRecord::Schema.define(version: 20170129000348) do
+ActiveRecord::Schema.define(version: 20170205175257) do
 
   # These are extensions that must be enabled in order to support this database
   enable_extension "plpgsql"
@@ -54,15 +54,6 @@ ActiveRecord::Schema.define(version: 20170129000348) do
     t.index ["account_id", "target_account_id"], name: "index_blocks_on_account_id_and_target_account_id", unique: true, using: :btree
   end
 
-  create_table "devices", force: :cascade do |t|
-    t.integer  "account_id",                   null: false
-    t.string   "registration_id", default: "", null: false
-    t.datetime "created_at",                   null: false
-    t.datetime "updated_at",                   null: false
-    t.index ["account_id"], name: "index_devices_on_account_id", using: :btree
-    t.index ["registration_id"], name: "index_devices_on_registration_id", using: :btree
-  end
-
   create_table "domain_blocks", force: :cascade do |t|
     t.string   "domain",       default: "", null: false
     t.datetime "created_at",                null: false
diff --git a/docker-compose.yml b/docker-compose.yml
index e1f1f1c4c..e6002eaa5 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -19,6 +19,16 @@ services:
     volumes:
       - ./public/assets:/mastodon/public/assets
       - ./public/system:/mastodon/public/system
+  streaming:
+    restart: always
+    build: .
+    env_file: .env.production
+    command: npm run start
+    ports:
+      - "4000:4000"
+    depends_on:
+      - db
+      - redis
   sidekiq:
     restart: always
     build: .
diff --git a/docs/Running-Mastodon/Production-guide.md b/docs/Running-Mastodon/Production-guide.md
index 76964d995..ff4427dd2 100644
--- a/docs/Running-Mastodon/Production-guide.md
+++ b/docs/Running-Mastodon/Production-guide.md
@@ -49,6 +49,22 @@ server {
     tcp_nodelay on;
   }
 
+  location /api/v1/streaming {
+    proxy_set_header Host $host;
+    proxy_set_header X-Real-IP $remote_addr;
+    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+    proxy_set_header X-Forwarded-Proto https;
+
+    proxy_pass http://localhost:4000;
+    proxy_buffering off;
+    proxy_redirect off;
+    proxy_http_version 1.1;
+    proxy_set_header Upgrade $http_upgrade;
+    proxy_set_header Connection $connection_upgrade;
+
+    tcp_nodelay on;
+  }
+
   error_page 500 501 502 503 504 /500.html;
 }
 ```
@@ -162,6 +178,27 @@ Restart=always
 WantedBy=multi-user.target
 ```
 
+Example systemd configuration file for the streaming API, to be placed in `/etc/systemd/system/mastodon-streaming.service`:
+
+```systemd
+[Unit]
+Description=mastodon-streaming
+After=network.target
+
+[Service]
+Type=simple
+User=mastodon
+WorkingDirectory=/home/mastodon/live
+Environment="NODE_ENV=production"
+Environment="PORT=4000"
+ExecStart=/usr/bin/npm run start
+TimeoutSec=15
+Restart=always
+
+[Install]
+WantedBy=multi-user.target
+```
+
 This allows you to `sudo systemctl enable mastodon-*.service` and `sudo systemctl start mastodon-*.service` to get things going.
 
 ## Cronjobs
diff --git a/docs/Using-the-API/API.md b/docs/Using-the-API/API.md
index 51b465927..07c1b25a9 100644
--- a/docs/Using-the-API/API.md
+++ b/docs/Using-the-API/API.md
@@ -222,22 +222,6 @@ Creates a new OAuth app. Returns `id`, `client_id` and `client_secret` which can
 
 These values should be requested in the app itself from the API for each new app install + mastodon domain combo, and stored in the app for future requests.
 
-**POST /api/v1/devices/register**
-
-Form data:
-
-- `registration_id`: Device token (also called registration token/registration ID)
-
-Apps can use Firebase Cloud Messaging to receive push notifications from the instances, given that the instance admin has acquired a Firebase API key. More in [push notifications](Push-notifications.md). This method requires a user context, i.e. your app will receive notifications for the authorized user.
-
-**POST /api/v1/devices/unregister**
-
-Form data:
-
-- `registration_id`: Device token (also called registration token/registration ID)
-
-To remove the device from receiving push notifications for the user.
-
 ___
 
 ## Entities
diff --git a/docs/Using-the-API/Push-notifications.md b/docs/Using-the-API/Push-notifications.md
index fd50a75bd..d98c8833a 100644
--- a/docs/Using-the-API/Push-notifications.md
+++ b/docs/Using-the-API/Push-notifications.md
@@ -2,18 +2,3 @@ Push notifications
 ==================
 
 **Note: This push notification design turned out to not be fully operational on the side of Firebase. A different approach is in consideration**
-
-Mastodon can communicate with the Firebase Cloud Messaging API to send push notifications to apps on users' devices. For this to work, these conditions must be met:
-
-* Responsibility of an instance owner: `FCM_API_KEY` set on the instance. This can be obtained on the Firebase dashboard, in project settings, under Cloud Messaging, as "server key"
-* Responsibility of the app developer: Firebase added/enabled in the Android/iOS app. [See Guide](https://firebase.google.com/docs/cloud-messaging/)
-
-When the app obtains/refreshes a registration ID from Firebase, it needs to send that ID to the `/api/v1/devices/register` endpoint of the authorized user's instance via a POST request. The app can opt out of notifications by sending a similiar request with `unregister` instead of `register`.
-
-The push notifications will be triggered by the notifications of the type you can normally find in `/api/v1/notifications`. However, the push notifications will not contain any inline content. They will contain JSON data of this format ("12" is an example value):
-
-```json
-{ "notification_id": 12 }
-```
-
-Your app can then retrieve the actual content of the notification from the `/api/v1/notifications/12` API endpoint.
diff --git a/package.json b/package.json
index 9685f07a4..9f2bd3df9 100644
--- a/package.json
+++ b/package.json
@@ -18,6 +18,7 @@
     "babelify": "^7.3.0",
     "browserify": "^13.1.0",
     "browserify-incremental": "^3.1.1",
+    "bufferutil": "^2.0.0",
     "chai": "^3.5.0",
     "chai-enzyme": "^0.5.2",
     "css-loader": "^0.26.1",
@@ -64,6 +65,10 @@
     "sass-loader": "^4.0.2",
     "sinon": "^1.17.6",
     "style-loader": "^0.13.1",
-    "webpack": "^1.14.0"
+    "utf-8-validate": "^3.0.0",
+    "uuid": "^3.0.1",
+    "webpack": "^1.14.0",
+    "websocket.js": "^0.1.7",
+    "ws": "^2.0.2"
   }
 }
diff --git a/spec/controllers/api/v1/devices_controller_spec.rb b/spec/controllers/api/v1/devices_controller_spec.rb
deleted file mode 100644
index 745a462e3..000000000
--- a/spec/controllers/api/v1/devices_controller_spec.rb
+++ /dev/null
@@ -1,38 +0,0 @@
-require 'rails_helper'
-
-RSpec.describe Api::V1::DevicesController, type: :controller do
-  let(:user)  { Fabricate(:user, account: Fabricate(:account, username: 'alice')) }
-  let(:token) { double acceptable?: true, resource_owner_id: user.id }
-
-  before do
-    allow(controller).to receive(:doorkeeper_token) { token }
-  end
-
-  describe 'POST #register' do
-    before do
-      post :register, params: { registration_id: 'foo123' }
-    end
-
-    it 'returns http success' do
-      expect(response).to have_http_status(:success)
-    end
-
-    it 'registers device' do
-      expect(Device.where(account: user.account, registration_id: 'foo123').first).to_not be_nil
-    end
-  end
-
-  describe 'POST #unregister' do
-    before do
-      post :unregister, params: { registration_id: 'foo123' }
-    end
-
-    it 'returns http success' do
-      expect(response).to have_http_status(:success)
-    end
-
-    it 'removes device' do
-      expect(Device.where(account: user.account, registration_id: 'foo123').first).to be_nil
-    end
-  end
-end
diff --git a/spec/fabricators/device_fabricator.rb b/spec/fabricators/device_fabricator.rb
deleted file mode 100644
index 02b24e8b3..000000000
--- a/spec/fabricators/device_fabricator.rb
+++ /dev/null
@@ -1,3 +0,0 @@
-Fabricator(:device) do
-  registration_id "12345678"
-end
diff --git a/spec/lib/formatter_spec.rb b/spec/lib/formatter_spec.rb
index 6ec28f5d8..0db1634e9 100644
--- a/spec/lib/formatter_spec.rb
+++ b/spec/lib/formatter_spec.rb
@@ -17,7 +17,7 @@ RSpec.describe Formatter do
     end
 
     it 'contains a link' do
-      expect(subject).to match('<a rel="nofollow noopener" target="_blank" href="http://google.com"><span class="invisible">http://</span><span class="ellipsis">google.com</span><span class="invisible"></span></a>')
+      expect(subject).to match('<a rel="nofollow noopener" target="_blank" href="http://google.com"><span class="invisible">http://</span><span class="">google.com</span><span class="invisible"></span></a>')
     end
   end
 
diff --git a/spec/models/device_spec.rb b/spec/models/device_spec.rb
deleted file mode 100644
index f56fbf978..000000000
--- a/spec/models/device_spec.rb
+++ /dev/null
@@ -1,5 +0,0 @@
-require 'rails_helper'
-
-RSpec.describe Device, type: :model do
-
-end
diff --git a/streaming/index.js b/streaming/index.js
index 43d8895f1..e2e8f943e 100644
--- a/streaming/index.js
+++ b/streaming/index.js
@@ -1,8 +1,12 @@
 import dotenv from 'dotenv'
 import express from 'express'
+import http from 'http'
 import redis from 'redis'
 import pg from 'pg'
 import log from 'npmlog'
+import url from 'url'
+import WebSocket from 'ws'
+import uuid from 'uuid'
 
 const env = process.env.NODE_ENV || 'development'
 
@@ -27,21 +31,27 @@ const pgConfigs = {
   }
 }
 
-const app = express()
+const app    = express()
 const pgPool = new pg.Pool(pgConfigs[env])
+const server = http.createServer(app)
+const wss    = new WebSocket.Server({ server })
 
-const authenticationMiddleware = (req, res, next) => {
-  const authorization = req.get('Authorization')
+const allowCrossDomain = (req, res, next) => {
+  res.header('Access-Control-Allow-Origin', '*')
+  res.header('Access-Control-Allow-Headers', 'Authorization, Accept, Cache-Control')
+  res.header('Access-Control-Allow-Methods', 'GET, OPTIONS')
 
-  if (!authorization) {
-    err = new Error('Missing access token')
-    err.statusCode = 401
+  next()
+}
 
-    return next(err)
-  }
+const setRequestId = (req, res, next) => {
+  req.requestId = uuid.v4()
+  res.header('X-Request-Id', req.requestId)
 
-  const token = authorization.replace(/^Bearer /, '')
+  next()
+}
 
+const accountFromToken = (token, req, next) => {
   pgPool.connect((err, client, done) => {
     if (err) {
       return next(err)
@@ -68,28 +78,46 @@ const authenticationMiddleware = (req, res, next) => {
   })
 }
 
+const authenticationMiddleware = (req, res, next) => {
+  if (req.method === 'OPTIONS') {
+    return next()
+  }
+
+  const authorization = req.get('Authorization')
+
+  if (!authorization) {
+    const err = new Error('Missing access token')
+    err.statusCode = 401
+
+    return next(err)
+  }
+
+  const token = authorization.replace(/^Bearer /, '')
+
+  accountFromToken(token, req, next)
+}
+
 const errorMiddleware = (err, req, res, next) => {
-  log.error(err)
+  log.error(req.requestId, err)
   res.writeHead(err.statusCode || 500, { 'Content-Type': 'application/json' })
-  res.end(JSON.stringify({ error: err.statusCode ? `${err}` : 'An unexpected error occured' }))
+  res.end(JSON.stringify({ error: err.statusCode ? `${err}` : 'An unexpected error occurred' }))
 }
 
 const placeholders = (arr, shift = 0) => arr.map((_, i) => `$${i + 1 + shift}`).join(', ');
 
-const streamFrom = (id, req, res, needsFiltering = false) => {
-  log.verbose(`Starting stream from ${id} for ${req.accountId}`)
+const streamFrom = (redisClient, id, req, output, needsFiltering = false) => {
+  log.verbose(req.requestId, `Starting stream from ${id} for ${req.accountId}`)
 
-  res.setHeader('Content-Type', 'text/event-stream')
-  res.setHeader('Transfer-Encoding', 'chunked')
+  redisClient.on('message', (channel, message) => {
+    const { event, payload, queued_at } = JSON.parse(message)
 
-  const redisClient = redis.createClient({
-    host:     process.env.REDIS_HOST     || '127.0.0.1',
-    port:     process.env.REDIS_PORT     || 6379,
-    password: process.env.REDIS_PASSWORD
-  })
+    const transmit = () => {
+      const now   = new Date().getTime()
+      const delta = now - queued_at;
 
-  redisClient.on('message', (channel, message) => {
-    const { event, payload } = JSON.parse(message)
+      log.silly(req.requestId, `Transmitting for ${req.accountId}: ${event} ${payload} Delay: ${delta}ms`)
+      output(event, payload)
+    }
 
     // Only messages that may require filtering are statuses, since notifications
     // are already personalized and deletes do not matter
@@ -115,35 +143,127 @@ const streamFrom = (id, req, res, needsFiltering = false) => {
             return
           }
 
-          res.write(`event: ${event}\n`)
-          res.write(`data: ${payload}\n\n`)
+          transmit()
         })
       })
     } else {
-      res.write(`event: ${event}\n`)
-      res.write(`data: ${payload}\n\n`)
+      transmit()
     }
   })
 
+  redisClient.subscribe(id)
+}
+
+// Setup stream output to HTTP
+const streamToHttp = (req, res, redisClient) => {
+  res.setHeader('Content-Type', 'text/event-stream')
+  res.setHeader('Transfer-Encoding', 'chunked')
+
   const heartbeat = setInterval(() => res.write(':thump\n'), 15000)
 
   req.on('close', () => {
-    log.verbose(`Ending stream from ${id} for ${req.accountId}`)
+    log.verbose(req.requestId, `Ending stream for ${req.accountId}`)
     clearInterval(heartbeat)
     redisClient.quit()
   })
 
-  redisClient.subscribe(id)
+  return (event, payload) => {
+    res.write(`event: ${event}\n`)
+    res.write(`data: ${payload}\n\n`)
+  }
+}
+
+// Setup stream output to WebSockets
+const streamToWs = (req, ws, redisClient) => {
+  ws.on('close', () => {
+    log.verbose(req.requestId, `Ending stream for ${req.accountId}`)
+    redisClient.quit()
+  })
+
+  return (event, payload) => {
+    if (ws.readyState !== ws.OPEN) {
+      log.error(req.requestId, 'Tried writing to closed socket')
+      return
+    }
+
+    ws.send(JSON.stringify({ event, payload }))
+  }
 }
 
+// Get new redis connection
+const getRedisClient = () => redis.createClient({
+  host:     process.env.REDIS_HOST     || '127.0.0.1',
+  port:     process.env.REDIS_PORT     || 6379,
+  password: process.env.REDIS_PASSWORD
+})
+
+app.use(setRequestId)
+app.use(allowCrossDomain)
 app.use(authenticationMiddleware)
 app.use(errorMiddleware)
 
-app.get('/api/v1/streaming/user',    (req, res) => streamFrom(`timeline:${req.accountId}`, req, res))
-app.get('/api/v1/streaming/public',  (req, res) => streamFrom('timeline:public', req, res, true))
-app.get('/api/v1/streaming/hashtag', (req, res) => streamFrom(`timeline:hashtag:${req.params.tag}`, req, res, true))
+app.get('/api/v1/streaming/user', (req, res) => {
+  const redisClient = getRedisClient()
+  streamFrom(redisClient, `timeline:${req.accountId}`, req, streamToHttp(req, res, redisClient))
+})
+
+app.get('/api/v1/streaming/public', (req, res) => {
+  const redisClient = getRedisClient()
+  streamFrom(redisClient, 'timeline:public', req, streamToHttp(req, res, redisClient), true)
+})
+
+app.get('/api/v1/streaming/public/local', (req, res) => {
+  const redisClient = getRedisClient()
+  streamFrom(redisClient, 'timeline:public:local', req, streamToHttp(req, res, redisClient), true)
+})
+
+app.get('/api/v1/streaming/hashtag', (req, res) => {
+  const redisClient = getRedisClient()
+  streamFrom(redisClient, `timeline:hashtag:${req.params.tag}`, req, streamToHttp(req, res, redisClient), true)
+})
+
+app.get('/api/v1/streaming/hashtag/local', (req, res) => {
+  const redisClient = getRedisClient()
+  streamFrom(redisClient, `timeline:hashtag:${req.params.tag}:local`, req, streamToHttp(req, res, redisClient), true)
+})
+
+wss.on('connection', ws => {
+  const location = url.parse(ws.upgradeReq.url, true)
+  const token    = location.query.access_token
+  const req      = { requestId: uuid.v4() }
+
+  accountFromToken(token, req, err => {
+    if (err) {
+      log.error(req.requestId, err)
+      ws.close()
+      return
+    }
 
-log.level = 'verbose'
-log.info(`Starting HTTP server on port ${process.env.PORT || 4000}`)
+    const redisClient = getRedisClient()
+
+    switch(location.query.stream) {
+    case 'user':
+      streamFrom(redisClient, `timeline:${req.accountId}`, req, streamToWs(req, ws, redisClient))
+      break;
+    case 'public':
+      streamFrom(redisClient, 'timeline:public', req, streamToWs(req, ws, redisClient), true)
+      break;
+    case 'public:local':
+      streamFrom(redisClient, 'timeline:public:local', req, streamToWs(req, ws, redisClient), true)
+      break;
+    case 'hashtag':
+      streamFrom(redisClient, `timeline:hashtag:${location.query.tag}`, req, streamToWs(req, ws, redisClient), true)
+      break;
+    case 'hashtag:local':
+      streamFrom(redisClient, `timeline:hashtag:${location.query.tag}:local`, req, streamToWs(req, ws, redisClient), true)
+      break;
+    default:
+      ws.close()
+    }
+  })
+})
 
-app.listen(process.env.PORT || 4000)
+server.listen(process.env.PORT || 4000, () => {
+  log.level = process.env.LOG_LEVEL || 'verbose'
+  log.info(`Starting streaming API server on port ${server.address().port}`)
+})
diff --git a/yarn.lock b/yarn.lock
index bd1747929..89236d45a 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -1237,6 +1237,12 @@ babylon@^6.15.0:
   version "6.15.0"
   resolved "https://registry.yarnpkg.com/babylon/-/babylon-6.15.0.tgz#ba65cfa1a80e1759b0e89fb562e27dccae70348e"
 
+backoff@^2.4.1:
+  version "2.5.0"
+  resolved "https://registry.yarnpkg.com/backoff/-/backoff-2.5.0.tgz#f616eda9d3e4b66b8ca7fca79f695722c5f8e26f"
+  dependencies:
+    precond "0.2"
+
 balanced-match@^0.4.1, balanced-match@^0.4.2:
   version "0.4.2"
   resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-0.4.2.tgz#cb3f3e3c732dc0f01ee70b403f302e61d7709838"
@@ -1263,6 +1269,10 @@ binary-extensions@^1.0.0:
   version "1.7.0"
   resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-1.7.0.tgz#6c1610db163abfb34edfe42fa423343a1e01185d"
 
+bindings@~1.2.1:
+  version "1.2.1"
+  resolved "https://registry.yarnpkg.com/bindings/-/bindings-1.2.1.tgz#14ad6113812d2d37d72e67b4cacb4bb726505f11"
+
 bl@~1.1.2:
   version "1.1.2"
   resolved "https://registry.yarnpkg.com/bl/-/bl-1.1.2.tgz#fdca871a99713aa00d19e3bbba41c44787a65398"
@@ -1479,6 +1489,13 @@ buffer@^4.1.0, buffer@^4.9.0:
     ieee754 "^1.1.4"
     isarray "^1.0.0"
 
+bufferutil@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/bufferutil/-/bufferutil-2.0.0.tgz#6588ed4bafa300798b26dc048494a51abde83507"
+  dependencies:
+    bindings "~1.2.1"
+    nan "~2.5.0"
+
 builtin-modules@^1.0.0:
   version "1.1.1"
   resolved "https://registry.yarnpkg.com/builtin-modules/-/builtin-modules-1.1.1.tgz#270f076c5a72c02f5b65a47df94c5fe3a278892f"
@@ -3664,9 +3681,9 @@ ms@0.7.2:
   version "0.7.2"
   resolved "https://registry.yarnpkg.com/ms/-/ms-0.7.2.tgz#ae25cf2512b3885a1d95d7f037868d8431124765"
 
-nan@^2.3.0, nan@^2.3.2:
-  version "2.4.0"
-  resolved "https://registry.yarnpkg.com/nan/-/nan-2.4.0.tgz#fb3c59d45fe4effe215f0b890f8adf6eb32d2232"
+nan@^2.3.0, nan@^2.3.2, nan@~2.5.0:
+  version "2.5.1"
+  resolved "https://registry.yarnpkg.com/nan/-/nan-2.5.1.tgz#d5b01691253326a97a2bbee9e61c55d8d60351e2"
 
 negotiator@0.6.1:
   version "0.6.1"
@@ -3808,16 +3825,7 @@ normalize-url@^1.4.0:
     gauge "~2.6.0"
     set-blocking "~2.0.0"
 
-npmlog@4.x, npmlog@^4.0.0:
-  version "4.0.0"
-  resolved "https://registry.yarnpkg.com/npmlog/-/npmlog-4.0.0.tgz#e094503961c70c1774eb76692080e8d578a9f88f"
-  dependencies:
-    are-we-there-yet "~1.1.2"
-    console-control-strings "~1.1.0"
-    gauge "~2.6.0"
-    set-blocking "~2.0.0"
-
-npmlog@^4.0.2:
+npmlog@4.x, npmlog@^4.0.0, npmlog@^4.0.2:
   version "4.0.2"
   resolved "https://registry.yarnpkg.com/npmlog/-/npmlog-4.0.2.tgz#d03950e0e78ce1527ba26d2a7592e9348ac3e75f"
   dependencies:
@@ -4401,6 +4409,10 @@ postgres-interval@~1.0.0:
   dependencies:
     xtend "^4.0.0"
 
+precond@0.2:
+  version "0.2.3"
+  resolved "https://registry.yarnpkg.com/precond/-/precond-0.2.3.tgz#aa9591bcaa24923f1e0f4849d240f47efc1075ac"
+
 prelude-ls@~1.1.2:
   version "1.1.2"
   resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.1.2.tgz#21932a549f5e52ffd9a827f570e04be62a97da54"
@@ -5556,6 +5568,10 @@ uid-number@~0.0.6:
   version "0.0.6"
   resolved "https://registry.yarnpkg.com/uid-number/-/uid-number-0.0.6.tgz#0ea10e8035e8eb5b8e4449f06da1c730663baa81"
 
+ultron@~1.1.0:
+  version "1.1.0"
+  resolved "https://registry.yarnpkg.com/ultron/-/ultron-1.1.0.tgz#b07a2e6a541a815fc6a34ccd4533baec307ca864"
+
 umd@^3.0.0:
   version "3.0.1"
   resolved "https://registry.yarnpkg.com/umd/-/umd-3.0.1.tgz#8ae556e11011f63c2596708a8837259f01b3d60e"
@@ -5603,6 +5619,13 @@ user-home@^1.1.1:
   version "1.1.1"
   resolved "https://registry.yarnpkg.com/user-home/-/user-home-1.1.1.tgz#2b5be23a32b63a7c9deb8d0f28d485724a3df190"
 
+utf-8-validate@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/utf-8-validate/-/utf-8-validate-3.0.0.tgz#42e54dfbc7cdfbd1d3bbf0a2f5000b4c6aeaa0c9"
+  dependencies:
+    bindings "~1.2.1"
+    nan "~2.5.0"
+
 util-deprecate@~1.0.1:
   version "1.0.2"
   resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf"
@@ -5621,6 +5644,10 @@ uuid@^2.0.1, uuid@^2.0.2:
   version "2.0.3"
   resolved "https://registry.yarnpkg.com/uuid/-/uuid-2.0.3.tgz#67e2e863797215530dff318e5bf9dcebfd47b21a"
 
+uuid@^3.0.1:
+  version "3.0.1"
+  resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.0.1.tgz#6544bba2dfda8c1cf17e629a3a305e2bb1fee6c1"
+
 v8flags@^2.0.10:
   version "2.0.11"
   resolved "https://registry.yarnpkg.com/v8flags/-/v8flags-2.0.11.tgz#bca8f30f0d6d60612cc2c00641e6962d42ae6881"
@@ -5727,6 +5754,12 @@ webpack@^1.13.1, webpack@^1.14.0:
     watchpack "^0.2.1"
     webpack-core "~0.6.9"
 
+websocket.js@^0.1.7:
+  version "0.1.7"
+  resolved "https://registry.yarnpkg.com/websocket.js/-/websocket.js-0.1.7.tgz#8d24cefb1a080c259e7e4740c02cab8f142df2b0"
+  dependencies:
+    backoff "^2.4.1"
+
 whatwg-fetch@>=0.10.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/whatwg-fetch/-/whatwg-fetch-1.0.0.tgz#01c2ac4df40e236aaa18480e3be74bd5c8eb798e"
@@ -5803,6 +5836,12 @@ write-file-atomic@^1.1.2:
     imurmurhash "^0.1.4"
     slide "^1.1.5"
 
+ws@^2.0.2:
+  version "2.0.2"
+  resolved "https://registry.yarnpkg.com/ws/-/ws-2.0.2.tgz#6257d1a679f0cb23658cba3dcad1316e2b1000c5"
+  dependencies:
+    ultron "~1.1.0"
+
 xdg-basedir@^2.0.0:
   version "2.0.0"
   resolved "https://registry.yarnpkg.com/xdg-basedir/-/xdg-basedir-2.0.0.tgz#edbc903cc385fc04523d966a335504b5504d1bd2"