about summary refs log tree commit diff
diff options
context:
space:
mode:
-rw-r--r--.travis.yml6
-rw-r--r--app/controllers/api/v1/accounts/follower_accounts_controller.rb2
-rw-r--r--app/controllers/api/v1/accounts/following_accounts_controller.rb2
-rw-r--r--app/controllers/follower_accounts_controller.rb4
-rw-r--r--app/controllers/following_accounts_controller.rb4
-rw-r--r--app/controllers/settings/preferences_controller.rb1
-rw-r--r--app/javascript/mastodon/actions/notifications.js15
-rw-r--r--app/javascript/mastodon/actions/streaming.js7
-rw-r--r--app/javascript/mastodon/actions/timelines.js25
-rw-r--r--app/javascript/mastodon/stream.js15
-rw-r--r--app/javascript/styles/mastodon/accounts.scss13
-rw-r--r--app/javascript/styles/mastodon/footer.scss2
-rw-r--r--app/lib/activitypub/activity.rb9
-rw-r--r--app/lib/activitypub/activity/add.rb5
-rw-r--r--app/lib/activitypub/activity/announce.rb10
-rw-r--r--app/lib/activitypub/activity/remove.rb2
-rw-r--r--app/lib/request.rb6
-rw-r--r--app/lib/user_settings_decorator.rb5
-rw-r--r--app/models/account.rb1
-rw-r--r--app/models/user.rb6
-rw-r--r--app/views/accounts/_follow_grid.html.haml3
-rw-r--r--app/views/accounts/_follow_grid_hidden.html.haml3
-rw-r--r--app/views/follower_accounts/index.html.haml5
-rw-r--r--app/views/following_accounts/index.html.haml5
-rw-r--r--app/views/layouts/public.html.haml4
-rw-r--r--app/views/settings/preferences/show.html.haml3
-rw-r--r--config/initializers/http_client_proxy.rb3
-rw-r--r--config/locales/en.yml1
-rw-r--r--config/locales/simple_form.en.yml2
-rw-r--r--config/settings.yml1
-rw-r--r--lib/mastodon/version.rb4
-rw-r--r--spec/lib/activitypub/activity/add_spec.rb25
32 files changed, 148 insertions, 51 deletions
diff --git a/.travis.yml b/.travis.yml
index 238b9a3f6..1529c81fc 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -37,7 +37,7 @@ addons:
 
 rvm:
   - 2.4.3
-  - 2.5.0
+  - 2.5.1
 
 services:
   - redis-server
@@ -47,6 +47,10 @@ install:
   - bundle install --path=vendor/bundle --with pam_authentication --without development production --retry=3 --jobs=16
   - yarn install
 
+# https://github.com/travis-ci/travis-ci/issues/9333
+before_install:
+  - gem install bundler
+
 before_script:
   - travis_wait ./bin/rails parallel:create parallel:load_schema parallel:prepare assets:precompile
 
diff --git a/app/controllers/api/v1/accounts/follower_accounts_controller.rb b/app/controllers/api/v1/accounts/follower_accounts_controller.rb
index c4f600c54..4578cf6ca 100644
--- a/app/controllers/api/v1/accounts/follower_accounts_controller.rb
+++ b/app/controllers/api/v1/accounts/follower_accounts_controller.rb
@@ -19,6 +19,8 @@ class Api::V1::Accounts::FollowerAccountsController < Api::BaseController
   end
 
   def load_accounts
+    return [] if @account.user_hides_network? && current_account.id != @account.id
+
     default_accounts.merge(paginated_follows).to_a
   end
 
diff --git a/app/controllers/api/v1/accounts/following_accounts_controller.rb b/app/controllers/api/v1/accounts/following_accounts_controller.rb
index 90b1f7fc5..ce2bbda85 100644
--- a/app/controllers/api/v1/accounts/following_accounts_controller.rb
+++ b/app/controllers/api/v1/accounts/following_accounts_controller.rb
@@ -19,6 +19,8 @@ class Api::V1::Accounts::FollowingAccountsController < Api::BaseController
   end
 
   def load_accounts
