about summary refs log tree commit diff
path: root/app
diff options
context:
space:
mode:
Diffstat (limited to 'app')
-rw-r--r--app/assets/javascripts/components/actions/accounts.jsx6
-rw-r--r--app/assets/javascripts/components/actions/cards.jsx3
-rw-r--r--app/assets/javascripts/components/actions/compose.jsx1
-rw-r--r--app/assets/javascripts/components/actions/notifications.jsx10
-rw-r--r--app/assets/javascripts/components/actions/statuses.jsx11
-rw-r--r--app/assets/javascripts/components/actions/timelines.jsx64
-rw-r--r--app/assets/javascripts/components/components/column_collapsable.jsx5
-rw-r--r--app/assets/javascripts/components/components/display_name.jsx2
-rw-r--r--app/assets/javascripts/components/components/status.jsx2
-rw-r--r--app/assets/javascripts/components/components/status_content.jsx2
-rw-r--r--app/assets/javascripts/components/components/status_list.jsx15
-rw-r--r--app/assets/javascripts/components/containers/mastodon.jsx2
-rw-r--r--app/assets/javascripts/components/containers/status_container.jsx49
-rw-r--r--app/assets/javascripts/components/features/account/components/header.jsx8
-rw-r--r--app/assets/javascripts/components/features/account_timeline/components/header.jsx7
-rw-r--r--app/assets/javascripts/components/features/account_timeline/index.jsx5
-rw-r--r--app/assets/javascripts/components/features/community_timeline/index.jsx75
-rw-r--r--app/assets/javascripts/components/features/compose/components/compose_form.jsx78
-rw-r--r--app/assets/javascripts/components/features/compose/components/drawer.jsx4
-rw-r--r--app/assets/javascripts/components/features/compose/components/private_toggle.jsx27
-rw-r--r--app/assets/javascripts/components/features/compose/components/reply_indicator.jsx17
-rw-r--r--app/assets/javascripts/components/features/compose/components/sensitive_toggle.jsx31
-rw-r--r--app/assets/javascripts/components/features/compose/components/spoiler_toggle.jsx27
-rw-r--r--app/assets/javascripts/components/features/compose/components/unlisted_toggle.jsx32
-rw-r--r--app/assets/javascripts/components/features/compose/containers/compose_form_container.jsx117
-rw-r--r--app/assets/javascripts/components/features/compose/containers/private_toggle_container.jsx17
-rw-r--r--app/assets/javascripts/components/features/compose/containers/reply_indicator_container.jsx24
-rw-r--r--app/assets/javascripts/components/features/compose/containers/sensitive_toggle_container.jsx18
-rw-r--r--app/assets/javascripts/components/features/compose/containers/spoiler_toggle_container.jsx17
-rw-r--r--app/assets/javascripts/components/features/compose/containers/unlisted_toggle_container.jsx31
-rw-r--r--app/assets/javascripts/components/features/getting_started/index.jsx4
-rw-r--r--app/assets/javascripts/components/features/hashtag_timeline/index.jsx8
-rw-r--r--app/assets/javascripts/components/features/home_timeline/index.jsx16
-rw-r--r--app/assets/javascripts/components/features/notifications/components/notification.jsx2
-rw-r--r--app/assets/javascripts/components/features/notifications/index.jsx25
-rw-r--r--app/assets/javascripts/components/features/public_timeline/index.jsx14
-rw-r--r--app/assets/javascripts/components/features/status/index.jsx5
-rw-r--r--app/assets/javascripts/components/features/ui/components/column.jsx7
-rw-r--r--app/assets/javascripts/components/features/ui/components/column_header.jsx7
-rw-r--r--app/assets/javascripts/components/features/ui/containers/modal_container.jsx11
-rw-r--r--app/assets/javascripts/components/features/ui/containers/status_list_container.jsx24
-rw-r--r--app/assets/javascripts/components/locales/en.jsx11
-rw-r--r--app/assets/javascripts/components/middleware/errors.jsx2
-rw-r--r--app/assets/javascripts/components/reducers/compose.jsx12
-rw-r--r--app/assets/javascripts/components/reducers/modal.jsx4
-rw-r--r--app/assets/javascripts/components/reducers/notifications.jsx24
-rw-r--r--app/assets/javascripts/components/reducers/statuses.jsx9
-rw-r--r--app/assets/javascripts/components/reducers/timelines.jsx50
-rw-r--r--app/assets/javascripts/components/selectors/index.jsx55
-rw-r--r--app/assets/stylesheets/components.scss25
-rw-r--r--app/controllers/api/v1/accounts_controller.rb4
-rw-r--r--app/controllers/api/v1/notifications_controller.rb2
-rw-r--r--app/controllers/api/v1/timelines_controller.rb6
-rw-r--r--app/lib/formatter.rb12
-rw-r--r--app/models/account.rb2
-rw-r--r--app/models/domain_block.rb2
-rw-r--r--app/models/status.rb2
-rw-r--r--app/services/favourite_service.rb5
-rw-r--r--app/services/follow_service.rb10
-rw-r--r--app/services/process_feed_service.rb3
-rw-r--r--app/services/remove_status_service.rb2
-rw-r--r--app/services/unfavourite_service.rb5
-rw-r--r--app/services/unfollow_service.rb5
63 files changed, 729 insertions, 353 deletions
diff --git a/app/assets/javascripts/components/actions/accounts.jsx b/app/assets/javascripts/components/actions/accounts.jsx
index 0be05034e..47c0d9f85 100644
--- a/app/assets/javascripts/components/actions/accounts.jsx
+++ b/app/assets/javascripts/components/actions/accounts.jsx
@@ -138,7 +138,8 @@ export function fetchAccountFail(id, error) {
   return {
     type: ACCOUNT_FETCH_FAIL,
     id,
-    error
+    error,
+    skipAlert: true
   };
 };
 
@@ -231,7 +232,8 @@ export function fetchAccountTimelineFail(id, error, skipLoading) {
     type: ACCOUNT_TIMELINE_FETCH_FAIL,
     id,
     error,
-    skipLoading
+    skipLoading,
+    skipAlert: error.response.status === 404
   };
 };
 
diff --git a/app/assets/javascripts/components/actions/cards.jsx b/app/assets/javascripts/components/actions/cards.jsx
index cc7baf376..d4c1eda60 100644
--- a/app/assets/javascripts/components/actions/cards.jsx
+++ b/app/assets/javascripts/components/actions/cards.jsx
@@ -46,6 +46,7 @@ export function fetchStatusCardFail(id, error) {
     type: STATUS_CARD_FETCH_FAIL,
     id,
     error,
-    skipLoading: true
+    skipLoading: true,
+    skipAlert: true
   };
 };
