about summary refs log tree commit diff
path: root/app
diff options
context:
space:
mode:
Diffstat (limited to 'app')
-rw-r--r--app/assets/images/boost_sprite.pngbin0 -> 1326 bytes
-rw-r--r--app/assets/javascripts/components/actions/accounts.jsx40
-rw-r--r--app/assets/javascripts/components/actions/cards.jsx48
-rw-r--r--app/assets/javascripts/components/actions/favourites.jsx83
-rw-r--r--app/assets/javascripts/components/actions/notifications.jsx8
-rw-r--r--app/assets/javascripts/components/actions/statuses.jsx28
-rw-r--r--app/assets/javascripts/components/actions/timelines.jsx38
-rw-r--r--app/assets/javascripts/components/components/account.jsx26
-rw-r--r--app/assets/javascripts/components/components/column_collapsable.jsx2
-rw-r--r--app/assets/javascripts/components/components/lightbox.jsx22
-rw-r--r--app/assets/javascripts/components/components/media_gallery.jsx53
-rw-r--r--app/assets/javascripts/components/components/video_player.jsx64
-rw-r--r--app/assets/javascripts/components/containers/account_container.jsx12
-rw-r--r--app/assets/javascripts/components/containers/mastodon.jsx2
-rw-r--r--app/assets/javascripts/components/features/account/index.jsx3
-rw-r--r--app/assets/javascripts/components/features/account_timeline/index.jsx3
-rw-r--r--app/assets/javascripts/components/features/compose/components/compose_form.jsx5
-rw-r--r--app/assets/javascripts/components/features/compose/components/upload_button.jsx9
-rw-r--r--app/assets/javascripts/components/features/compose/containers/compose_form_container.jsx3
-rw-r--r--app/assets/javascripts/components/features/compose/containers/upload_button_container.jsx2
-rw-r--r--app/assets/javascripts/components/features/favourited_statuses/index.jsx63
-rw-r--r--app/assets/javascripts/components/features/getting_started/index.jsx6
-rw-r--r--app/assets/javascripts/components/features/home_timeline/index.jsx9
-rw-r--r--app/assets/javascripts/components/features/notifications/components/column_settings.jsx7
-rw-r--r--app/assets/javascripts/components/features/notifications/index.jsx10
-rw-r--r--app/assets/javascripts/components/features/status/components/card.jsx100
-rw-r--r--app/assets/javascripts/components/features/status/components/detailed_status.jsx13
-rw-r--r--app/assets/javascripts/components/features/status/containers/card_container.jsx8
-rw-r--r--app/assets/javascripts/components/features/ui/containers/modal_container.jsx26
-rw-r--r--app/assets/javascripts/components/features/ui/index.jsx9
-rw-r--r--app/assets/javascripts/components/middleware/loading_bar.jsx25
-rw-r--r--app/assets/javascripts/components/reducers/accounts.jsx14
-rw-r--r--app/assets/javascripts/components/reducers/cards.jsx14
-rw-r--r--app/assets/javascripts/components/reducers/compose.jsx6
-rw-r--r--app/assets/javascripts/components/reducers/index.jsx6
-rw-r--r--app/assets/javascripts/components/reducers/modal.jsx18
-rw-r--r--app/assets/javascripts/components/reducers/search.jsx2
-rw-r--r--app/assets/javascripts/components/reducers/settings.jsx9
-rw-r--r--app/assets/javascripts/components/reducers/status_lists.jsx39
-rw-r--r--app/assets/javascripts/components/reducers/statuses.jsx68
-rw-r--r--app/assets/javascripts/components/reducers/user_lists.jsx38
-rw-r--r--app/assets/javascripts/components/store/configureStore.jsx19
-rw-r--r--app/assets/stylesheets/about.scss1
-rw-r--r--app/assets/stylesheets/components.scss26
-rw-r--r--app/controllers/api/v1/apps_controller.rb2
-rw-r--r--app/controllers/api/v1/favourites_controller.rb2
-rw-r--r--app/controllers/api/v1/notifications_controller.rb4
-rw-r--r--app/controllers/api/v1/statuses_controller.rb12
-rw-r--r--app/lib/application_extension.rb9
-rw-r--r--app/lib/url_validator.rb14
-rw-r--r--app/models/account.rb11
-rw-r--r--app/models/preview_card.rb20
-rw-r--r--app/models/status.rb5
-rw-r--r--app/services/fetch_link_card_service.rb33
-rw-r--r--app/services/follow_remote_account_service.rb2
-rw-r--r--app/services/post_status_service.rb10
-rw-r--r--app/services/process_feed_service.rb3
-rw-r--r--app/services/process_hashtags_service.rb2
-rw-r--r--app/services/update_remote_profile_service.rb2
-rw-r--r--app/views/about/more.html.haml27
-rw-r--r--app/views/api/v1/apps/show.rabl3
-rw-r--r--app/views/api/v1/statuses/_show.rabl4
-rw-r--r--app/views/api/v1/statuses/card.rabl5
-rw-r--r--app/views/settings/shared/_links.html.haml1
-rw-r--r--app/views/stream_entries/_detailed_status.html.haml10
-rw-r--r--app/workers/link_crawl_worker.rb13
66 files changed, 956 insertions, 225 deletions
diff --git a/app/assets/images/boost_sprite.png b/app/assets/images/boost_sprite.png
new file mode 100644
index 000000000..564bf2646
--- /dev/null
+++ b/app/assets/images/boost_sprite.png
Binary files differdiff --git a/app/assets/javascripts/components/actions/accounts.jsx b/app/assets/javascripts/components/actions/accounts.jsx
index 7ae87f30e..235f29194 100644
--- a/app/assets/javascripts/components/actions/accounts.jsx
+++ b/app/assets/javascripts/components/actions/accounts.jsx
@@ -80,21 +80,23 @@ export function fetchAccount(id) {
 
 export function fetchAccountTimeline(id, replace = false) {
   return (dispatch, getState) => {
-    dispatch(fetchAccountTimelineRequest(id));
-
     const ids      = getState().getIn(['timelines', 'accounts_timelines', id], Immutable.List());
     const newestId = ids.size > 0 ? ids.first() : null;
 
     let params = '';
+    let skipLoading = false;
 
     if (newestId !== null && !replace) {
-      params = `?since_id=${newestId}`;
+      params      = `?since_id=${newestId}`;
+      skipLoading = true;
     }
 
+    dispatch(fetchAccountTimelineRequest(id, skipLoading));
+
     api(getState).get(`/api/v1/accounts/${id}/statuses${params}`).then(response => {
-      dispatch(fetchAccountTimelineSuccess(id, response.data, replace));
+      dispatch(fetchAccountTimelineSuccess(id, response.data, replace, skipLoading));
     }).catch(error => {
-      dispatch(fetchAccountTimelineFail(id, error));
+      dispatch(fetchAccountTimelineFail(id, error, skipLoading));
     });
   };
 };
@@ -201,27 +203,30 @@ export function unfollowAccountFail(error) {
   };
 };
 
