about summary refs log tree commit diff
diff options
context:
space:
mode:
-rw-r--r--Gemfile2
-rw-r--r--Gemfile.lock36
-rw-r--r--app/controllers/api/v1/accounts_controller.rb2
-rw-r--r--app/controllers/concerns/signature_verification.rb7
-rw-r--r--app/javascript/mastodon/actions/accounts.js18
-rw-r--r--app/javascript/mastodon/actions/timelines.js10
-rw-r--r--app/javascript/mastodon/components/scrollable_list.js75
-rw-r--r--app/javascript/mastodon/components/status.js9
-rw-r--r--app/javascript/mastodon/components/status_action_bar.js5
-rw-r--r--app/javascript/mastodon/components/status_list.js6
-rw-r--r--app/javascript/mastodon/features/account_timeline/index.js3
-rw-r--r--app/javascript/mastodon/features/status/components/action_bar.js2
-rw-r--r--app/javascript/mastodon/reducers/relationships.js12
-rw-r--r--app/javascript/styles/mastodon/components.scss12
-rw-r--r--app/javascript/styles/mastodon/forms.scss7
-rw-r--r--app/lib/request.rb2
-rw-r--r--app/services/concerns/author_extractor.rb2
-rw-r--r--app/services/follow_service.rb8
-rw-r--r--app/services/process_mentions_service.rb2
-rw-r--r--app/services/resolve_account_service.rb32
-rw-r--r--app/validators/follow_limit_validator.rb2
-rw-r--r--app/views/shared/_error_messages.html.haml2
-rw-r--r--app/workers/local_notification_worker.rb13
-rw-r--r--config/puma.rb4
-rw-r--r--lib/mastodon/media_cli.rb13
-rw-r--r--spec/controllers/authorize_interactions_controller_spec.rb4
26 files changed, 192 insertions, 98 deletions
diff --git a/Gemfile b/Gemfile
index 49d78d273..c1f5d1b99 100644
--- a/Gemfile
+++ b/Gemfile
@@ -123,7 +123,7 @@ group :development do
   gem 'annotate', '~> 2.7'
   gem 'better_errors', '~> 2.5'
   gem 'binding_of_caller', '~> 0.7'
-  gem 'bullet', '~> 5.7'
+  gem 'bullet', '~> 5.8'
   gem 'letter_opener', '~> 1.4'
   gem 'letter_opener_web', '~> 1.3'
   gem 'memory_profiler'
diff --git a/Gemfile.lock b/Gemfile.lock
index 8c0e05756..3737f2299 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -76,8 +76,8 @@ GEM
     av (0.9.0)
       cocaine (~> 0.5.3)
     aws-eventstream (1.0.1)
-    aws-partitions (1.106.0)
-    aws-sdk-core (3.35.0)
+    aws-partitions (1.107.0)
+    aws-sdk-core (3.36.0)
       aws-eventstream (~> 1.0)
       aws-partitions (~> 1.0)
       aws-sigv4 (~> 1.0)
@@ -85,7 +85,7 @@ GEM
     aws-sdk-kms (1.11.0)
       aws-sdk-core (~> 3, >= 3.26.0)
       aws-sigv4 (~> 1.0)
-    aws-sdk-s3 (1.23.0)
+    aws-sdk-s3 (1.23.1)
       aws-sdk-core (~> 3, >= 3.26.0)
       aws-sdk-kms (~> 1)
       aws-sigv4 (~> 1.0)
@@ -103,9 +103,9 @@ GEM
     brakeman (4.3.1)
     browser (2.5.3)
     builder (3.2.3)
-    bullet (5.7.6)
+    bullet (5.8.1)
       activesupport (>= 3.0.0)
-      uniform_notifier (~> 1.11.0)
+      uniform_notifier (~> 1.11)
     bundler-audit (0.6.0)
       bundler (~> 1.2)
       thor (~> 0.18)
@@ -126,7 +126,7 @@ GEM
       sshkit (~> 1.3)
     capistrano-yarn (2.0.2)
       capistrano (~> 3.0)
-    capybara (3.10.0)
+    capybara (3.10.1)
       addressable
       mini_mime (>= 0.1.3)
       nokogiri (~> 1.8)
@@ -254,8 +254,7 @@ GEM
     hashie (3.5.7)
     heapy (0.1.4)
     highline (2.0.0)
-    hiredis (0.6.1)
-    hitimes (1.3.0)
+    hiredis (0.6.3)
     hkdf (0.3.0)
     html2text (0.2.1)
       nokogiri (~> 1.6)
@@ -333,7 +332,7 @@ GEM
     mario-redis-lock (1.2.1)
       redis (>= 3.0.5)
     memory_profiler (0.9.12)
-    method_source (0.9.0)
+    method_source (0.9.1)
     microformats (4.0.7)
       json
       nokogiri
@@ -389,7 +388,7 @@ GEM
       av (~> 0.9.0)
       paperclip (>= 2.5.2)
     parallel (1.12.1)
-    parallel_tests (2.26.0)
+    parallel_tests (2.26.2)
       parallel
     parser (2.5.3.0)
       ast (~> 2.4.0)
@@ -399,7 +398,7 @@ GEM
     pg (1.1.3)
     pghero (2.2.0)
       activerecord
-    pkg-config (1.3.1)
+    pkg-config (1.3.2)
     powerpack (0.1.2)
     premailer (1.11.1)
       addressable
@@ -409,13 +408,13 @@ GEM
       actionmailer (>= 3, < 6)
       premailer (~> 1.7, >= 1.7.9)
     private_address_check (0.5.0)