+    return [] if @account.user_hides_network? && current_account.id != @account.id
+
     default_accounts.merge(paginated_follows).to_a
   end
 
diff --git a/app/controllers/follower_accounts_controller.rb b/app/controllers/follower_accounts_controller.rb
index 30bf733ba..f5670c6bf 100644
--- a/app/controllers/follower_accounts_controller.rb
+++ b/app/controllers/follower_accounts_controller.rb
@@ -8,11 +8,15 @@ class FollowerAccountsController < ApplicationController
       format.html do
         use_pack 'public'
 
+        next if @account.user_hides_network?
+
         follows
         @relationships = AccountRelationshipsPresenter.new(follows.map(&:account_id), current_user.account_id) if user_signed_in?
       end
 
       format.json do
+        raise Mastodon::NotPermittedError if params[:page].present? && @account.user_hides_network?
+
         render json: collection_presenter,
                serializer: ActivityPub::CollectionSerializer,
                adapter: ActivityPub::Adapter,
diff --git a/app/controllers/following_accounts_controller.rb b/app/controllers/following_accounts_controller.rb
index e7cd58739..098b2a20c 100644
--- a/app/controllers/following_accounts_controller.rb
+++ b/app/controllers/following_accounts_controller.rb
@@ -8,11 +8,15 @@ class FollowingAccountsController < ApplicationController
       format.html do
         use_pack 'public'
 
+        next if @account.user_hides_network?
+
         follows
         @relationships = AccountRelationshipsPresenter.new(follows.map(&:target_account_id), current_user.account_id) if user_signed_in?
       end
 
       format.json do
+        raise Mastodon::NotPermittedError if params[:page].present? && @account.user_hides_network?
+
         render json: collection_presenter,
                serializer: ActivityPub::CollectionSerializer,
                adapter: ActivityPub::Adapter,
diff --git a/app/controllers/settings/preferences_controller.rb b/app/controllers/settings/preferences_controller.rb
index c853b5ab7..425664d49 100644
--- a/app/controllers/settings/preferences_controller.rb
+++ b/app/controllers/settings/preferences_controller.rb
@@ -40,6 +40,7 @@ class Settings::PreferencesController < Settings::BaseController
       :setting_reduce_motion,
       :setting_system_font_ui,
       :setting_noindex,
+      :setting_hide_network,
       notification_emails: %i(follow follow_request reblog favourite mention digest),
       interactions: %i(must_be_follower must_be_following)
     )