diff --git a/app/assets/javascripts/components/actions/compose.jsx b/app/assets/javascripts/components/actions/compose.jsx
index 03aae885e..8d030fd30 100644
--- a/app/assets/javascripts/components/actions/compose.jsx
+++ b/app/assets/javascripts/components/actions/compose.jsx
@@ -85,6 +85,7 @@ export function submitCompose() {
       dispatch(updateTimeline('home', { ...response.data }));
 
       if (response.data.in_reply_to_id === null && response.data.visibility === 'public') {
+        dispatch(updateTimeline('community', { ...response.data }));
         dispatch(updateTimeline('public', { ...response.data }));
       }
     }).catch(function (error) {
diff --git a/app/assets/javascripts/components/actions/notifications.jsx b/app/assets/javascripts/components/actions/notifications.jsx
index df82e73fc..980b7d63e 100644
--- a/app/assets/javascripts/components/actions/notifications.jsx
+++ b/app/assets/javascripts/components/actions/notifications.jsx
@@ -14,7 +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';
+export const NOTIFICATIONS_CLEAR      = 'NOTIFICATIONS_CLEAR';
+export const NOTIFICATIONS_SCROLL_TOP = 'NOTIFICATIONS_SCROLL_TOP';
 
 const fetchRelatedRelationships = (dispatch, notifications) => {
   const accountIds = notifications.filter(item => item.type === 'follow').map(item => item.account.id);
@@ -151,3 +152,10 @@ export function clearNotifications() {
     api(getState).post('/api/v1/notifications/clear');
   };
 };
+
+export function scrollTopNotifications(top) {
+  return {
+    type: NOTIFICATIONS_SCROLL_TOP,
+    top
+  };
+};
diff --git a/app/assets/javascripts/components/actions/statuses.jsx b/app/assets/javascripts/components/actions/statuses.jsx
index ee662fe79..19df2c36c 100644
--- a/app/assets/javascripts/components/actions/statuses.jsx
+++ b/app/assets/javascripts/components/actions/statuses.jsx
@@ -57,7 +57,8 @@ export function fetchStatusFail(id, error, skipLoading) {
     type: STATUS_FETCH_FAIL,
     id,
     error,
-    skipLoading
+    skipLoading,
+    skipAlert: true
   };
 };
 
@@ -102,7 +103,12 @@ export function fetchContext(id) {
 
     api(getState).get(`/api/v1/statuses/${id}/context`).then(response => {
       dispatch(fetchContextSuccess(id, response.data.ancestors, response.data.descendants));
+
     }).catch(error => {
+      if (error.response.status === 404) {
+        dispatch(deleteFromTimelines(id));
+      }
+
       dispatch(fetchContextFail(id, error));
     });
   };
@@ -129,6 +135,7 @@ export function fetchContextFail(id, error) {
   return {
     type: CONTEXT_FETCH_FAIL,
     id,
-    error
+    error,
+    skipAlert: true
   };
 };
diff --git a/app/assets/javascripts/components/actions/timelines.jsx b/app/assets/javascripts/components/actions/timelines.jsx
index 1531b89a3..311b08033 100644
--- a/app/assets/javascripts/components/actions/timelines.jsx
+++ b/app/assets/javascripts/components/actions/timelines.jsx
@@ -1,4 +1,4 @@
-import api from '../api'
+import api, { getLinks } from '../api'
 import Immutable from 'immutable';
 
 export const TIMELINE_UPDATE  = 'TIMELINE_UPDATE';
@@ -14,12 +14,13 @@ export const TIMELINE_EXPAND_FAIL    = 'TIMELINE_EXPAND_FAIL';
 
 export const TIMELINE_SCROLL_TOP = 'TIMELINE_SCROLL_TOP';
 
-export function refreshTimelineSuccess(timeline, statuses, skipLoading) {
+export function refreshTimelineSuccess(timeline, statuses, skipLoading, next) {
   return {
     type: TIMELINE_REFRESH_SUCCESS,
     timeline,
     statuses,
-    skipLoading
+    skipLoading,
+    next
   };
 };
 
@@ -69,25 +70,22 @@ export function refreshTimeline(timeline, id = null) {
 
     const ids      = getState().getIn(['timelines', timeline, 'items'], Immutable.List());
     const newestId = ids.size > 0 ? ids.first() : null;
+    let params     = getState().getIn(['timelines', timeline, 'params'], {});
+    const path     = getState().getIn(['timelines', timeline, 'path'])(id);
 
-    let params      = '';
-    let path        = timeline;
     let skipLoading = false;
 
     if (newestId !== null && getState().getIn(['timelines', timeline, 'loaded']) && (id === null || getState().getIn(['timelines', timeline, 'id']) === id)) {
-      params      = `?since_id=${newestId}`;
-      skipLoading = true;
-    }
-
-    if (id) {
-      path = `${path}/${id}`
+      params          = { ...params, since_id: newestId };
+      skipLoading     = true;
     }
 
     dispatch(refreshTimelineRequest(timeline, id, skipLoading));
 
-    api(getState).get(`/api/v1/timelines/${path}${params}`).then(function (response) {
-      dispatch(refreshTimelineSuccess(timeline, response.data, skipLoading));
-    }).catch(function (error) {
+    api(getState).get(path, { params }).then(response => {
+      const next = getLinks(response).refs.find(link => link.rel === 'next');
+      dispatch(refreshTimelineSuccess(timeline, response.data, skipLoading, next ? next.uri : null));
+    }).catch(error => {
       dispatch(refreshTimelineFail(timeline, error, skipLoading));
     });
   };
@@ -102,50 +100,48 @@ export function refreshTimelineFail(timeline, error, skipLoading) {
   };
 };
 
-export function expandTimeline(timeline, id = null) {
+export function expandTimeline(timeline) {
   return (dispatch, getState) => {
-    const lastId = getState().getIn(['timelines', timeline, 'items'], Immutable.List()).last();
-
-    if (!lastId || getState().getIn(['timelines', timeline, 'isLoading'])) {
-      // If timeline is empty, don't try to load older posts since there are none
-      // Also if already loading
+    if (getState().getIn(['timelines', timeline, 'isLoading'])) {
       return;
     }
 
-    dispatch(expandTimelineRequest(timeline, id));
+    const next   = getState().getIn(['timelines', timeline, 'next']);
+    const params = getState().getIn(['timelines', timeline, 'params'], {});
 
-    let path = timeline;
-
-    if (id) {
-      path = `${path}/${id}`
+    if (next === null) {
+      return;
     }
 
-    api(getState).get(`/api/v1/timelines/${path}`, {
+    dispatch(expandTimelineRequest(timeline));
+
+    api(getState).get(next, {
       params: {
-        limit: 10,
-        max_id: lastId
+        ...params,
+        limit: 10
       }
     }).then(response => {
-      dispatch(expandTimelineSuccess(timeline, response.data));
+      const next = getLinks(response).refs.find(link => link.rel === 'next');
+      dispatch(expandTimelineSuccess(timeline, response.data, next ? next.uri : null));
     }).catch(error => {
       dispatch(expandTimelineFail(timeline, error));
     });
   };
 };
 
-export function expandTimelineRequest(timeline, id) {
+export function expandTimelineRequest(timeline) {
   return {
     type: TIMELINE_EXPAND_REQUEST,
-    timeline,
-    id
+    timeline
   };
 };
 
-export function expandTimelineSuccess(timeline, statuses) {
+export function expandTimelineSuccess(timeline, statuses, next) {
   return {
     type: TIMELINE_EXPAND_SUCCESS,
     timeline,
-    statuses
+    statuses,
+    next
   };
 };
 
diff --git a/app/assets/javascripts/components/components/column_collapsable.jsx b/app/assets/javascripts/components/components/column_collapsable.jsx
index 676759055..729d00617 100644
--- a/app/assets/javascripts/components/components/column_collapsable.jsx
+++ b/app/assets/javascripts/components/components/column_collapsable.jsx
@@ -7,7 +7,8 @@ const iconStyle = {
   position: 'absolute',
   right: '0',
   top: '-48px',
-  cursor: 'pointer'
+  cursor: 'pointer',
+  zIndex: '3'
 };
 
 const ColumnCollapsable = React.createClass({
@@ -41,7 +42,7 @@ const ColumnCollapsable = React.createClass({
     const { icon, fullHeight, children } = this.props;
     const { collapsed } = this.state;
     const collapsedClassName = collapsed ? 'collapsable-collapsed' : 'collapsable';
-    
+
     return (
       <div style={{ position: 'relative' }}>
         <div style={{...iconStyle }} className={collapsedClassName} onClick={this.handleToggleCollapsed}><i className={`fa fa-${icon}`} /></div>
diff --git a/app/assets/javascripts/components/components/display_name.jsx b/app/assets/javascripts/components/components/display_name.jsx
index 053b5290c..aa48608d3 100644
--- a/app/assets/javascripts/components/components/display_name.jsx
+++ b/app/assets/javascripts/components/components/display_name.jsx
@@ -1,6 +1,6 @@
 import ImmutablePropTypes from 'react-immutable-proptypes';
 import PureRenderMixin from 'react-addons-pure-render-mixin';
-import escapeTextContentForBrowser from 'react/lib/escapeTextContentForBrowser';
+import escapeTextContentForBrowser from 'escape-html';
 import emojify from '../emoji';
 
 const DisplayName = React.createClass({
diff --git a/app/assets/javascripts/components/components/status.jsx b/app/assets/javascripts/components/components/status.jsx
index 66c41b5f7..110d26c6d 100644
--- a/app/assets/javascripts/components/components/status.jsx
+++ b/app/assets/javascripts/components/components/status.jsx
@@ -9,7 +9,7 @@ import StatusContent from './status_content';
 import StatusActionBar from './status_action_bar';
 import { FormattedMessage } from 'react-intl';
 import emojify from '../emoji';
-import escapeTextContentForBrowser from 'react/lib/escapeTextContentForBrowser';
+import escapeTextContentForBrowser from 'escape-html';
 
 const Status = React.createClass({
 
diff --git a/app/assets/javascripts/components/components/status_content.jsx b/app/assets/javascripts/components/components/status_content.jsx
index c0397e81c..43bbb9582 100644
--- a/app/assets/javascripts/components/components/status_content.jsx
+++ b/app/assets/javascripts/components/components/status_content.jsx
@@ -1,6 +1,6 @@
 import ImmutablePropTypes from 'react-immutable-proptypes';
 import PureRenderMixin from 'react-addons-pure-render-mixin';
-import escapeTextContentForBrowser from 'react/lib/escapeTextContentForBrowser';
+import escapeTextContentForBrowser from 'escape-html';
 import emojify from '../emoji';
 import { FormattedMessage } from 'react-intl';
 import Permalink from './permalink';
diff --git a/app/assets/javascripts/components/components/status_list.jsx b/app/assets/javascripts/components/components/status_list.jsx
index 69a2354c7..345944e4d 100644
--- a/app/assets/javascripts/components/components/status_list.jsx
+++ b/app/assets/javascripts/components/components/status_list.jsx
@@ -14,6 +14,8 @@ const StatusList = React.createClass({
     onScroll: React.PropTypes.func,
     trackScroll: React.PropTypes.bool,
     isLoading: React.PropTypes.bool,
+    isUnread: React.PropTypes.bool,
+    hasMore: React.PropTypes.bool,
     prepend: React.PropTypes.node,
     emptyMessage: React.PropTypes.node
   },
@@ -72,18 +74,25 @@ const StatusList = React.createClass({
   },
 
   render () {
-    const { statusIds, onScrollToBottom, trackScroll, isLoading, prepend, emptyMessage } = this.props;
+    const { statusIds, onScrollToBottom, trackScroll, isLoading, isUnread, hasMore, prepend, emptyMessage } = this.props;
 
-    let loadMore = '';
+    let loadMore       = '';
     let scrollableArea = '';
+    let unread         = '';
 
-    if (!isLoading && statusIds.size > 0) {
+    if (!isLoading && statusIds.size > 0 && hasMore) {
       loadMore = <LoadMore onClick={this.handleLoadMore} />;
     }
 
+    if (isUnread) {
+      unread = <div className='status-list__unread-indicator' />;
+    }
+
     if (isLoading || statusIds.size > 0 || !emptyMessage) {
       scrollableArea = (
         <div className='scrollable' ref={this.setRef}>
+          {unread}
+
           <div>
             {prepend}
 
diff --git a/app/assets/javascripts/components/containers/mastodon.jsx b/app/assets/javascripts/components/containers/mastodon.jsx
index ebef5c81b..40fbac525 100644
--- a/app/assets/javascripts/components/containers/mastodon.jsx
+++ b/app/assets/javascripts/components/containers/mastodon.jsx
@@ -21,6 +21,7 @@ import UI from '../features/ui';
 import Status from '../features/status';
 import GettingStarted from '../features/getting_started';
 import PublicTimeline from '../features/public_timeline';
+import CommunityTimeline from '../features/community_timeline';
 import AccountTimeline from '../features/account_timeline';
 import HomeTimeline from '../features/home_timeline';
 import Compose from '../features/compose';
@@ -116,6 +117,7 @@ const Mastodon = React.createClass({
               <Route path='getting-started' component={GettingStarted} />
               <Route path='timelines/home' component={HomeTimeline} />
               <Route path='timelines/public' component={PublicTimeline} />
+              <Route path='timelines/public/local' component={CommunityTimeline} />
               <Route path='timelines/tag/:id' component={HashtagTimeline} />
 
               <Route path='notifications' component={Notifications} />
diff --git a/app/assets/javascripts/components/containers/status_container.jsx b/app/assets/javascripts/components/containers/status_container.jsx
index fc096a375..81265bc50 100644
--- a/app/assets/javascripts/components/containers/status_container.jsx
+++ b/app/assets/javascripts/components/containers/status_container.jsx
@@ -18,45 +18,12 @@ import { openMedia } from '../actions/modal';
 import { createSelector } from 'reselect'
 import { isMobile } from '../is_mobile'
 
-const mapStateToProps = (state, props) => ({
-  statusBase: state.getIn(['statuses', props.id]),
-  me: state.getIn(['meta', 'me'])
-});
-
-const makeMapStateToPropsInner = () => {
-  const getStatus = (() => {
-    return createSelector(
-      [
-        (_, base)     => base,
-        (state, base) => (base ? state.getIn(['accounts', base.get('account')]) : null),
-        (state, base) => (base ? state.getIn(['statuses', base.get('reblog')], null) : null)
-      ],
-
-      (base, account, reblog) => (base ? base.set('account', account).set('reblog', reblog) : null)
-    );
-  })();
-
-  const mapStateToProps = (state, { statusBase }) => ({
-    status: getStatus(state, statusBase)
-  });
-
-  return mapStateToProps;
-};
-
-const makeMapStateToPropsLast = () => {
-  const getStatus = (() => {
-    return createSelector(
-      [
-        (_, status)     => status,
-        (state, status) => (status ? state.getIn(['accounts', status.getIn(['reblog', 'account'])], null) : null)
-      ],
-
-      (status, reblogAccount) => (status && status.get('reblog') ? status.setIn(['reblog', 'account'], reblogAccount) : status)
-    );
-  })();
+const makeMapStateToProps = () => {
+  const getStatus = makeGetStatus();
 
-  const mapStateToProps = (state, { status }) => ({
-    status: getStatus(state, status)
+  const mapStateToProps = (state, props) => ({
+    status: getStatus(state, props.id),
+    me: state.getIn(['meta', 'me'])
   });
 
   return mapStateToProps;
@@ -106,8 +73,4 @@ const mapDispatchToProps = (dispatch) => ({
 
 });
 
-export default connect(mapStateToProps, mapDispatchToProps)(
-  connect(makeMapStateToPropsInner)(
-    connect(makeMapStateToPropsLast)(Status)
-  )
-);
+export default connect(makeMapStateToProps, mapDispatchToProps)(Status);
diff --git a/app/assets/javascripts/components/features/account/components/header.jsx b/app/assets/javascripts/components/features/account/components/header.jsx
index a4f0ca768..e1aae3c77 100644
--- a/app/assets/javascripts/components/features/account/components/header.jsx
+++ b/app/assets/javascripts/components/features/account/components/header.jsx
@@ -1,7 +1,7 @@
 import PureRenderMixin from 'react-addons-pure-render-mixin';
 import ImmutablePropTypes from 'react-immutable-proptypes';
 import emojify from '../../../emoji';
-import escapeTextContentForBrowser from 'react/lib/escapeTextContentForBrowser';
+import escapeTextContentForBrowser from 'escape-html';
 import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
 import IconButton from '../../../components/icon_button';
 
@@ -14,7 +14,7 @@ const messages = defineMessages({
 const Header = React.createClass({
 
   propTypes: {
-    account: ImmutablePropTypes.map.isRequired,
+    account: ImmutablePropTypes.map,
     me: React.PropTypes.number.isRequired,
     onFollow: React.PropTypes.func.isRequired,
     intl: React.PropTypes.object.isRequired
@@ -25,6 +25,10 @@ const Header = React.createClass({
   render () {
     const { account, me, intl } = this.props;
 
+    if (!account) {
+      return null;
+    }
+
     let displayName = account.get('display_name');
     let info        = '';
     let actionBtn   = '';
diff --git a/app/assets/javascripts/components/features/account_timeline/components/header.jsx b/app/assets/javascripts/components/features/account_timeline/components/header.jsx
index 0cdfc8b02..2dd3ca7b1 100644
--- a/app/assets/javascripts/components/features/account_timeline/components/header.jsx
+++ b/app/assets/javascripts/components/features/account_timeline/components/header.jsx
@@ -2,6 +2,7 @@ import PureRenderMixin from 'react-addons-pure-render-mixin';
 import ImmutablePropTypes from 'react-immutable-proptypes';
 import InnerHeader from '../../account/components/header';
 import ActionBar from '../../account/components/action_bar';
+import MissingIndicator from '../../../components/missing_indicator';
 
 const Header = React.createClass({
   contextTypes: {
@@ -9,7 +10,7 @@ const Header = React.createClass({
   },
 
   propTypes: {
-    account: ImmutablePropTypes.map.isRequired,
+    account: ImmutablePropTypes.map,
     me: React.PropTypes.number.isRequired,
     onFollow: React.PropTypes.func.isRequired,
     onBlock: React.PropTypes.func.isRequired,
@@ -39,8 +40,8 @@ const Header = React.createClass({
   render () {
     const { account, me } = this.props;
 
-    if (!account) {
-      return null;
+    if (account === null) {
+      return <MissingIndicator />;
     }
 
     return (
diff --git a/app/assets/javascripts/components/features/account_timeline/index.jsx b/app/assets/javascripts/components/features/account_timeline/index.jsx
index 349510295..f92e1b49c 100644
--- a/app/assets/javascripts/components/features/account_timeline/index.jsx
+++ b/app/assets/javascripts/components/features/account_timeline/index.jsx
@@ -16,6 +16,7 @@ import Immutable from 'immutable';
 const mapStateToProps = (state, props) => ({
   statusIds: state.getIn(['timelines', 'accounts_timelines', Number(props.params.accountId), 'items'], Immutable.List()),
   isLoading: state.getIn(['timelines', 'accounts_timelines', Number(props.params.accountId), 'isLoading']),
+  hasMore: !!state.getIn(['timelines', 'accounts_timelines', Number(props.params.accountId), 'next']),
   me: state.getIn(['meta', 'me'])
 });
 
@@ -26,6 +27,7 @@ const AccountTimeline = React.createClass({
     dispatch: React.PropTypes.func.isRequired,
     statusIds: ImmutablePropTypes.list,
     isLoading: React.PropTypes.bool,
+    hasMore: React.PropTypes.bool,
     me: React.PropTypes.number.isRequired
   },
 
@@ -48,7 +50,7 @@ const AccountTimeline = React.createClass({
   },
 
   render () {
-    const { statusIds, isLoading, me } = this.props;
+    const { statusIds, isLoading, hasMore, me } = this.props;
 
     if (!statusIds && isLoading) {
       return (
@@ -66,6 +68,7 @@ const AccountTimeline = React.createClass({
           prepend={<HeaderContainer accountId={this.props.params.accountId} />}
           statusIds={statusIds}
           isLoading={isLoading}
+          hasMore={hasMore}
           me={me}
           onScrollToBottom={this.handleScrollToBottom}
         />
diff --git a/app/assets/javascripts/components/features/community_timeline/index.jsx b/app/assets/javascripts/components/features/community_timeline/index.jsx
new file mode 100644
index 000000000..aa1b8368e
--- /dev/null
+++ b/app/assets/javascripts/components/features/community_timeline/index.jsx
@@ -0,0 +1,75 @@
+import { connect } from 'react-redux';
+import PureRenderMixin from 'react-addons-pure-render-mixin';
+import StatusListContainer from '../ui/containers/status_list_container';
+import Column from '../ui/components/column';
+import {
+  refreshTimeline,
+  updateTimeline,
+  deleteFromTimelines
+} from '../../actions/timelines';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import ColumnBackButtonSlim from '../../components/column_back_button_slim';
+import createStream from '../../stream';
+
+const messages = defineMessages({
+  title: { id: 'column.community', defaultMessage: 'Local' }
+});
+
+const mapStateToProps = state => ({
+  hasUnread: state.getIn(['timelines', 'community', 'unread']) > 0,
+  accessToken: state.getIn(['meta', 'access_token'])
+});
+
+const CommunityTimeline = React.createClass({
+
+  propTypes: {
+    dispatch: React.PropTypes.func.isRequired,
+    intl: React.PropTypes.object.isRequired,
+    accessToken: React.PropTypes.string.isRequired,
+    hasUnread: React.PropTypes.bool
+  },
+
+  mixins: [PureRenderMixin],
+
+  componentDidMount () {
+    const { dispatch, accessToken } = this.props;
+
+    dispatch(refreshTimeline('community'));
+
+    this.subscription = createStream(accessToken, 'public:local', {
+
+      received (data) {
+        switch(data.event) {
+        case 'update':
+          dispatch(updateTimeline('community', JSON.parse(data.payload)));
+          break;
+        case 'delete':
+          dispatch(deleteFromTimelines(data.payload));
+          break;
+        }
+      }
+
+    });
+  },
+
+  componentWillUnmount () {
+    if (typeof this.subscription !== 'undefined') {
+      this.subscription.close();
+      this.subscription = null;
+    }
+  },
+
+  render () {
+    const { intl, hasUnread } = this.props;
+
+    return (
+      <Column icon='users' active={hasUnread} heading={intl.formatMessage(messages.title)}>
+        <ColumnBackButtonSlim />
+        <StatusListContainer type='community' emptyMessage={<FormattedMessage id='empty_column.community' defaultMessage='The local timeline is empty. Write something publicly to get the ball rolling!' />} />
+      </Column>
+    );
+  },
+
+});
+
+export default connect(mapStateToProps)(injectIntl(CommunityTimeline));
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 9edc01ed7..31ae8e034 100644
--- a/app/assets/javascripts/components/features/compose/components/compose_form.jsx
+++ b/app/assets/javascripts/components/features/compose/components/compose_form.jsx
@@ -2,7 +2,7 @@ import CharacterCounter from './character_counter';
 import Button from '../../../components/button';
 import PureRenderMixin from 'react-addons-pure-render-mixin';
 import ImmutablePropTypes from 'react-immutable-proptypes';
-import ReplyIndicator from './reply_indicator';
+import ReplyIndicatorContainer from '../containers/reply_indicator_container';
 import UploadButton from './upload_button';
 import AutosuggestTextarea from '../../../components/autosuggest_textarea';
 import AutosuggestAccountContainer from '../../compose/containers/autosuggest_account_container';
@@ -11,6 +11,10 @@ import UploadButtonContainer from '../containers/upload_button_container';
 import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
 import Toggle from 'react-toggle';
 import Collapsable from '../../../components/collapsable';
+import UnlistedToggleContainer from '../containers/unlisted_toggle_container';
+import SpoilerToggleContainer from '../containers/spoiler_toggle_container';
+import PrivateToggleContainer from '../containers/private_toggle_container';
+import SensitiveToggleContainer from '../containers/sensitive_toggle_container';
 
 const messages = defineMessages({
   placeholder: { id: 'compose_form.placeholder', defaultMessage: 'What is on your mind?' },
@@ -25,30 +29,24 @@ const ComposeForm = React.createClass({
     text: React.PropTypes.string.isRequired,
     suggestion_token: React.PropTypes.string,
     suggestions: ImmutablePropTypes.list,
-    sensitive: React.PropTypes.bool,
     spoiler: React.PropTypes.bool,
-    spoiler_text: React.PropTypes.string,
-    unlisted: React.PropTypes.bool,
     private: React.PropTypes.bool,
+    unlisted: React.PropTypes.bool,
+    spoiler_text: React.PropTypes.string,
     fileDropDate: React.PropTypes.instanceOf(Date),
+    focusDate: React.PropTypes.instanceOf(Date),
+    preselectDate: React.PropTypes.instanceOf(Date),
     is_submitting: React.PropTypes.bool,
     is_uploading: React.PropTypes.bool,
-    in_reply_to: ImmutablePropTypes.map,
-    media_count: React.PropTypes.number,
     me: React.PropTypes.number,
     needsPrivacyWarning: React.PropTypes.bool,
     mentionedDomains: React.PropTypes.array.isRequired,
     onChange: React.PropTypes.func.isRequired,
     onSubmit: React.PropTypes.func.isRequired,
-    onCancelReply: React.PropTypes.func.isRequired,
     onClearSuggestions: React.PropTypes.func.isRequired,
     onFetchSuggestions: React.PropTypes.func.isRequired,
     onSuggestionSelected: React.PropTypes.func.isRequired,
-    onChangeSensitivity: React.PropTypes.func.isRequired,
-    onChangeSpoilerness: React.PropTypes.func.isRequired,
     onChangeSpoilerText: React.PropTypes.func.isRequired,
-    onChangeVisibility: React.PropTypes.func.isRequired,
-    onChangeListability: React.PropTypes.func.isRequired,
   },
 
   mixins: [PureRenderMixin],
@@ -80,34 +78,17 @@ const ComposeForm = React.createClass({
     this.props.onSuggestionSelected(tokenStart, token, value);
   },
 
-  handleChangeSensitivity (e) {
-    this.props.onChangeSensitivity(e.target.checked);
-  },
-
-  handleChangeSpoilerness (e) {
-    this.props.onChangeSpoilerness(e.target.checked);
-    this.props.onChangeSpoilerText('');
-  },
-
   handleChangeSpoilerText (e) {
     this.props.onChangeSpoilerText(e.target.value);
   },
 
-  handleChangeVisibility (e) {
-    this.props.onChangeVisibility(e.target.checked);
-  },
-
-  handleChangeListability (e) {
-    this.props.onChangeListability(e.target.checked);
-  },
-
   componentDidUpdate (prevProps) {
-    if ((prevProps.in_reply_to === null && this.props.in_reply_to !== null) || (prevProps.in_reply_to !== null && this.props.in_reply_to !== null && prevProps.in_reply_to.get('id') !== this.props.in_reply_to.get('id'))) {
+    if (this.props.focusDate !== prevProps.focusDate) {
       // If replying to zero or one users, places the cursor at the end of the textbox.
       // If replying to more than one user, selects any usernames past the first;
       // this provides a convenient shortcut to drop everyone else from the conversation.
-      const selectionStart = this.props.text.search(/\s/) + 1;
       const selectionEnd   = this.props.text.length;
+      const selectionStart = (this.props.preselectDate !== prevProps.preselectDate) ? (this.props.text.search(/\s/) + 1) : selectionEnd;
 
       this.autosuggestTextarea.textarea.setSelectionRange(selectionStart, selectionEnd);
       this.autosuggestTextarea.textarea.focus();
@@ -122,14 +103,9 @@ const ComposeForm = React.createClass({
     const { intl, needsPrivacyWarning, mentionedDomains } = this.props;
     const disabled = this.props.is_submitting || this.props.is_uploading;
 
-    let replyArea      = '';
     let publishText    = '';
     let privacyWarning = '';
-    let reply_to_other = !!this.props.in_reply_to && (this.props.in_reply_to.getIn(['account', 'id']) !== this.props.me);
-
-    if (this.props.in_reply_to) {
-      replyArea = <ReplyIndicator status={this.props.in_reply_to} onCancel={this.props.onCancelReply} />;
-    }
+    let reply_to_other = false;
 
     if (needsPrivacyWarning) {
       privacyWarning = (
@@ -158,7 +134,8 @@ const ComposeForm = React.createClass({
         </Collapsable>
 
         {privacyWarning}
-        {replyArea}
+
+        <ReplyIndicatorContainer />
 
         <AutosuggestTextarea
           ref={this.setAutosuggestTextarea}
@@ -180,29 +157,10 @@ const ComposeForm = React.createClass({
           <UploadButtonContainer style={{ paddingTop: '4px' }} />
         </div>
 
-        <label className='compose-form__label with-border' style={{ marginTop: '10px' }}>
-          <Toggle checked={this.props.spoiler} onChange={this.handleChangeSpoilerness} />
-          <span className='compose-form__label__text'><FormattedMessage id='compose_form.spoiler' defaultMessage='Hide text behind warning' /></span>
-        </label>
-
-        <label className='compose-form__label with-border'>
-          <Toggle checked={this.props.private} onChange={this.handleChangeVisibility} />
-          <span className='compose-form__label__text'><FormattedMessage id='compose_form.private' defaultMessage='Mark as private' /></span>
-        </label>
-
-        <Collapsable isVisible={!(this.props.private || reply_to_other)} fullHeight={39.5}>
-          <label className='compose-form__label'>
-            <Toggle checked={this.props.unlisted} onChange={this.handleChangeListability} />
-            <span className='compose-form__label__text'><FormattedMessage id='compose_form.unlisted' defaultMessage='Do not display in public timeline' /></span>
-          </label>
-        </Collapsable>
-
-        <Collapsable isVisible={this.props.media_count > 0} fullHeight={39.5}>
-          <label className='compose-form__label'>
-            <Toggle checked={this.props.sensitive} onChange={this.handleChangeSensitivity} />
-            <span className='compose-form__label__text'><FormattedMessage id='compose_form.sensitive' defaultMessage='Mark media as sensitive' /></span>
-          </label>
-        </Collapsable>
+        <SpoilerToggleContainer />
+        <PrivateToggleContainer />
+        <UnlistedToggleContainer />
+        <SensitiveToggleContainer />
       </div>
     );
   }
diff --git a/app/assets/javascripts/components/features/compose/components/drawer.jsx b/app/assets/javascripts/components/features/compose/components/drawer.jsx
index 83f3fa27d..ab67c86ea 100644
--- a/app/assets/javascripts/components/features/compose/components/drawer.jsx
+++ b/app/assets/javascripts/components/features/compose/components/drawer.jsx
@@ -3,7 +3,8 @@ import { injectIntl, defineMessages } from 'react-intl';
 
 const messages = defineMessages({
   start: { id: 'getting_started.heading', defaultMessage: 'Getting started' },
-  public: { id: 'navigation_bar.public_timeline', defaultMessage: 'Public timeline' },
+  public: { id: 'navigation_bar.public_timeline', defaultMessage: 'Whole Known Network' },
+  community: { id: 'navigation_bar.community_timeline', defaultMessage: 'Local timeline' },
   preferences: { id: 'navigation_bar.preferences', defaultMessage: 'Preferences' },
   logout: { id: 'navigation_bar.logout', defaultMessage: 'Logout' }
 });
@@ -15,6 +16,7 @@ const Drawer = ({ children, withHeader, intl }) => {
     header = (
       <div className='drawer__header'>
         <Link title={intl.formatMessage(messages.start)} className='drawer__tab' to='/getting-started'><i className='fa fa-fw fa-asterisk' /></Link>
+        <Link title={intl.formatMessage(messages.community)} className='drawer__tab' to='/timelines/public/local'><i className='fa fa-fw fa-users' /></Link>
         <Link title={intl.formatMessage(messages.public)} className='drawer__tab' to='/timelines/public'><i className='fa fa-fw fa-globe' /></Link>
         <a title={intl.formatMessage(messages.preferences)} className='drawer__tab' href='/settings/preferences'><i className='fa fa-fw fa-cog' /></a>
         <a title={intl.formatMessage(messages.logout)} className='drawer__tab' href='/auth/sign_out' data-method='delete'><i className='fa fa-fw fa-sign-out' /></a>
diff --git a/app/assets/javascripts/components/features/compose/components/private_toggle.jsx b/app/assets/javascripts/components/features/compose/components/private_toggle.jsx
new file mode 100644
index 000000000..902ee70ca
--- /dev/null
+++ b/app/assets/javascripts/components/features/compose/components/private_toggle.jsx
@@ -0,0 +1,27 @@
+import PureRenderMixin from 'react-addons-pure-render-mixin';
+import { FormattedMessage } from 'react-intl';
+import Toggle from 'react-toggle';
+
+const PrivateToggle = React.createClass({
+
+  propTypes: {
+    isPrivate: React.PropTypes.bool,
+    onChange: React.PropTypes.func.isRequired
+  },
+
+  mixins: [PureRenderMixin],
+
+  render () {
+    const { isPrivate, onChange } = this.props;
+
+    return (
+      <label className='compose-form__label with-border'>
+        <Toggle checked={isPrivate} onChange={onChange} />
+        <span className='compose-form__label__text'><FormattedMessage id='compose_form.private' defaultMessage='Mark as private' /></span>
+      </label>
+    );
+  }
+
+});
+
+export default PrivateToggle;
diff --git a/app/assets/javascripts/components/features/compose/components/reply_indicator.jsx b/app/assets/javascripts/components/features/compose/components/reply_indicator.jsx
index 73e5ee99e..a72bd32c2 100644
--- a/app/assets/javascripts/components/features/compose/components/reply_indicator.jsx
+++ b/app/assets/javascripts/components/features/compose/components/reply_indicator.jsx
@@ -17,7 +17,7 @@ const ReplyIndicator = React.createClass({
   },
 
   propTypes: {
-    status: ImmutablePropTypes.map.isRequired,
+    status: ImmutablePropTypes.map,
     onCancel: React.PropTypes.func.isRequired,
     intl: React.PropTypes.object.isRequired
   },
@@ -36,17 +36,22 @@ const ReplyIndicator = React.createClass({
   },
 
   render () {
-    const { intl } = this.props;
-    const content  = { __html: emojify(this.props.status.get('content')) };
+    const { status, intl } = this.props;
+
+    if (!status) {
+      return null;
+    }
+
+    const content  = { __html: emojify(status.get('content')) };
 
     return (
       <div className='reply-indicator'>
         <div style={{ overflow: 'hidden', marginBottom: '5px' }}>
           <div style={{ float: 'right', lineHeight: '24px' }}><IconButton title={intl.formatMessage(messages.cancel)} icon='times' onClick={this.handleClick} /></div>
 
-          <a href={this.props.status.getIn(['account', 'url'])} onClick={this.handleAccountClick} className='reply-indicator__display-name' style={{ display: 'block', maxWidth: '100%', paddingRight: '25px', textDecoration: 'none', overflow: 'hidden', lineHeight: '24px' }}>
-            <div style={{ float: 'left', marginRight: '5px' }}><Avatar size={24} src={this.props.status.getIn(['account', 'avatar'])} /></div>
-            <DisplayName account={this.props.status.get('account')} />
+          <a href={status.getIn(['account', 'url'])} onClick={this.handleAccountClick} className='reply-indicator__display-name' style={{ display: 'block', maxWidth: '100%', paddingRight: '25px', textDecoration: 'none', overflow: 'hidden', lineHeight: '24px' }}>
+            <div style={{ float: 'left', marginRight: '5px' }}><Avatar size={24} src={status.getIn(['account', 'avatar'])} /></div>
+            <DisplayName account={status.get('account')} />
           </a>
         </div>
 
diff --git a/app/assets/javascripts/components/features/compose/components/sensitive_toggle.jsx b/app/assets/javascripts/components/features/compose/components/sensitive_toggle.jsx
new file mode 100644
index 000000000..97cc9487e
--- /dev/null
+++ b/app/assets/javascripts/components/features/compose/components/sensitive_toggle.jsx
@@ -0,0 +1,31 @@
+import PureRenderMixin from 'react-addons-pure-render-mixin';
+import { FormattedMessage } from 'react-intl';
+import Toggle from 'react-toggle';
+import Collapsable from '../../../components/collapsable';
+
+const SensitiveToggle = React.createClass({
+
+  propTypes: {
+    hasMedia: React.PropTypes.bool,
+    isSensitive: React.PropTypes.bool,
+    onChange: React.PropTypes.func.isRequired
+  },
+
+  mixins: [PureRenderMixin],
+
+  render () {
+    const { hasMedia, isSensitive, onChange } = this.props;
+
+    return (
+      <Collapsable isVisible={hasMedia} fullHeight={39.5}>
+        <label className='compose-form__label'>
+          <Toggle checked={isSensitive} onChange={onChange} />
+          <span className='compose-form__label__text'><FormattedMessage id='compose_form.sensitive' defaultMessage='Mark media as sensitive' /></span>
+        </label>
+      </Collapsable>
+    );
+  }
+
+});
+
+export default SensitiveToggle;
diff --git a/app/assets/javascripts/components/features/compose/components/spoiler_toggle.jsx b/app/assets/javascripts/components/features/compose/components/spoiler_toggle.jsx
new file mode 100644
index 000000000..1c59e4393
--- /dev/null
+++ b/app/assets/javascripts/components/features/compose/components/spoiler_toggle.jsx
@@ -0,0 +1,27 @@
+import PureRenderMixin from 'react-addons-pure-render-mixin';
+import { FormattedMessage } from 'react-intl';
+import Toggle from 'react-toggle';
+
+const SpoilerToggle = React.createClass({
+
+  propTypes: {
+    isSpoiler: React.PropTypes.bool,
+    onChange: React.PropTypes.func.isRequired
+  },
+
+  mixins: [PureRenderMixin],
+
+  render () {
+    const { isSpoiler, onChange } = this.props;
+
+    return (
+      <label className='compose-form__label with-border' style={{ marginTop: '10px' }}>
+        <Toggle checked={isSpoiler} onChange={onChange} />
+        <span className='compose-form__label__text'><FormattedMessage id='compose_form.spoiler' defaultMessage='Hide text behind warning' /></span>
+      </label>
+    );
+  }
+
+});
+
+export default SpoilerToggle;
diff --git a/app/assets/javascripts/components/features/compose/components/unlisted_toggle.jsx b/app/assets/javascripts/components/features/compose/components/unlisted_toggle.jsx
new file mode 100644
index 000000000..0745051eb
--- /dev/null
+++ b/app/assets/javascripts/components/features/compose/components/unlisted_toggle.jsx
@@ -0,0 +1,32 @@
+import PureRenderMixin from 'react-addons-pure-render-mixin';
+import { FormattedMessage } from 'react-intl';
+import Toggle from 'react-toggle';
+import Collapsable from '../../../components/collapsable';
+
+const UnlistedToggle = React.createClass({
+
+  propTypes: {
+    isPrivate: React.PropTypes.bool,
+    isUnlisted: React.PropTypes.bool,
+    isReplyToOther: React.PropTypes.bool,
+    onChangeListability: React.PropTypes.func.isRequired
+  },
+
+  mixins: [PureRenderMixin],
+
+  render () {
+    const { isPrivate, isUnlisted, isReplyToOther, onChangeListability } = this.props;
+
+    return (
+      <Collapsable isVisible={!(isPrivate || isReplyToOther)} fullHeight={39.5}>
+        <label className='compose-form__label'>
+          <Toggle checked={isUnlisted} onChange={onChangeListability} />
+          <span className='compose-form__label__text'><FormattedMessage id='compose_form.unlisted' defaultMessage='Do not display on public timelines' /></span>
+        </label>
+      </Collapsable>
+    );
+  }
+
+});
+
+export default UnlistedToggle;
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 2671ea618..53129af6e 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
@@ -1,95 +1,70 @@
 import { connect } from 'react-redux';
 import ComposeForm from '../components/compose_form';
+import { createSelector } from 'reselect';
 import {
   changeCompose,
   submitCompose,
-  cancelReplyCompose,
   clearComposeSuggestions,
   fetchComposeSuggestions,
   selectComposeSuggestion,
-  changeComposeSensitivity,
-  changeComposeSpoilerness,
   changeComposeSpoilerText,
-  changeComposeVisibility,
-  changeComposeListability
 } from '../../../actions/compose';
-import { makeGetStatus } from '../../../selectors';
 
-const makeMapStateToProps = () => {
-  const getStatus = makeGetStatus();
+const getMentionedUsernames = createSelector(state => state.getIn(['compose', 'text']), text => text.match(/(?:^|[^\/\w])@([a-z0-9_]+@[a-z0-9\.\-]+)/ig));
 
-  const mapStateToProps = function (state, props) {
-    const mentionedUsernamesWithDomains = state.getIn(['compose', 'text']).match(/(?:^|[^\/\w])@([a-z0-9_]+@[a-z0-9\.\-]+)/ig);
+const getMentionedDomains = createSelector(getMentionedUsernames, mentionedUsernamesWithDomains => {
+  return mentionedUsernamesWithDomains !== null ? [...new Set(mentionedUsernamesWithDomains.map(item => item.split('@')[2]))] : [];
+});
 
-    return {
-      text: state.getIn(['compose', 'text']),
-      suggestion_token: state.getIn(['compose', 'suggestion_token']),
-      suggestions: state.getIn(['compose', 'suggestions']),
-      sensitive: state.getIn(['compose', 'sensitive']),
-      spoiler: state.getIn(['compose', 'spoiler']),
-      spoiler_text: state.getIn(['compose', 'spoiler_text']),
-      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']),
-      needsPrivacyWarning: state.getIn(['compose', 'private']) && mentionedUsernamesWithDomains !== null,
-      mentionedDomains: mentionedUsernamesWithDomains !== null ? [...new Set(mentionedUsernamesWithDomains.map(item => item.split('@')[2]))] : []
-    };
-  };
-
-  return mapStateToProps;
-};
+const mapStateToProps = (state, props) => {
+  const mentionedUsernames = getMentionedUsernames(state);
+  const mentionedUsernamesWithDomains = getMentionedDomains(state);
 
-const mapDispatchToProps = function (dispatch) {
   return {
-    onChange (text) {
-      dispatch(changeCompose(text));
-    },
-
-    onSubmit () {
-      dispatch(submitCompose());
-    },
-
-    onCancelReply () {
-      dispatch(cancelReplyCompose());
-    },
+    text: state.getIn(['compose', 'text']),
+    suggestion_token: state.getIn(['compose', 'suggestion_token']),
+    suggestions: state.getIn(['compose', 'suggestions']),
+    spoiler: state.getIn(['compose', 'spoiler']),
+    spoiler_text: state.getIn(['compose', 'spoiler_text']),
+    unlisted: state.getIn(['compose', 'unlisted'], ),
+    private: state.getIn(['compose', 'private']),
+    fileDropDate: state.getIn(['compose', 'fileDropDate']),
+    focusDate: state.getIn(['compose', 'focusDate']),
+    preselectDate: state.getIn(['compose', 'preselectDate']),
+    is_submitting: state.getIn(['compose', 'is_submitting']),
+    is_uploading: state.getIn(['compose', 'is_uploading']),
+    me: state.getIn(['compose', 'me']),
+    needsPrivacyWarning: state.getIn(['compose', 'private']) && mentionedUsernames !== null,
+    mentionedDomains: mentionedUsernamesWithDomains
+  };
+};
 
-    onClearSuggestions () {
-      dispatch(clearComposeSuggestions());
-    },
+const mapDispatchToProps = (dispatch) => ({
 
-    onFetchSuggestions (token) {
-      dispatch(fetchComposeSuggestions(token));
-    },
+  onChange (text) {
+    dispatch(changeCompose(text));
+  },
 
-    onSuggestionSelected (position, token, accountId) {
-      dispatch(selectComposeSuggestion(position, token, accountId));
-    },
+  onSubmit () {
+    dispatch(submitCompose());
+  },
 
-    onChangeSensitivity (checked) {
-      dispatch(changeComposeSensitivity(checked));
-    },
+  onClearSuggestions () {
+    dispatch(clearComposeSuggestions());
+  },
 
-    onChangeSpoilerness (checked) {
-      dispatch(changeComposeSpoilerness(checked));
-    },
+  onFetchSuggestions (token) {
+    dispatch(fetchComposeSuggestions(token));
+  },
 
-    onChangeSpoilerText (checked) {
-      dispatch(changeComposeSpoilerText(checked));
-    },
+  onSuggestionSelected (position, token, accountId) {
+    dispatch(selectComposeSuggestion(position, token, accountId));
+  },
 
-    onChangeVisibility (checked) {
-      dispatch(changeComposeVisibility(checked));
-    },
+  onChangeSpoilerText (checked) {
+    dispatch(changeComposeSpoilerText(checked));
+  },
 
-    onChangeListability (checked) {
-      dispatch(changeComposeListability(checked));
-    }
-  }
-};
+});
 
-export default connect(makeMapStateToProps, mapDispatchToProps)(ComposeForm);
+export default connect(mapStateToProps, mapDispatchToProps)(ComposeForm);
diff --git a/app/assets/javascripts/components/features/compose/containers/private_toggle_container.jsx b/app/assets/javascripts/components/features/compose/containers/private_toggle_container.jsx
new file mode 100644
index 000000000..ee3596902
--- /dev/null
+++ b/app/assets/javascripts/components/features/compose/containers/private_toggle_container.jsx
@@ -0,0 +1,17 @@
+import { connect } from 'react-redux';
+import PrivateToggle from '../components/private_toggle';
+import { changeComposeVisibility } from '../../../actions/compose';
+
+const mapStateToProps = state => ({
+  isPrivate: state.getIn(['compose', 'private'])
+});
+
+const mapDispatchToProps = dispatch => ({
+
+  onChange (e) {
+    dispatch(changeComposeVisibility(e.target.checked));
+  }
+
+});
+
+export default connect(mapStateToProps, mapDispatchToProps)(PrivateToggle);
diff --git a/app/assets/javascripts/components/features/compose/containers/reply_indicator_container.jsx b/app/assets/javascripts/components/features/compose/containers/reply_indicator_container.jsx
new file mode 100644
index 000000000..39b48f3b6
--- /dev/null
+++ b/app/assets/javascripts/components/features/compose/containers/reply_indicator_container.jsx
@@ -0,0 +1,24 @@
+import { connect } from 'react-redux';
+import { cancelReplyCompose } from '../../../actions/compose';
+import { makeGetStatus } from '../../../selectors';
+import ReplyIndicator from '../components/reply_indicator';
+
+const makeMapStateToProps = () => {
+  const getStatus = makeGetStatus();
+
+  const mapStateToProps = (state, props) => ({
+    status: getStatus(state, state.getIn(['compose', 'in_reply_to'])),
+  });
+
+  return mapStateToProps;
+};
+
+const mapDispatchToProps = dispatch => ({
+
+  onCancel () {
+    dispatch(cancelReplyCompose());
+  }
+
+});
+
+export default connect(makeMapStateToProps, mapDispatchToProps)(ReplyIndicator);
diff --git a/app/assets/javascripts/components/features/compose/containers/sensitive_toggle_container.jsx b/app/assets/javascripts/components/features/compose/containers/sensitive_toggle_container.jsx
new file mode 100644
index 000000000..97b3361ba
--- /dev/null
+++ b/app/assets/javascripts/components/features/compose/containers/sensitive_toggle_container.jsx
@@ -0,0 +1,18 @@
+import { connect } from 'react-redux';
+import SensitiveToggle from '../components/sensitive_toggle';
+import { changeComposeSensitivity } from '../../../actions/compose';
+
+const mapStateToProps = state => ({
+  hasMedia: state.getIn(['compose', 'media_attachments']).size > 0,
+  isSensitive: state.getIn(['compose', 'sensitive'])
+});
+
+const mapDispatchToProps = dispatch => ({
+
+  onChange (e) {
+    dispatch(changeComposeSensitivity(e.target.checked));
+  }
+
+});
+
+export default connect(mapStateToProps, mapDispatchToProps)(SensitiveToggle);
diff --git a/app/assets/javascripts/components/features/compose/containers/spoiler_toggle_container.jsx b/app/assets/javascripts/components/features/compose/containers/spoiler_toggle_container.jsx
new file mode 100644
index 000000000..0bd4df759
--- /dev/null
+++ b/app/assets/javascripts/components/features/compose/containers/spoiler_toggle_container.jsx
@@ -0,0 +1,17 @@
+import { connect } from 'react-redux';
+import SpoilerToggle from '../components/spoiler_toggle';
+import { changeComposeSpoilerness } from '../../../actions/compose';
+
+const mapStateToProps = state => ({
+  isSpoiler: state.getIn(['compose', 'spoiler'])
+});
+
+const mapDispatchToProps = dispatch => ({
+
+  onChange (e) {
+    dispatch(changeComposeSpoilerness(e.target.checked));
+  }
+
+});
+
+export default connect(mapStateToProps, mapDispatchToProps)(SpoilerToggle);
diff --git a/app/assets/javascripts/components/features/compose/containers/unlisted_toggle_container.jsx b/app/assets/javascripts/components/features/compose/containers/unlisted_toggle_container.jsx
new file mode 100644
index 000000000..ceac903d9
--- /dev/null
+++ b/app/assets/javascripts/components/features/compose/containers/unlisted_toggle_container.jsx
@@ -0,0 +1,31 @@
+import { connect } from 'react-redux';
+import UnlistedToggle from '../components/unlisted_toggle';
+import { makeGetStatus } from '../../../selectors';
+import { changeComposeListability } from '../../../actions/compose';
+
+const makeMapStateToProps = () => {
+  const getStatus = makeGetStatus();
+
+  const mapStateToProps = state => {
+    const status = getStatus(state, state.getIn(['compose', 'in_reply_to']));
+    const me     = state.getIn(['compose', 'me']);
+
+    return {
+      isPrivate: state.getIn(['compose', 'private']),
+      isUnlisted: state.getIn(['compose', 'unlisted']),
+      isReplyToOther: status ? status.getIn(['account', 'id']) !== me : false
+    };
+  };
+
+  return mapStateToProps;
+};
+
+const mapDispatchToProps = dispatch => ({
+
+  onChangeListability (e) {
+    dispatch(changeComposeListability(e.target.checked));
+  }
+
+});
+
+export default connect(makeMapStateToProps, mapDispatchToProps)(UnlistedToggle);
diff --git a/app/assets/javascripts/components/features/getting_started/index.jsx b/app/assets/javascripts/components/features/getting_started/index.jsx
index af86919c1..f8433b8f4 100644
--- a/app/assets/javascripts/components/features/getting_started/index.jsx
+++ b/app/assets/javascripts/components/features/getting_started/index.jsx
@@ -7,7 +7,8 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
 
 const messages = defineMessages({
   heading: { id: 'getting_started.heading', defaultMessage: 'Getting started' },
-  public_timeline: { id: 'navigation_bar.public_timeline', defaultMessage: 'Public timeline' },
+  public_timeline: { id: 'navigation_bar.public_timeline', defaultMessage: 'Whole Known Network' },
+  community_timeline: { id: 'navigation_bar.community_timeline', defaultMessage: 'Local timeline' },
   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' },
@@ -30,6 +31,7 @@ const GettingStarted = ({ intl, me }) => {
   return (
     <Column icon='asterisk' heading={intl.formatMessage(messages.heading)}>
       <div style={{ position: 'relative' }}>
+        <ColumnLink icon='users' text={intl.formatMessage(messages.community_timeline)} to='/timelines/public/local' />
         <ColumnLink icon='globe' text={intl.formatMessage(messages.public_timeline)} to='/timelines/public' />
         <ColumnLink icon='cog' text={intl.formatMessage(messages.preferences)} href='/settings/preferences' />
         <ColumnLink icon='star' text={intl.formatMessage(messages.favourites)} to='/favourites' />
diff --git a/app/assets/javascripts/components/features/hashtag_timeline/index.jsx b/app/assets/javascripts/components/features/hashtag_timeline/index.jsx
index 6cb9e5482..7fb413336 100644
--- a/app/assets/javascripts/components/features/hashtag_timeline/index.jsx
+++ b/app/assets/javascripts/components/features/hashtag_timeline/index.jsx
@@ -12,6 +12,7 @@ import { FormattedMessage } from 'react-intl';
 import createStream from '../../stream';
 
 const mapStateToProps = state => ({
+  hasUnread: state.getIn(['timelines', 'tag', 'unread']) > 0,
   accessToken: state.getIn(['meta', 'access_token'])
 });
 
@@ -20,7 +21,8 @@ const HashtagTimeline = React.createClass({
   propTypes: {
     params: React.PropTypes.object.isRequired,
     dispatch: React.PropTypes.func.isRequired,
-    accessToken: React.PropTypes.string.isRequired
+    accessToken: React.PropTypes.string.isRequired,
+    hasUnread: React.PropTypes.bool
   },
 
   mixins: [PureRenderMixin],
@@ -72,10 +74,10 @@ const HashtagTimeline = React.createClass({
   },
 
   render () {
-    const { id } = this.props.params;
+    const { id, hasUnread } = this.props.params;
 
     return (
-      <Column icon='hashtag' heading={id}>
+      <Column icon='hashtag' active={hasUnread} heading={id}>
         <ColumnBackButtonSlim />
         <StatusListContainer type='tag' id={id} emptyMessage={<FormattedMessage id='empty_column.hashtag' defaultMessage='There is nothing in this hashtag yet.' />} />
       </Column>
diff --git a/app/assets/javascripts/components/features/home_timeline/index.jsx b/app/assets/javascripts/components/features/home_timeline/index.jsx
index 23e198701..a2b775764 100644
--- a/app/assets/javascripts/components/features/home_timeline/index.jsx
+++ b/app/assets/javascripts/components/features/home_timeline/index.jsx
@@ -1,3 +1,4 @@
+import { connect } from 'react-redux';
 import PureRenderMixin from 'react-addons-pure-render-mixin';
 import StatusListContainer from '../ui/containers/status_list_container';
 import Column from '../ui/components/column';
@@ -9,25 +10,30 @@ const messages = defineMessages({
   title: { id: 'column.home', defaultMessage: 'Home' }
 });
 
+const mapStateToProps = state => ({
+  hasUnread: state.getIn(['timelines', 'home', 'unread']) > 0
+});
+
 const HomeTimeline = React.createClass({
 
   propTypes: {
-    intl: React.PropTypes.object.isRequired
+    intl: React.PropTypes.object.isRequired,
+    hasUnread: React.PropTypes.bool
   },
 
   mixins: [PureRenderMixin],
 
   render () {
-    const { intl } = this.props;
+    const { intl, hasUnread } = this.props;
 
     return (
-      <Column icon='home' heading={intl.formatMessage(messages.title)}>
+      <Column icon='home' active={hasUnread} heading={intl.formatMessage(messages.title)}>
         <ColumnSettingsContainer />
-        <StatusListContainer {...this.props} type='home' emptyMessage={<FormattedMessage id='empty_column.home' defaultMessage="You aren’t following anyone yet. Visit {public} or use search to get started and meet other users." values={{ public: <Link to='/timelines/public'><FormattedMessage id='empty_column.home.public_timeline' defaultMessage='the public timeline' /></Link> }} />} />
+        <StatusListContainer {...this.props} type='home' emptyMessage={<FormattedMessage id='empty_column.home' defaultMessage="You aren't following anyone yet. Visit {public} or use search to get started and meet other users." values={{ public: <Link to='/timelines/public'><FormattedMessage id='empty_column.home.public_timeline' defaultMessage='the public timeline' /></Link> }} />} />
       </Column>
     );
   },
 
 });
 
-export default injectIntl(HomeTimeline);
+export default connect(mapStateToProps)(injectIntl(HomeTimeline));
diff --git a/app/assets/javascripts/components/features/notifications/components/notification.jsx b/app/assets/javascripts/components/features/notifications/components/notification.jsx
index fa8466140..0de4df52e 100644
--- a/app/assets/javascripts/components/features/notifications/components/notification.jsx
+++ b/app/assets/javascripts/components/features/notifications/components/notification.jsx
@@ -5,7 +5,7 @@ import AccountContainer from '../../../containers/account_container';
 import { FormattedMessage } from 'react-intl';
 import Permalink from '../../../components/permalink';
 import emojify from '../../../emoji';
-import escapeTextContentForBrowser from 'react/lib/escapeTextContentForBrowser';
+import escapeTextContentForBrowser from 'escape-html';
 
 const linkStyle = {
   fontWeight: '500'
diff --git a/app/assets/javascripts/components/features/notifications/index.jsx b/app/assets/javascripts/components/features/notifications/index.jsx
index 9532b8af8..0da3544f6 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, clearNotifications } from '../../actions/notifications';
+import { expandNotifications, clearNotifications, scrollTopNotifications } from '../../actions/notifications';
 import NotificationContainer from './containers/notification_container';
 import { ScrollContainer } from 'react-router-scroll';
 import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
@@ -23,7 +23,8 @@ const getNotifications = createSelector([
 
 const mapStateToProps = state => ({
   notifications: getNotifications(state),
-  isLoading: state.getIn(['notifications', 'isLoading'], true)
+  isLoading: state.getIn(['notifications', 'isLoading'], true),
+  isUnread: state.getIn(['notifications', 'unread']) > 0
 });
 
 const Notifications = React.createClass({
@@ -33,7 +34,8 @@ const Notifications = React.createClass({
     dispatch: React.PropTypes.func.isRequired,
     trackScroll: React.PropTypes.bool,
     intl: React.PropTypes.object.isRequired,
-    isLoading: React.PropTypes.bool
+    isLoading: React.PropTypes.bool,
+    isUnread: React.PropTypes.bool
   },
 
   getDefaultProps () {
@@ -51,6 +53,10 @@ const Notifications = React.createClass({
 
     if (250 > offset && !this.props.isLoading) {
       this.props.dispatch(expandNotifications());
+    } else if (scrollTop < 100) {
+      this.props.dispatch(scrollTopNotifications(true));
+    } else {
+      this.props.dispatch(scrollTopNotifications(false));
     }
   },
 
@@ -74,18 +80,25 @@ const Notifications = React.createClass({
   },
 
   render () {
-    const { intl, notifications, trackScroll, isLoading } = this.props;
+    const { intl, notifications, trackScroll, isLoading, isUnread } = this.props;
 
     let loadMore       = '';
     let scrollableArea = '';
+    let unread         = '';
 
     if (!isLoading && notifications.size > 0) {
       loadMore = <LoadMore onClick={this.handleLoadMore} />;
     }
 
+    if (isUnread) {
+      unread = <div className='notifications__unread-indicator' />;
+    }
+
     if (isLoading || notifications.size > 0) {
       scrollableArea = (
         <div className='scrollable' onScroll={this.handleScroll} ref={this.setRef}>
+          {unread}
+
           <div>
             {notifications.map(item => <NotificationContainer key={item.get('id')} notification={item} accountId={item.get('account')} />)}
             {loadMore}
@@ -102,7 +115,7 @@ const Notifications = React.createClass({
 
     if (trackScroll) {
       return (
-        <Column icon='bell' heading={intl.formatMessage(messages.title)}>
+        <Column icon='bell' active={isUnread} heading={intl.formatMessage(messages.title)}>
           <ColumnSettingsContainer />
           <ClearColumnButton onClick={this.handleClear} />
           <ScrollContainer scrollKey='notifications'>
@@ -112,7 +125,7 @@ const Notifications = React.createClass({
       );
     } else {
       return (
-        <Column icon='bell' heading={intl.formatMessage(messages.title)}>
+        <Column icon='bell' active={isUnread} heading={intl.formatMessage(messages.title)}>
           <ColumnSettingsContainer />
           <ClearColumnButton onClick={this.handleClear} />
           {scrollableArea}
diff --git a/app/assets/javascripts/components/features/public_timeline/index.jsx b/app/assets/javascripts/components/features/public_timeline/index.jsx
index 36d68dbbb..ce4eacc92 100644
--- a/app/assets/javascripts/components/features/public_timeline/index.jsx
+++ b/app/assets/javascripts/components/features/public_timeline/index.jsx
@@ -7,15 +7,16 @@ import {
   updateTimeline,
   deleteFromTimelines
 } from '../../actions/timelines';
-import { defineMessages, injectIntl } from 'react-intl';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
 import ColumnBackButtonSlim from '../../components/column_back_button_slim';
 import createStream from '../../stream';
 
 const messages = defineMessages({
-  title: { id: 'column.public', defaultMessage: 'Public' }
+  title: { id: 'column.public', defaultMessage: 'Whole Known Network' }
 });
 
 const mapStateToProps = state => ({
+  hasUnread: state.getIn(['timelines', 'public', 'unread']) > 0,
   accessToken: state.getIn(['meta', 'access_token'])
 });
 
@@ -24,7 +25,8 @@ const PublicTimeline = React.createClass({
   propTypes: {
     dispatch: React.PropTypes.func.isRequired,
     intl: React.PropTypes.object.isRequired,
-    accessToken: React.PropTypes.string.isRequired
+    accessToken: React.PropTypes.string.isRequired,
+    hasUnread: React.PropTypes.bool
   },
 
   mixins: [PureRenderMixin],
@@ -58,12 +60,12 @@ const PublicTimeline = React.createClass({
   },
 
   render () {
-    const { intl } = this.props;
+    const { intl, hasUnread } = this.props;
 
     return (
-      <Column icon='globe' heading={intl.formatMessage(messages.title)}>
+      <Column icon='globe' active={hasUnread} heading={intl.formatMessage(messages.title)}>
         <ColumnBackButtonSlim />
-        <StatusListContainer type='public' />
+        <StatusListContainer type='public' emptyMessage={<FormattedMessage id='empty_column.public' defaultMessage='There is nothing here! Write something publicly, or manually follow users from other instances to fill it up' />} />
       </Column>
     );
   },
diff --git a/app/assets/javascripts/components/features/status/index.jsx b/app/assets/javascripts/components/features/status/index.jsx
index e17c078d2..6a7635cc6 100644
--- a/app/assets/javascripts/components/features/status/index.jsx
+++ b/app/assets/javascripts/components/features/status/index.jsx
@@ -4,7 +4,7 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
 import { fetchStatus } from '../../actions/statuses';
 import Immutable from 'immutable';
 import EmbeddedStatus from '../../components/status';
-import LoadingIndicator from '../../components/loading_indicator';
+import MissingIndicator from '../../components/missing_indicator';
 import DetailedStatus from './components/detailed_status';
 import ActionBar from './components/action_bar';
 import Column from '../ui/components/column';
@@ -117,7 +117,8 @@ const Status = React.createClass({
     if (status === null) {
       return (
         <Column>
-          <LoadingIndicator />
+          <ColumnBackButton />
+          <MissingIndicator />
         </Column>
       );
     }
diff --git a/app/assets/javascripts/components/features/ui/components/column.jsx b/app/assets/javascripts/components/features/ui/components/column.jsx
index 5b0603ee9..2b7e11bf1 100644
--- a/app/assets/javascripts/components/features/ui/components/column.jsx
+++ b/app/assets/javascripts/components/features/ui/components/column.jsx
@@ -34,7 +34,8 @@ const Column = React.createClass({
   propTypes: {
     heading: React.PropTypes.string,
     icon: React.PropTypes.string,
-    children: React.PropTypes.node
+    children: React.PropTypes.node,
+    active: React.PropTypes.bool
   },
 
   mixins: [PureRenderMixin],
@@ -51,12 +52,12 @@ const Column = React.createClass({
   },
 
   render () {
-    const { heading, icon, children } = this.props;
+    const { heading, icon, children, active } = this.props;
 
     let header = '';
 
     if (heading) {
-      header = <ColumnHeader icon={icon} type={heading} onClick={this.handleHeaderClick} />;
+      header = <ColumnHeader icon={icon} active={active} type={heading} onClick={this.handleHeaderClick} />;
     }
 
     return (
diff --git a/app/assets/javascripts/components/features/ui/components/column_header.jsx b/app/assets/javascripts/components/features/ui/components/column_header.jsx
index 8b072d723..de55fa748 100644
--- a/app/assets/javascripts/components/features/ui/components/column_header.jsx
+++ b/app/assets/javascripts/components/features/ui/components/column_header.jsx
@@ -5,6 +5,7 @@ const ColumnHeader = React.createClass({
   propTypes: {
     icon: React.PropTypes.string,
     type: React.PropTypes.string,
+    active: React.PropTypes.bool,
     onClick: React.PropTypes.func
   },
 
@@ -15,6 +16,8 @@ const ColumnHeader = React.createClass({
   },
 
   render () {
+    const { type, active } = this.props;
+
     let icon = '';
 
     if (this.props.icon) {
@@ -22,9 +25,9 @@ const ColumnHeader = React.createClass({
     }
 
     return (
-      <div className='column-header' onClick={this.handleClick}>
+      <div className={`column-header ${active ? 'active' : ''}`} onClick={this.handleClick}>
         {icon}
-        {this.props.type}
+        {type}
       </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 4c47fb8c5..d8301b20f 100644
--- a/app/assets/javascripts/components/features/ui/containers/modal_container.jsx
+++ b/app/assets/javascripts/components/features/ui/containers/modal_container.jsx
@@ -131,19 +131,14 @@ const Modal = React.createClass({
       return null;
     }
 
-    const url      = media.get(index).get('url');
-    const hasLeft  = index > 0;
-    const hasRight = index + 1 < media.size;
+    const url = media.get(index).get('url');
 
     let leftNav, rightNav;
 
     leftNav = rightNav = '';
 
-    if (hasLeft) {
-      leftNav = <div style={leftNavStyle} className='modal-container--nav' onClick={this.handlePrevClick}><i className='fa fa-fw fa-chevron-left' /></div>;
-    }
-
-    if (hasRight) {
+    if (media.size > 1) {
+      leftNav  = <div style={leftNavStyle} className='modal-container--nav' onClick={this.handlePrevClick}><i className='fa fa-fw fa-chevron-left' /></div>;
       rightNav = <div style={rightNavStyle} className='modal-container--nav' onClick={this.handleNextClick}><i className='fa fa-fw fa-chevron-right' /></div>;
     }
 
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 100989d22..f249240d8 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
@@ -3,8 +3,9 @@ import StatusList from '../../../components/status_list';
 import { expandTimeline, scrollTopTimeline } from '../../../actions/timelines';
 import Immutable from 'immutable';
 import { createSelector } from 'reselect';
+import { debounce } from 'react-decoration';
 
-const getStatusIds = createSelector([
+const makeGetStatusIds = () => createSelector([
   (state, { type }) => state.getIn(['settings', type], Immutable.Map()),
   (state, { type }) => state.getIn(['timelines', type, 'items'], Immutable.List()),
   (state)           => state.get('statuses'),
@@ -33,26 +34,37 @@ const getStatusIds = createSelector([
   return showStatus;
 }));
 
-const mapStateToProps = (state, props) => ({
-  statusIds: getStatusIds(state, props),
-  isLoading: state.getIn(['timelines', props.type, 'isLoading'], true)
-});
+const makeMapStateToProps = () => {
+  const getStatusIds = makeGetStatusIds();
+
+  const mapStateToProps = (state, props) => ({
+    statusIds: getStatusIds(state, props),
+    isLoading: state.getIn(['timelines', props.type, 'isLoading'], true),
+    isUnread: state.getIn(['timelines', props.type, 'unread']) > 0,
+    hasMore: !!state.getIn(['timelines', props.type, 'next'])
+  });
+
+  return mapStateToProps;
+};
 
 const mapDispatchToProps = (dispatch, { type, id }) => ({
 
+  @debounce(300, true)
   onScrollToBottom () {
     dispatch(scrollTopTimeline(type, false));
     dispatch(expandTimeline(type, id));
   },
 
+  @debounce(100)
   onScrollToTop () {
     dispatch(scrollTopTimeline(type, true));
   },
 
+  @debounce(100)
   onScroll () {
     dispatch(scrollTopTimeline(type, false));
   }
 
 });
 
-export default connect(mapStateToProps, mapDispatchToProps)(StatusList);
+export default connect(makeMapStateToProps, mapDispatchToProps)(StatusList);
diff --git a/app/assets/javascripts/components/locales/en.jsx b/app/assets/javascripts/components/locales/en.jsx
index 95962fd73..f1d6a6dbc 100644
--- a/app/assets/javascripts/components/locales/en.jsx
+++ b/app/assets/javascripts/components/locales/en.jsx
@@ -28,13 +28,13 @@ const en = {
   "getting_started.about_developer": "The developer of this project can be followed as Gargron@mastodon.social",
   "getting_started.open_source_notice": "Mastodon is open source software. You can contribute or report issues on github at {github}.",
   "column.home": "Home",
-  "column.mentions": "Mentions",
-  "column.public": "Public",
+  "column.community": "Local",
+  "column.public": "Whole Known Network",
   "column.notifications": "Notifications",
   "tabs_bar.compose": "Compose",
   "tabs_bar.home": "Home",
   "tabs_bar.mentions": "Mentions",
-  "tabs_bar.public": "Public",
+  "tabs_bar.public": "Whole Known Network",
   "tabs_bar.notifications": "Notifications",
   "compose_form.placeholder": "What is on your mind?",
   "compose_form.publish": "Toot",
@@ -42,10 +42,11 @@ const en = {
   "compose_form.spoiler": "Hide text behind warning",
   "compose_form.private": "Mark as private",
   "compose_form.privacy_disclaimer": "Your private status will be delivered to mentioned users on {domains}. Do you trust {domainsCount, plural, one {that server} other {those servers}} to not leak your status?",
-  "compose_form.unlisted": "Do not display in public timeline",
+  "compose_form.unlisted": "Do not display on public timelines",
   "navigation_bar.edit_profile": "Edit profile",
   "navigation_bar.preferences": "Preferences",
-  "navigation_bar.public_timeline": "Public timeline",
+  "navigation_bar.community_timeline": "Local timeline",
+  "navigation_bar.public_timeline": "Whole Known Network",
   "navigation_bar.logout": "Logout",
   "reply_indicator.cancel": "Cancel",
   "search.placeholder": "Search",
diff --git a/app/assets/javascripts/components/middleware/errors.jsx b/app/assets/javascripts/components/middleware/errors.jsx
index 74d77f0f9..4aca75f1e 100644
--- a/app/assets/javascripts/components/middleware/errors.jsx
+++ b/app/assets/javascripts/components/middleware/errors.jsx
@@ -5,7 +5,7 @@ const defaultFailSuffix = 'FAIL';
 
 export default function errorsMiddleware() {
   return ({ dispatch }) => next => action => {
-    if (action.type) {
+    if (action.type && !action.skipAlert) {
       const isFail = new RegExp(`${defaultFailSuffix}$`, 'g');
       const isSuccess = new RegExp(`${defaultSuccessSuffix}$`, 'g');
 
diff --git a/app/assets/javascripts/components/reducers/compose.jsx b/app/assets/javascripts/components/reducers/compose.jsx
index 8d281048e..dead5fd77 100644
--- a/app/assets/javascripts/components/reducers/compose.jsx
+++ b/app/assets/javascripts/components/reducers/compose.jsx
@@ -35,6 +35,8 @@ const initialState = Immutable.Map({
   private: false,
   text: '',
   fileDropDate: null,
+  focusDate: null,
+  preselectDate: null,
   in_reply_to: null,
   is_submitting: false,
   is_uploading: false,
@@ -99,6 +101,7 @@ const insertSuggestion = (state, position, token, completion) => {
     map.update('text', oldText => `${oldText.slice(0, position)}${completion} ${oldText.slice(position + token.length)}`);
     map.set('suggestion_token', null);
     map.update('suggestions', Immutable.List(), list => list.clear());
+    map.set('focusDate', new Date());
   });
 };
 
@@ -113,7 +116,10 @@ export default function compose(state = initialState, action) {
   case COMPOSE_SENSITIVITY_CHANGE:
     return state.set('sensitive', action.checked);
   case COMPOSE_SPOILERNESS_CHANGE:
-    return (action.checked ? state : state.set('spoiler_text', '')).set('spoiler', action.checked);
+    return state.withMutations(map => {
+      map.set('spoiler_text', '');
+      map.set('spoiler', action.checked);
+    });
   case COMPOSE_SPOILER_TEXT_CHANGE:
     return state.set('spoiler_text', action.text);
   case COMPOSE_VISIBILITY_CHANGE:
@@ -128,6 +134,8 @@ export default function compose(state = initialState, action) {
       map.set('text', statusToTextMentions(state, action.status));
       map.set('unlisted', action.status.get('visibility') === 'unlisted' || state.get('default_privacy') === 'unlisted');
       map.set('private', action.status.get('visibility') === 'private' || state.get('default_privacy') === 'private');
+      map.set('focusDate', new Date());
+      map.set('preselectDate', new Date());
     });
   case COMPOSE_REPLY_CANCEL:
     return state.withMutations(map => {
@@ -156,7 +164,7 @@ export default function compose(state = initialState, action) {
   case COMPOSE_UPLOAD_PROGRESS:
     return state.set('progress', Math.round((action.loaded / action.total) * 100));
   case COMPOSE_MENTION:
-    return state.update('text', text => `${text}@${action.account.get('acct')} `);
+    return state.update('text', text => `${text}@${action.account.get('acct')} `).set('focusDate', new Date());
   case COMPOSE_SUGGESTIONS_CLEAR:
     return state.update('suggestions', Immutable.List(), list => list.clear()).set('suggestion_token', null);
   case COMPOSE_SUGGESTIONS_READY:
diff --git a/app/assets/javascripts/components/reducers/modal.jsx b/app/assets/javascripts/components/reducers/modal.jsx
index 07da65771..37ffbc62b 100644
--- a/app/assets/javascripts/components/reducers/modal.jsx
+++ b/app/assets/javascripts/components/reducers/modal.jsx
@@ -23,9 +23,9 @@ export default function modal(state = initialState, action) {
   case MODAL_CLOSE:
     return state.set('open', false);
   case MODAL_INDEX_DECREASE:
-    return state.update('index', index => Math.max(index - 1, 0));
+    return state.update('index', index => (index - 1) % state.get('media').size);
   case MODAL_INDEX_INCREASE:
-    return state.update('index', index => Math.min(index + 1, state.get('media').size - 1));
+    return state.update('index', index => (index + 1) % state.get('media').size);
   default:
     return state;
   }
diff --git a/app/assets/javascripts/components/reducers/notifications.jsx b/app/assets/javascripts/components/reducers/notifications.jsx
index 4a7af8856..1406a388a 100644
--- a/app/assets/javascripts/components/reducers/notifications.jsx
+++ b/app/assets/javascripts/components/reducers/notifications.jsx
@@ -6,7 +6,8 @@ import {
   NOTIFICATIONS_EXPAND_REQUEST,
   NOTIFICATIONS_REFRESH_FAIL,
   NOTIFICATIONS_EXPAND_FAIL,
-  NOTIFICATIONS_CLEAR
+  NOTIFICATIONS_CLEAR,
+  NOTIFICATIONS_SCROLL_TOP
 } from '../actions/notifications';
 import { ACCOUNT_BLOCK_SUCCESS } from '../actions/accounts';
 import Immutable from 'immutable';
@@ -14,6 +15,8 @@ import Immutable from 'immutable';
 const initialState = Immutable.Map({
   items: Immutable.List(),
   next: null,
+  top: true,
+  unread: 0,
   loaded: false,
   isLoading: true
 });
@@ -26,6 +29,10 @@ const notificationToMap = notification => Immutable.Map({
 });
 
 const normalizeNotification = (state, notification) => {
+  if (!state.get('top')) {
+    state = state.update('unread', unread => unread + 1);
+  }
+
   return state.update('items', list => list.unshift(notificationToMap(notification)));
 };
 
@@ -37,9 +44,12 @@ const normalizeNotifications = (state, notifications, next) => {
     items = items.set(i, notificationToMap(n));
   });
 
+  if (state.get('next') === null) {
+    state = state.set('next', next);
+  }
+
   return state
     .update('items', list => loaded ? list.unshift(...items) : list.push(...items))
-    .set('next', next)
     .set('loaded', true)
     .set('isLoading', false);
 };
@@ -61,6 +71,14 @@ const filterNotifications = (state, relationship) => {
   return state.update('items', list => list.filterNot(item => item.get('account') === relationship.id));
 };
 
+const updateTop = (state, top) => {
+  if (top) {
+    state = state.set('unread', 0);
+  }
+
+  return state.set('top', top);
+};
+
 export default function notifications(state = initialState, action) {
   switch(action.type) {
   case NOTIFICATIONS_REFRESH_REQUEST:
@@ -68,6 +86,8 @@ export default function notifications(state = initialState, action) {
   case NOTIFICATIONS_REFRESH_FAIL:
   case NOTIFICATIONS_EXPAND_FAIL:
     return state.set('isLoading', true);
+  case NOTIFICATIONS_SCROLL_TOP:
+    return updateTop(state, action.top);
   case NOTIFICATIONS_UPDATE:
     return normalizeNotification(state, action.notification);
   case NOTIFICATIONS_REFRESH_SUCCESS:
diff --git a/app/assets/javascripts/components/reducers/statuses.jsx b/app/assets/javascripts/components/reducers/statuses.jsx
index 6323e0fbe..ce791eab6 100644
--- a/app/assets/javascripts/components/reducers/statuses.jsx
+++ b/app/assets/javascripts/components/reducers/statuses.jsx
@@ -39,14 +39,15 @@ const normalizeStatus = (state, status) => {
     return state;
   }
 
-  status.account = status.account.id;
+  const normalStatus   = { ...status };
+  normalStatus.account = status.account.id;
 
   if (status.reblog && status.reblog.id) {
-    state         = normalizeStatus(state, status.reblog);
-    status.reblog = status.reblog.id;
+    state               = normalizeStatus(state, status.reblog);
+    normalStatus.reblog = status.reblog.id;
   }
 
-  return state.update(status.id, Immutable.Map(), map => map.mergeDeep(Immutable.fromJS(status)));
+  return state.update(status.id, Immutable.Map(), map => map.mergeDeep(Immutable.fromJS(normalStatus)));
 };
 
 const normalizeStatuses = (state, statuses) => {
diff --git a/app/assets/javascripts/components/reducers/timelines.jsx b/app/assets/javascripts/components/reducers/timelines.jsx
index 6f2d26dcb..6472ac6a0 100644
--- a/app/assets/javascripts/components/reducers/timelines.jsx
+++ b/app/assets/javascripts/components/reducers/timelines.jsx
@@ -31,31 +31,44 @@ import Immutable from 'immutable';
 
 const initialState = Immutable.Map({
   home: Immutable.Map({
+    path: () => '/api/v1/timelines/home',
+    next: null,
     isLoading: false,
     loaded: false,
     top: true,
+    unread: 0,
     items: Immutable.List()
   }),
 
-  mentions: Immutable.Map({
+  public: Immutable.Map({
+    path: () => '/api/v1/timelines/public',
+    next: null,
     isLoading: false,
     loaded: false,
     top: true,
+    unread: 0,
     items: Immutable.List()
   }),
 
-  public: Immutable.Map({
+  community: Immutable.Map({
+    path: () => '/api/v1/timelines/public',
+    next: null,
+    params: { local: true },
     isLoading: false,
     loaded: false,
     top: true,
+    unread: 0,
     items: Immutable.List()
   }),
 
   tag: Immutable.Map({
+    path: (id) => `/api/v1/timelines/tag/${id}`,
+    next: null,
     isLoading: false,
     id: null,
     loaded: false,
     top: true,
+    unread: 0,
     items: Immutable.List()
   }),
 
@@ -81,7 +94,7 @@ const normalizeStatus = (state, status) => {
   return state;
 };
 
-const normalizeTimeline = (state, timeline, statuses, replace = false) => {
+const normalizeTimeline = (state, timeline, statuses, next) => {
   let ids      = Immutable.List();
   const loaded = state.getIn([timeline, 'loaded']);
 
@@ -93,10 +106,14 @@ const normalizeTimeline = (state, timeline, statuses, replace = false) => {
   state = state.setIn([timeline, 'loaded'], true);
   state = state.setIn([timeline, 'isLoading'], false);
 
+  if (state.getIn([timeline, 'next']) === null) {
+    state = state.setIn([timeline, 'next'], next);
+  }
+
   return state.updateIn([timeline, 'items'], Immutable.List(), list => (loaded ? list.unshift(...ids) : ids));
 };
 
-const appendNormalizedTimeline = (state, timeline, statuses) => {
+const appendNormalizedTimeline = (state, timeline, statuses, next) => {
   let moreIds = Immutable.List();
 
   statuses.forEach((status, i) => {
@@ -105,6 +122,7 @@ const appendNormalizedTimeline = (state, timeline, statuses) => {
   });
 
   state = state.setIn([timeline, 'isLoading'], false);
+  state = state.setIn([timeline, 'next'], next);
 
   return state.updateIn([timeline, 'items'], Immutable.List(), list => list.push(...moreIds));
 };
@@ -141,6 +159,10 @@ const updateTimeline = (state, timeline, status, references) => {
 
   state = normalizeStatus(state, status);
 
+  if (!top) {
+    state = state.updateIn([timeline, 'unread'], unread => unread + 1);
+  }
+
   state = state.updateIn([timeline, 'items'], Immutable.List(), list => {
     if (top && list.size > 40) {
       list = list.take(20);
@@ -169,7 +191,7 @@ const deleteStatus = (state, id, accountId, references, reblogOf) => {
   }
 
   // Remove references from timelines
-  ['home', 'mentions', 'public', 'tag'].forEach(function (timeline) {
+  ['home', 'public', 'community', 'tag'].forEach(function (timeline) {
     state = state.updateIn([timeline, 'items'], list => list.filterNot(item => item === id));
   });
 
@@ -221,11 +243,13 @@ const normalizeContext = (state, id, ancestors, descendants) => {
 };
 
 const resetTimeline = (state, timeline, id) => {
-  if (timeline === 'tag' && state.getIn([timeline, 'id']) !== id) {
+  if (timeline === 'tag' && typeof id !== 'undefined' && state.getIn([timeline, 'id']) !== id) {
     state = state.update(timeline, map => map
         .set('id', id)
         .set('isLoading', true)
         .set('loaded', false)
+        .set('next', null)
+        .set('top', true)
         .update('items', list => list.clear()));
   } else {
     state = state.setIn([timeline, 'isLoading'], true);
@@ -234,6 +258,14 @@ const resetTimeline = (state, timeline, id) => {
   return state;
 };
 
+const updateTop = (state, timeline, top) => {
+  if (top) {
+    state = state.setIn([timeline, 'unread'], 0);
+  }
+
+  return state.setIn([timeline, 'top'], top);
+};
+
 export default function timelines(state = initialState, action) {
   switch(action.type) {
   case TIMELINE_REFRESH_REQUEST:
@@ -243,9 +275,9 @@ export default function timelines(state = initialState, action) {
   case TIMELINE_EXPAND_FAIL:
     return state.setIn([action.timeline, 'isLoading'], false);
   case TIMELINE_REFRESH_SUCCESS:
-    return normalizeTimeline(state, action.timeline, Immutable.fromJS(action.statuses));
+    return normalizeTimeline(state, action.timeline, Immutable.fromJS(action.statuses), action.next);
   case TIMELINE_EXPAND_SUCCESS:
-    return appendNormalizedTimeline(state, action.timeline, Immutable.fromJS(action.statuses));
+    return appendNormalizedTimeline(state, action.timeline, Immutable.fromJS(action.statuses), action.next);
   case TIMELINE_UPDATE:
     return updateTimeline(state, action.timeline, Immutable.fromJS(action.status), action.references);
   case TIMELINE_DELETE:
@@ -265,7 +297,7 @@ export default function timelines(state = initialState, action) {
   case ACCOUNT_BLOCK_SUCCESS:
     return filterTimelines(state, action.relationship, action.statuses);
   case TIMELINE_SCROLL_TOP:
-    return state.setIn([action.timeline, 'top'], action.top);
+    return updateTop(state, action.timeline, action.top);
   default:
     return state;
   }
diff --git a/app/assets/javascripts/components/selectors/index.jsx b/app/assets/javascripts/components/selectors/index.jsx
index 20debe604..0e88654a1 100644
--- a/app/assets/javascripts/components/selectors/index.jsx
+++ b/app/assets/javascripts/components/selectors/index.jsx
@@ -1,4 +1,4 @@
-import { createSelector } from 'reselect'
+import { createSelector } from 'reselect';
 import Immutable from 'immutable';
 
 const getStatuses = state => state.get('statuses');
@@ -17,37 +17,32 @@ export const makeGetAccount = () => {
   });
 };
 
-const getStatusBase = (state, id) => state.getIn(['statuses', id], null);
-
 export const makeGetStatus = () => {
-  return createSelector([getStatusBase, getStatuses, getAccounts], (base, statuses, accounts) => {
-    if (base === null) {
-      return null;
+  return createSelector(
+    [
+      (state, id) => state.getIn(['statuses', id]),
+      (state, id) => state.getIn(['statuses', state.getIn(['statuses', id, 'reblog'])]),
+      (state, id) => state.getIn(['accounts', state.getIn(['statuses', id, 'account'])]),
+      (state, id) => state.getIn(['accounts', state.getIn(['statuses', state.getIn(['statuses', id, 'reblog']), 'account'])]),
+    ],
+
+    (statusBase, statusReblog, accountBase, accountReblog) => {
+      if (!statusBase) {
+        return null;
+      }
+
+      if (statusReblog) {
+        statusReblog = statusReblog.set('account', accountReblog);
+      } else {
+        statusReblog = null;
+      }
+
+      return statusBase.withMutations(map => {
+        map.set('reblog', statusReblog);
+        map.set('account', accountBase);
+      });
     }
-
-    return assembleStatus(base.get('id'), statuses, accounts);
-  });
-};
-
-const assembleStatus = (id, statuses, accounts) => {
-  let status = statuses.get(id, null);
-  let reblog = null;
-
-  if (status === null) {
-    return null;
-  }
-
-  if (status.get('reblog', null) !== null) {
-    reblog = statuses.get(status.get('reblog'), null);
-
-    if (reblog !== null) {
-      reblog = reblog.set('account', accounts.get(reblog.get('account')));
-    } else {
-      return null;
-    }
-  }
-
-  return status.set('reblog', reblog).set('account', accounts.get(status.get('account')));
+  );
 };
 
 const getAlertsBase = state => state.get('alerts');
diff --git a/app/assets/stylesheets/components.scss b/app/assets/stylesheets/components.scss
index bc56891f3..94c351520 100644
--- a/app/assets/stylesheets/components.scss
+++ b/app/assets/stylesheets/components.scss
@@ -786,6 +786,7 @@ a.status__content__spoiler-link {
   flex: 0 0 auto;
   cursor: pointer;
   color: $color4;
+  z-index: 3;
 
   &:hover {
     text-decoration: underline;
@@ -1079,6 +1080,17 @@ button.active i.fa-retweet {
   background: lighten($color1, 4%);
   flex: 0 0 auto;
   cursor: pointer;
+  position: relative;
+  z-index: 2;
+
+  &.active {
+    box-shadow: 0 1px 0 rgba($color4, 0.3);
+  }
+
+  &.active .fa {
+    color: $color4;
+    text-shadow: 0 0 10px rgba($color4, 0.4);
+  }
 }
 
 .search {
@@ -1201,3 +1213,16 @@ button.active i.fa-retweet {
     }
   }
 }
+
+.status-list__unread-indicator, .notifications__unread-indicator {
+  position: absolute;
+  top: 35px;
+  left: 0;
+  right: 0;
+  margin: 0 auto;
+  width: 60%;
+  pointer-events: none;
+  height: 28px;
+  z-index: 1;
+  background: radial-gradient(ellipse, rgba($color4, 0.23) 0%, rgba($color4, 0) 60%);
+}
diff --git a/app/controllers/api/v1/accounts_controller.rb b/app/controllers/api/v1/accounts_controller.rb
index 0d02294eb..94dba1d03 100644
--- a/app/controllers/api/v1/accounts_controller.rb
+++ b/app/controllers/api/v1/accounts_controller.rb
@@ -52,7 +52,7 @@ class Api::V1::AccountsController < ApiController
     set_maps(@statuses)
     set_counters_maps(@statuses)
 
-    next_path = statuses_api_v1_account_url(max_id: @statuses.last.id)    if @statuses.size == limit_param(DEFAULT_STATUSES_LIMIT)
+    next_path = statuses_api_v1_account_url(max_id: @statuses.last.id)    unless @statuses.empty?
     prev_path = statuses_api_v1_account_url(since_id: @statuses.first.id) unless @statuses.empty?
 
     set_pagination_headers(next_path, prev_path)
@@ -66,7 +66,7 @@ class Api::V1::AccountsController < ApiController
     set_maps(@statuses)
     set_counters_maps(@statuses)
 
-    next_path = media_statuses_api_v1_account_url(max_id: @statuses.last.id)    if @statuses.size == limit_param(DEFAULT_STATUSES_LIMIT)
+    next_path = media_statuses_api_v1_account_url(max_id: @statuses.last.id)    unless @statuses.empty?
     prev_path = media_statuses_api_v1_account_url(since_id: @statuses.first.id) unless @statuses.empty?
 
     set_pagination_headers(next_path, prev_path)
diff --git a/app/controllers/api/v1/notifications_controller.rb b/app/controllers/api/v1/notifications_controller.rb
index 877356a75..544ba2442 100644
--- a/app/controllers/api/v1/notifications_controller.rb
+++ b/app/controllers/api/v1/notifications_controller.rb
@@ -17,7 +17,7 @@ class Api::V1::NotificationsController < ApiController
     set_counters_maps(statuses)
     set_account_counters_maps(@notifications.map(&:from_account))
 
-    next_path = api_v1_notifications_url(max_id: @notifications.last.id)    if @notifications.size == limit_param(DEFAULT_NOTIFICATIONS_LIMIT)
+    next_path = api_v1_notifications_url(max_id: @notifications.last.id)    unless @notifications.empty?
     prev_path = api_v1_notifications_url(since_id: @notifications.first.id) unless @notifications.empty?
 
     set_pagination_headers(next_path, prev_path)
diff --git a/app/controllers/api/v1/timelines_controller.rb b/app/controllers/api/v1/timelines_controller.rb
index a8cc2b288..af6e5b7df 100644
--- a/app/controllers/api/v1/timelines_controller.rb
+++ b/app/controllers/api/v1/timelines_controller.rb
@@ -14,7 +14,7 @@ class Api::V1::TimelinesController < ApiController
     set_counters_maps(@statuses)
     set_account_counters_maps(@statuses.flat_map { |s| [s.account, s.reblog? ? s.reblog.account : nil] }.compact.uniq)
 
-    next_path = api_v1_home_timeline_url(max_id: @statuses.last.id)    if @statuses.size == limit_param(DEFAULT_STATUSES_LIMIT)
+    next_path = api_v1_home_timeline_url(max_id: @statuses.last.id)    unless @statuses.empty?
     prev_path = api_v1_home_timeline_url(since_id: @statuses.first.id) unless @statuses.empty?
 
     set_pagination_headers(next_path, prev_path)
@@ -30,7 +30,7 @@ class Api::V1::TimelinesController < ApiController
     set_counters_maps(@statuses)
     set_account_counters_maps(@statuses.flat_map { |s| [s.account, s.reblog? ? s.reblog.account : nil] }.compact.uniq)
 
-    next_path = api_v1_public_timeline_url(max_id: @statuses.last.id)    if @statuses.size == limit_param(DEFAULT_STATUSES_LIMIT)
+    next_path = api_v1_public_timeline_url(max_id: @statuses.last.id)    unless @statuses.empty?
     prev_path = api_v1_public_timeline_url(since_id: @statuses.first.id) unless @statuses.empty?
 
     set_pagination_headers(next_path, prev_path)
@@ -47,7 +47,7 @@ class Api::V1::TimelinesController < ApiController
     set_counters_maps(@statuses)
     set_account_counters_maps(@statuses.flat_map { |s| [s.account, s.reblog? ? s.reblog.account : nil] }.compact.uniq)
 
-    next_path = api_v1_hashtag_timeline_url(params[:id], max_id: @statuses.last.id)    if @statuses.size == limit_param(DEFAULT_STATUSES_LIMIT)
+    next_path = api_v1_hashtag_timeline_url(params[:id], max_id: @statuses.last.id)    unless @statuses.empty?
     prev_path = api_v1_hashtag_timeline_url(params[:id], since_id: @statuses.first.id) unless @statuses.empty?
 
     set_pagination_headers(next_path, prev_path)
diff --git a/app/lib/formatter.rb b/app/lib/formatter.rb
index 044407a6c..e353c3504 100644
--- a/app/lib/formatter.rb
+++ b/app/lib/formatter.rb
@@ -9,6 +9,8 @@ class Formatter
   include ActionView::Helpers::TextHelper
   include ActionView::Helpers::SanitizeHelper
 
+  AUTOLINK_RE = /https?:\/\/([\S]+\.[!#$&-;=?-[\]_a-z~]|%[\w\d]{2}]+[\w])/i
+
   def format(status)
     return reformat(status.content) unless status.local?
 
@@ -24,7 +26,7 @@ class Formatter
   end
 
   def reformat(html)
-    sanitize(html, tags: %w(a br p), attributes: %w(href rel))
+    sanitize(html, tags: %w(a br p span), attributes: %w(href rel class))
   end
 
   def simplified_format(account)
@@ -44,9 +46,9 @@ class Formatter
   end
 
   def link_urls(html)
-    html.gsub(URI.regexp(%w(http https))) do |match|
-      link_html(match)
-    end
+    Twitter::Autolink.auto_link_urls(html, url_target: '_blank',
+                                           link_attribute_block: lambda { |_, a| a[:rel] << ' noopener' },
+                                           link_text_block: lambda { |_, text| link_html(text) })
   end
 
   def link_mentions(html, mentions)
@@ -70,7 +72,7 @@ class Formatter
     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=\"#{cutoff ? 'ellipsis' : ''}\">#{text}</span><span class=\"invisible\">#{suffix}</span></a>"
+    "<span class=\"invisible\">#{prefix}</span><span class=\"#{cutoff ? 'ellipsis' : ''}\">#{text}</span><span class=\"invisible\">#{suffix}</span>"
   end
 
   def hashtag_html(match)
diff --git a/app/models/account.rb b/app/models/account.rb
index ed5c46197..469695acd 100644
--- a/app/models/account.rb
+++ b/app/models/account.rb
@@ -138,7 +138,7 @@ class Account < ApplicationRecord
   def avatar_remote_url=(url)
     parsed_url = URI.parse(url)
 
-    return if !%w(http https).include?(parsed_url.scheme) || self[:avatar_remote_url] == url
+    return if !%w(http https).include?(parsed_url.scheme) || parsed_url.host.empty? || self[:avatar_remote_url] == url
 
     self.avatar              = parsed_url
     self[:avatar_remote_url] = url
diff --git a/app/models/domain_block.rb b/app/models/domain_block.rb
index b4606da60..3548ccd69 100644
--- a/app/models/domain_block.rb
+++ b/app/models/domain_block.rb
@@ -6,6 +6,6 @@ class DomainBlock < ApplicationRecord
   validates :domain, presence: true, uniqueness: true
 
   def self.blocked?(domain)
-    where(domain: domain).exists?
+    where(domain: domain, severity: :suspend).exists?
   end
 end
diff --git a/app/models/status.rb b/app/models/status.rb
index 46d92ea33..1b40897f3 100644
--- a/app/models/status.rb
+++ b/app/models/status.rb
@@ -192,6 +192,6 @@ class Status < ApplicationRecord
   private
 
   def filter_from_context?(status, account)
-    account&.blocking?(status.account_id) || !status.permitted?(account)
+    account&.blocking?(status.account_id) || (status.account.silenced? && !account&.following?(status.account_id)) || !status.permitted?(account)
   end
 end
diff --git a/app/services/favourite_service.rb b/app/services/favourite_service.rb
index 824729ed6..818898302 100644
--- a/app/services/favourite_service.rb
+++ b/app/services/favourite_service.rb
@@ -22,10 +22,13 @@ class FavouriteService < BaseService
   private
 
   def build_xml(favourite)
+    description = "#{favourite.account.acct} favourited a status by #{favourite.status.account.acct}"
+
     Nokogiri::XML::Builder.new do |xml|
       entry(xml, true) do
         unique_id xml, favourite.created_at, favourite.id, 'Favourite'
-        title xml, "#{favourite.account.acct} favourited a status by #{favourite.status.account.acct}"
+        title xml, description
+        content xml, description
 
         author(xml) do
           include_author xml, favourite.account
diff --git a/app/services/follow_service.rb b/app/services/follow_service.rb
index d67b1bf2d..915f95b4c 100644
--- a/app/services/follow_service.rb
+++ b/app/services/follow_service.rb
@@ -55,10 +55,13 @@ class FollowService < BaseService
   end
 
   def build_follow_request_xml(follow_request)
+    description = "#{follow_request.account.acct} requested to follow #{follow_request.target_account.acct}"
+
     Nokogiri::XML::Builder.new do |xml|
       entry(xml, true) do
         unique_id xml, follow_request.created_at, follow_request.id, 'FollowRequest'
-        title xml, "#{follow_request.account.acct} requested to follow #{follow_request.target_account.acct}"
+        title xml, description
+        content xml, description
 
         author(xml) do
           include_author xml, follow_request.account
@@ -75,10 +78,13 @@ class FollowService < BaseService
   end
 
   def build_follow_xml(follow)
+    description = "#{follow.account.acct} started following #{follow.target_account.acct}"
+
     Nokogiri::XML::Builder.new do |xml|
       entry(xml, true) do
         unique_id xml, follow.created_at, follow.id, 'Follow'
-        title xml, "#{follow.account.acct} started following #{follow.target_account.acct}"
+        title xml, description
+        content xml, description
 
         author(xml) do
           include_author xml, follow.account
diff --git a/app/services/process_feed_service.rb b/app/services/process_feed_service.rb
index f0a62aa14..5d952df6f 100644
--- a/app/services/process_feed_service.rb
+++ b/app/services/process_feed_service.rb
@@ -181,6 +181,9 @@ class ProcessFeedService < BaseService
         next unless link['href']
 
         media = MediaAttachment.where(status: parent, remote_url: link['href']).first_or_initialize(account: parent.account, status: parent, remote_url: link['href'])
+        parsed_url = URI.parse(link['href'])
+
+        next if !%w(http https).include?(parsed_url.scheme) || parsed_url.host.empty?
 
         begin
           media.file_remote_url = link['href']
diff --git a/app/services/remove_status_service.rb b/app/services/remove_status_service.rb
index 73b545f17..cf1f432e4 100644
--- a/app/services/remove_status_service.rb
+++ b/app/services/remove_status_service.rb
@@ -59,7 +59,7 @@ class RemoveStatusService < BaseService
   end
 
   def unpush(type, receiver, status)
-    if status.reblog?
+    if status.reblog? && !redis.zscore(FeedManager.instance.key(type, receiver.id), status.reblog_of_id).nil?
       redis.zadd(FeedManager.instance.key(type, receiver.id), status.reblog_of_id, status.reblog_of_id)
     else
       redis.zremrangebyscore(FeedManager.instance.key(type, receiver.id), status.id, status.id)
diff --git a/app/services/unfavourite_service.rb b/app/services/unfavourite_service.rb
index 1d3e6f06d..5f0ba4254 100644
--- a/app/services/unfavourite_service.rb
+++ b/app/services/unfavourite_service.rb
@@ -13,10 +13,13 @@ class UnfavouriteService < BaseService
   private
 
   def build_xml(favourite)
+    description = "#{favourite.account.acct} no longer favourites a status by #{favourite.status.account.acct}"
+
     Nokogiri::XML::Builder.new do |xml|
       entry(xml, true) do
         unique_id xml, Time.now.utc, favourite.id, 'Favourite'
-        title xml, "#{favourite.account.acct} no longer favourites a status by #{favourite.status.account.acct}"
+        title xml, description
+        content xml, description
 
         author(xml) do
           include_author xml, favourite.account
diff --git a/app/services/unfollow_service.rb b/app/services/unfollow_service.rb
index 07f9b93dd..3440da364 100644
--- a/app/services/unfollow_service.rb
+++ b/app/services/unfollow_service.rb
@@ -13,10 +13,13 @@ class UnfollowService < BaseService
   private
 
   def build_xml(follow)
+    description = "#{follow.account.acct} is no longer following #{follow.target_account.acct}"
+
     Nokogiri::XML::Builder.new do |xml|
       entry(xml, true) do
         unique_id xml, Time.now.utc, follow.id, 'Follow'
-        title xml, "#{follow.account.acct} is no longer following #{follow.target_account.acct}"
+        title xml, description
+        content xml, description
 
         author(xml) do
           include_author xml, follow.account