-    pry (0.11.3)
+    pry (0.12.0)
       coderay (~> 1.1.0)
       method_source (~> 0.9.0)
     pry-byebug (3.6.0)
       byebug (~> 10.0)
       pry (~> 0.10)
-    pry-rails (0.3.6)
+    pry-rails (0.3.7)
       pry (>= 0.10.4)
     public_suffix (3.0.3)
     puma (3.12.0)
@@ -550,7 +549,7 @@ GEM
     scss_lint (0.57.1)
       rake (>= 0.9, < 13)
       sass (~> 3.5, >= 3.5.5)
-    sidekiq (5.2.2)
+    sidekiq (5.2.3)
       connection_pool (~> 2.2, >= 2.2.2)
       rack-protection (>= 1.5.0)
       redis (>= 3.3.5, < 5)
@@ -600,13 +599,12 @@ GEM
     thor (0.20.0)
     thread_safe (0.3.6)
     tilt (2.0.8)
-    timers (4.1.2)
-      hitimes
+    timers (4.2.0)
     tty-color (0.4.3)
     tty-command (0.8.2)
       pastel (~> 0.7.0)
     tty-cursor (0.6.0)
-    tty-prompt (0.17.1)
+    tty-prompt (0.17.2)
       necromancer (~> 0.4.0)
       pastel (~> 0.7.0)
       timers (~> 4.0)
@@ -627,7 +625,7 @@ GEM
       unf_ext
     unf_ext (0.0.7.5)
     unicode-display_width (1.4.0)
-    uniform_notifier (1.11.0)
+    uniform_notifier (1.12.1)
     warden (1.2.7)
       rack (>= 1.0)
     webmock (3.4.2)
@@ -662,7 +660,7 @@ DEPENDENCIES
   bootsnap (~> 1.3)
   brakeman (~> 4.3)
   browser
-  bullet (~> 5.7)
+  bullet (~> 5.8)
   bundler-audit (~> 0.6)
   capistrano (~> 3.11)
   capistrano-rails (~> 1.4)
diff --git a/app/controllers/api/v1/accounts_controller.rb b/app/controllers/api/v1/accounts_controller.rb
index 1d5372a8c..f711c4676 100644
--- a/app/controllers/api/v1/accounts_controller.rb
+++ b/app/controllers/api/v1/accounts_controller.rb
@@ -17,7 +17,7 @@ class Api::V1::AccountsController < Api::BaseController
   end
 
   def follow
-    FollowService.new.call(current_user.account, @account.acct, reblogs: truthy_param?(:reblogs))
+    FollowService.new.call(current_user.account, @account, reblogs: truthy_param?(:reblogs))
 
     options = @account.locked? ? {} : { following_map: { @account.id => { reblogs: truthy_param?(:reblogs) } }, requested_map: { @account.id => false } }
 
diff --git a/app/controllers/concerns/signature_verification.rb b/app/controllers/concerns/signature_verification.rb
index e5d5e2ca6..7e491641b 100644
--- a/app/controllers/concerns/signature_verification.rb
+++ b/app/controllers/concerns/signature_verification.rb
@@ -43,7 +43,12 @@ module SignatureVerification
       return
     end
 
-    account = account_from_key_id(signature_params['keyId'])
+    account_stoplight = Stoplight("source:#{request.ip}") { account_from_key_id(signature_params['keyId']) }
+      .with_fallback { nil }
+      .with_threshold(1)
+      .with_cool_off_time(5.minutes.seconds)
+
+    account = account_stoplight.run
 
     if account.nil?
       @signature_verification_failure_reason = "Public key not found for key #{signature_params['keyId']}"
diff --git a/app/javascript/mastodon/actions/accounts.js b/app/javascript/mastodon/actions/accounts.js
index cbae62a0f..d4a824e2c 100644
--- a/app/javascript/mastodon/actions/accounts.js
+++ b/app/javascript/mastodon/actions/accounts.js
@@ -145,12 +145,14 @@ export function fetchAccountFail(id, error) {
 export function followAccount(id, reblogs = true) {
   return (dispatch, getState) => {
     const alreadyFollowing = getState().getIn(['relationships', id, 'following']);
-    dispatch(followAccountRequest(id));
+    const locked = getState().getIn(['accounts', id, 'locked'], false);
+
+    dispatch(followAccountRequest(id, locked));
 
     api(getState).post(`/api/v1/accounts/${id}/follow`, { reblogs }).then(response => {
       dispatch(followAccountSuccess(response.data, alreadyFollowing));
     }).catch(error => {
-      dispatch(followAccountFail(error));
+      dispatch(followAccountFail(error, locked));
     });
   };
 };
@@ -167,10 +169,12 @@ export function unfollowAccount(id) {
   };
 };
 
-export function followAccountRequest(id) {
+export function followAccountRequest(id, locked) {
   return {
     type: ACCOUNT_FOLLOW_REQUEST,
     id,
+    locked,
+    skipLoading: true,
   };
 };
 
@@ -179,13 +183,16 @@ export function followAccountSuccess(relationship, alreadyFollowing) {
     type: ACCOUNT_FOLLOW_SUCCESS,
     relationship,
     alreadyFollowing,
+    skipLoading: true,
   };
 };
 