diff --git a/app/javascript/mastodon/actions/notifications.js b/app/javascript/mastodon/actions/notifications.js
index 393268811..641ad0e14 100644
--- a/app/javascript/mastodon/actions/notifications.js
+++ b/app/javascript/mastodon/actions/notifications.js
@@ -76,9 +76,14 @@ export function updateNotifications(notification, intlMessages, intlLocale) {
 
 const excludeTypesFromSettings = state => state.getIn(['settings', 'notifications', 'shows']).filter(enabled => !enabled).keySeq().toJS();
 
-export function expandNotifications({ maxId } = {}) {
+const noOp = () => {};
+
+export function expandNotifications({ maxId } = {}, done = noOp) {
   return (dispatch, getState) => {
-    if (getState().getIn(['notifications', 'isLoading'])) {
+    const notifications = getState().get('notifications');
+
+    if (notifications.get('isLoading')) {
+      done();
       return;
     }
 
@@ -87,6 +92,10 @@ export function expandNotifications({ maxId } = {}) {
       exclude_types: excludeTypesFromSettings(getState()),
     };
 
+    if (!maxId && notifications.get('items').size > 0) {
+      params.since_id = notifications.getIn(['items', 0]);
+    }
+
     dispatch(expandNotificationsRequest());
 
     api(getState).get('/api/v1/notifications', { params }).then(response => {
@@ -97,8 +106,10 @@ export function expandNotifications({ maxId } = {}) {
 
       dispatch(expandNotificationsSuccess(response.data, next ? next.uri : null));
       fetchRelatedRelationships(dispatch, response.data);
+      done();
     }).catch(error => {
       dispatch(expandNotificationsFail(error));
+      done();
     });
   };
 };
diff --git a/app/javascript/mastodon/actions/streaming.js b/app/javascript/mastodon/actions/streaming.js
index 14215ab6d..10e68bf3a 100644
--- a/app/javascript/mastodon/actions/streaming.js
+++ b/app/javascript/mastodon/actions/streaming.js
@@ -36,10 +36,9 @@ export function connectTimelineStream (timelineId, path, pollingRefresh = null)
   });
 }
 
-function refreshHomeTimelineAndNotification (dispatch) {
-  dispatch(expandHomeTimeline());
-  dispatch(expandNotifications());
-}
+const refreshHomeTimelineAndNotification = (dispatch, done) => {
+  dispatch(expandHomeTimeline({}, () => dispatch(expandNotifications({}, done))));
+};
 
 export const connectUserStream = () => connectTimelineStream('home', 'user', refreshHomeTimelineAndNotification);
 export const connectCommunityStream = () => connectTimelineStream('community', 'public:local');
diff --git a/app/javascript/mastodon/actions/timelines.js b/app/javascript/mastodon/actions/timelines.js
index eca847ee7..8bcfe4db9 100644
--- a/app/javascript/mastodon/actions/timelines.js
+++ b/app/javascript/mastodon/actions/timelines.js
@@ -1,6 +1,6 @@
 import { importFetchedStatus, importFetchedStatuses } from './importer';
 import api, { getLinks } from '../api';
-import { Map as ImmutableMap } from 'immutable';
+import { Map as ImmutableMap, List as ImmutableList } from 'immutable';
 
 export const TIMELINE_UPDATE  = 'TIMELINE_UPDATE';
 export const TIMELINE_DELETE  = 'TIMELINE_DELETE';
@@ -64,35 +64,44 @@ export function deleteFromTimelines(id) {
   };
 };
 