-export function fetchAccountTimelineRequest(id) {
+export function fetchAccountTimelineRequest(id, skipLoading) {
   return {
     type: ACCOUNT_TIMELINE_FETCH_REQUEST,
-    id
+    id,
+    skipLoading
   };
 };
 
-export function fetchAccountTimelineSuccess(id, statuses, replace) {
+export function fetchAccountTimelineSuccess(id, statuses, replace, skipLoading) {
   return {
     type: ACCOUNT_TIMELINE_FETCH_SUCCESS,
     id,
     statuses,
-    replace
+    replace,
+    skipLoading
   };
 };
 
-export function fetchAccountTimelineFail(id, error) {
+export function fetchAccountTimelineFail(id, error, skipLoading) {
   return {
     type: ACCOUNT_TIMELINE_FETCH_FAIL,
     id,
-    error
+    error,
+    skipLoading
   };
 };
 
@@ -486,6 +491,10 @@ export function expandFollowingFail(id, error) {
 
 export function fetchRelationships(account_ids) {
   return (dispatch, getState) => {
+    if (account_ids.length === 0) {
+      return;
+    }
+
     dispatch(fetchRelationshipsRequest(account_ids));
 
     api(getState).get(`/api/v1/accounts/relationships?${account_ids.map(id => `id[]=${id}`).join('&')}`).then(response => {
@@ -499,21 +508,24 @@ export function fetchRelationships(account_ids) {
 export function fetchRelationshipsRequest(ids) {
   return {
     type: RELATIONSHIPS_FETCH_REQUEST,
-    ids
+    ids,
+    skipLoading: true
   };
 };
 
 export function fetchRelationshipsSuccess(relationships) {
   return {
     type: RELATIONSHIPS_FETCH_SUCCESS,
-    relationships
+    relationships,
+    skipLoading: true
   };
 };
 
 export function fetchRelationshipsFail(error) {
   return {
     type: RELATIONSHIPS_FETCH_FAIL,
-    error
+    error,
+    skipLoading: true
   };
 };
 
diff --git a/app/assets/javascripts/components/actions/cards.jsx b/app/assets/javascripts/components/actions/cards.jsx
new file mode 100644
index 000000000..ee421d5d7
--- /dev/null
+++ b/app/assets/javascripts/components/actions/cards.jsx
@@ -0,0 +1,48 @@
+import api from '../api';
+
+export const STATUS_CARD_FETCH_REQUEST = 'STATUS_CARD_FETCH_REQUEST';
+export const STATUS_CARD_FETCH_SUCCESS = 'STATUS_CARD_FETCH_SUCCESS';
+export const STATUS_CARD_FETCH_FAIL    = 'STATUS_CARD_FETCH_FAIL';
+
+export function fetchStatusCard(id) {
+  return (dispatch, getState) => {
+    dispatch(fetchStatusCardRequest(id));
+
+    api(getState).get(`/api/v1/statuses/${id}/card`).then(response => {
+      dispatch(fetchStatusCardSuccess(id, response.data));
+    }).catch(error => {
+      if (error.response.status === 404) {
+        // This is fine
+        return;
+      }
+
+      dispatch(fetchStatusCardFail(id, error));
+    });
+  };
+};
+
+export function fetchStatusCardRequest(id) {
+  return {
+    type: STATUS_CARD_FETCH_REQUEST,
+    id,
+    skipLoading: true
+  };
+};
+
+export function fetchStatusCardSuccess(id, card) {
+  return {
+    type: STATUS_CARD_FETCH_SUCCESS,
+    id,
+    card,
+    skipLoading: true
+  };
+};
+
+export function fetchStatusCardFail(id, error) {
+  return {
+    type: STATUS_CARD_FETCH_FAIL,
+    id,
+    error,
+    skipLoading: true
+  };
+};
diff --git a/app/assets/javascripts/components/actions/favourites.jsx b/app/assets/javascripts/components/actions/favourites.jsx
new file mode 100644
index 000000000..a25c1ae1c
--- /dev/null
+++ b/app/assets/javascripts/components/actions/favourites.jsx
@@ -0,0 +1,83 @@
+import api, { getLinks } from '../api'
+
+export const FAVOURITED_STATUSES_FETCH_REQUEST = 'FAVOURITED_STATUSES_FETCH_REQUEST';
+export const FAVOURITED_STATUSES_FETCH_SUCCESS = 'FAVOURITED_STATUSES_FETCH_SUCCESS';
+export const FAVOURITED_STATUSES_FETCH_FAIL    = 'FAVOURITED_STATUSES_FETCH_FAIL';
+
+export const FAVOURITED_STATUSES_EXPAND_REQUEST = 'FAVOURITED_STATUSES_EXPAND_REQUEST';
+export const FAVOURITED_STATUSES_EXPAND_SUCCESS = 'FAVOURITED_STATUSES_EXPAND_SUCCESS';
+export const FAVOURITED_STATUSES_EXPAND_FAIL    = 'FAVOURITED_STATUSES_EXPAND_FAIL';
+
+export function fetchFavouritedStatuses() {
+  return (dispatch, getState) => {
+    dispatch(fetchFavouritedStatusesRequest());
+
+    api(getState).get('/api/v1/favourites').then(response => {
+      const next = getLinks(response).refs.find(link => link.rel === 'next');
+      dispatch(fetchFavouritedStatusesSuccess(response.data, next ? next.uri : null));
+    }).catch(error => {
+      dispatch(fetchFavouritedStatusesFail(error));
+    });
+  };
+};
+
+export function fetchFavouritedStatusesRequest() {
+  return {
+    type: FAVOURITED_STATUSES_FETCH_REQUEST
+  };
+};
+
+export function fetchFavouritedStatusesSuccess(statuses, next) {
+  return {
+    type: FAVOURITED_STATUSES_FETCH_SUCCESS,
+    statuses,
+    next
+  };
+};
+
+export function fetchFavouritedStatusesFail(error) {
+  return {
+    type: FAVOURITED_STATUSES_FETCH_FAIL,
+    error
+  };
+};
+
+export function expandFavouritedStatuses() {
+  return (dispatch, getState) => {
+    const url = getState().getIn(['status_lists', 'favourites', 'next'], null);
+
+    if (url === null) {
+      return;
+    }
+
+    dispatch(expandFavouritedStatusesRequest());
+
+    api(getState).get(url).then(response => {
+      const next = getLinks(response).refs.find(link => link.rel === 'next');
+      dispatch(expandFavouritedStatusesSuccess(response.data, next ? next.uri : null));
+    }).catch(error => {
+      dispatch(expandFavouritedStatusesFail(error));
+    });
+  };
+};
+
+export function expandFavouritedStatusesRequest() {
+  return {
+    type: FAVOURITED_STATUSES_EXPAND_REQUEST
+  };
+};
+
+export function expandFavouritedStatusesSuccess(statuses, next) {
+  return {
+    type: FAVOURITED_STATUSES_EXPAND_SUCCESS,
+    statuses,
+    next
+  };
+};
+
+export function expandFavouritedStatusesFail(error) {
+  return {
+    type: FAVOURITED_STATUSES_EXPAND_FAIL,
+    error
+  };
+};
diff --git a/app/assets/javascripts/components/actions/notifications.jsx b/app/assets/javascripts/components/actions/notifications.jsx
index 1e5b2c382..23bdcb5c7 100644
--- a/app/assets/javascripts/components/actions/notifications.jsx
+++ b/app/assets/javascripts/components/actions/notifications.jsx
@@ -24,17 +24,21 @@ const fetchRelatedRelationships = (dispatch, notifications) => {
 
 export function updateNotifications(notification, intlMessages, intlLocale) {
   return (dispatch, getState) => {
+    const showAlert = getState().getIn(['settings', 'notifications', 'alerts', notification.type], true);
+    const playSound = getState().getIn(['settings', 'notifications', 'sounds', notification.type], true);
+
     dispatch({
       type: NOTIFICATIONS_UPDATE,
       notification,
       account: notification.account,
-      status: notification.status
+      status: notification.status,
+      meta: playSound ? { sound: 'boop' } : undefined
     });
 
     fetchRelatedRelationships(dispatch, [notification]);
 
     // Desktop notifications
-    if (typeof window.Notification !== 'undefined' && getState().getIn(['notifications', 'settings', 'alerts', notification.type], false)) {
+    if (typeof window.Notification !== 'undefined' && showAlert) {
       const title = new IntlMessageFormat(intlMessages[`notification.${notification.type}`], intlLocale).format({ name: notification.account.display_name.length > 0 ? notification.account.display_name : notification.account.username });
       const body  = $('<p>').html(notification.status ? notification.status.content : '').text();
 
diff --git a/app/assets/javascripts/components/actions/statuses.jsx b/app/assets/javascripts/components/actions/statuses.jsx
index cbee94bca..9ac215727 100644
--- a/app/assets/javascripts/components/actions/statuses.jsx
+++ b/app/assets/javascripts/components/actions/statuses.jsx
@@ -1,6 +1,7 @@
 import api from '../api';
 
 import { deleteFromTimelines } from './timelines';
+import { fetchStatusCard } from './cards';
 
 export const STATUS_FETCH_REQUEST = 'STATUS_FETCH_REQUEST';
 export const STATUS_FETCH_SUCCESS = 'STATUS_FETCH_SUCCESS';
@@ -14,39 +15,44 @@ export const CONTEXT_FETCH_REQUEST = 'CONTEXT_FETCH_REQUEST';
 export const CONTEXT_FETCH_SUCCESS = 'CONTEXT_FETCH_SUCCESS';
 export const CONTEXT_FETCH_FAIL    = 'CONTEXT_FETCH_FAIL';
 
-export function fetchStatusRequest(id) {
+export function fetchStatusRequest(id, skipLoading) {
   return {
     type: STATUS_FETCH_REQUEST,
-    id: id
+    id,
+    skipLoading
   };
 };
 
 export function fetchStatus(id) {
   return (dispatch, getState) => {
-    dispatch(fetchStatusRequest(id));
+    const skipLoading = getState().getIn(['statuses', id], null) !== null;
+
+    dispatch(fetchStatusRequest(id, skipLoading));
 
     api(getState).get(`/api/v1/statuses/${id}`).then(response => {
-      dispatch(fetchStatusSuccess(response.data));
+      dispatch(fetchStatusSuccess(response.data, skipLoading));
       dispatch(fetchContext(id));
+      dispatch(fetchStatusCard(id));
     }).catch(error => {
-      dispatch(fetchStatusFail(id, error));
+      dispatch(fetchStatusFail(id, error, skipLoading));
     });
   };
 };
 
-export function fetchStatusSuccess(status, context) {
+export function fetchStatusSuccess(status, skipLoading) {
   return {
     type: STATUS_FETCH_SUCCESS,
-    status: status,
-    context: context
+    status,
+    skipLoading
   };
 };
 
-export function fetchStatusFail(id, error) {
+export function fetchStatusFail(id, error, skipLoading) {
   return {
     type: STATUS_FETCH_FAIL,
-    id: id,
-    error: error
+    id,
+    error,
+    skipLoading
   };
 };
 
diff --git a/app/assets/javascripts/components/actions/timelines.jsx b/app/assets/javascripts/components/actions/timelines.jsx
index 8bb939d31..72e949e87 100644
--- a/app/assets/javascripts/components/actions/timelines.jsx
+++ b/app/assets/javascripts/components/actions/timelines.jsx
@@ -14,11 +14,12 @@ export const TIMELINE_EXPAND_FAIL    = 'TIMELINE_EXPAND_FAIL';
 
 export const TIMELINE_SCROLL_TOP = 'TIMELINE_SCROLL_TOP';
 
-export function refreshTimelineSuccess(timeline, statuses) {
+export function refreshTimelineSuccess(timeline, statuses, skipLoading) {
   return {
     type: TIMELINE_REFRESH_SUCCESS,
-    timeline: timeline,
-    statuses: statuses
+    timeline,
+    statuses,
+    skipLoading
   };
 };
 
@@ -51,45 +52,49 @@ export function deleteFromTimelines(id) {
   };
 };
 
-export function refreshTimelineRequest(timeline, id) {
+export function refreshTimelineRequest(timeline, id, skipLoading) {
   return {
     type: TIMELINE_REFRESH_REQUEST,
     timeline,
-    id
+    id,
+    skipLoading
   };
 };
 
 export function refreshTimeline(timeline, id = null) {
   return function (dispatch, getState) {
-    dispatch(refreshTimelineRequest(timeline, id));
-
     const ids      = getState().getIn(['timelines', timeline, 'items'], Immutable.List());
     const newestId = ids.size > 0 ? ids.first() : null;
 
-    let params = '';
-    let path   = timeline;
+    let params      = '';
+    let path        = timeline;
+    let skipLoading = false;
 
     if (newestId !== null && getState().getIn(['timelines', timeline, 'loaded'])) {
-      params = `?since_id=${newestId}`;
+      params      = `?since_id=${newestId}`;
+      skipLoading = true;
     }
 
     if (id) {
       path = `${path}/${id}`
     }
 
+    dispatch(refreshTimelineRequest(timeline, id, skipLoading));
+
     api(getState).get(`/api/v1/timelines/${path}${params}`).then(function (response) {
-      dispatch(refreshTimelineSuccess(timeline, response.data));
+      dispatch(refreshTimelineSuccess(timeline, response.data, skipLoading));
     }).catch(function (error) {
-      dispatch(refreshTimelineFail(timeline, error));
+      dispatch(refreshTimelineFail(timeline, error, skipLoading));
     });
   };
 };
 
-export function refreshTimelineFail(timeline, error) {
+export function refreshTimelineFail(timeline, error, skipLoading) {
   return {
     type: TIMELINE_REFRESH_FAIL,
     timeline,
-    error
+    error,
+    skipLoading
   };
 };
 
@@ -97,6 +102,11 @@ export function expandTimeline(timeline, id = null) {
   return (dispatch, getState) => {
     const lastId = getState().getIn(['timelines', timeline, 'items'], Immutable.List()).last();
 
+    if (!lastId) {
+      // If timeline is empty, don't try to load older posts since there are none
+      return;
+    }
+
     dispatch(expandTimelineRequest(timeline));
 
     let path = timeline;
diff --git a/app/assets/javascripts/components/components/account.jsx b/app/assets/javascripts/components/components/account.jsx
index 814d8a9c8..108401b2f 100644
--- a/app/assets/javascripts/components/components/account.jsx
+++ b/app/assets/javascripts/components/components/account.jsx
@@ -8,7 +8,9 @@ import { defineMessages, injectIntl } from 'react-intl';
 
 const messages = defineMessages({
   follow: { id: 'account.follow', defaultMessage: 'Follow' },
-  unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' }
+  unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
+  requested: { id: 'account.requested', defaultMessage: 'Awaiting approval' },
+  unblock: { id: 'account.unblock', defaultMessage: 'Unblock' }
 });
 
 const outerStyle = {
@@ -42,7 +44,9 @@ const Account = React.createClass({
     account: ImmutablePropTypes.map.isRequired,
     me: React.PropTypes.number.isRequired,
     onFollow: React.PropTypes.func.isRequired,
-    withNote: React.PropTypes.bool
+    onBlock: React.PropTypes.func.isRequired,
+    withNote: React.PropTypes.bool,
+    intl: React.PropTypes.object.isRequired
   },
 
   getDefaultProps () {
@@ -57,6 +61,10 @@ const Account = React.createClass({
     this.props.onFollow(this.props.account);
   },
 
+  handleBlock () {
+    this.props.onBlock(this.props.account);
+  },
+
   render () {
     const { account, me, withNote, intl } = this.props;
 
@@ -70,10 +78,18 @@ const Account = React.createClass({
       note = <div style={noteStyle}>{account.get('note')}</div>;
     }
 
-    if (account.get('id') !== me && account.get('relationship', null) != null) {
+    if (account.get('id') !== me && account.get('relationship', null) !== null) {
       const following = account.getIn(['relationship', 'following']);
-
-      buttons = <IconButton icon={following ? 'user-times' : 'user-plus'} title={intl.formatMessage(following ? messages.unfollow : messages.follow)} onClick={this.handleFollow} active={following} />;
+      const requested = account.getIn(['relationship', 'requested']);
+      const blocking  = account.getIn(['relationship', 'blocking']);
+
+      if (requested) {
+        buttons = <IconButton disabled={true} icon='hourglass' title={intl.formatMessage(messages.requested)} />
+      } else if (blocking) {
+        buttons = <IconButton active={true} icon='unlock-alt' title={intl.formatMessage(messages.unblock)} onClick={this.handleBlock} />;
+      } else {
+        buttons = <IconButton icon={following ? 'user-times' : 'user-plus'} title={intl.formatMessage(following ? messages.unfollow : messages.follow)} onClick={this.handleFollow} active={following} />;
+      }
     }
 
     return (
diff --git a/app/assets/javascripts/components/components/column_collapsable.jsx b/app/assets/javascripts/components/components/column_collapsable.jsx
index 8d74fd8a7..203dc5e0c 100644
--- a/app/assets/javascripts/components/components/column_collapsable.jsx
+++ b/app/assets/javascripts/components/components/column_collapsable.jsx
@@ -45,7 +45,7 @@ const ColumnCollapsable = React.createClass({
       <div style={{ position: 'relative' }}>
         <div style={{...iconStyle, color: collapsed ? '#9baec8' : '#fff', background: collapsed ? '#2f3441' : '#373b4a' }} onClick={this.handleToggleCollapsed}><i className={`fa fa-${icon}`} /></div>
 
-        <Motion defaultStyle={{ opacity: 0, height: 0 }} style={{ opacity: spring(collapsed ? 0 : 100), height: spring(collapsed ? 0 : fullHeight, { stiffness: 150, damping: 9 }) }}>
+        <Motion defaultStyle={{ opacity: 0, height: 0 }} style={{ opacity: spring(collapsed ? 0 : 100), height: spring(collapsed ? 0 : fullHeight, collapsed ? undefined : { stiffness: 150, damping: 9 }) }}>
           {({ opacity, height }) =>
             <div style={{ overflow: 'hidden', height: `${height}px`, opacity: opacity / 100 }}>
               {children}
diff --git a/app/assets/javascripts/components/components/lightbox.jsx b/app/assets/javascripts/components/components/lightbox.jsx
index b5c2a69d8..1e3a88955 100644
--- a/app/assets/javascripts/components/components/lightbox.jsx
+++ b/app/assets/javascripts/components/components/lightbox.jsx
@@ -35,7 +35,9 @@ const Lightbox = React.createClass({
   propTypes: {
     isVisible: React.PropTypes.bool,
     onOverlayClicked: React.PropTypes.func,
-    onCloseClicked: React.PropTypes.func
+    onCloseClicked: React.PropTypes.func,
+    intl: React.PropTypes.object.isRequired,
+    children: React.PropTypes.node
   },
 
   mixins: [PureRenderMixin],
@@ -57,19 +59,17 @@ const Lightbox = React.createClass({
   render () {
     const { intl, isVisible, onOverlayClicked, onCloseClicked, children } = this.props;
 
-    const content = isVisible ? children : <div />;
-
     return (
-      <div className='lightbox' style={{...overlayStyle, display: isVisible ? 'flex' : 'none'}} onClick={onOverlayClicked}>
-        <Motion defaultStyle={{ y: -200 }} style={{ y: spring(isVisible ? 0 : -200) }}>
-          {({ y }) =>
-            <div style={{...dialogStyle, transform: `translateY(${y}px)`}}>
+      <Motion defaultStyle={{ backgroundOpacity: 0, opacity: 0, y: -400 }} style={{ backgroundOpacity: spring(isVisible ? 50 : 0), opacity: isVisible ? spring(200) : 0, y: spring(isVisible ? 0 : -400, { stiffness: 150, damping: 12 }) }}>
+        {({ backgroundOpacity, opacity, y }) =>
+          <div className='lightbox' style={{...overlayStyle, background: `rgba(0, 0, 0, ${backgroundOpacity / 100})`, display: Math.floor(backgroundOpacity) === 0 ? 'none' : 'flex'}} onClick={onOverlayClicked}>
+            <div style={{...dialogStyle, transform: `translateY(${y}px)`, opacity: opacity / 100 }}>
               <IconButton title={intl.formatMessage({ id: 'lightbox.close', defaultMessage: 'Close' })} icon='times' onClick={onCloseClicked} size={16} style={closeStyle} />
-              {content}
+              {children}
             </div>
-          }
-        </Motion>
-      </div>
+          </div>
+        }
+      </Motion>
     );
   }
 
diff --git a/app/assets/javascripts/components/components/media_gallery.jsx b/app/assets/javascripts/components/components/media_gallery.jsx
index 9aafd8181..7e92abe2d 100644
--- a/app/assets/javascripts/components/components/media_gallery.jsx
+++ b/app/assets/javascripts/components/components/media_gallery.jsx
@@ -1,12 +1,18 @@
 import ImmutablePropTypes from 'react-immutable-proptypes';
 import PureRenderMixin from 'react-addons-pure-render-mixin';
-import { FormattedMessage } from 'react-intl';
+import IconButton from './icon_button';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+
+const messages = defineMessages({
+  toggle_visible: { id: 'media_gallery.toggle_visible', defaultMessage: 'Toggle visibility' }
+});
 
 const outerStyle = {
   marginTop: '8px',
   overflow: 'hidden',
   width: '100%',
-  boxSizing: 'border-box'
+  boxSizing: 'border-box',
+  position: 'relative'
 };
 
 const spoilerStyle = {
@@ -32,11 +38,18 @@ const spoilerSubSpanStyle = {
   fontWeight: '500'
 };
 
+const spoilerButtonStyle = {
+  position: 'absolute',
+  top: '6px',
+  left: '8px',
+  zIndex: '100'
+};
+
 const MediaGallery = React.createClass({
 
   getInitialState () {
     return {
-      visible: false
+      visible: !this.props.sensitive
     };
   },
 
@@ -59,21 +72,30 @@ const MediaGallery = React.createClass({
   },
 
   handleOpen () {
-    this.setState({ visible: true });
+    this.setState({ visible: !this.state.visible });
   },
 
   render () {
-    const { media, sensitive } = this.props;
+    const { media, intl, sensitive } = this.props;
 
     let children;
 
-    if (sensitive && !this.state.visible) {
-      children = (
-        <div style={spoilerStyle} onClick={this.handleOpen}>
-          <span style={spoilerSpanStyle}><FormattedMessage id='status.sensitive_warning' defaultMessage='Sensitive content' /></span>
-          <span style={spoilerSubSpanStyle}><FormattedMessage id='status.sensitive_toggle' defaultMessage='Click to view' /></span>
-        </div>
-      );
+    if (!this.state.visible) {
+      if (sensitive) {
+        children = (
+          <div style={spoilerStyle} onClick={this.handleOpen}>
+            <span style={spoilerSpanStyle}><FormattedMessage id='status.sensitive_warning' defaultMessage='Sensitive content' /></span>
+            <span style={spoilerSubSpanStyle}><FormattedMessage id='status.sensitive_toggle' defaultMessage='Click to view' /></span>
+          </div>
+        );
+      } else {
+        children = (
+          <div style={spoilerStyle} onClick={this.handleOpen}>
+            <span style={spoilerSpanStyle}><FormattedMessage id='status.media_hidden' defaultMessage='Media hidden' /></span>
+            <span style={spoilerSubSpanStyle}><FormattedMessage id='status.sensitive_toggle' defaultMessage='Click to view' /></span>
+          </div>
+        );
+      }
     } else {
       const size = media.take(4).size;
 
@@ -134,9 +156,12 @@ const MediaGallery = React.createClass({
         );
       });
     }
-
+    
     return (
       <div style={{ ...outerStyle, height: `${this.props.height}px` }}>
+        <div style={spoilerButtonStyle} >
+          <IconButton title={intl.formatMessage(messages.toggle_visible)} icon={this.state.visible ? 'eye' : 'eye-slash'} onClick={this.handleOpen} />
+        </div>
         {children}
       </div>
     );
@@ -144,4 +169,4 @@ const MediaGallery = React.createClass({
 
 });
 
-export default MediaGallery;
+export default injectIntl(MediaGallery);
diff --git a/app/assets/javascripts/components/components/video_player.jsx b/app/assets/javascripts/components/components/video_player.jsx
index bd6e746ef..3edc8f672 100644
--- a/app/assets/javascripts/components/components/video_player.jsx
+++ b/app/assets/javascripts/components/components/video_player.jsx
@@ -4,7 +4,8 @@ import IconButton from './icon_button';
 import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
 
 const messages = defineMessages({
-  toggle_sound: { id: 'video_player.toggle_sound', defaultMessage: 'Toggle sound' }
+  toggle_sound: { id: 'video_player.toggle_sound', defaultMessage: 'Toggle sound' },
+  toggle_visible: { id: 'video_player.toggle_visible', defaultMessage: 'Toggle visibility' }
 });
 
 const videoStyle = {
@@ -20,7 +21,7 @@ const videoStyle = {
 const muteStyle = {
   position: 'absolute',
   top: '10px',
-  left: '10px',
+  right: '10px',
   opacity: '0.8',
   zIndex: '5'
 };
@@ -35,7 +36,8 @@ const spoilerStyle = {
   display: 'flex',
   alignItems: 'center',
   justifyContent: 'center',
-  flexDirection: 'column'
+  flexDirection: 'column',
+  position: 'relative'
 };
 
 const spoilerSpanStyle = {
@@ -49,6 +51,13 @@ const spoilerSubSpanStyle = {
   fontWeight: '500'
 };
 
+const spoilerButtonStyle = {
+  position: 'absolute',
+  top: '6px',
+  left: '8px',
+  zIndex: '100'
+};
+
 const VideoPlayer = React.createClass({
   propTypes: {
     media: ImmutablePropTypes.map.isRequired,
@@ -66,7 +75,8 @@ const VideoPlayer = React.createClass({
 
   getInitialState () {
     return {
-      visible: false,
+      visible: !this.props.sensitive,
+      preview: true,
       muted: true
     };
   },
@@ -90,22 +100,49 @@ const VideoPlayer = React.createClass({
   },
 
   handleOpen () {
-    this.setState({ visible: true });
+    this.setState({ preview: !this.state.preview });
+  },
+
+  handleVisibility () {
+    this.setState({
+      visible: !this.state.visible,
+      preview: true
+    });
   },
 
   render () {
     const { media, intl, width, height, sensitive } = this.props;
 
-    if (sensitive && !this.state.visible) {
-      return (
-        <div style={{...spoilerStyle, width: `${width}px`, height: `${height}px` }} onClick={this.handleOpen}>
-          <span style={spoilerSpanStyle}><FormattedMessage id='status.sensitive_warning' defaultMessage='Sensitive content' /></span>
-          <span style={spoilerSubSpanStyle}><FormattedMessage id='status.sensitive_toggle' defaultMessage='Click to view' /></span>
-        </div>
-      );
-    } else if (!sensitive && !this.state.visible) {
+    let spoilerButton = (
+      <div style={spoilerButtonStyle} >
+        <IconButton title={intl.formatMessage(messages.toggle_visible)} icon={this.state.visible ? 'eye' : 'eye-slash'} onClick={this.handleVisibility} />
+      </div>
+    );
+
+    if (!this.state.visible) {
+      if (sensitive) {
+        return (
+          <div style={{...spoilerStyle, width: `${width}px`, height: `${height}px` }} onClick={this.handleVisibility}>
+            {spoilerButton}
+            <span style={spoilerSpanStyle}><FormattedMessage id='status.sensitive_warning' defaultMessage='Sensitive content' /></span>
+            <span style={spoilerSubSpanStyle}><FormattedMessage id='status.sensitive_toggle' defaultMessage='Click to view' /></span>
+          </div>
+        );
+      } else {
+        return (
+          <div style={{...spoilerStyle, width: `${width}px`, height: `${height}px` }} onClick={this.handleOpen}>
+            {spoilerButton}
+            <span style={spoilerSpanStyle}><FormattedMessage id='status.media_hidden' defaultMessage='Media hidden' /></span>
+            <span style={spoilerSubSpanStyle}><FormattedMessage id='status.sensitive_toggle' defaultMessage='Click to view' /></span>
+          </div>
+        );
+      }
+    }
+
+    if (this.state.preview) {
       return (
         <div style={{ cursor: 'pointer', position: 'relative', marginTop: '8px', width: `${width}px`, height: `${height}px`, background: `url(${media.get('preview_url')}) no-repeat center`, backgroundSize: 'cover' }} onClick={this.handleOpen}>
+          {spoilerButton}
           <div style={{ position: 'absolute', top: '50%', left: '50%', fontSize: '36px', transform: 'translate(-50%, -50%)', padding: '5px', borderRadius: '100px', color: 'rgba(255, 255, 255, 0.8)' }}><i className='fa fa-play' /></div>
         </div>
       );
@@ -113,6 +150,7 @@ const VideoPlayer = React.createClass({
 
     return (
       <div style={{ cursor: 'default', marginTop: '8px', overflow: 'hidden', width: `${width}px`, height: `${height}px`, boxSizing: 'border-box', background: '#000', position: 'relative' }}>
+        {spoilerButton}
         <div style={muteStyle}><IconButton title={intl.formatMessage(messages.toggle_sound)} icon={this.state.muted ? 'volume-off' : 'volume-up'} onClick={this.handleClick} /></div>
         <video src={media.get('url')} autoPlay='true' loop={true} muted={this.state.muted} style={videoStyle} onClick={this.handleVideoClick} />
       </div>
diff --git a/app/assets/javascripts/components/containers/account_container.jsx b/app/assets/javascripts/components/containers/account_container.jsx
index 1f49f9819..889c0ac4c 100644
--- a/app/assets/javascripts/components/containers/account_container.jsx
+++ b/app/assets/javascripts/components/containers/account_container.jsx
@@ -3,7 +3,9 @@ import { makeGetAccount } from '../selectors';
 import Account from '../components/account';
 import {
   followAccount,
-  unfollowAccount
+  unfollowAccount,
+  blockAccount,
+  unblockAccount
 } from '../actions/accounts';
 
 const makeMapStateToProps = () => {
@@ -24,6 +26,14 @@ const mapDispatchToProps = (dispatch) => ({
     } else {
       dispatch(followAccount(account.get('id')));
     }
+  },
+
+  onBlock (account) {
+    if (account.getIn(['relationship', 'blocking'])) {
+      dispatch(unblockAccount(account.get('id')));
+    } else {
+      dispatch(blockAccount(account.get('id')));
+    }
   }
 });
 
diff --git a/app/assets/javascripts/components/containers/mastodon.jsx b/app/assets/javascripts/components/containers/mastodon.jsx
index af495652f..5f4b2cf79 100644
--- a/app/assets/javascripts/components/containers/mastodon.jsx
+++ b/app/assets/javascripts/components/containers/mastodon.jsx
@@ -34,6 +34,7 @@ import HashtagTimeline from '../features/hashtag_timeline';
 import Notifications from '../features/notifications';
 import FollowRequests from '../features/follow_requests';
 import GenericNotFound from '../features/generic_not_found';
+import FavouritedStatuses from '../features/favourited_statuses';
 import { IntlProvider, addLocaleData } from 'react-intl';
 import en from 'react-intl/locale-data/en';
 import de from 'react-intl/locale-data/de';
@@ -113,6 +114,7 @@ const Mastodon = React.createClass({
               <Route path='timelines/tag/:id' component={HashtagTimeline} />
 
               <Route path='notifications' component={Notifications} />
+              <Route path='favourites' component={FavouritedStatuses} />
 
               <Route path='statuses/new' component={Compose} />
               <Route path='statuses/:statusId' component={Status} />
diff --git a/app/assets/javascripts/components/features/account/index.jsx b/app/assets/javascripts/components/features/account/index.jsx
index 2a9eba28a..3a9b48f21 100644
--- a/app/assets/javascripts/components/features/account/index.jsx
+++ b/app/assets/javascripts/components/features/account/index.jsx
@@ -43,7 +43,8 @@ const Account = React.createClass({
     params: React.PropTypes.object.isRequired,
     dispatch: React.PropTypes.func.isRequired,
     account: ImmutablePropTypes.map,
-    me: React.PropTypes.number.isRequired
+    me: React.PropTypes.number.isRequired,
+    children: React.PropTypes.node
   },
 
   mixins: [PureRenderMixin],
diff --git a/app/assets/javascripts/components/features/account_timeline/index.jsx b/app/assets/javascripts/components/features/account_timeline/index.jsx
index 7a3dbe160..4a66dbbf5 100644
--- a/app/assets/javascripts/components/features/account_timeline/index.jsx
+++ b/app/assets/javascripts/components/features/account_timeline/index.jsx
@@ -18,7 +18,8 @@ const AccountTimeline = React.createClass({
   propTypes: {
     params: React.PropTypes.object.isRequired,
     dispatch: React.PropTypes.func.isRequired,
-    statusIds: ImmutablePropTypes.list
+    statusIds: ImmutablePropTypes.list,
+    me: React.PropTypes.number.isRequired
   },
 
   mixins: [PureRenderMixin],
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 b9f90c569..80cb38e16 100644
--- a/app/assets/javascripts/components/features/compose/components/compose_form.jsx
+++ b/app/assets/javascripts/components/features/compose/components/compose_form.jsx
@@ -32,6 +32,7 @@ const ComposeForm = React.createClass({
     is_uploading: React.PropTypes.bool,
     in_reply_to: ImmutablePropTypes.map,
     media_count: React.PropTypes.number,
+    me: React.PropTypes.number,
     onChange: React.PropTypes.func.isRequired,
     onSubmit: React.PropTypes.func.isRequired,
     onCancelReply: React.PropTypes.func.isRequired,
@@ -110,6 +111,8 @@ const ComposeForm = React.createClass({
       replyArea = <ReplyIndicator status={this.props.in_reply_to} onCancel={this.props.onCancelReply} />;
     }
 
+    let reply_to_other = !!this.props.in_reply_to && (this.props.in_reply_to.getIn(['account', 'id']) !== this.props.me);
+
     return (
       <div style={{ padding: '10px' }}>
         {replyArea}
@@ -139,7 +142,7 @@ const ComposeForm = React.createClass({
           <span style={{ display: 'inline-block', verticalAlign: 'middle', marginBottom: '14px', marginLeft: '8px', color: '#9baec8' }}><FormattedMessage id='compose_form.private' defaultMessage='Mark as private' /></span>
         </label>
 
-        <Motion defaultStyle={{ opacity: (this.props.private || this.props.in_reply_to) ? 0 : 100, height: (this.props.private || this.props_in_reply_to) ? 39.5 : 0 }} style={{ opacity: spring((this.props.private || this.props.in_reply_to) ? 0 : 100), height: spring((this.props.private || this.props_in_reply_to) ? 0 : 39.5) }}>
+        <Motion defaultStyle={{ opacity: (this.props.private || reply_to_other) ? 0 : 100, height: (this.props.private || reply_to_other) ? 39.5 : 0 }} style={{ opacity: spring((this.props.private || reply_to_other) ? 0 : 100), height: spring((this.props.private || reply_to_other) ? 0 : 39.5) }}>
           {({ opacity, height }) =>
             <label style={{ display: 'block', lineHeight: '24px', verticalAlign: 'middle', height: `${height}px`, overflow: 'hidden', opacity: opacity / 100 }}>
               <Toggle checked={this.props.unlisted} onChange={this.handleChangeListability} />
diff --git a/app/assets/javascripts/components/features/compose/components/upload_button.jsx b/app/assets/javascripts/components/features/compose/components/upload_button.jsx
index f00ef3f8f..4c8181aa1 100644
--- a/app/assets/javascripts/components/features/compose/components/upload_button.jsx
+++ b/app/assets/javascripts/components/features/compose/components/upload_button.jsx
@@ -12,7 +12,8 @@ const UploadButton = React.createClass({
     disabled: React.PropTypes.bool,
     onSelectFile: React.PropTypes.func.isRequired,
     style: React.PropTypes.object,
-    key: React.PropTypes.number
+    resetFileKey: React.PropTypes.number,
+    intl: React.PropTypes.object.isRequired
   },
 
   mixins: [PureRenderMixin],
@@ -32,12 +33,12 @@ const UploadButton = React.createClass({
   },
 
   render () {
-    const { intl } = this.props;
+    const { intl, resetFileKey, disabled } = this.props;
 
     return (
       <div style={this.props.style}>
-        <IconButton icon='photo' title={intl.formatMessage(messages.upload)} disabled={this.props.disabled} onClick={this.handleClick} size={24} />
-        <input key={this.props.key} ref={this.setRef} type='file' multiple={false} onChange={this.handleChange} disabled={this.props.disabled} style={{ display: 'none' }} />
+        <IconButton icon='photo' title={intl.formatMessage(messages.upload)} disabled={disabled} onClick={this.handleClick} size={24} />
+        <input key={resetFileKey} ref={this.setRef} type='file' multiple={false} onChange={this.handleChange} disabled={disabled} style={{ display: 'none' }} />
       </div>
     );
   }
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 2b6ee1ae7..1b5a506d5 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
@@ -28,7 +28,8 @@ const makeMapStateToProps = () => {
       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
+      media_count: state.getIn(['compose', 'media_attachments']).size,
+      me: state.getIn(['compose', 'me'])
     };
   };
 
diff --git a/app/assets/javascripts/components/features/compose/containers/upload_button_container.jsx b/app/assets/javascripts/components/features/compose/containers/upload_button_container.jsx
index 7afa7d355..78e5312f5 100644
--- a/app/assets/javascripts/components/features/compose/containers/upload_button_container.jsx
+++ b/app/assets/javascripts/components/features/compose/containers/upload_button_container.jsx
@@ -4,7 +4,7 @@ import { uploadCompose } from '../../../actions/compose';
 
 const mapStateToProps = state => ({
   disabled: state.getIn(['compose', 'is_uploading']) || (state.getIn(['compose', 'media_attachments']).size > 3 || state.getIn(['compose', 'media_attachments']).some(m => m.get('type') === 'video')),
-  key: Math.floor((Math.random() * 0x10000))
+  resetFileKey: state.getIn(['compose', 'resetFileKey'])
 });
 
 const mapDispatchToProps = dispatch => ({
diff --git a/app/assets/javascripts/components/features/favourited_statuses/index.jsx b/app/assets/javascripts/components/features/favourited_statuses/index.jsx
new file mode 100644
index 000000000..a2d521736
--- /dev/null
+++ b/app/assets/javascripts/components/features/favourited_statuses/index.jsx
@@ -0,0 +1,63 @@
+import { connect } from 'react-redux';
+import PureRenderMixin from 'react-addons-pure-render-mixin';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import LoadingIndicator from '../../components/loading_indicator';
+import { fetchFavouritedStatuses, expandFavouritedStatuses } from '../../actions/favourites';
+import Column from '../ui/components/column';
+import StatusList from '../../components/status_list';
+import ColumnBackButton from '../public_timeline/components/column_back_button';
+import { defineMessages, injectIntl } from 'react-intl';
+
+const messages = defineMessages({
+  heading: { id: 'column.favourites', defaultMessage: 'Favourites' }
+});
+
+const mapStateToProps = state => ({
+  statusIds: state.getIn(['status_lists', 'favourites', 'items']),
+  loaded: state.getIn(['status_lists', 'favourites', 'loaded']),
+  me: state.getIn(['meta', 'me'])
+});
+
+const Favourites = React.createClass({
+
+  propTypes: {
+    params: React.PropTypes.object.isRequired,
+    dispatch: React.PropTypes.func.isRequired,
+    statusIds: ImmutablePropTypes.list.isRequired,
+    loaded: React.PropTypes.bool,
+    intl: React.PropTypes.object.isRequired,
+    me: React.PropTypes.number.isRequired
+  },
+
+  mixins: [PureRenderMixin],
+
+  componentWillMount () {
+    this.props.dispatch(fetchFavouritedStatuses());
+  },
+
+  handleScrollToBottom () {
+    this.props.dispatch(expandFavouritedStatuses());
+  },
+
+  render () {
+    const { statusIds, loaded, intl, me } = this.props;
+
+    if (!loaded) {
+      return (
+        <Column>
+          <LoadingIndicator />
+        </Column>
+      );
+    }
+
+    return (
+      <Column icon='star' heading={intl.formatMessage(messages.heading)}>
+        <ColumnBackButton />
+        <StatusList statusIds={statusIds} me={me} onScrollToBottom={this.handleScrollToBottom} />
+      </Column>
+    );
+  }
+
+});
+
+export default connect(mapStateToProps)(injectIntl(Favourites));
diff --git a/app/assets/javascripts/components/features/getting_started/index.jsx b/app/assets/javascripts/components/features/getting_started/index.jsx
index 58623fbba..42e0a9e24 100644
--- a/app/assets/javascripts/components/features/getting_started/index.jsx
+++ b/app/assets/javascripts/components/features/getting_started/index.jsx
@@ -10,7 +10,8 @@ const messages = defineMessages({
   public_timeline: { id: 'navigation_bar.public_timeline', defaultMessage: 'Public 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' }
+  sign_out: { id: 'navigation_bar.logout', defaultMessage: 'Sign out' },
+  favourites: { id: 'navigation_bar.favourites', defaultMessage: 'Favourites' }
 });
 
 const mapStateToProps = state => ({
@@ -29,8 +30,9 @@ const GettingStarted = ({ intl, me }) => {
       <div style={{ position: 'relative' }}>
         <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='sign-out' text={intl.formatMessage(messages.sign_out)} href='/auth/sign_out' method='delete' />
+        <ColumnLink icon='star' text={intl.formatMessage(messages.favourites)} to='/favourites' />
         {followRequests}
+        <ColumnLink icon='sign-out' text={intl.formatMessage(messages.sign_out)} href='/auth/sign_out' method='delete' />
       </div>
 
       <div className='scrollable optionally-scrollable'>
diff --git a/app/assets/javascripts/components/features/home_timeline/index.jsx b/app/assets/javascripts/components/features/home_timeline/index.jsx
index 8703d0b70..5d2263f15 100644
--- a/app/assets/javascripts/components/features/home_timeline/index.jsx
+++ b/app/assets/javascripts/components/features/home_timeline/index.jsx
@@ -1,8 +1,6 @@
-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 } from '../../actions/timelines';
 import { defineMessages, injectIntl } from 'react-intl';
 import ColumnSettingsContainer from './containers/column_settings_container';
 
@@ -13,16 +11,11 @@ const messages = defineMessages({
 const HomeTimeline = React.createClass({
 
   propTypes: {
-    dispatch: React.PropTypes.func.isRequired,
     intl: React.PropTypes.object.isRequired
   },
 
   mixins: [PureRenderMixin],
 
-  componentWillMount () {
-    this.props.dispatch(refreshTimeline('home'));
-  },
-
   render () {
     const { intl } = this.props;
 
@@ -36,4 +29,4 @@ const HomeTimeline = React.createClass({
 
 });
 
-export default connect()(injectIntl(HomeTimeline));
+export default injectIntl(HomeTimeline);
diff --git a/app/assets/javascripts/components/features/notifications/components/column_settings.jsx b/app/assets/javascripts/components/features/notifications/components/column_settings.jsx
index dfb59713c..b63c1881a 100644
--- a/app/assets/javascripts/components/features/notifications/components/column_settings.jsx
+++ b/app/assets/javascripts/components/features/notifications/components/column_settings.jsx
@@ -36,15 +36,17 @@ const ColumnSettings = React.createClass({
 
     const alertStr = <FormattedMessage id='notifications.column_settings.alert' defaultMessage='Desktop notifications' />;
     const showStr  = <FormattedMessage id='notifications.column_settings.show' defaultMessage='Show in column' />;
+    const soundStr = <FormattedMessage id='notifications.column_settings.sound' defaultMessage='Play sound' />;
 
     return (
-      <ColumnCollapsable icon='sliders' fullHeight={458} onCollapse={onSave}>
+      <ColumnCollapsable icon='sliders' fullHeight={616} onCollapse={onSave}>
         <div style={outerStyle}>
           <span style={sectionStyle}><FormattedMessage id='notifications.column_settings.follow' defaultMessage='New followers:' /></span>
 
           <div style={rowStyle}>
             <SettingToggle settings={settings} settingKey={['alerts', 'follow']} onChange={onChange} label={alertStr} />
             <SettingToggle settings={settings} settingKey={['shows', 'follow']} onChange={onChange} label={showStr} />
+            <SettingToggle settings={settings} settingKey={['sounds', 'follow']} onChange={onChange} label={soundStr} />
           </div>
 
           <span style={sectionStyle}><FormattedMessage id='notifications.column_settings.favourite' defaultMessage='Favourites:' /></span>
@@ -52,6 +54,7 @@ const ColumnSettings = React.createClass({
           <div style={rowStyle}>
             <SettingToggle settings={settings} settingKey={['alerts', 'favourite']} onChange={onChange} label={alertStr} />
             <SettingToggle settings={settings} settingKey={['shows', 'favourite']} onChange={onChange} label={showStr} />
+            <SettingToggle settings={settings} settingKey={['sounds', 'favourite']} onChange={onChange} label={soundStr} />
           </div>
 
           <span style={sectionStyle}><FormattedMessage id='notifications.column_settings.mention' defaultMessage='Mentions:' /></span>
@@ -59,6 +62,7 @@ const ColumnSettings = React.createClass({
           <div style={rowStyle}>
             <SettingToggle settings={settings} settingKey={['alerts', 'mention']} onChange={onChange} label={alertStr} />
             <SettingToggle settings={settings} settingKey={['shows', 'mention']} onChange={onChange} label={showStr} />
+            <SettingToggle settings={settings} settingKey={['sounds', 'mention']} onChange={onChange} label={soundStr} />
           </div>
 
           <span style={sectionStyle}><FormattedMessage id='notifications.column_settings.reblog' defaultMessage='Boosts:' /></span>
@@ -66,6 +70,7 @@ const ColumnSettings = React.createClass({
           <div style={rowStyle}>
             <SettingToggle settings={settings} settingKey={['alerts', 'reblog']} onChange={onChange} label={alertStr} />
             <SettingToggle settings={settings} settingKey={['shows', 'reblog']} onChange={onChange} label={showStr} />
+            <SettingToggle settings={settings} settingKey={['sounds', 'reblog']} onChange={onChange} label={soundStr} />
           </div>
         </div>
       </ColumnCollapsable>
diff --git a/app/assets/javascripts/components/features/notifications/index.jsx b/app/assets/javascripts/components/features/notifications/index.jsx
index 29be491eb..d243f178f 100644
--- a/app/assets/javascripts/components/features/notifications/index.jsx
+++ b/app/assets/javascripts/components/features/notifications/index.jsx
@@ -2,10 +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 {
-  refreshNotifications,
-  expandNotifications
-} from '../../actions/notifications';
+import { expandNotifications } from '../../actions/notifications';
 import NotificationContainer from './containers/notification_container';
 import { ScrollContainer } from 'react-router-scroll';
 import { defineMessages, injectIntl } from 'react-intl';
@@ -43,11 +40,6 @@ const Notifications = React.createClass({
 
   mixins: [PureRenderMixin],
 
-  componentWillMount () {
-    const { dispatch } = this.props;
-    dispatch(refreshNotifications());
-  },
-
   handleScroll (e) {
     const { scrollTop, scrollHeight, clientHeight } = e.target;
 
diff --git a/app/assets/javascripts/components/features/status/components/card.jsx b/app/assets/javascripts/components/features/status/components/card.jsx
new file mode 100644
index 000000000..ccb06dfd5
--- /dev/null
+++ b/app/assets/javascripts/components/features/status/components/card.jsx
@@ -0,0 +1,100 @@
+import PureRenderMixin from 'react-addons-pure-render-mixin';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+
+const outerStyle = {
+  display: 'flex',
+  cursor: 'pointer',
+  fontSize: '14px',
+  border: '1px solid #363c4b',
+  borderRadius: '4px',
+  color: '#616b86',
+  marginTop: '14px',
+  textDecoration: 'none',
+  overflow: 'hidden'
+};
+
+const contentStyle = {
+  flex: '1 1 auto',
+  padding: '8px',
+  paddingLeft: '14px',
+  overflow: 'hidden'
+};
+
+const titleStyle = {
+  display: 'block',
+  fontWeight: '500',
+  marginBottom: '5px',
+  color: '#d9e1e8',
+  overflow: 'hidden',
+  textOverflow: 'ellipsis',
+  whiteSpace: 'nowrap'
+};
+
+const descriptionStyle = {
+  color: '#d9e1e8'
+};
+
+const imageOuterStyle = {
+  flex: '0 0 100px',
+  background: '#373b4a'
+};
+
+const imageStyle = {
+  display: 'block',
+  width: '100%',
+  height: 'auto',
+  margin: '0',
+  borderRadius: '4px 0 0 4px'
+};
+
+const hostStyle = {
+  display: 'block',
+  marginTop: '5px',
+  fontSize: '13px'
+};
+
+const getHostname = url => {
+  const parser = document.createElement('a');
+  parser.href = url;
+  return parser.hostname;
+};
+
+const Card = React.createClass({
+  propTypes: {
+    card: ImmutablePropTypes.map
+  },
+
+  mixins: [PureRenderMixin],
+
+  render () {
+    const { card } = this.props;
+
+    if (card === null) {
+      return null;
+    }
+
+    let image = '';
+
+    if (card.get('image')) {
+      image = (
+        <div style={imageOuterStyle}>
+          <img src={card.get('image')} alt={card.get('title')} style={imageStyle} />
+        </div>
+      );
+    }
+
+    return (
+      <a style={outerStyle} href={card.get('url')} className='status-card'>
+        {image}
+
+        <div style={contentStyle}>
+          <strong style={titleStyle} title={card.get('title')}>{card.get('title')}</strong>
+          <p style={descriptionStyle}>{card.get('description').substring(0, 50)}</p>
+          <span style={hostStyle}>{getHostname(card.get('url'))}</span>
+        </div>
+      </a>
+    );
+  }
+});
+
+export default Card;
diff --git a/app/assets/javascripts/components/features/status/components/detailed_status.jsx b/app/assets/javascripts/components/features/status/components/detailed_status.jsx
index b967d966f..f2d6ae48a 100644
--- a/app/assets/javascripts/components/features/status/components/detailed_status.jsx
+++ b/app/assets/javascripts/components/features/status/components/detailed_status.jsx
@@ -7,6 +7,7 @@ import MediaGallery from '../../../components/media_gallery';
 import VideoPlayer from '../../../components/video_player';
 import { Link } from 'react-router';
 import { FormattedDate, FormattedNumber } from 'react-intl';
+import CardContainer from '../containers/card_container';
 
 const DetailedStatus = React.createClass({
 
@@ -32,7 +33,9 @@ const DetailedStatus = React.createClass({
 
   render () {
     const status = this.props.status.get('reblog') ? this.props.status.get('reblog') : this.props.status;
-    let media    = '';
+
+    let media           = '';
+    let applicationLink = '';
 
     if (status.get('media_attachments').size > 0) {
       if (status.getIn(['media_attachments', 0, 'type']) === 'video') {
@@ -40,6 +43,12 @@ const DetailedStatus = React.createClass({
       } else {
         media = <MediaGallery sensitive={status.get('sensitive')} media={status.get('media_attachments')} height={300} onOpenMedia={this.props.onOpenMedia} />;
       }
+    } else {
+      media = <CardContainer statusId={status.get('id')} />;
+    }
+
+    if (status.get('application')) {
+      applicationLink = <span> · <a className='detailed-status__application' style={{ color: 'inherit' }} href={status.getIn(['application', 'website'])} target='_blank' rel='nooopener'>{status.getIn(['application', 'name'])}</a></span>;
     }
 
     return (
@@ -54,7 +63,7 @@ const DetailedStatus = React.createClass({
         {media}
 
         <div style={{ marginTop: '15px', color: '#616b86', fontSize: '14px', lineHeight: '18px' }}>
-          <a className='detailed-status__datetime' style={{ color: 'inherit' }} href={status.get('url')} target='_blank' rel='noopener'><FormattedDate value={new Date(status.get('created_at'))} hour12={false} year='numeric' month='short' day='2-digit' hour='2-digit' minute='2-digit' /></a> · <Link to={`/statuses/${status.get('id')}/reblogs`} style={{ color: 'inherit', textDecoration: 'none' }}><i className='fa fa-retweet' /><span style={{ fontWeight: '500', fontSize: '12px', marginLeft: '6px', display: 'inline-block' }}><FormattedNumber value={status.get('reblogs_count')} /></span></Link> · <Link to={`/statuses/${status.get('id')}/favourites`} style={{ color: 'inherit', textDecoration: 'none' }}><i className='fa fa-star' /><span style={{ fontWeight: '500', fontSize: '12px', marginLeft: '6px', display: 'inline-block' }}><FormattedNumber value={status.get('favourites_count')} /></span></Link>
+          <a className='detailed-status__datetime' style={{ color: 'inherit' }} href={status.get('url')} target='_blank' rel='noopener'><FormattedDate value={new Date(status.get('created_at'))} hour12={false} year='numeric' month='short' day='2-digit' hour='2-digit' minute='2-digit' /></a>{applicationLink} · <Link to={`/statuses/${status.get('id')}/reblogs`} style={{ color: 'inherit', textDecoration: 'none' }}><i className='fa fa-retweet' /><span style={{ fontWeight: '500', fontSize: '12px', marginLeft: '6px', display: 'inline-block' }}><FormattedNumber value={status.get('reblogs_count')} /></span></Link> · <Link to={`/statuses/${status.get('id')}/favourites`} style={{ color: 'inherit', textDecoration: 'none' }}><i className='fa fa-star' /><span style={{ fontWeight: '500', fontSize: '12px', marginLeft: '6px', display: 'inline-block' }}><FormattedNumber value={status.get('favourites_count')} /></span></Link>
         </div>
       </div>
     );
diff --git a/app/assets/javascripts/components/features/status/containers/card_container.jsx b/app/assets/javascripts/components/features/status/containers/card_container.jsx
new file mode 100644
index 000000000..5c8bfeec2
--- /dev/null
+++ b/app/assets/javascripts/components/features/status/containers/card_container.jsx
@@ -0,0 +1,8 @@
+import { connect } from 'react-redux';
+import Card from '../components/card';
+
+const mapStateToProps = (state, { statusId }) => ({
+  card: state.getIn(['cards', statusId], null)
+});
+
+export default connect(mapStateToProps)(Card);
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 cd7d63a4a..66dfe915e 100644
--- a/app/assets/javascripts/components/features/ui/containers/modal_container.jsx
+++ b/app/assets/javascripts/components/features/ui/containers/modal_container.jsx
@@ -1,6 +1,8 @@
-import { connect }           from 'react-redux';
-import { closeModal }        from '../../../actions/modal';
-import Lightbox              from '../../../components/lightbox';
+import { connect } from 'react-redux';
+import { closeModal } from '../../../actions/modal';
+import Lightbox from '../../../components/lightbox';
+import ImageLoader from 'react-imageloader';
+import LoadingIndicator from '../../../components/loading_indicator';
 
 const mapStateToProps = state => ({
   url: state.getIn(['modal', 'url']),
@@ -23,6 +25,18 @@ const imageStyle = {
   maxHeight: '80vh'
 };
 
+const loadingStyle = {
+  background: '#373b4a',
+  width: '400px',
+  paddingBottom: '120px'
+};
+
+const preloader = () => (
+  <div style={loadingStyle}>
+    <LoadingIndicator />
+  </div>
+);
+
 const Modal = React.createClass({
 
   propTypes: {
@@ -37,7 +51,11 @@ const Modal = React.createClass({
 
     return (
       <Lightbox {...other}>
-        <img src={url} style={imageStyle} />
+        <ImageLoader
+          src={url}
+          preloader={preloader}
+          imgProps={{ style: imageStyle }}
+        />
       </Lightbox>
     );
   }
diff --git a/app/assets/javascripts/components/features/ui/index.jsx b/app/assets/javascripts/components/features/ui/index.jsx
index ee2e29d6f..003d061ad 100644
--- a/app/assets/javascripts/components/features/ui/index.jsx
+++ b/app/assets/javascripts/components/features/ui/index.jsx
@@ -8,10 +8,12 @@ import Compose from '../compose';
 import TabsBar from './components/tabs_bar';
 import ModalContainer from './containers/modal_container';
 import Notifications from '../notifications';
+import { connect } from 'react-redux';
+import { isMobile } from '../../is_mobile';
 import { debounce } from 'react-decoration';
 import { uploadCompose } from '../../actions/compose';
-import { connect } from 'react-redux';
-import { isMobile } from '../../is_mobile'
+import { refreshTimeline } from '../../actions/timelines';
+import { refreshNotifications } from '../../actions/notifications';
 
 const UI = React.createClass({
 
@@ -56,6 +58,9 @@ const UI = React.createClass({
     window.addEventListener('resize', this.handleResize, { passive: true });
     window.addEventListener('dragover', this.handleDragOver);
     window.addEventListener('drop', this.handleDrop);
+
+    this.props.dispatch(refreshTimeline('home'));
+    this.props.dispatch(refreshNotifications());
   },
 
   componentWillUnmount () {
diff --git a/app/assets/javascripts/components/middleware/loading_bar.jsx b/app/assets/javascripts/components/middleware/loading_bar.jsx
new file mode 100644
index 000000000..a98f1bb2b
--- /dev/null
+++ b/app/assets/javascripts/components/middleware/loading_bar.jsx
@@ -0,0 +1,25 @@
+import { showLoading, hideLoading } from 'react-redux-loading-bar';
+
+const defaultTypeSuffixes = ['PENDING', 'FULFILLED', 'REJECTED'];
+
+export default function loadingBarMiddleware(config = {}) {
+  const promiseTypeSuffixes = config.promiseTypeSuffixes || defaultTypeSuffixes;
+
+  return ({ dispatch }) => next => (action) => {
+    if (action.type && !action.skipLoading) {
+      const [PENDING, FULFILLED, REJECTED] = promiseTypeSuffixes;
+
+      const isPending = new RegExp(`${PENDING}$`, 'g');
+      const isFulfilled = new RegExp(`${FULFILLED}$`, 'g');
+      const isRejected = new RegExp(`${REJECTED}$`, 'g');
+
+      if (action.type.match(isPending)) {
+        dispatch(showLoading());
+      } else if (action.type.match(isFulfilled) || action.type.match(isRejected)) {
+        dispatch(hideLoading());
+      }
+    }
+
+    return next(action);
+  };
+};
diff --git a/app/assets/javascripts/components/reducers/accounts.jsx b/app/assets/javascripts/components/reducers/accounts.jsx
index ae048df3b..409dfd663 100644
--- a/app/assets/javascripts/components/reducers/accounts.jsx
+++ b/app/assets/javascripts/components/reducers/accounts.jsx
@@ -6,7 +6,9 @@ import {
   FOLLOWING_EXPAND_SUCCESS,
   ACCOUNT_TIMELINE_FETCH_SUCCESS,
   ACCOUNT_TIMELINE_EXPAND_SUCCESS,
-  FOLLOW_REQUESTS_FETCH_SUCCESS
+  FOLLOW_REQUESTS_FETCH_SUCCESS,
+  ACCOUNT_FOLLOW_SUCCESS,
+  ACCOUNT_UNFOLLOW_SUCCESS
 } from '../actions/accounts';
 import { COMPOSE_SUGGESTIONS_READY } from '../actions/compose';
 import {
@@ -32,6 +34,10 @@ import {
   NOTIFICATIONS_REFRESH_SUCCESS,
   NOTIFICATIONS_EXPAND_SUCCESS
 } from '../actions/notifications';
+import {
+  FAVOURITED_STATUSES_FETCH_SUCCESS,
+  FAVOURITED_STATUSES_EXPAND_SUCCESS
+} from '../actions/favourites';
 import { STORE_HYDRATE } from '../actions/store';
 import Immutable from 'immutable';
 
@@ -90,6 +96,8 @@ export default function accounts(state = initialState, action) {
   case ACCOUNT_TIMELINE_FETCH_SUCCESS:
   case ACCOUNT_TIMELINE_EXPAND_SUCCESS:
   case CONTEXT_FETCH_SUCCESS:
+  case FAVOURITED_STATUSES_FETCH_SUCCESS:
+  case FAVOURITED_STATUSES_EXPAND_SUCCESS:
     return normalizeAccountsFromStatuses(state, action.statuses);
   case REBLOG_SUCCESS:
   case FAVOURITE_SUCCESS:
@@ -99,6 +107,10 @@ export default function accounts(state = initialState, action) {
   case TIMELINE_UPDATE:
   case STATUS_FETCH_SUCCESS:
     return normalizeAccountFromStatus(state, action.status);
+  case ACCOUNT_FOLLOW_SUCCESS:
+    return state.updateIn([action.relationship.id, 'followers_count'], num => num + 1);
+  case ACCOUNT_UNFOLLOW_SUCCESS:
+    return state.updateIn([action.relationship.id, 'followers_count'], num => Math.max(0, num - 1));
   default:
     return state;
   }
diff --git a/app/assets/javascripts/components/reducers/cards.jsx b/app/assets/javascripts/components/reducers/cards.jsx
new file mode 100644
index 000000000..3c9395011
--- /dev/null
+++ b/app/assets/javascripts/components/reducers/cards.jsx
@@ -0,0 +1,14 @@
+import { STATUS_CARD_FETCH_SUCCESS } from '../actions/cards';
+
+import Immutable from 'immutable';
+
+const initialState = Immutable.Map();
+
+export default function cards(state = initialState, action) {
+  switch(action.type) {
+  case STATUS_CARD_FETCH_SUCCESS:
+    return state.set(action.id, Immutable.fromJS(action.card));
+  default:
+    return state;
+  }
+};
diff --git a/app/assets/javascripts/components/reducers/compose.jsx b/app/assets/javascripts/components/reducers/compose.jsx
index baa7d7f5a..2df50c45b 100644
--- a/app/assets/javascripts/components/reducers/compose.jsx
+++ b/app/assets/javascripts/components/reducers/compose.jsx
@@ -38,7 +38,8 @@ const initialState = Immutable.Map({
   media_attachments: Immutable.List(),
   suggestion_token: null,
   suggestions: Immutable.List(),
-  me: null
+  me: null,
+  resetFileKey: Math.floor((Math.random() * 0x10000))
 });
 
 function statusToTextMentions(state, status) {
@@ -65,6 +66,7 @@ function appendMedia(state, media) {
   return state.withMutations(map => {
     map.update('media_attachments', list => list.push(media));
     map.set('is_uploading', false);
+    map.set('resetFileKey', Math.floor((Math.random() * 0x10000)));
     map.update('text', oldText => `${oldText} ${media.get('text_url')}`.trim());
   });
 };
@@ -80,7 +82,7 @@ function removeMedia(state, mediaId) {
 
 const insertSuggestion = (state, position, token, completion) => {
   return state.withMutations(map => {
-    map.update('text', oldText => `${oldText.slice(0, position)}${completion}${oldText.slice(position + token.length)}`);
+    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());
   });
diff --git a/app/assets/javascripts/components/reducers/index.jsx b/app/assets/javascripts/components/reducers/index.jsx
index 068491949..0798116c4 100644
--- a/app/assets/javascripts/components/reducers/index.jsx
+++ b/app/assets/javascripts/components/reducers/index.jsx
@@ -12,6 +12,8 @@ import relationships from './relationships';
 import search from './search';
 import notifications from './notifications';
 import settings from './settings';
+import status_lists from './status_lists';
+import cards from './cards';
 
 export default combineReducers({
   timelines,
@@ -21,10 +23,12 @@ export default combineReducers({
   loadingBar: loadingBarReducer,
   modal,
   user_lists,
+  status_lists,
   accounts,
   statuses,
   relationships,
   search,
   notifications,
-  settings
+  settings,
+  cards
 });
diff --git a/app/assets/javascripts/components/reducers/modal.jsx b/app/assets/javascripts/components/reducers/modal.jsx
index b529b6aa8..ac53ea210 100644
--- a/app/assets/javascripts/components/reducers/modal.jsx
+++ b/app/assets/javascripts/components/reducers/modal.jsx
@@ -8,14 +8,14 @@ const initialState = Immutable.Map({
 
 export default function modal(state = initialState, action) {
   switch(action.type) {
-    case MEDIA_OPEN:
-      return state.withMutations(map => {
-        map.set('url', action.url);
-        map.set('open', true);
-      });
-    case MODAL_CLOSE:
-      return state.set('open', false);
-    default:
-      return state;
+  case MEDIA_OPEN:
+    return state.withMutations(map => {
+      map.set('url', action.url);
+      map.set('open', true);
+    });
+  case MODAL_CLOSE:
+    return state.set('open', false);
+  default:
+    return state;
   }
 };
diff --git a/app/assets/javascripts/components/reducers/search.jsx b/app/assets/javascripts/components/reducers/search.jsx
index 9c2041863..d835ef268 100644
--- a/app/assets/javascripts/components/reducers/search.jsx
+++ b/app/assets/javascripts/components/reducers/search.jsx
@@ -23,7 +23,7 @@ const normalizeSuggestions = (state, value, accounts) => {
     }
   ];
 
-  if (value.indexOf('@') === -1) {
+  if (value.indexOf('@') === -1 && value.indexOf(' ') === -1) {
     newSuggestions.push({
       title: 'hashtag',
       items: [
diff --git a/app/assets/javascripts/components/reducers/settings.jsx b/app/assets/javascripts/components/reducers/settings.jsx
index 8bd9edae2..8acc3faca 100644
--- a/app/assets/javascripts/components/reducers/settings.jsx
+++ b/app/assets/javascripts/components/reducers/settings.jsx
@@ -23,6 +23,13 @@ const initialState = Immutable.Map({
       favourite: true,
       reblog: true,
       mention: true
+    }),
+
+    sounds: Immutable.Map({
+      follow: true,
+      favourite: true,
+      reblog: true,
+      mention: true
     })
   })
 });
@@ -30,7 +37,7 @@ const initialState = Immutable.Map({
 export default function settings(state = initialState, action) {
   switch(action.type) {
   case STORE_HYDRATE:
-    return state.merge(action.state.get('settings'));
+    return state.mergeDeep(action.state.get('settings'));
   case SETTING_CHANGE:
     return state.setIn(action.key, action.value);
   default:
diff --git a/app/assets/javascripts/components/reducers/status_lists.jsx b/app/assets/javascripts/components/reducers/status_lists.jsx
new file mode 100644
index 000000000..b883b1c58
--- /dev/null
+++ b/app/assets/javascripts/components/reducers/status_lists.jsx
@@ -0,0 +1,39 @@
+import {
+  FAVOURITED_STATUSES_FETCH_SUCCESS,
+  FAVOURITED_STATUSES_EXPAND_SUCCESS
+} from '../actions/favourites';
+import Immutable from 'immutable';
+
+const initialState = Immutable.Map({
+  favourites: Immutable.Map({
+    next: null,
+    loaded: false,
+    items: Immutable.List()
+  })
+});
+
+const normalizeList = (state, listType, statuses, next) => {
+  return state.update(listType, listMap => listMap.withMutations(map => {
+    map.set('next', next);
+    map.set('loaded', true);
+    map.set('items', Immutable.List(statuses.map(item => item.id)));
+  }));
+};
+
+const appendToList = (state, listType, statuses, next) => {
+  return state.update(listType, listMap => listMap.withMutations(map => {
+    map.set('next', next);
+    map.set('items', map.get('items').push(...statuses.map(item => item.id)));
+  }));
+};
+
+export default function statusLists(state = initialState, action) {
+  switch(action.type) {
+  case FAVOURITED_STATUSES_FETCH_SUCCESS:
+    return normalizeList(state, 'favourites', action.statuses, action.next);
+  case FAVOURITED_STATUSES_EXPAND_SUCCESS:
+    return appendToList(state, 'favourites', action.statuses, action.next);
+  default:
+    return state;
+  }
+};
diff --git a/app/assets/javascripts/components/reducers/statuses.jsx b/app/assets/javascripts/components/reducers/statuses.jsx
index c740b6d64..084b6304c 100644
--- a/app/assets/javascripts/components/reducers/statuses.jsx
+++ b/app/assets/javascripts/components/reducers/statuses.jsx
@@ -28,6 +28,10 @@ import {
   NOTIFICATIONS_REFRESH_SUCCESS,
   NOTIFICATIONS_EXPAND_SUCCESS
 } from '../actions/notifications';
+import {
+  FAVOURITED_STATUSES_FETCH_SUCCESS,
+  FAVOURITED_STATUSES_EXPAND_SUCCESS
+} from '../actions/favourites';
 import Immutable from 'immutable';
 
 const normalizeStatus = (state, status) => {
@@ -77,36 +81,38 @@ const initialState = Immutable.Map();
 
 export default function statuses(state = initialState, action) {
   switch(action.type) {
-    case TIMELINE_UPDATE:
-    case STATUS_FETCH_SUCCESS:
-    case NOTIFICATIONS_UPDATE:
-      return normalizeStatus(state, action.status);
-    case REBLOG_SUCCESS:
-    case UNREBLOG_SUCCESS:
-    case FAVOURITE_SUCCESS:
-    case UNFAVOURITE_SUCCESS:
-      return normalizeStatus(state, action.response);
-    case FAVOURITE_REQUEST:
-      return state.setIn([action.status.get('id'), 'favourited'], true);
-    case FAVOURITE_FAIL:
-      return state.setIn([action.status.get('id'), 'favourited'], false);
-    case REBLOG_REQUEST:
-      return state.setIn([action.status.get('id'), 'reblogged'], true);
-    case REBLOG_FAIL:
-      return state.setIn([action.status.get('id'), 'reblogged'], false);
-    case TIMELINE_REFRESH_SUCCESS:
-    case TIMELINE_EXPAND_SUCCESS:
-    case ACCOUNT_TIMELINE_FETCH_SUCCESS:
-    case ACCOUNT_TIMELINE_EXPAND_SUCCESS:
-    case CONTEXT_FETCH_SUCCESS:
-    case NOTIFICATIONS_REFRESH_SUCCESS:
-    case NOTIFICATIONS_EXPAND_SUCCESS:
-      return normalizeStatuses(state, action.statuses);
-    case TIMELINE_DELETE:
-      return deleteStatus(state, action.id, action.references);
-    case ACCOUNT_BLOCK_SUCCESS:
-      return filterStatuses(state, action.relationship);
-    default:
-      return state;
+  case TIMELINE_UPDATE:
+  case STATUS_FETCH_SUCCESS:
+  case NOTIFICATIONS_UPDATE:
+    return normalizeStatus(state, action.status);
+  case REBLOG_SUCCESS:
+  case UNREBLOG_SUCCESS:
+  case FAVOURITE_SUCCESS:
+  case UNFAVOURITE_SUCCESS:
+    return normalizeStatus(state, action.response);
+  case FAVOURITE_REQUEST:
+    return state.setIn([action.status.get('id'), 'favourited'], true);
+  case FAVOURITE_FAIL:
+    return state.setIn([action.status.get('id'), 'favourited'], false);
+  case REBLOG_REQUEST:
+    return state.setIn([action.status.get('id'), 'reblogged'], true);
+  case REBLOG_FAIL:
+    return state.setIn([action.status.get('id'), 'reblogged'], false);
+  case TIMELINE_REFRESH_SUCCESS:
+  case TIMELINE_EXPAND_SUCCESS:
+  case ACCOUNT_TIMELINE_FETCH_SUCCESS:
+  case ACCOUNT_TIMELINE_EXPAND_SUCCESS:
+  case CONTEXT_FETCH_SUCCESS:
+  case NOTIFICATIONS_REFRESH_SUCCESS:
+  case NOTIFICATIONS_EXPAND_SUCCESS:
+  case FAVOURITED_STATUSES_FETCH_SUCCESS:
+  case FAVOURITED_STATUSES_EXPAND_SUCCESS:
+    return normalizeStatuses(state, action.statuses);
+  case TIMELINE_DELETE:
+    return deleteStatus(state, action.id, action.references);
+  case ACCOUNT_BLOCK_SUCCESS:
+    return filterStatuses(state, action.relationship);
+  default:
+    return state;
   }
 };
diff --git a/app/assets/javascripts/components/reducers/user_lists.jsx b/app/assets/javascripts/components/reducers/user_lists.jsx
index 36093663f..72922f509 100644
--- a/app/assets/javascripts/components/reducers/user_lists.jsx
+++ b/app/assets/javascripts/components/reducers/user_lists.jsx
@@ -36,24 +36,24 @@ const appendToList = (state, type, id, accounts, next) => {
 
 export default function userLists(state = initialState, action) {
   switch(action.type) {
-    case FOLLOWERS_FETCH_SUCCESS:
-      return normalizeList(state, 'followers', action.id, action.accounts, action.next);
-    case FOLLOWERS_EXPAND_SUCCESS:
-      return appendToList(state, 'followers', action.id, action.accounts, action.next);
-    case FOLLOWING_FETCH_SUCCESS:
-      return normalizeList(state, 'following', action.id, action.accounts, action.next);
-    case FOLLOWING_EXPAND_SUCCESS:
-      return appendToList(state, 'following', action.id, action.accounts, action.next);
-    case REBLOGS_FETCH_SUCCESS:
-      return state.setIn(['reblogged_by', action.id], Immutable.List(action.accounts.map(item => item.id)));
-    case FAVOURITES_FETCH_SUCCESS:
-      return state.setIn(['favourited_by', action.id], Immutable.List(action.accounts.map(item => item.id)));
-    case FOLLOW_REQUESTS_FETCH_SUCCESS:
-      return state.setIn(['follow_requests', 'items'], Immutable.List(action.accounts.map(item => item.id))).setIn(['follow_requests', 'next'], action.next);
-    case FOLLOW_REQUEST_AUTHORIZE_SUCCESS:
-    case FOLLOW_REQUEST_REJECT_SUCCESS:
-      return state.updateIn(['follow_requests', 'items'], list => list.filterNot(item => item === action.id));
-    default:
-      return state;
+  case FOLLOWERS_FETCH_SUCCESS:
+    return normalizeList(state, 'followers', action.id, action.accounts, action.next);
+  case FOLLOWERS_EXPAND_SUCCESS:
+    return appendToList(state, 'followers', action.id, action.accounts, action.next);
+  case FOLLOWING_FETCH_SUCCESS:
+    return normalizeList(state, 'following', action.id, action.accounts, action.next);
+  case FOLLOWING_EXPAND_SUCCESS:
+    return appendToList(state, 'following', action.id, action.accounts, action.next);
+  case REBLOGS_FETCH_SUCCESS:
+    return state.setIn(['reblogged_by', action.id], Immutable.List(action.accounts.map(item => item.id)));
+  case FAVOURITES_FETCH_SUCCESS:
+    return state.setIn(['favourited_by', action.id], Immutable.List(action.accounts.map(item => item.id)));
+  case FOLLOW_REQUESTS_FETCH_SUCCESS:
+    return state.setIn(['follow_requests', 'items'], Immutable.List(action.accounts.map(item => item.id))).setIn(['follow_requests', 'next'], action.next);
+  case FOLLOW_REQUEST_AUTHORIZE_SUCCESS:
+  case FOLLOW_REQUEST_REJECT_SUCCESS:
+    return state.updateIn(['follow_requests', 'items'], list => list.filterNot(item => item === action.id));
+  default:
+    return state;
   }
 };
diff --git a/app/assets/javascripts/components/store/configureStore.jsx b/app/assets/javascripts/components/store/configureStore.jsx
index 2c1476e5d..ad0427b52 100644
--- a/app/assets/javascripts/components/store/configureStore.jsx
+++ b/app/assets/javascripts/components/store/configureStore.jsx
@@ -1,12 +1,23 @@
 import { createStore, applyMiddleware, compose } from 'redux';
 import thunk from 'redux-thunk';
 import appReducer from '../reducers';
-import { loadingBarMiddleware } from 'react-redux-loading-bar';
+import loadingBarMiddleware from '../middleware/loading_bar';
 import errorsMiddleware from '../middleware/errors';
+import soundsMiddleware from 'redux-sounds';
+import Howler from 'howler';
 import Immutable from 'immutable';
 
+Howler.mobileAutoEnable = false;
+
+const soundsData = {
+  boop: '/sounds/boop.mp3'
+};
+
 export default function configureStore() {
-  return createStore(appReducer, compose(applyMiddleware(thunk, loadingBarMiddleware({
-    promiseTypeSuffixes: ['REQUEST', 'SUCCESS', 'FAIL'],
-  }), errorsMiddleware()), window.devToolsExtension ? window.devToolsExtension() : f => f));
+  return createStore(appReducer, compose(applyMiddleware(
+    thunk,
+    loadingBarMiddleware({ promiseTypeSuffixes: ['REQUEST', 'SUCCESS', 'FAIL'] }),
+    errorsMiddleware(),
+    soundsMiddleware(soundsData)
+  ), window.devToolsExtension ? window.devToolsExtension() : f => f));
 };
diff --git a/app/assets/stylesheets/about.scss b/app/assets/stylesheets/about.scss
index 674d1eb28..13c67d496 100644
--- a/app/assets/stylesheets/about.scss
+++ b/app/assets/stylesheets/about.scss
@@ -245,6 +245,7 @@
           margin: 0;
           font-family: inherit;
           font-size: 13px;
+          line-height: 18px;
 
           a {
             display: block;
diff --git a/app/assets/stylesheets/components.scss b/app/assets/stylesheets/components.scss
index f1edfce9d..7e61323ab 100644
--- a/app/assets/stylesheets/components.scss
+++ b/app/assets/stylesheets/components.scss
@@ -183,7 +183,7 @@
   }
 }
 
-.status__display-name, .status__relative-time, .detailed-status__display-name, .detailed-status__datetime, .account__display-name {
+.status__display-name, .status__relative-time, .detailed-status__display-name, .detailed-status__datetime, .detailed-status__application, .account__display-name {
   text-decoration: none;
 }
 
@@ -662,3 +662,27 @@
     border-bottom-color: #2b90d9;
   }
 }
+
+button i.fa-retweet {
+  height: 19px;
+  width: 22px;
+  background: image-url('boost_sprite.png') no-repeat;
+  background-position: 0 0;
+  transition: background-position 0.9s steps(11);
+  transition-duration: 0s;
+
+  &::before {
+    display: none !important;
+  }
+}
+
+button.active i.fa-retweet {
+  transition-duration: 0.9s;
+  background-position: 0 -209px;
+}
+
+.status-card {
+  &:hover {
+    background: #363c4b;
+  }
+}
diff --git a/app/controllers/api/v1/apps_controller.rb b/app/controllers/api/v1/apps_controller.rb
index 1b33770f4..ca9dd0b7e 100644
--- a/app/controllers/api/v1/apps_controller.rb
+++ b/app/controllers/api/v1/apps_controller.rb
@@ -4,6 +4,6 @@ class Api::V1::AppsController < ApiController
   respond_to :json
 
   def create
-    @app = Doorkeeper::Application.create!(name: params[:client_name], redirect_uri: params[:redirect_uris], scopes: (params[:scopes] || Doorkeeper.configuration.default_scopes))
+    @app = Doorkeeper::Application.create!(name: params[:client_name], redirect_uri: params[:redirect_uris], scopes: (params[:scopes] || Doorkeeper.configuration.default_scopes), website: params[:website])
   end
 end
diff --git a/app/controllers/api/v1/favourites_controller.rb b/app/controllers/api/v1/favourites_controller.rb
index a71592acd..ea799fd55 100644
--- a/app/controllers/api/v1/favourites_controller.rb
+++ b/app/controllers/api/v1/favourites_controller.rb
@@ -13,7 +13,7 @@ class Api::V1::FavouritesController < ApiController
     set_maps(@statuses)
     set_counters_maps(@statuses)
 
-    next_path = api_v1_favourites_url(max_id: results.last.id)    if results.size == DEFAULT_ACCOUNTS_LIMIT
+    next_path = api_v1_favourites_url(max_id: results.last.id)    if results.size == DEFAULT_STATUSES_LIMIT
     prev_path = api_v1_favourites_url(since_id: results.first.id) unless results.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 c8f162cb0..3fd701997 100644
--- a/app/controllers/api/v1/notifications_controller.rb
+++ b/app/controllers/api/v1/notifications_controller.rb
@@ -20,4 +20,8 @@ class Api::V1::NotificationsController < ApiController
 
     set_pagination_headers(next_path, prev_path)
   end
+
+  def show
+    @notification = Notification.where(account: current_account).find(params[:id])
+  end
 end
diff --git a/app/controllers/api/v1/statuses_controller.rb b/app/controllers/api/v1/statuses_controller.rb
index f7b4ed610..37ed5e6dd 100644
--- a/app/controllers/api/v1/statuses_controller.rb
+++ b/app/controllers/api/v1/statuses_controller.rb
@@ -3,8 +3,8 @@
 class Api::V1::StatusesController < ApiController
   before_action -> { doorkeeper_authorize! :read }, except: [:create, :destroy, :reblog, :unreblog, :favourite, :unfavourite]
   before_action -> { doorkeeper_authorize! :write }, only:  [:create, :destroy, :reblog, :unreblog, :favourite, :unfavourite]
-  before_action :require_user!, except: [:show, :context, :reblogged_by, :favourited_by]
-  before_action :set_status, only:      [:show, :context, :reblogged_by, :favourited_by]
+  before_action :require_user!, except: [:show, :context, :card, :reblogged_by, :favourited_by]
+  before_action :set_status, only:      [:show, :context, :card, :reblogged_by, :favourited_by]
 
   respond_to :json
 
@@ -14,13 +14,17 @@ class Api::V1::StatusesController < ApiController
   end
 
   def context
-    @context = OpenStruct.new(ancestors: @status.ancestors(current_account), descendants: @status.descendants(current_account))
+    @context = OpenStruct.new(ancestors: @status.in_reply_to_id.nil? ? [] : @status.ancestors(current_account), descendants: @status.descendants(current_account))
     statuses = [@status] + @context[:ancestors] + @context[:descendants]
 
     set_maps(statuses)
     set_counters_maps(statuses)
   end
 
+  def card
+    @card = PreviewCard.find_by!(status: @status)
+  end
+
   def reblogged_by
     results   = @status.reblogs.paginate_by_max_id(DEFAULT_ACCOUNTS_LIMIT, params[:max_id], params[:since_id])
     accounts  = Account.where(id: results.map(&:account_id)).map { |a| [a.id, a] }.to_h
@@ -52,7 +56,7 @@ class Api::V1::StatusesController < ApiController
   end
 
   def create
-    @status = PostStatusService.new.call(current_user.account, params[:status], params[:in_reply_to_id].blank? ? nil : Status.find(params[:in_reply_to_id]), media_ids: params[:media_ids], sensitive: params[:sensitive], visibility: params[:visibility])
+    @status = PostStatusService.new.call(current_user.account, params[:status], params[:in_reply_to_id].blank? ? nil : Status.find(params[:in_reply_to_id]), media_ids: params[:media_ids], sensitive: params[:sensitive], visibility: params[:visibility], application: doorkeeper_token.application)
     render action: :show
   end
 
diff --git a/app/lib/application_extension.rb b/app/lib/application_extension.rb
new file mode 100644
index 000000000..93c0f42f0
--- /dev/null
+++ b/app/lib/application_extension.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+module ApplicationExtension
+  extend ActiveSupport::Concern
+
+  included do
+    validates :website, url: true, unless: 'website.blank?'
+  end
+end
diff --git a/app/lib/url_validator.rb b/app/lib/url_validator.rb
new file mode 100644
index 000000000..4a5c4ef3f
--- /dev/null
+++ b/app/lib/url_validator.rb
@@ -0,0 +1,14 @@
+# frozen_string_literal: true
+
+class UrlValidator < ActiveModel::EachValidator
+  def validate_each(record, attribute, value)
+    record.errors.add(attribute, I18n.t('applications.invalid_url')) unless compliant?(value)
+  end
+
+  private
+
+  def compliant?(url)
+    parsed_url = Addressable::URI.parse(url)
+    !parsed_url.nil? && %w(http https).include?(parsed_url.scheme) && parsed_url.host
+  end
+end
diff --git a/app/models/account.rb b/app/models/account.rb
index 5f07615fc..c2a41c4c6 100644
--- a/app/models/account.rb
+++ b/app/models/account.rb
@@ -125,13 +125,10 @@ class Account < ApplicationRecord
 
   def save_with_optional_avatar!
     save!
-  rescue ActiveRecord::RecordInvalid => invalid
-    if invalid.record.errors[:avatar_file_size] || invalid[:avatar_content_type]
-      self.avatar = nil
-      retry
-    end
-
-    raise invalid
+  rescue ActiveRecord::RecordInvalid
+    self.avatar              = nil
+    self[:avatar_remote_url] = ''
+    save!
   end
 
   def avatar_remote_url=(url)
diff --git a/app/models/preview_card.rb b/app/models/preview_card.rb
new file mode 100644
index 000000000..e59b05eb8
--- /dev/null
+++ b/app/models/preview_card.rb
@@ -0,0 +1,20 @@
+# frozen_string_literal: true
+
+class PreviewCard < ApplicationRecord
+  IMAGE_MIME_TYPES = ['image/jpeg', 'image/png', 'image/gif'].freeze
+
+  belongs_to :status
+
+  has_attached_file :image, styles: { original: '120x120#' }, convert_options: { all: '-quality 80 -strip' }
+
+  validates :url, presence: true
+  validates_attachment_content_type :image, content_type: IMAGE_MIME_TYPES
+  validates_attachment_size :image, less_than: 1.megabytes
+
+  def save_with_optional_image!
+    save!
+  rescue ActiveRecord::RecordInvalid
+    self.image = nil
+    save!
+  end
+end
diff --git a/app/models/status.rb b/app/models/status.rb
index bc595c93b..d5f52b55c 100644
--- a/app/models/status.rb
+++ b/app/models/status.rb
@@ -7,6 +7,8 @@ class Status < ApplicationRecord
 
   enum visibility: [:public, :unlisted, :private], _suffix: :visibility
 
+  belongs_to :application, class_name: 'Doorkeeper::Application'
+
   belongs_to :account, inverse_of: :statuses
   belongs_to :in_reply_to_account, foreign_key: 'in_reply_to_account_id', class_name: 'Account'
 
@@ -21,6 +23,7 @@ class Status < ApplicationRecord
   has_and_belongs_to_many :tags
 
   has_one :notification, as: :activity, dependent: :destroy
+  has_one :preview_card, dependent: :destroy
 
   validates :account, presence: true
   validates :uri, uniqueness: true, unless: 'local?'
@@ -33,7 +36,7 @@ class Status < ApplicationRecord
   scope :remote, -> { where.not(uri: nil) }
   scope :local, -> { where(uri: nil) }
 
-  cache_associated :account, :media_attachments, :tags, :stream_entry, mentions: :account, reblog: [:account, :stream_entry, :tags, :media_attachments, mentions: :account], thread: :account
+  cache_associated :account, :application, :media_attachments, :tags, :stream_entry, mentions: :account, reblog: [:account, :application, :stream_entry, :tags, :media_attachments, mentions: :account], thread: :account
 
   def local?
     uri.nil?
diff --git a/app/services/fetch_link_card_service.rb b/app/services/fetch_link_card_service.rb
new file mode 100644
index 000000000..2779b79b5
--- /dev/null
+++ b/app/services/fetch_link_card_service.rb
@@ -0,0 +1,33 @@
+# frozen_string_literal: true
+
+class FetchLinkCardService < BaseService
+  def call(status)
+    # Get first URL
+    url = URI.extract(status.text).reject { |uri| (uri =~ /\Ahttps?:\/\//).nil? }.first
+
+    return if url.nil?
+
+    response = http_client.get(url)
+
+    return if response.code != 200
+
+    page = Nokogiri::HTML(response.to_s)
+    card = PreviewCard.where(status: status).first_or_initialize(status: status, url: url)
+
+    card.title       = meta_property(page, 'og:title') || page.at_xpath('//title')&.content
+    card.description = meta_property(page, 'og:description') || meta_property(page, 'description')
+    card.image       = URI.parse(meta_property(page, 'og:image')) if meta_property(page, 'og:image')
+
+    card.save_with_optional_image!
+  end
+
+  private
+
+  def http_client
+    HTTP.timeout(:per_operation, write: 10, connect: 10, read: 10).follow
+  end
+
+  def meta_property(html, property)
+    html.at_xpath("//meta[@property=\"#{property}\"]")&.attribute('content')&.value || html.at_xpath("//meta[@name=\"#{property}\"]")&.attribute('content')&.value
+  end
+end
diff --git a/app/services/follow_remote_account_service.rb b/app/services/follow_remote_account_service.rb
index f640222b0..d17cf0f45 100644
--- a/app/services/follow_remote_account_service.rb
+++ b/app/services/follow_remote_account_service.rb
@@ -14,7 +14,6 @@ class FollowRemoteAccountService < BaseService
     username, domain = uri.split('@')
 
     return Account.find_local(username) if TagManager.instance.local_domain?(domain)
-    return nil if DomainBlock.blocked?(domain)
 
     account = Account.find_remote(username, domain)
     return account unless account.nil?
@@ -41,6 +40,7 @@ class FollowRemoteAccountService < BaseService
     account.url         = data.link('http://webfinger.net/rel/profile-page').href
     account.public_key  = magic_key_to_pem(data.link('magic-public-key').href)
     account.private_key = nil
+    account.suspended   = true if DomainBlock.blocked?(domain)
 
     xml  = get_feed(account.remote_url)
     hubs = get_hubs(xml)
diff --git a/app/services/post_status_service.rb b/app/services/post_status_service.rb
index 55405c0db..8765ef5e3 100644
--- a/app/services/post_status_service.rb
+++ b/app/services/post_status_service.rb
@@ -7,14 +7,22 @@ class PostStatusService < BaseService
   # @param [Status] in_reply_to Optional status to reply to
   # @param [Hash] options
   # @option [Boolean] :sensitive
+  # @option [String] :visibility
   # @option [Enumerable] :media_ids Optional array of media IDs to attach
+  # @option [Doorkeeper::Application] :application
   # @return [Status]
   def call(account, text, in_reply_to = nil, options = {})
-    status = account.statuses.create!(text: text, thread: in_reply_to, sensitive: options[:sensitive], visibility: options[:visibility])
+    status = account.statuses.create!(text:        text,
+                                      thread:      in_reply_to,
+                                      sensitive:   options[:sensitive],
+                                      visibility:  options[:visibility],
+                                      application: options[:application])
+
     attach_media(status, options[:media_ids])
     process_mentions_service.call(status)
     process_hashtags_service.call(status)
 
+    LinkCrawlWorker.perform_async(status.id)
     DistributionWorker.perform_async(status.id)
     Pubsubhubbub::DistributionWorker.perform_async(status.stream_entry.id)
 
diff --git a/app/services/process_feed_service.rb b/app/services/process_feed_service.rb
index cc35e65b8..fad03e580 100644
--- a/app/services/process_feed_service.rb
+++ b/app/services/process_feed_service.rb
@@ -45,7 +45,7 @@ class ProcessFeedService < BaseService
       status = status_from_xml(@xml)
 
       return if status.nil?
-      
+
       if verb == :share
         original_status = status_from_xml(@xml.at_xpath('.//activity:object', activity: TagManager::AS_XMLNS))
         status.reblog   = original_status
@@ -61,6 +61,7 @@ class ProcessFeedService < BaseService
       status.save!
 
       NotifyService.new.call(status.reblog.account, status) if status.reblog? && status.reblog.account.local?
+      # LinkCrawlWorker.perform_async(status.reblog? ? status.reblog_of_id : status.id)
       Rails.logger.debug "Queuing remote status #{status.id} (#{id}) for distribution"
       DistributionWorker.perform_async(status.id)
       status
diff --git a/app/services/process_hashtags_service.rb b/app/services/process_hashtags_service.rb
index ddcc64aa5..8d7fbe92b 100644
--- a/app/services/process_hashtags_service.rb
+++ b/app/services/process_hashtags_service.rb
@@ -4,7 +4,7 @@ class ProcessHashtagsService < BaseService
   def call(status, tags = [])
     tags = status.text.scan(Tag::HASHTAG_RE).map(&:first) if status.local?
 
-    tags.map { |str| str.mb_chars.downcase }.uniq.each do |tag|
+    tags.map { |str| str.mb_chars.downcase }.uniq{ |t| t.to_s }.each do |tag|
       status.tags << Tag.where(name: tag).first_or_initialize(name: tag)
     end
 
diff --git a/app/services/update_remote_profile_service.rb b/app/services/update_remote_profile_service.rb
index d961eda39..cfa547996 100644
--- a/app/services/update_remote_profile_service.rb
+++ b/app/services/update_remote_profile_service.rb
@@ -10,7 +10,7 @@ class UpdateRemoteProfileService < BaseService
     unless author_xml.nil?
       account.display_name      = author_xml.at_xpath('./poco:displayName', poco: TagManager::POCO_XMLNS).content unless author_xml.at_xpath('./poco:displayName', poco: TagManager::POCO_XMLNS).nil?
       account.note              = author_xml.at_xpath('./poco:note', poco: TagManager::POCO_XMLNS).content unless author_xml.at_xpath('./poco:note', poco: TagManager::POCO_XMLNS).nil?
-      account.avatar_remote_url = author_xml.at_xpath('./xmlns:link[@rel="avatar"]', xmlns: TagManager::XMLNS)['href'] unless author_xml.at_xpath('./xmlns:link[@rel="avatar"]', xmlns: TagManager::XMLNS).nil? || author_xml.at_xpath('./xmlns:link[@rel="avatar"]', xmlns: TagManager::XMLNS)['href'].blank?
+      account.avatar_remote_url = author_xml.at_xpath('./xmlns:link[@rel="avatar"]', xmlns: TagManager::XMLNS)['href'] unless author_xml.at_xpath('./xmlns:link[@rel="avatar"]', xmlns: TagManager::XMLNS).nil? || author_xml.at_xpath('./xmlns:link[@rel="avatar"]', xmlns: TagManager::XMLNS)['href'].blank? || account.suspended?
     end
 
     old_hub_url     = account.hub_url
diff --git a/app/views/about/more.html.haml b/app/views/about/more.html.haml
index 8b0925f00..2de3bf986 100644
--- a/app/views/about/more.html.haml
+++ b/app/views/about/more.html.haml
@@ -31,21 +31,26 @@
       .panel
         .panel-header= t 'about.contact'
         .panel-body
-          .owner
-            .avatar= image_tag @contact_account.avatar.url
-            .name
-              = link_to TagManager.instance.url_for(@contact_account) do
-                %span.display_name.emojify= display_name(@contact_account)
-                %span.username= "@#{@contact_account.acct}"
+          - if @contact_account
+            .owner
+              .avatar= image_tag @contact_account.avatar.url
+              .name
+                = link_to TagManager.instance.url_for(@contact_account) do
+                  %span.display_name.emojify= display_name(@contact_account)
+                  %span.username= "@#{@contact_account.acct}"
 
-          .contact-email
-            = t 'about.business_email'
-            %strong= @contact_email
+          - unless @contact_email.blank?
+            .contact-email
+              = t 'about.business_email'
+              %strong= @contact_email
       .panel
         .panel-header= t 'about.links'
         .panel-list
           %ul
-            %li= link_to t('about.get_started'), new_user_registration_path
-            %li= link_to t('auth.login'), new_user_session_path
+            - if user_signed_in?
+              %li= link_to t('about.get_started'), root_path
+            - else
+              %li= link_to t('about.get_started'), new_user_registration_path
+              %li= link_to t('auth.login'), new_user_session_path
             %li= link_to t('about.terms'), terms_path
             %li= link_to t('about.source_code'), 'https://github.com/tootsuite/mastodon'
diff --git a/app/views/api/v1/apps/show.rabl b/app/views/api/v1/apps/show.rabl
new file mode 100644
index 000000000..6d9e607db
--- /dev/null
+++ b/app/views/api/v1/apps/show.rabl
@@ -0,0 +1,3 @@
+object @application
+
+attributes :name, :website
diff --git a/app/views/api/v1/statuses/_show.rabl b/app/views/api/v1/statuses/_show.rabl
index a3391a67e..a3fc78763 100644
--- a/app/views/api/v1/statuses/_show.rabl
+++ b/app/views/api/v1/statuses/_show.rabl
@@ -6,6 +6,10 @@ node(:url)              { |status| TagManager.instance.url_for(status) }
 node(:reblogs_count)    { |status| defined?(@reblogs_counts_map)    ? (@reblogs_counts_map[status.id]    || 0) : status.reblogs.count }
 node(:favourites_count) { |status| defined?(@favourites_counts_map) ? (@favourites_counts_map[status.id] || 0) : status.favourites.count }
 
+child :application do
+  extends 'api/v1/apps/show'
+end
+
 child :account do
   extends 'api/v1/accounts/show'
 end
diff --git a/app/views/api/v1/statuses/card.rabl b/app/views/api/v1/statuses/card.rabl
new file mode 100644
index 000000000..8ba8dcbb1
--- /dev/null
+++ b/app/views/api/v1/statuses/card.rabl
@@ -0,0 +1,5 @@
+object @card
+
+attributes :url, :title, :description
+
+node(:image) { |card| card.image? ? full_asset_url(card.image.url(:original)) : nil }
diff --git a/app/views/settings/shared/_links.html.haml b/app/views/settings/shared/_links.html.haml
index 44f097950..a6e90f457 100644
--- a/app/views/settings/shared/_links.html.haml
+++ b/app/views/settings/shared/_links.html.haml
@@ -5,3 +5,4 @@
     %li= link_to t('settings.preferences'), settings_preferences_path
   - if controller_name != 'registrations'
     %li= link_to t('auth.change_password'), edit_user_registration_path
+  %li= link_to t('settings.back'), root_path
\ No newline at end of file
diff --git a/app/views/stream_entries/_detailed_status.html.haml b/app/views/stream_entries/_detailed_status.html.haml
index 32f7c2e40..bc09d3597 100644
--- a/app/views/stream_entries/_detailed_status.html.haml
+++ b/app/views/stream_entries/_detailed_status.html.haml
@@ -28,10 +28,16 @@
     = link_to TagManager.instance.url_for(status), class: 'detailed-status__datetime u-url u-uid', target: @external_links ? '_blank' : nil, rel: 'noopener' do
       %span= l(status.created_at)
     ·
-    %span
+    - if status.application
+      - if status.application.website.blank?
+        %strong.detailed-status__application= status.application.name
+      - else
+        = link_to status.application.name, status.application.website, class: 'detailed-status__application', target: '_blank', rel: 'noopener'
+      ·
+    %span<
       = fa_icon('retweet')
       %span= status.reblogs.count
     ·
-    %span
+    %span<
       = fa_icon('star')
       %span= status.favourites.count
diff --git a/app/workers/link_crawl_worker.rb b/app/workers/link_crawl_worker.rb
new file mode 100644
index 000000000..af3394b8b
--- /dev/null
+++ b/app/workers/link_crawl_worker.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+class LinkCrawlWorker
+  include Sidekiq::Worker
+
+  sidekiq_options retry: false
+
+  def perform(status_id)
+    FetchLinkCardService.new.call(Status.find(status_id))
+  rescue ActiveRecord::RecordNotFound
+    true
+  end
+end