-export function followAccountFail(error) {
+export function followAccountFail(error, locked) {
   return {
     type: ACCOUNT_FOLLOW_FAIL,
     error,
+    locked,
+    skipLoading: true,
   };
 };
 
@@ -193,6 +200,7 @@ export function unfollowAccountRequest(id) {
   return {
     type: ACCOUNT_UNFOLLOW_REQUEST,
     id,
+    skipLoading: true,
   };
 };
 
@@ -201,6 +209,7 @@ export function unfollowAccountSuccess(relationship, statuses) {
     type: ACCOUNT_UNFOLLOW_SUCCESS,
     relationship,
     statuses,
+    skipLoading: true,
   };
 };
 
@@ -208,6 +217,7 @@ export function unfollowAccountFail(error) {
   return {
     type: ACCOUNT_UNFOLLOW_FAIL,
     error,
+    skipLoading: true,
   };
 };
 
diff --git a/app/javascript/mastodon/actions/timelines.js b/app/javascript/mastodon/actions/timelines.js
index 2b7962a6e..8cb06c157 100644
--- a/app/javascript/mastodon/actions/timelines.js
+++ b/app/javascript/mastodon/actions/timelines.js
@@ -97,13 +97,12 @@ export const expandAccountTimeline         = (accountId, { maxId, withReplies }
 export const expandAccountFeaturedTimeline = accountId => expandTimeline(`account:${accountId}:pinned`, `/api/v1/accounts/${accountId}/statuses`, { pinned: true });
 export const expandAccountMediaTimeline    = (accountId, { maxId } = {}) => expandTimeline(`account:${accountId}:media`, `/api/v1/accounts/${accountId}/statuses`, { max_id: maxId, only_media: true });
 export const expandListTimeline            = (id, { maxId } = {}, done = noOp) => expandTimeline(`list:${id}`, `/api/v1/timelines/list/${id}`, { max_id: maxId }, done);
-
 export const expandHashtagTimeline         = (hashtag, { maxId, tags } = {}, done = noOp) => {
   return expandTimeline(`hashtag:${hashtag}`, `/api/v1/timelines/tag/${hashtag}`, {
     max_id: maxId,
-    any: parseTags(tags, 'any'),
-    all: parseTags(tags, 'all'),
-    none: parseTags(tags, 'none'),
+    any:    parseTags(tags, 'any'),
+    all:    parseTags(tags, 'all'),
+    none:   parseTags(tags, 'none'),
   }, done);
 };
 
@@ -111,6 +110,7 @@ export function expandTimelineRequest(timeline) {
   return {
     type: TIMELINE_EXPAND_REQUEST,
     timeline,
+    skipLoading: true,
   };
 };
 
@@ -121,6 +121,7 @@ export function expandTimelineSuccess(timeline, statuses, next, partial) {
     statuses,
     next,
     partial,
+    skipLoading: true,
   };
 };
 
@@ -129,6 +130,7 @@ export function expandTimelineFail(timeline, error) {
     type: TIMELINE_EXPAND_FAIL,
     timeline,
     error,
+    skipLoading: true,
   };
 };
 
diff --git a/app/javascript/mastodon/components/scrollable_list.js b/app/javascript/mastodon/components/scrollable_list.js
index e51c83c2b..91a895bce 100644
--- a/app/javascript/mastodon/components/scrollable_list.js
+++ b/app/javascript/mastodon/components/scrollable_list.js
@@ -8,6 +8,7 @@ import { throttle } from 'lodash';
 import { List as ImmutableList } from 'immutable';
 import classNames from 'classnames';
 import { attachFullscreenListener, detachFullscreenListener, isFullscreen } from '../features/ui/util/fullscreen';
+import LoadingIndicator from './loading_indicator';
 
 const MOUSE_IDLE_DELAY = 300;
 