-export function expandTimeline(timelineId, path, params = {}) {
+const noOp = () => {};
+
+export function expandTimeline(timelineId, path, params = {}, done = noOp) {
   return (dispatch, getState) => {
     const timeline = getState().getIn(['timelines', timelineId], ImmutableMap());
 
     if (timeline.get('isLoading')) {
+      done();
       return;
     }
 
+    if (!params.max_id && timeline.get('items', ImmutableList()).size > 0) {
+      params.since_id = timeline.getIn(['items', 0]);
+    }
+
     dispatch(expandTimelineRequest(timelineId));
 
     api(getState).get(path, { params }).then(response => {
       const next = getLinks(response).refs.find(link => link.rel === 'next');
       dispatch(importFetchedStatuses(response.data));
       dispatch(expandTimelineSuccess(timelineId, response.data, next ? next.uri : null, response.code === 206));
+      done();
     }).catch(error => {
       dispatch(expandTimelineFail(timelineId, error));
+      done();
     });
   };
 };
 
-export const expandHomeTimeline         = ({ maxId } = {}) => expandTimeline('home', '/api/v1/timelines/home', { max_id: maxId });
-export const expandPublicTimeline       = ({ maxId } = {}) => expandTimeline('public', '/api/v1/timelines/public', { max_id: maxId });
-export const expandCommunityTimeline    = ({ maxId } = {}) => expandTimeline('community', '/api/v1/timelines/public', { local: true, max_id: maxId });
-export const expandDirectTimeline       = ({ maxId } = {}) => expandTimeline('direct', '/api/v1/timelines/direct', { max_id: maxId });
+export const expandHomeTimeline         = ({ maxId } = {}, done = noOp) => expandTimeline('home', '/api/v1/timelines/home', { max_id: maxId }, done);
+export const expandPublicTimeline       = ({ maxId } = {}, done = noOp) => expandTimeline('public', '/api/v1/timelines/public', { max_id: maxId }, done);
+export const expandCommunityTimeline    = ({ maxId } = {}, done = noOp) => expandTimeline('community', '/api/v1/timelines/public', { local: true, max_id: maxId }, done);
+export const expandDirectTimeline       = ({ maxId } = {}, done = noOp) => expandTimeline('direct', '/api/v1/timelines/direct', { max_id: maxId }, done);
 export const expandAccountTimeline      = (accountId, { maxId, withReplies } = {}) => expandTimeline(`account:${accountId}${withReplies ? ':with_replies' : ''}`, `/api/v1/accounts/${accountId}/statuses`, { exclude_replies: !withReplies, max_id: maxId });
 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 expandHashtagTimeline      = (hashtag, { maxId } = {}) => expandTimeline(`hashtag:${hashtag}`, `/api/v1/timelines/tag/${hashtag}`, { max_id: maxId });
-export const expandListTimeline         = (id, { maxId } = {}) => expandTimeline(`list:${id}`, `/api/v1/timelines/list/${id}`, { max_id: maxId });
+export const expandHashtagTimeline      = (hashtag, { maxId } = {}, done = noOp) => expandTimeline(`hashtag:${hashtag}`, `/api/v1/timelines/tag/${hashtag}`, { max_id: maxId }, done);
+export const expandListTimeline         = (id, { maxId } = {}, done = noOp) => expandTimeline(`list:${id}`, `/api/v1/timelines/list/${id}`, { max_id: maxId }, done);
 
 export function expandTimelineRequest(timeline) {
   return {
diff --git a/app/javascript/mastodon/stream.js b/app/javascript/mastodon/stream.js
index 6c67ba275..9928d0dd7 100644
--- a/app/javascript/mastodon/stream.js
+++ b/app/javascript/mastodon/stream.js
@@ -1,21 +1,24 @@
 import WebSocketClient from 'websocket.js';
 
+const randomIntUpTo = max => Math.floor(Math.random() * Math.floor(max));
+
 export function connectStream(path, pollingRefresh = null, callbacks = () => ({ onDisconnect() {}, onReceive() {} })) {
   return (dispatch, getState) => {
     const streamingAPIBaseURL = getState().getIn(['meta', 'streaming_api_base_url']);
     const accessToken = getState().getIn(['meta', 'access_token']);
     const { onDisconnect, onReceive } = callbacks(dispatch, getState);
+
     let polling = null;
 
     const setupPolling = () => {
-      polling = setInterval(() => {
-        pollingRefresh(dispatch);
-      }, 20000);
+      pollingRefresh(dispatch, () => {
+        polling = setTimeout(() => setupPolling(), 20000 + randomIntUpTo(20000));
+      });
     };
 
     const clearPolling = () => {
       if (polling) {
-        clearInterval(polling);
+        clearTimeout(polling);
         polling = null;
       }
     };
@@ -29,8 +32,9 @@ export function connectStream(path, pollingRefresh = null, callbacks = () => ({
 
       disconnected () {
         if (pollingRefresh) {
-          setupPolling();
+          polling = setTimeout(() => setupPolling(), randomIntUpTo(40000));
         }
+
         onDisconnect();
       },
 
@@ -51,6 +55,7 @@ export function connectStream(path, pollingRefresh = null, callbacks = () => ({
       if (subscription) {
         subscription.close();
       }
+
       clearPolling();
     };
 
diff --git a/app/javascript/styles/mastodon/accounts.scss b/app/javascript/styles/mastodon/accounts.scss
index b063ca52d..93aa134cf 100644
--- a/app/javascript/styles/mastodon/accounts.scss
+++ b/app/javascript/styles/mastodon/accounts.scss
@@ -322,6 +322,15 @@
   z-index: 2;
   position: relative;
 
+  &.empty img {
+    position: absolute;
+    opacity: 0.2;
+    height: 200px;
+    left: 0;
+    bottom: 0;
+    pointer-events: none;
+  }
+
   @media screen and (max-width: 740px) {
     border-radius: 0;
     box-shadow: none;
@@ -438,8 +447,8 @@
   font-size: 14px;
   font-weight: 500;
   text-align: center;
-  padding: 60px 0;
-  padding-top: 55px;
+  padding: 130px 0;
+  padding-top: 125px;
   margin: 0 auto;
   cursor: default;
 }
diff --git a/app/javascript/styles/mastodon/footer.scss b/app/javascript/styles/mastodon/footer.scss
index ba2a06954..dd3c1b688 100644
--- a/app/javascript/styles/mastodon/footer.scss
+++ b/app/javascript/styles/mastodon/footer.scss
@@ -4,7 +4,7 @@
   font-size: 12px;
   color: $darker-text-color;
 
-  .domain {
+  .footer__domain {
     font-weight: 500;
 
     a {
diff --git a/app/lib/activitypub/activity.rb b/app/lib/activitypub/activity.rb
index 84d4b1752..03476920b 100644
--- a/app/lib/activitypub/activity.rb
+++ b/app/lib/activitypub/activity.rb
@@ -118,4 +118,13 @@ class ActivityPub::Activity
   def delete_later!(uri)
     redis.setex("delete_upon_arrival:#{@account.id}:#{uri}", 6.hours.seconds, uri)
   end
+
+  def fetch_remote_original_status
+    if object_uri.start_with?('http')
+      return if ActivityPub::TagManager.instance.local_uri?(object_uri)
+      ActivityPub::FetchRemoteStatusService.new.call(object_uri, id: true, on_behalf_of: @account.followers.local.first)
+    elsif @object['url'].present?
+      ::FetchRemoteStatusService.new.call(@object['url'])
+    end
+  end
 end
diff --git a/app/lib/activitypub/activity/add.rb b/app/lib/activitypub/activity/add.rb
index ea94d2f98..688ab00b3 100644
--- a/app/lib/activitypub/activity/add.rb
+++ b/app/lib/activitypub/activity/add.rb
@@ -4,9 +4,10 @@ class ActivityPub::Activity::Add < ActivityPub::Activity
   def perform
     return unless @json['target'].present? && value_or_id(@json['target']) == @account.featured_collection_url
 
-    status = status_from_uri(object_uri)
+    status   = status_from_uri(object_uri)
+    status ||= fetch_remote_original_status
 
-    return unless status.account_id == @account.id && !@account.pinned?(status)
+    return unless !status.nil? && status.account_id == @account.id && !@account.pinned?(status)
 
     StatusPin.create!(account: @account, status: status)
   end
diff --git a/app/lib/activitypub/activity/announce.rb b/app/lib/activitypub/activity/announce.rb
index f810c88a2..1147a4481 100644
--- a/app/lib/activitypub/activity/announce.rb
+++ b/app/lib/activitypub/activity/announce.rb
@@ -26,16 +26,6 @@ class ActivityPub::Activity::Announce < ActivityPub::Activity
 
   private
 
-  def fetch_remote_original_status
-    if object_uri.start_with?('http')
-      return if ActivityPub::TagManager.instance.local_uri?(object_uri)
-
-      ActivityPub::FetchRemoteStatusService.new.call(object_uri, id: true, on_behalf_of: @account.followers.local.first)
-    elsif @object['url'].present?
-      ::FetchRemoteStatusService.new.call(@object['url'])
-    end
-  end
-
   def announceable?(status)
     status.account_id == @account.id || status.public_visibility? || status.unlisted_visibility?
   end
diff --git a/app/lib/activitypub/activity/remove.rb b/app/lib/activitypub/activity/remove.rb
index 62a1e3196..f523ead9f 100644
--- a/app/lib/activitypub/activity/remove.rb
+++ b/app/lib/activitypub/activity/remove.rb
@@ -6,7 +6,7 @@ class ActivityPub::Activity::Remove < ActivityPub::Activity
 
     status = status_from_uri(object_uri)
 
-    return unless status.account_id == @account.id
+    return unless !status.nil? && status.account_id == @account.id
 
     pin = StatusPin.find_by(account: @account, status: status)
     pin&.destroy!
diff --git a/app/lib/request.rb b/app/lib/request.rb
index 731bf7687..397614fac 100644
--- a/app/lib/request.rb
+++ b/app/lib/request.rb
@@ -58,7 +58,7 @@ class Request
 
   def set_common_headers!
     @headers[REQUEST_TARGET]    = "#{@verb} #{@url.path}"
-    @headers['User-Agent']      = user_agent
+    @headers['User-Agent']      = Mastodon::Version.user_agent
     @headers['Host']            = @url.host
     @headers['Date']            = Time.now.utc.httpdate
     @headers['Accept-Encoding'] = 'gzip' if @verb != :head
@@ -83,10 +83,6 @@ class Request
     @headers.keys.join(' ').downcase
   end
 
-  def user_agent
-    @user_agent ||= "#{HTTP::Request::USER_AGENT} (Mastodon/#{Mastodon::Version}; +#{root_url})"
-  end
-
   def key_id
     case @key_id_format
     when :acct
diff --git a/app/lib/user_settings_decorator.rb b/app/lib/user_settings_decorator.rb
index 78b3aa77c..f8bacb036 100644
--- a/app/lib/user_settings_decorator.rb
+++ b/app/lib/user_settings_decorator.rb
@@ -30,6 +30,7 @@ class UserSettingsDecorator
     user.settings['noindex']                 = noindex_preference if change?('setting_noindex')
     user.settings['flavour']                 = flavour_preference if change?('setting_flavour')
     user.settings['skin']                    = skin_preference if change?('setting_skin')
+    user.settings['hide_network']            = hide_network_preference if change?('setting_hide_network')
   end
 
   def merged_notification_emails
@@ -92,6 +93,10 @@ class UserSettingsDecorator
     settings['setting_skin']
   end
 
+  def hide_network_preference
+    boolean_cast_setting 'setting_hide_network'
+  end
+
   def boolean_cast_setting(key)
     ActiveModel::Type::Boolean.new.cast(settings[key])
   end
diff --git a/app/models/account.rb b/app/models/account.rb
index 06c190446..48f284785 100644
--- a/app/models/account.rb
+++ b/app/models/account.rb
@@ -139,6 +139,7 @@ class Account < ApplicationRecord
            :moderator?,
            :staff?,
            :locale,
+           :hides_network?,
            to: :user,
            prefix: true,
            allow_nil: true
diff --git a/app/models/user.rb b/app/models/user.rb
index d5ca9be36..9bdf8807f 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -86,7 +86,7 @@ class User < ApplicationRecord
   has_many :session_activations, dependent: :destroy
 
   delegate :auto_play_gif, :default_sensitive, :unfollow_modal, :boost_modal, :favourite_modal, :delete_modal,
-           :reduce_motion, :system_font_ui, :noindex, :flavour, :skin, :display_sensitive_media,
+           :reduce_motion, :system_font_ui, :noindex, :flavour, :skin, :display_sensitive_media, :hide_network,
            to: :settings, prefix: :setting, allow_nil: false
 
   attr_accessor :invite_code
@@ -219,6 +219,10 @@ class User < ApplicationRecord
     settings.notification_emails['digest']
   end
 
+  def hides_network?
+    @hides_network ||= settings.hide_network
+  end
+
   def token_for_app(a)
     return nil if a.nil? || a.owner != self
     Doorkeeper::AccessToken
diff --git a/app/views/accounts/_follow_grid.html.haml b/app/views/accounts/_follow_grid.html.haml
index a6d0ee817..fdcef84be 100644
--- a/app/views/accounts/_follow_grid.html.haml
+++ b/app/views/accounts/_follow_grid.html.haml
@@ -1,5 +1,6 @@
-.accounts-grid
+.accounts-grid{ class: accounts.empty? ? 'empty' : '' }
   - if accounts.empty?
+    = image_tag asset_pack_path('elephant_ui_greeting.svg'), alt: '', role: 'presentational'
     = render partial: 'accounts/nothing_here'
   - else
     = render partial: 'accounts/grid_card', collection: accounts, as: :account, cached: !user_signed_in?
diff --git a/app/views/accounts/_follow_grid_hidden.html.haml b/app/views/accounts/_follow_grid_hidden.html.haml
new file mode 100644
index 000000000..e970350e6
--- /dev/null
+++ b/app/views/accounts/_follow_grid_hidden.html.haml
@@ -0,0 +1,3 @@
+.accounts-grid.empty
+  = image_tag asset_pack_path('elephant_ui_greeting.svg'), alt: '', role: 'presentational'
+  %p.nothing-here= t('accounts.network_hidden')
diff --git a/app/views/follower_accounts/index.html.haml b/app/views/follower_accounts/index.html.haml
index a24e4ea20..65af81a5b 100644
--- a/app/views/follower_accounts/index.html.haml
+++ b/app/views/follower_accounts/index.html.haml
@@ -7,4 +7,7 @@
 
 = render 'accounts/header', account: @account
 
-= render 'accounts/follow_grid', follows: @follows, accounts: @follows.map(&:account)
+- if @account.user_hides_network?
+  = render 'accounts/follow_grid_hidden'
+- else
+  = render 'accounts/follow_grid', follows: @follows, accounts: @follows.map(&:account)
diff --git a/app/views/following_accounts/index.html.haml b/app/views/following_accounts/index.html.haml
index 67f6cfede..8fd95a0b4 100644
--- a/app/views/following_accounts/index.html.haml
+++ b/app/views/following_accounts/index.html.haml
@@ -7,4 +7,7 @@
 
 = render 'accounts/header', account: @account
 
-= render 'accounts/follow_grid', follows: @follows, accounts: @follows.map(&:target_account)
+- if @account.user_hides_network?
+  = render 'accounts/follow_grid_hidden'
+- else
+  = render 'accounts/follow_grid', follows: @follows, accounts: @follows.map(&:target_account)
diff --git a/app/views/layouts/public.html.haml b/app/views/layouts/public.html.haml
index 07441a77d..858d354fa 100644
--- a/app/views/layouts/public.html.haml
+++ b/app/views/layouts/public.html.haml
@@ -5,9 +5,9 @@
       %span.single-user-login
         = link_to t('auth.login'), new_user_session_path
         &mdash;
-      %span.domain= link_to site_hostname, about_path
+      %span.footer__domain= link_to site_hostname, about_path
     - else
-      %span.domain= link_to site_hostname, root_path
+      %span.footer__domain= link_to site_hostname, root_path
     %span.powered-by
       != t('generic.powered_by', link: link_to('Mastodon', 'https://joinmastodon.org'))
 
diff --git a/app/views/settings/preferences/show.html.haml b/app/views/settings/preferences/show.html.haml
index 102e4d200..4632034d7 100644
--- a/app/views/settings/preferences/show.html.haml
+++ b/app/views/settings/preferences/show.html.haml
@@ -26,6 +26,9 @@
   .fields-group
     = f.input :setting_noindex, as: :boolean, wrapper: :with_label
 
+  .fields-group
+    = f.input :setting_hide_network, as: :boolean, wrapper: :with_label
+
   %h4= t 'preferences.web'
 
   .fields-group
diff --git a/config/initializers/http_client_proxy.rb b/config/initializers/http_client_proxy.rb
index f5026d59e..52c595c5d 100644
--- a/config/initializers/http_client_proxy.rb
+++ b/config/initializers/http_client_proxy.rb
@@ -18,7 +18,8 @@ module Goldfinger
   def self.finger(uri, opts = {})
     to_hidden = /\.(onion|i2p)(:\d+)?$/.match(uri)
     raise Mastodon::HostValidationError, 'Instance does not support hidden service connections' if !Rails.configuration.x.access_to_hidden_service && to_hidden
-    opts = opts.merge(Rails.configuration.x.http_client_proxy).merge(ssl: !to_hidden)
+    opts = { ssl: !to_hidden, headers: {} }.merge(Rails.configuration.x.http_client_proxy).merge(opts)
+    opts[:headers]['User-Agent'] ||= Mastodon::Version.user_agent
     Goldfinger::Client.new(uri, opts).finger
   end
 end
diff --git a/config/locales/en.yml b/config/locales/en.yml
index 0117a9680..2282823d0 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -40,6 +40,7 @@ en:
     following: Following
     media: Media
     moved_html: "%{name} has moved to %{new_profile_link}:"
+    network_hidden: This information is not available
     nothing_here: There is nothing here!
     people_followed_by: People whom %{name} follows
     people_who_follow: People who follow %{name}
diff --git a/config/locales/simple_form.en.yml b/config/locales/simple_form.en.yml
index c0c3b4b85..851b678e1 100644
--- a/config/locales/simple_form.en.yml
+++ b/config/locales/simple_form.en.yml
@@ -15,6 +15,7 @@ en:
         note:
           one: <span class="note-counter">1</span> character left
           other: <span class="note-counter">%{count}</span> characters left
+        setting_hide_network: Who you follow and who follows you will not be shown on your profile
         setting_noindex: Affects your public profile and status pages
         setting_skin: Reskins the selected Mastodon flavour
       imports:
@@ -55,6 +56,7 @@ en:
         setting_delete_modal: Show confirmation dialog before deleting a toot
         setting_display_sensitive_media: Always show media marked as sensitive
         setting_favourite_modal: Show confirmation dialog before favouriting
+        setting_hide_network: Hide your network
         setting_noindex: Opt-out of search engine indexing
         setting_reduce_motion: Reduce motion in animations
         setting_skin: Skin
diff --git a/config/settings.yml b/config/settings.yml
index a92a0bfd0..4a3720c2d 100644
--- a/config/settings.yml
+++ b/config/settings.yml
@@ -20,6 +20,7 @@ defaults: &defaults
   min_invite_role: 'admin'
   show_staff_badge: true
   default_sensitive: false
+  hide_network: false
   unfollow_modal: false
   boost_modal: false
   favourite_modal: false
diff --git a/lib/mastodon/version.rb b/lib/mastodon/version.rb
index 849b564e7..60f133085 100644
--- a/lib/mastodon/version.rb
+++ b/lib/mastodon/version.rb
@@ -48,5 +48,9 @@ module Mastodon
         source_base_url
       end
     end
+
+    def user_agent
+      @user_agent ||= "#{HTTP::Request::USER_AGENT} (Mastodon/#{Version}; +http#{Rails.configuration.x.use_https ? 's' : ''}://#{Rails.configuration.x.web_domain}/)"
+    end
   end
 end
diff --git a/spec/lib/activitypub/activity/add_spec.rb b/spec/lib/activitypub/activity/add_spec.rb
index 3ebab4e37..16db71c88 100644
--- a/spec/lib/activitypub/activity/add_spec.rb
+++ b/spec/lib/activitypub/activity/add_spec.rb
@@ -18,12 +18,31 @@ RSpec.describe ActivityPub::Activity::Add do
   describe '#perform' do
     subject { described_class.new(json, sender) }
 
-    before do
+    it 'creates a pin' do
       subject.perform
+      expect(sender.pinned?(status)).to be true
     end
 
-    it 'creates a pin' do
-      expect(sender.pinned?(status)).to be true
+    context 'when status was not known before' do
+      let(:json) do
+        {
+          '@context': 'https://www.w3.org/ns/activitystreams',
+          id: 'foo',
+          type: 'Add',
+          actor: ActivityPub::TagManager.instance.uri_for(sender),
+          object: 'https://example.com/unknown',
+          target: sender.featured_collection_url,
+        }.with_indifferent_access
+      end
+
+      before do
+        stub_request(:get, 'https://example.com/unknown').to_return(status: 410)
+      end
+
+      it 'fetches the status' do
+        subject.perform
+        expect(a_request(:get, 'https://example.com/unknown')).to have_been_made.at_least_once
+      end
     end
   end
 end