@@ -25,6 +26,7 @@ export default class ScrollableList extends PureComponent {
     trackScroll: PropTypes.bool,
     shouldUpdateScroll: PropTypes.func,
     isLoading: PropTypes.bool,
+    showLoading: PropTypes.bool,
     hasMore: PropTypes.bool,
     prepend: PropTypes.node,
     alwaysPrepend: PropTypes.bool,
@@ -39,8 +41,6 @@ export default class ScrollableList extends PureComponent {
 
   state = {
     fullscreen: null,
-    mouseMovedRecently: false,
-    scrollToTopOnMouseIdle: false,
   };
 
   intersectionObserverWrapper = new IntersectionObserverWrapper();
@@ -65,11 +65,14 @@ export default class ScrollableList extends PureComponent {
   });
 
   mouseIdleTimer = null;
+  mouseMovedRecently = false;
+  scrollToTopOnMouseIdle = false;
 
   clearMouseIdleTimer = () => {
     if (this.mouseIdleTimer === null) {
       return;
     }
+
     clearTimeout(this.mouseIdleTimer);
     this.mouseIdleTimer = null;
   };
@@ -77,37 +80,36 @@ export default class ScrollableList extends PureComponent {
   handleMouseMove = throttle(() => {
     // As long as the mouse keeps moving, clear and restart the idle timer.
     this.clearMouseIdleTimer();
-    this.mouseIdleTimer =
-      setTimeout(this.handleMouseIdle, MOUSE_IDLE_DELAY);
-
-    this.setState(({
-      mouseMovedRecently,
-      scrollToTopOnMouseIdle,
-    }) => ({
-      mouseMovedRecently: true,
-      // Only set scrollToTopOnMouseIdle if we just started moving and were
-      // scrolled to the top. Otherwise, just retain the previous state.
-      scrollToTopOnMouseIdle:
-        mouseMovedRecently
-          ? scrollToTopOnMouseIdle
-          : (this.node.scrollTop === 0),
-    }));
+    this.mouseIdleTimer = setTimeout(this.handleMouseIdle, MOUSE_IDLE_DELAY);
+
+    if (!this.mouseMovedRecently && this.node.scrollTop === 0) {
+      // Only set if we just started moving and are scrolled to the top.
+      this.scrollToTopOnMouseIdle = true;
+    }
+
+    // Save setting this flag for last, so we can do the comparison above.
+    this.mouseMovedRecently = true;
   }, MOUSE_IDLE_DELAY / 2);
 
+  handleWheel = throttle(() => {
+    this.scrollToTopOnMouseIdle = false;
+  }, 150, {
+    trailing: true,
+  });
+
   handleMouseIdle = () => {
-    if (this.state.scrollToTopOnMouseIdle) {
+    if (this.scrollToTopOnMouseIdle) {
       this.node.scrollTop = 0;
-      this.props.onScrollToTop();
     }
-    this.setState({
-      mouseMovedRecently: false,
-      scrollToTopOnMouseIdle: false,
-    });
+
+    this.mouseMovedRecently = false;
+    this.scrollToTopOnMouseIdle = false;
   }
 
   componentDidMount () {
     this.attachScrollListener();
     this.attachIntersectionObserver();
+
     attachFullscreenListener(this.onFullScreenChange);
 
     // Handle initial scroll posiiton
@@ -118,7 +120,8 @@ export default class ScrollableList extends PureComponent {
     const someItemInserted = React.Children.count(prevProps.children) > 0 &&
       React.Children.count(prevProps.children) < React.Children.count(this.props.children) &&
       this.getFirstChildKey(prevProps) !== this.getFirstChildKey(this.props);
-    if ((someItemInserted && this.node.scrollTop > 0) || this.state.mouseMovedRecently) {
+
+    if ((someItemInserted && this.node.scrollTop > 0) || this.mouseMovedRecently) {
       return this.node.scrollHeight - this.node.scrollTop;
     } else {
       return null;
@@ -161,20 +164,24 @@ export default class ScrollableList extends PureComponent {
 
   attachScrollListener () {
     this.node.addEventListener('scroll', this.handleScroll);
+    this.node.addEventListener('wheel', this.handleWheel);
   }
 
   detachScrollListener () {
     this.node.removeEventListener('scroll', this.handleScroll);
+    this.node.removeEventListener('wheel', this.handleWheel);
   }
 
   getFirstChildKey (props) {
     const { children } = props;
-    let firstChild = children;
+    let firstChild     = children;
+
     if (children instanceof ImmutableList) {
       firstChild = children.get(0);
     } else if (Array.isArray(children)) {
       firstChild = children[0];
     }
+
     return firstChild && firstChild.key;
   }
 
@@ -182,20 +189,32 @@ export default class ScrollableList extends PureComponent {
     this.node = c;
   }
 
-  handleLoadMore = (e) => {
+  handleLoadMore = e => {
     e.preventDefault();
     this.props.onLoadMore();
   }
 
   render () {
-    const { children, scrollKey, trackScroll, shouldUpdateScroll, isLoading, hasMore, prepend, alwaysPrepend, alwaysShowScrollbar, emptyMessage, onLoadMore } = this.props;
+    const { children, scrollKey, trackScroll, shouldUpdateScroll, showLoading, isLoading, hasMore, prepend, alwaysPrepend, alwaysShowScrollbar, emptyMessage, onLoadMore } = this.props;
     const { fullscreen } = this.state;
     const childrenCount = React.Children.count(children);
 
     const loadMore     = (hasMore && childrenCount > 0 && onLoadMore) ? <LoadMore visible={!isLoading} onClick={this.handleLoadMore} /> : null;
     let scrollableArea = null;
 
-    if (isLoading || childrenCount > 0 || !emptyMessage) {
+    if (showLoading) {
+      scrollableArea = (
+        <div className='scrollable scrollable--flex' ref={this.setRef}>
+          <div role='feed' className='item-list'>
+            {prepend}
+          </div>
+
+          <div className='scrollable__append'>
+            <LoadingIndicator />
+          </div>
+        </div>
+      );
+    } else if (isLoading || childrenCount > 0 || !emptyMessage) {
       scrollableArea = (
         <div className={classNames('scrollable', { fullscreen })} ref={this.setRef} onMouseMove={this.handleMouseMove}>
           <div role='feed' className='item-list'>
diff --git a/app/javascript/mastodon/components/status.js b/app/javascript/mastodon/components/status.js
index 9fa8cc008..fd0780025 100644
--- a/app/javascript/mastodon/components/status.js
+++ b/app/javascript/mastodon/components/status.js
@@ -67,6 +67,7 @@ class Status extends ImmutablePureComponent {
     unread: PropTypes.bool,
     onMoveUp: PropTypes.func,
     onMoveDown: PropTypes.func,
+    showThread: PropTypes.bool,
   };
 
   // Avoid checking props that are functions (and whose equality will always
@@ -168,7 +169,7 @@ class Status extends ImmutablePureComponent {
     let media = null;
     let statusAvatar, prepend, rebloggedByText;
 
-    const { intl, hidden, featured, otherAccounts, unread } = this.props;
+    const { intl, hidden, featured, otherAccounts, unread, showThread } = this.props;
 
     let { status, account, ...other } = this.props;
 
@@ -309,6 +310,12 @@ class Status extends ImmutablePureComponent {
 
             {media}
 
+            {showThread && status.get('in_reply_to_id') && status.get('in_reply_to_account_id') === status.getIn(['account', 'id']) && (
+              <button className='status__content__read-more-button' onClick={this.handleClick}>
+                <FormattedMessage id='status.show_thread' defaultMessage='Show thread' />
+              </button>
+            )}
+
             <StatusActionBar status={status} account={account} {...other} />
           </div>
         </div>
diff --git a/app/javascript/mastodon/components/status_action_bar.js b/app/javascript/mastodon/components/status_action_bar.js
index e7e5b0a6c..68a1fda24 100644
--- a/app/javascript/mastodon/components/status_action_bar.js
+++ b/app/javascript/mastodon/components/status_action_bar.js
@@ -148,7 +148,6 @@ class StatusActionBar extends ImmutablePureComponent {
 
     let menu = [];
     let reblogIcon = 'retweet';
-    let replyIcon;
     let replyTitle;
 
     menu.push({ text: intl.formatMessage(messages.open), action: this.handleOpen });
@@ -191,10 +190,8 @@ class StatusActionBar extends ImmutablePureComponent {
     }
 
     if (status.get('in_reply_to_id', null) === null) {
-      replyIcon = 'reply';
       replyTitle = intl.formatMessage(messages.reply);
     } else {
-      replyIcon = 'reply-all';
       replyTitle = intl.formatMessage(messages.replyAll);
     }
 
@@ -204,7 +201,7 @@ class StatusActionBar extends ImmutablePureComponent {
 
     return (
       <div className='status__action-bar'>
-        <div className='status__action-bar__counter'><IconButton className='status__action-bar-button' disabled={anonymousAccess} title={replyTitle} icon={replyIcon} onClick={this.handleReplyClick} /><span className='status__action-bar__counter__label' >{obfuscatedCount(status.get('replies_count'))}</span></div>
+        <div className='status__action-bar__counter'><IconButton className='status__action-bar-button' disabled={anonymousAccess} title={replyTitle} icon='reply' onClick={this.handleReplyClick} /><span className='status__action-bar__counter__label' >{obfuscatedCount(status.get('replies_count'))}</span></div>
         <IconButton className='status__action-bar-button' disabled={anonymousAccess || !publicStatus} active={status.get('reblogged')} pressed={status.get('reblogged')} title={!publicStatus ? intl.formatMessage(messages.cannot_reblog) : intl.formatMessage(messages.reblog)} icon={reblogIcon} onClick={this.handleReblogClick} />
         <IconButton className='status__action-bar-button star-icon' disabled={anonymousAccess} animate active={status.get('favourited')} pressed={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} />
         {shareButton}
diff --git a/app/javascript/mastodon/components/status_list.js b/app/javascript/mastodon/components/status_list.js
index 37f21fb44..01cc05661 100644
--- a/app/javascript/mastodon/components/status_list.js
+++ b/app/javascript/mastodon/components/status_list.js
@@ -25,7 +25,7 @@ export default class StatusList extends ImmutablePureComponent {
     prepend: PropTypes.node,
     emptyMessage: PropTypes.node,
     alwaysPrepend: PropTypes.bool,
-    timelineId: PropTypes.string.isRequired,
+    timelineId: PropTypes.string,
   };
 
   static defaultProps = {
@@ -104,6 +104,7 @@ export default class StatusList extends ImmutablePureComponent {
           onMoveUp={this.handleMoveUp}
           onMoveDown={this.handleMoveDown}
           contextType={timelineId}
+          showThread
         />
       ))
     ) : null;
@@ -117,12 +118,13 @@ export default class StatusList extends ImmutablePureComponent {
           onMoveUp={this.handleMoveUp}
           onMoveDown={this.handleMoveDown}
           contextType={timelineId}
+          showThread
         />
       )).concat(scrollableContent);
     }
 
     return (
-      <ScrollableList {...other} onLoadMore={onLoadMore && this.handleLoadOlder} shouldUpdateScroll={shouldUpdateScroll} ref={this.setRef}>
+      <ScrollableList {...other} showLoading={isLoading && statusIds.size === 0} onLoadMore={onLoadMore && this.handleLoadOlder} shouldUpdateScroll={shouldUpdateScroll} ref={this.setRef}>
         {scrollableContent}
       </ScrollableList>
     );
diff --git a/app/javascript/mastodon/features/account_timeline/index.js b/app/javascript/mastodon/features/account_timeline/index.js
index 6055af51d..afc484c60 100644
--- a/app/javascript/mastodon/features/account_timeline/index.js
+++ b/app/javascript/mastodon/features/account_timeline/index.js
@@ -11,6 +11,7 @@ import HeaderContainer from './containers/header_container';
 import ColumnBackButton from '../../components/column_back_button';
 import { List as ImmutableList } from 'immutable';
 import ImmutablePureComponent from 'react-immutable-pure-component';
+import { FormattedMessage } from 'react-intl';
 
 const mapStateToProps = (state, { params: { accountId }, withReplies = false }) => {
   const path = withReplies ? `${accountId}:with_replies` : accountId;
@@ -78,6 +79,7 @@ class AccountTimeline extends ImmutablePureComponent {
 
         <StatusList
           prepend={<HeaderContainer accountId={this.props.params.accountId} />}
+          alwaysPrepend
           scrollKey='account_timeline'
           statusIds={statusIds}
           featuredStatusIds={featuredStatusIds}
@@ -85,6 +87,7 @@ class AccountTimeline extends ImmutablePureComponent {
           hasMore={hasMore}
           onLoadMore={this.handleLoadMore}
           shouldUpdateScroll={shouldUpdateScroll}
+          emptyMessage={<FormattedMessage id='empty_column.account_timeline' defaultMessage='No toots here!' />}
         />
       </Column>
     );
diff --git a/app/javascript/mastodon/features/status/components/action_bar.js b/app/javascript/mastodon/features/status/components/action_bar.js
index fa6fd56e5..565009be2 100644
--- a/app/javascript/mastodon/features/status/components/action_bar.js
+++ b/app/javascript/mastodon/features/status/components/action_bar.js
@@ -159,7 +159,7 @@ class ActionBar extends React.PureComponent {
 
     return (
       <div className='detailed-status__action-bar'>
-        <div className='detailed-status__button'><IconButton title={intl.formatMessage(messages.reply)} icon={status.get('in_reply_to_id', null) === null ? 'reply' : 'reply-all'} onClick={this.handleReplyClick} /></div>
+        <div className='detailed-status__button'><IconButton title={intl.formatMessage(messages.reply)} icon='reply' onClick={this.handleReplyClick} /></div>
         <div className='detailed-status__button'><IconButton disabled={reblog_disabled} active={status.get('reblogged')} title={reblog_disabled ? intl.formatMessage(messages.cannot_reblog) : intl.formatMessage(messages.reblog)} icon={reblogIcon} onClick={this.handleReblogClick} /></div>
         <div className='detailed-status__button'><IconButton className='star-icon' animate active={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} /></div>
         {shareButton}
diff --git a/app/javascript/mastodon/reducers/relationships.js b/app/javascript/mastodon/reducers/relationships.js
index f46049297..8322780de 100644
--- a/app/javascript/mastodon/reducers/relationships.js
+++ b/app/javascript/mastodon/reducers/relationships.js
@@ -1,6 +1,10 @@
 import {
   ACCOUNT_FOLLOW_SUCCESS,
+  ACCOUNT_FOLLOW_REQUEST,
+  ACCOUNT_FOLLOW_FAIL,
   ACCOUNT_UNFOLLOW_SUCCESS,
+  ACCOUNT_UNFOLLOW_REQUEST,
+  ACCOUNT_UNFOLLOW_FAIL,
   ACCOUNT_BLOCK_SUCCESS,
   ACCOUNT_UNBLOCK_SUCCESS,
   ACCOUNT_MUTE_SUCCESS,
@@ -37,6 +41,14 @@ const initialState = ImmutableMap();
 
 export default function relationships(state = initialState, action) {
   switch(action.type) {
+  case ACCOUNT_FOLLOW_REQUEST:
+    return state.setIn([action.id, action.locked ? 'requested' : 'following'], true);
+  case ACCOUNT_FOLLOW_FAIL:
+    return state.setIn([action.id, action.locked ? 'requested' : 'following'], false);
+  case ACCOUNT_UNFOLLOW_REQUEST:
+    return state.setIn([action.id, 'following'], false);
+  case ACCOUNT_UNFOLLOW_FAIL:
+    return state.setIn([action.id, 'following'], true);
   case ACCOUNT_FOLLOW_SUCCESS:
   case ACCOUNT_UNFOLLOW_SUCCESS:
   case ACCOUNT_BLOCK_SUCCESS:
diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss
index e669bf2e2..0ee0b002f 100644
--- a/app/javascript/styles/mastodon/components.scss
+++ b/app/javascript/styles/mastodon/components.scss
@@ -1847,7 +1847,7 @@ a.account__display-name {
 }
 
 .column {
-  width: 330px;
+  width: 350px;
   position: relative;
   box-sizing: border-box;
   display: flex;
@@ -2092,6 +2092,16 @@ a.account__display-name {
   @supports(display: grid) { // hack to fix Chrome <57
     contain: strict;
   }
+
+  &--flex {
+    display: flex;
+    flex-direction: column;
+  }
+
+  &__append {
+    flex: 1 1 auto;
+    position: relative;
+  }
 }
 
 .scrollable.fullscreen {
diff --git a/app/javascript/styles/mastodon/forms.scss b/app/javascript/styles/mastodon/forms.scss
index 8c4c934ea..46ef85774 100644
--- a/app/javascript/styles/mastodon/forms.scss
+++ b/app/javascript/styles/mastodon/forms.scss
@@ -330,9 +330,12 @@ code {
     }
 
     input[type=text],
+    input[type=number],
     input[type=email],
-    input[type=password] {
-      border-bottom-color: $valid-value-color;
+    input[type=password],
+    textarea,
+    select {
+      border-color: lighten($error-red, 12%);
     }
 
     .error {
diff --git a/app/lib/request.rb b/app/lib/request.rb
index 36c211dbf..73b495ce1 100644
--- a/app/lib/request.rb
+++ b/app/lib/request.rb
@@ -94,7 +94,7 @@ class Request
   end
 
   def timeout
-    { write: 10, connect: 10, read: 10 }
+    { connect: 1, read: 10, write: 10 }
   end
 
   def http_client
diff --git a/app/services/concerns/author_extractor.rb b/app/services/concerns/author_extractor.rb
index 1e00eb803..c2419e9ec 100644
--- a/app/services/concerns/author_extractor.rb
+++ b/app/services/concerns/author_extractor.rb
@@ -18,6 +18,6 @@ module AuthorExtractor
       acct   = "#{username}@#{domain}"
     end
 
-    ResolveAccountService.new.call(acct, update_profile)
+    ResolveAccountService.new.call(acct, update_profile: update_profile)
   end
 end
diff --git a/app/services/follow_service.rb b/app/services/follow_service.rb
index f6888a68d..0020bc9fe 100644
--- a/app/services/follow_service.rb
+++ b/app/services/follow_service.rb
@@ -7,9 +7,9 @@ class FollowService < BaseService
   # @param [Account] source_account From which to follow
   # @param [String, Account] uri User URI to follow in the form of username@domain (or account record)
   # @param [true, false, nil] reblogs Whether or not to show reblogs, defaults to true
-  def call(source_account, uri, reblogs: nil)
+  def call(source_account, target_account, reblogs: nil)
     reblogs = true if reblogs.nil?
-    target_account = uri.is_a?(Account) ? uri : ResolveAccountService.new.call(uri)
+    target_account = ResolveAccountService.new.call(target_account, skip_webfinger: true)
 
     raise ActiveRecord::RecordNotFound if target_account.nil? || target_account.id == source_account.id || target_account.suspended?
     raise Mastodon::NotPermittedError  if target_account.blocking?(source_account) || source_account.blocking?(target_account)
@@ -42,7 +42,7 @@ class FollowService < BaseService
     follow_request = FollowRequest.create!(account: source_account, target_account: target_account, show_reblogs: reblogs)
 
     if target_account.local?
-      NotifyService.new.call(target_account, follow_request)
+      LocalNotificationWorker.perform_async(target_account.id, follow_request.id, follow_request.class.name)
     elsif target_account.ostatus?
       NotificationWorker.perform_async(build_follow_request_xml(follow_request), source_account.id, target_account.id)
       AfterRemoteFollowRequestWorker.perform_async(follow_request.id)
@@ -57,7 +57,7 @@ class FollowService < BaseService
     follow = source_account.follow!(target_account, reblogs: reblogs)
 
     if target_account.local?
-      NotifyService.new.call(target_account, follow)
+      LocalNotificationWorker.perform_async(target_account.id, follow.id, follow.class.name)
     else
       Pubsubhubbub::SubscribeWorker.perform_async(target_account.id) unless target_account.subscribed?
       NotificationWorker.perform_async(build_follow_xml(follow), source_account.id, target_account.id)
diff --git a/app/services/process_mentions_service.rb b/app/services/process_mentions_service.rb
index b4641c4b4..ec7d33b1d 100644
--- a/app/services/process_mentions_service.rb
+++ b/app/services/process_mentions_service.rb
@@ -47,7 +47,7 @@ class ProcessMentionsService < BaseService
     mentioned_account = mention.account
 
     if mentioned_account.local?
-      LocalNotificationWorker.perform_async(mention.id)
+      LocalNotificationWorker.perform_async(mentioned_account.id, mention.id, mention.class.name)
     elsif mentioned_account.ostatus? && !@status.stream_entry.hidden?
       NotificationWorker.perform_async(ostatus_xml, @status.account_id, mentioned_account.id)
     elsif mentioned_account.activitypub?
diff --git a/app/services/resolve_account_service.rb b/app/services/resolve_account_service.rb
index 4323e7f06..c3064211d 100644
--- a/app/services/resolve_account_service.rb
+++ b/app/services/resolve_account_service.rb
@@ -9,17 +9,27 @@ class ResolveAccountService < BaseService
   # Find or create a local account for a remote user.
   # When creating, look up the user's webfinger and fetch all
   # important information from their feed
-  # @param [String] uri User URI in the form of username@domain
+  # @param [String, Account] uri User URI in the form of username@domain
+  # @param [Hash] options
   # @return [Account]
-  def call(uri, update_profile = true, redirected = nil)
-    @username, @domain = uri.split('@')
-    @update_profile    = update_profile
+  def call(uri, options = {})
+    @options = options
 
-    return Account.find_local(@username) if TagManager.instance.local_domain?(@domain)
+    if uri.is_a?(Account)
+      @account  = uri
+      @username = @account.username
+      @domain   = @account.domain
+
+      return @account if @account.local? || !webfinger_update_due?
+    else
+      @username, @domain = uri.split('@')
 
-    @account = Account.find_remote(@username, @domain)
+      return Account.find_local(@username) if TagManager.instance.local_domain?(@domain)
 
-    return @account unless webfinger_update_due?
+      @account = Account.find_remote(@username, @domain)
+
+      return @account unless webfinger_update_due?
+    end
 
     Rails.logger.debug "Looking up webfinger for #{uri}"
 
@@ -30,8 +40,8 @@ class ResolveAccountService < BaseService
     if confirmed_username.casecmp(@username).zero? && confirmed_domain.casecmp(@domain).zero?
       @username = confirmed_username
       @domain   = confirmed_domain
-    elsif redirected.nil?
-      return call("#{confirmed_username}@#{confirmed_domain}", update_profile, true)
+    elsif options[:redirected].nil?
+      return call("#{confirmed_username}@#{confirmed_domain}", options.merge(redirected: true))
     else
       Rails.logger.debug 'Requested and returned acct URIs do not match'
       return
@@ -76,7 +86,7 @@ class ResolveAccountService < BaseService
   end
 
   def webfinger_update_due?
-    @account.nil? || @account.possibly_stale?
+    @account.nil? || ((!@options[:skip_webfinger] || @account.ostatus?) && @account.possibly_stale?)
   end
 
   def activitypub_ready?
@@ -93,7 +103,7 @@ class ResolveAccountService < BaseService
   end
 
   def update_profile?
-    @update_profile
+    @options[:update_profile]
   end
 
   def handle_activitypub
diff --git a/app/validators/follow_limit_validator.rb b/app/validators/follow_limit_validator.rb
index eb083ed85..409bf0176 100644
--- a/app/validators/follow_limit_validator.rb
+++ b/app/validators/follow_limit_validator.rb
@@ -14,7 +14,7 @@ class FollowLimitValidator < ActiveModel::Validator
       if account.following_count < LIMIT
         LIMIT
       else
-        account.followers_count * RATIO
+        [(account.followers_count * RATIO).round, LIMIT].max
       end
     end
   end
diff --git a/app/views/shared/_error_messages.html.haml b/app/views/shared/_error_messages.html.haml
index b73890216..28becd6c4 100644
--- a/app/views/shared/_error_messages.html.haml
+++ b/app/views/shared/_error_messages.html.haml
@@ -1,3 +1,3 @@
 - if object.errors.any?
-  .flash-message#error_explanation
+  .flash-message.alert#error_explanation
     %strong= t('generic.validation_errors', count: object.errors.count)
diff --git a/app/workers/local_notification_worker.rb b/app/workers/local_notification_worker.rb
index 748270563..48635e498 100644
--- a/app/workers/local_notification_worker.rb
+++ b/app/workers/local_notification_worker.rb
@@ -3,9 +3,16 @@
 class LocalNotificationWorker
   include Sidekiq::Worker
 
-  def perform(mention_id)
-    mention = Mention.find(mention_id)
-    NotifyService.new.call(mention.account, mention)
+  def perform(receiver_account_id, activity_id = nil, activity_class_name = nil)
+    if activity_id.nil? && activity_class_name.nil?
+      activity = Mention.find(receiver_account_id)
+      receiver = activity.account
+    else
+      receiver = Account.find(receiver_account_id)
+      activity = activity_class_name.constantize.find(activity_id)
+    end
+
+    NotifyService.new.call(receiver, activity)
   rescue ActiveRecord::RecordNotFound
     true
   end
diff --git a/config/puma.rb b/config/puma.rb
index 5ebf5ed19..1afdb1c6d 100644
--- a/config/puma.rb
+++ b/config/puma.rb
@@ -13,7 +13,9 @@ workers     ENV.fetch('WEB_CONCURRENCY') { 2 }
 preload_app!
 
 on_worker_boot do
-  ActiveRecord::Base.establish_connection if defined?(ActiveRecord)
+  ActiveSupport.on_load(:active_record) do
+    ActiveRecord::Base.establish_connection
+  end
 end
 
 plugin :tmp_restart
diff --git a/lib/mastodon/media_cli.rb b/lib/mastodon/media_cli.rb
index 179d1b6b5..99660dd1d 100644
--- a/lib/mastodon/media_cli.rb
+++ b/lib/mastodon/media_cli.rb
@@ -6,6 +6,8 @@ require_relative 'cli_helper'
 
 module Mastodon
   class MediaCLI < Thor
+    include ActionView::Helpers::NumberHelper
+
     def self.exit_on_failure?
       true
     end
@@ -36,11 +38,13 @@ module Mastodon
       time_ago  = options[:days].days.ago
       queued    = 0
       processed = 0
-      dry_run = options[:dry_run] ? '(DRY RUN)' : ''
+      size      = 0
+      dry_run   = options[:dry_run] ? '(DRY RUN)' : ''
 
       if options[:background]
-        MediaAttachment.where.not(remote_url: '').where.not(file_file_name: nil).where('created_at < ?', time_ago).select(:id).reorder(nil).find_in_batches do |media_attachments|
+        MediaAttachment.where.not(remote_url: '').where.not(file_file_name: nil).where('created_at < ?', time_ago).select(:id, :file_file_size).reorder(nil).find_in_batches do |media_attachments|
           queued += media_attachments.size
+          size   += media_attachments.reduce(0) { |sum, m| sum + (m.file_file_size || 0) }
           Maintenance::UncacheMediaWorker.push_bulk(media_attachments.map(&:id)) unless options[:dry_run]
         end
       else
@@ -49,6 +53,7 @@ module Mastodon
             Maintenance::UncacheMediaWorker.new.perform(m) unless options[:dry_run]
             options[:verbose] ? say(m.id) : say('.', :green, false)
             processed += 1
+            size      += m.file_file_size || 0
           end
         end
       end
@@ -56,9 +61,9 @@ module Mastodon
       say
 
       if options[:background]
-        say("Scheduled the deletion of #{queued} media attachments #{dry_run}", :green, true)
+        say("Scheduled the deletion of #{queued} media attachments (approx. #{number_to_human_size(size)}) #{dry_run}", :green, true)
       else
-        say("Removed #{processed} media attachments #{dry_run}", :green, true)
+        say("Removed #{processed} media attachments (approx. #{number_to_human_size(size)}) #{dry_run}", :green, true)
       end
     end
   end
diff --git a/spec/controllers/authorize_interactions_controller_spec.rb b/spec/controllers/authorize_interactions_controller_spec.rb
index 81fd9ceb7..ce4257b68 100644
--- a/spec/controllers/authorize_interactions_controller_spec.rb
+++ b/spec/controllers/authorize_interactions_controller_spec.rb
@@ -99,10 +99,12 @@ describe AuthorizeInteractionsController do
 
         allow(ResolveAccountService).to receive(:new).and_return(service)
         allow(service).to receive(:call).with('user@hostname').and_return(target_account)
+        allow(service).to receive(:call).with(target_account, skip_webfinger: true).and_return(target_account)
+
 
         post :create, params: { acct: 'acct:user@hostname' }
 
-        expect(service).to have_received(:call).with('user@hostname')
+        expect(service).to have_received(:call).with(target_account, skip_webfinger: true)
         expect(account.following?(target_account)).to be true
         expect(response).to render_template(:success)
       end