From cef924556b39f7ddae425300b759aa1956d3e7f1 Mon Sep 17 00:00:00 2001
From: noellabo <noel.yoshiba@gmail.com>
Date: Thu, 12 Dec 2019 20:02:35 +0900
Subject: [PATCH] Add account subscribe support to WebUI

---
 app/chewy/accounts_index.rb                   |   1 +
 .../api/v1/account_subscribes_controller.rb   |  43 -----
 .../subscribing_accounts_controller.rb        |  64 +++++++
 app/controllers/api/v1/accounts_controller.rb |  14 +-
 .../settings/preferences_controller.rb        |   1 +
 app/javascript/mastodon/actions/accounts.js   | 181 ++++++++++++++++++
 app/javascript/mastodon/components/account.js |  27 ++-
 .../mastodon/containers/account_container.js  |  21 +-
 .../features/account/components/header.js     |  29 +++
 .../account_timeline/components/header.js     |   6 +
 .../containers/header_container.js            |  21 +-
 .../directory/components/account_card.js      |  55 +++++-
 .../mastodon/features/subscribing/index.js    | 103 ++++++++++
 app/javascript/mastodon/features/ui/index.js  |   2 +
 .../features/ui/util/async-components.js      |   4 +
 app/javascript/mastodon/initial_state.js      |   1 +
 app/javascript/mastodon/locales/en.json       |   6 +
 app/javascript/mastodon/locales/ja.json       |   6 +
 app/javascript/mastodon/reducers/accounts.js  |   1 +
 .../mastodon/reducers/accounts_counters.js    |   1 +
 .../mastodon/reducers/relationships.js        |  16 ++
 app/javascript/mastodon/reducers/timelines.js |   2 +
 .../mastodon/reducers/user_lists.js           |   7 +
 .../styles/mastodon/components.scss           |   7 +-
 app/lib/user_settings_decorator.rb            |   5 +
 app/models/account_stat.rb                    |  19 +-
 app/models/account_subscribe.rb               |  16 ++
 app/models/concerns/account_counters.rb       |   2 +
 app/models/concerns/account_interactions.rb   |  10 +-
 .../concerns/status_threading_concern.rb      |   1 +
 app/models/export.rb                          |   4 +
 app/models/user.rb                            |   2 +-
 .../account_relationships_presenter.rb        |   6 +-
 app/serializers/initial_state_serializer.rb   |   1 +
 app/serializers/rest/account_serializer.rb    |   2 +-
 .../rest/relationship_serializer.rb           |   6 +-
 app/services/account_subscribe_service.rb     |   7 +-
 app/services/follow_service.rb                |   1 -
 app/services/search_service.rb                |   1 +
 app/services/suspend_account_service.rb       |  28 +--
 .../preferences/appearance/show.html.haml     |   1 +
 config/locales/simple_form.en.yml             |   1 +
 config/locales/simple_form.ja.yml             |   1 +
 config/routes.rb                              |   4 +-
 config/settings.yml                           |   1 +
 ...8_add_subscribing_count_to_account_stat.rb |   5 +
 db/schema.rb                                  |   1 +
 lib/mastodon/cache_cli.rb                     |   9 +-
 spec/lib/settings/scoped_settings_spec.rb     |   2 +-
 spec/lib/user_settings_decorator_spec.rb      |   7 +
 50 files changed, 662 insertions(+), 100 deletions(-)
 delete mode 100644 app/controllers/api/v1/account_subscribes_controller.rb
 create mode 100644 app/controllers/api/v1/accounts/subscribing_accounts_controller.rb
 create mode 100644 app/javascript/mastodon/features/subscribing/index.js
 create mode 100644 db/migrate/20191214025518_add_subscribing_count_to_account_stat.rb

diff --git a/app/chewy/accounts_index.rb b/app/chewy/accounts_index.rb
index b814e009e5..4ef383c12c 100644
--- a/app/chewy/accounts_index.rb
+++ b/app/chewy/accounts_index.rb
@@ -37,6 +37,7 @@ class AccountsIndex < Chewy::Index
 
       field :following_count, type: 'long', value: ->(account) { account.following.local.count }
       field :followers_count, type: 'long', value: ->(account) { account.followers.local.count }
+      field :subscribing_count, type: 'long', value: ->(account) { account.subscribing.local.count }
       field :last_status_at, type: 'date', value: ->(account) { account.last_status_at || account.created_at }
     end
   end
diff --git a/app/controllers/api/v1/account_subscribes_controller.rb b/app/controllers/api/v1/account_subscribes_controller.rb
deleted file mode 100644
index 5da428bbd9..0000000000
--- a/app/controllers/api/v1/account_subscribes_controller.rb
+++ /dev/null
@@ -1,43 +0,0 @@
-# frozen_string_literal: true
-
-class Api::V1::AccountSubscribesController < Api::BaseController
-  before_action -> { doorkeeper_authorize! :read, :'read:follows' }, only: [:index, :show]
-  before_action -> { doorkeeper_authorize! :write, :'write:follows' }, except: [:index, :show]
-
-  before_action :require_user!
-  before_action :set_account_subscribe, except: [:index, :create]
-
-  def index
-    @account_subscribes = AccountSubscribe.where(account: current_account).all
-    render json: @account_subscribes, each_serializer: REST::AccountSubscribeSerializer
-  end
-
-  def show
-    render json: @account_subscribe, serializer: REST::AccountSubscribeSerializer
-  end
-
-  def create
-    @account_subscribe = AccountSubscribe.create!(account_subscribe_params.merge(account: current_account))
-    render json: @account_subscribe, serializer: REST::AccountSubscribeSerializer
-  end
-
-  def update
-    @account_subscribe.update!(account_subscribe_params)
-    render json: @account_subscribe, serializer: REST::AccountSubscribeSerializer
-  end
-
-  def destroy
-    @account_subscribe.destroy!
-    render_empty
-  end
-
-  private
-
-  def set_account_subscribe
-    @account_subscribe = AccountSubscribe.where(account: current_account).find(params[:id])
-  end
-
-  def account_subscribe_params
-    params.permit(:acct)
-  end
-end
diff --git a/app/controllers/api/v1/accounts/subscribing_accounts_controller.rb b/app/controllers/api/v1/accounts/subscribing_accounts_controller.rb
new file mode 100644
index 0000000000..3fbfcc70d5
--- /dev/null
+++ b/app/controllers/api/v1/accounts/subscribing_accounts_controller.rb
@@ -0,0 +1,64 @@
+# frozen_string_literal: true
+
+class Api::V1::Accounts::SubscribingAccountsController < Api::BaseController
+  before_action -> { doorkeeper_authorize! :read, :'read:accounts' }
+  before_action :require_user!
+  after_action :insert_pagination_headers
+
+  respond_to :json
+
+  def index
+    @accounts = load_accounts
+    render json: @accounts, each_serializer: REST::AccountSerializer
+  end
+
+  private
+
+  def load_accounts
+    default_accounts.merge(paginated_subscribings).to_a
+  end
+
+  def default_accounts
+    Account.includes(:passive_subscribes, :account_stat).references(:passive_subscribes)
+  end
+
+  def paginated_subscribings
+    AccountSubscribe.where(account_id: current_user.account_id).paginate_by_max_id(
+      limit_param(DEFAULT_ACCOUNTS_LIMIT),
+      params[:max_id],
+      params[:since_id]
+    )
+  end
+
+  def insert_pagination_headers
+    set_pagination_headers(next_path, prev_path)
+  end
+
+  def next_path
+    if records_continue?
+      api_v1_accounts_subscribing_index_url pagination_params(max_id: pagination_max_id)
+    end
+  end
+
+  def prev_path
+    unless @accounts.empty?
+      api_v1_accounts_subscribing_index_url pagination_params(since_id: pagination_since_id)
+    end
+  end
+
+  def pagination_max_id
+    @accounts.last.passive_subscribes.first.id
+  end
+
+  def pagination_since_id
+    @accounts.first.passive_subscribes.first.id
+  end
+
+  def records_continue?
+    @accounts.size == limit_param(DEFAULT_ACCOUNTS_LIMIT)
+  end
+
+  def pagination_params(core_params)
+    params.slice(:limit).permit(:limit).merge(core_params)
+  end
+end
diff --git a/app/controllers/api/v1/accounts_controller.rb b/app/controllers/api/v1/accounts_controller.rb
index d68d2715f7..235deef34b 100644
--- a/app/controllers/api/v1/accounts_controller.rb
+++ b/app/controllers/api/v1/accounts_controller.rb
@@ -1,8 +1,8 @@
 # frozen_string_literal: true
 
 class Api::V1::AccountsController < Api::BaseController
-  before_action -> { authorize_if_got_token! :read, :'read:accounts' }, except: [:create, :follow, :unfollow, :block, :unblock, :mute, :unmute]
-  before_action -> { doorkeeper_authorize! :follow, :'write:follows' }, only: [:follow, :unfollow]
+  before_action -> { authorize_if_got_token! :read, :'read:accounts' }, except: [:create, :follow, :unfollow, :subscribe, :unsubscribe, :block, :unblock, :mute, :unmute]
+  before_action -> { doorkeeper_authorize! :follow, :'write:follows' }, only: [:follow, :unfollow, :subscribe, :unsubscribe]
   before_action -> { doorkeeper_authorize! :follow, :'write:mutes' }, only: [:mute, :unmute]
   before_action -> { doorkeeper_authorize! :follow, :'write:blocks' }, only: [:block, :unblock]
   before_action -> { doorkeeper_authorize! :write, :'write:accounts' }, only: [:create]
@@ -38,6 +38,11 @@ class Api::V1::AccountsController < Api::BaseController
     render json: @account, serializer: REST::RelationshipSerializer, relationships: relationships(options)
   end
 
+  def subscribe
+    AccountSubscribeService.new.call(current_user.account, @account)
+    render json: @account, serializer: REST::RelationshipSerializer, relationships: relationships
+  end
+
   def block
     BlockService.new.call(current_user.account, @account)
     render json: @account, serializer: REST::RelationshipSerializer, relationships: relationships
@@ -53,6 +58,11 @@ class Api::V1::AccountsController < Api::BaseController
     render json: @account, serializer: REST::RelationshipSerializer, relationships: relationships
   end
 
+  def unsubscribe
+    UnsubscribeAccountService.new.call(current_user.account, @account)
+    render json: @account, serializer: REST::RelationshipSerializer, relationships: relationships
+  end
+
   def unblock
     UnblockService.new.call(current_user.account, @account)
     render json: @account, serializer: REST::RelationshipSerializer, relationships: relationships
diff --git a/app/controllers/settings/preferences_controller.rb b/app/controllers/settings/preferences_controller.rb
index bac9b329d4..ec00f0c066 100644
--- a/app/controllers/settings/preferences_controller.rb
+++ b/app/controllers/settings/preferences_controller.rb
@@ -41,6 +41,7 @@ class Settings::PreferencesController < Settings::BaseController
       :setting_default_sensitive,
       :setting_default_language,
       :setting_unfollow_modal,
+      :setting_unsubscribe_modal,
       :setting_boost_modal,
       :setting_delete_modal,
       :setting_auto_play_gif,
diff --git a/app/javascript/mastodon/actions/accounts.js b/app/javascript/mastodon/actions/accounts.js
index d4a824e2c9..f0caefbf4c 100644
--- a/app/javascript/mastodon/actions/accounts.js
+++ b/app/javascript/mastodon/actions/accounts.js
@@ -14,6 +14,14 @@ export const ACCOUNT_UNFOLLOW_REQUEST = 'ACCOUNT_UNFOLLOW_REQUEST';
 export const ACCOUNT_UNFOLLOW_SUCCESS = 'ACCOUNT_UNFOLLOW_SUCCESS';
 export const ACCOUNT_UNFOLLOW_FAIL    = 'ACCOUNT_UNFOLLOW_FAIL';
 
+export const ACCOUNT_SUBSCRIBE_REQUEST = 'ACCOUNT_SUBSCRIBE_REQUEST';
+export const ACCOUNT_SUBSCRIBE_SUCCESS = 'ACCOUNT_SUBSCRIBE_SUCCESS';
+export const ACCOUNT_SUBSCRIBE_FAIL    = 'ACCOUNT_SUBSCRIBE_FAIL';
+
+export const ACCOUNT_UNSUBSCRIBE_REQUEST = 'ACCOUNT_UNSUBSCRIBE_REQUEST';
+export const ACCOUNT_UNSUBSCRIBE_SUCCESS = 'ACCOUNT_UNSUBSCRIBE_SUCCESS';
+export const ACCOUNT_UNSUBSCRIBE_FAIL    = 'ACCOUNT_UNSUBSCRIBE_FAIL';
+
 export const ACCOUNT_BLOCK_REQUEST = 'ACCOUNT_BLOCK_REQUEST';
 export const ACCOUNT_BLOCK_SUCCESS = 'ACCOUNT_BLOCK_SUCCESS';
 export const ACCOUNT_BLOCK_FAIL    = 'ACCOUNT_BLOCK_FAIL';
@@ -54,6 +62,14 @@ export const FOLLOWING_EXPAND_REQUEST = 'FOLLOWING_EXPAND_REQUEST';
 export const FOLLOWING_EXPAND_SUCCESS = 'FOLLOWING_EXPAND_SUCCESS';
 export const FOLLOWING_EXPAND_FAIL    = 'FOLLOWING_EXPAND_FAIL';
 
+export const SUBSCRIBING_FETCH_REQUEST = 'SUBSCRIBING_FETCH_REQUEST';
+export const SUBSCRIBING_FETCH_SUCCESS = 'SUBSCRIBING_FETCH_SUCCESS';
+export const SUBSCRIBING_FETCH_FAIL    = 'SUBSCRIBING_FETCH_FAIL';
+
+export const SUBSCRIBING_EXPAND_REQUEST = 'SUBSCRIBING_EXPAND_REQUEST';
+export const SUBSCRIBING_EXPAND_SUCCESS = 'SUBSCRIBING_EXPAND_SUCCESS';
+export const SUBSCRIBING_EXPAND_FAIL    = 'SUBSCRIBING_EXPAND_FAIL';
+
 export const RELATIONSHIPS_FETCH_REQUEST = 'RELATIONSHIPS_FETCH_REQUEST';
 export const RELATIONSHIPS_FETCH_SUCCESS = 'RELATIONSHIPS_FETCH_SUCCESS';
 export const RELATIONSHIPS_FETCH_FAIL    = 'RELATIONSHIPS_FETCH_FAIL';
@@ -221,6 +237,85 @@ export function unfollowAccountFail(error) {
   };
 };
 
+export function subscribeAccount(id, reblogs = true) {
+  return (dispatch, getState) => {
+    const alreadySubscribe = getState().getIn(['relationships', id, 'subscribing']);
+    const locked = getState().getIn(['accounts', id, 'locked'], false);
+
+    dispatch(subscribeAccountRequest(id, locked));
+
+    api(getState).post(`/api/v1/accounts/${id}/subscribe`).then(response => {
+      dispatch(subscribeAccountSuccess(response.data, alreadySubscribe));
+    }).catch(error => {
+      dispatch(subscribeAccountFail(error, locked));
+    });
+  };
+};
+
+export function unsubscribeAccount(id) {
+  return (dispatch, getState) => {
+    dispatch(unsubscribeAccountRequest(id));
+
+    api(getState).post(`/api/v1/accounts/${id}/unsubscribe`).then(response => {
+      dispatch(unsubscribeAccountSuccess(response.data, getState().get('statuses')));
+    }).catch(error => {
+      dispatch(unsubscribeAccountFail(error));
+    });
+  };
+};
+
+export function subscribeAccountRequest(id, locked) {
+  return {
+    type: ACCOUNT_SUBSCRIBE_REQUEST,
+    id,
+    locked,
+    skipLoading: true,
+  };
+};
+
+export function subscribeAccountSuccess(relationship, alreadySubscribe) {
+  return {
+    type: ACCOUNT_SUBSCRIBE_SUCCESS,
+    relationship,
+    alreadySubscribe,
+    skipLoading: true,
+  };
+};
+
+export function subscribeAccountFail(error, locked) {
+  return {
+    type: ACCOUNT_SUBSCRIBE_FAIL,
+    error,
+    locked,
+    skipLoading: true,
+  };
+};
+
+export function unsubscribeAccountRequest(id) {
+  return {
+    type: ACCOUNT_UNSUBSCRIBE_REQUEST,
+    id,
+    skipLoading: true,
+  };
+};
+
+export function unsubscribeAccountSuccess(relationship, statuses) {
+  return {
+    type: ACCOUNT_UNSUBSCRIBE_SUCCESS,
+    relationship,
+    statuses,
+    skipLoading: true,
+  };
+};
+
+export function unsubscribeAccountFail(error) {
+  return {
+    type: ACCOUNT_UNSUBSCRIBE_FAIL,
+    error,
+    skipLoading: true,
+  };
+};
+
 export function blockAccount(id) {
   return (dispatch, getState) => {
     dispatch(blockAccountRequest(id));
@@ -531,6 +626,92 @@ export function expandFollowingFail(id, error) {
   };
 };
 
+export function fetchSubscribing(id) {
+  return (dispatch, getState) => {
+    dispatch(fetchSubscribeRequest(id));
+
+    api(getState).get(`/api/v1/accounts/subscribing`).then(response => {
+      const next = getLinks(response).refs.find(link => link.rel === 'next');
+
+      dispatch(importFetchedAccounts(response.data));
+      dispatch(fetchSubscribeSuccess(id, response.data, next ? next.uri : null));
+      dispatch(fetchRelationships(response.data.map(item => item.id)));
+    }).catch(error => {
+      dispatch(fetchSubscribeFail(id, error));
+    });
+  };
+};
+
+export function fetchSubscribeRequest(id) {
+  return {
+    type: SUBSCRIBING_FETCH_REQUEST,
+    id,
+  };
+};
+
+export function fetchSubscribeSuccess(id, accounts, next) {
+  return {
+    type: SUBSCRIBING_FETCH_SUCCESS,
+    id,
+    accounts,
+    next,
+  };
+};
+
+export function fetchSubscribeFail(id, error) {
+  return {
+    type: SUBSCRIBING_FETCH_FAIL,
+    id,
+    error,
+  };
+};
+
+export function expandSubscribing(id) {
+  return (dispatch, getState) => {
+    const url = getState().getIn(['user_lists', 'subscribing', id, 'next']);
+
+    if (url === null) {
+      return;
+    }
+
+    dispatch(expandSubscribeRequest(id));
+
+    api(getState).get(url).then(response => {
+      const next = getLinks(response).refs.find(link => link.rel === 'next');
+
+      dispatch(importFetchedAccounts(response.data));
+      dispatch(expandSubscribeSuccess(id, response.data, next ? next.uri : null));
+      dispatch(fetchRelationships(response.data.map(item => item.id)));
+    }).catch(error => {
+      dispatch(expandSubscribeFail(id, error));
+    });
+  };
+};
+
+export function expandSubscribeRequest(id) {
+  return {
+    type: SUBSCRIBING_EXPAND_REQUEST,
+    id,
+  };
+};
+
+export function expandSubscribeSuccess(id, accounts, next) {
+  return {
+    type: SUBSCRIBING_EXPAND_SUCCESS,
+    id,
+    accounts,
+    next,
+  };
+};
+
+export function expandSubscribeFail(id, error) {
+  return {
+    type: SUBSCRIBING_EXPAND_FAIL,
+    id,
+    error,
+  };
+};
+
 export function fetchRelationships(accountIds) {
   return (dispatch, getState) => {
     const loadedRelationships = getState().get('relationships');
diff --git a/app/javascript/mastodon/components/account.js b/app/javascript/mastodon/components/account.js
index 2705a60013..3f78263540 100644
--- a/app/javascript/mastodon/components/account.js
+++ b/app/javascript/mastodon/components/account.js
@@ -12,6 +12,8 @@ import { me } from '../initial_state';
 const messages = defineMessages({
   follow: { id: 'account.follow', defaultMessage: 'Follow' },
   unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
+  unsubscribe: { id: 'account.unsubscribe', defaultMessage: 'Unsubscribe' },
+  subscribe: { id: 'account.subscribe', defaultMessage: 'Subscribe' },
   requested: { id: 'account.requested', defaultMessage: 'Awaiting approval' },
   unblock: { id: 'account.unblock', defaultMessage: 'Unblock @{name}' },
   unmute: { id: 'account.unmute', defaultMessage: 'Unmute @{name}' },
@@ -25,6 +27,7 @@ class Account extends ImmutablePureComponent {
   static propTypes = {
     account: ImmutablePropTypes.map.isRequired,
     onFollow: PropTypes.func.isRequired,
+    onSubscribe: PropTypes.func.isRequired,
     onBlock: PropTypes.func.isRequired,
     onMute: PropTypes.func.isRequired,
     onMuteNotifications: PropTypes.func.isRequired,
@@ -39,6 +42,10 @@ class Account extends ImmutablePureComponent {
     this.props.onFollow(this.props.account);
   }
 
+  handleSubscribe = () => {
+    this.props.onSubscribe(this.props.account);
+  }
+
   handleBlock = () => {
     this.props.onBlock(this.props.account);
   }
@@ -80,10 +87,11 @@ class Account extends ImmutablePureComponent {
     if (onActionClick && actionIcon) {
       buttons = <IconButton icon={actionIcon} title={actionTitle} onClick={this.handleAction} />;
     } else if (account.get('id') !== me && account.get('relationship', null) !== null) {
-      const following = account.getIn(['relationship', 'following']);
-      const requested = account.getIn(['relationship', 'requested']);
-      const blocking  = account.getIn(['relationship', 'blocking']);
-      const muting  = account.getIn(['relationship', 'muting']);
+      const following   = account.getIn(['relationship', 'following']);
+      const subscribing = account.getIn(['relationship', 'subscribing']);
+      const requested   = account.getIn(['relationship', 'requested']);
+      const blocking    = account.getIn(['relationship', 'blocking']);
+      const muting      = account.getIn(['relationship', 'muting']);
 
       if (requested) {
         buttons = <IconButton disabled icon='hourglass' title={intl.formatMessage(messages.requested)} />;
@@ -102,8 +110,15 @@ class Account extends ImmutablePureComponent {
             {hidingNotificationsButton}
           </Fragment>
         );
-      } else if (!account.get('moved') || following) {
-        buttons = <IconButton icon={following ? 'user-times' : 'user-plus'} title={intl.formatMessage(following ? messages.unfollow : messages.follow)} onClick={this.handleFollow} active={following} />;
+      } else {
+        let following_buttons, subscribing_buttons;
+        if (!account.get('moved') || subscribing ) {
+          subscribing_buttons = <IconButton icon='rss-square' title={intl.formatMessage(subscribing ? messages.unsubscribe : messages.subscribe)} onClick={this.handleSubscribe} active={subscribing} />;
+        }
+        if (!account.get('moved') || following) {
+          following_buttons = <IconButton icon={following ? 'user-times' : 'user-plus'} title={intl.formatMessage(following ? messages.unfollow : messages.follow)} onClick={this.handleFollow} active={following} />;
+        }
+        buttons = <span>{subscribing_buttons}{following_buttons}</span>
       }
     }
 
diff --git a/app/javascript/mastodon/containers/account_container.js b/app/javascript/mastodon/containers/account_container.js
index 5a5136dd18..ac69b91501 100644
--- a/app/javascript/mastodon/containers/account_container.js
+++ b/app/javascript/mastodon/containers/account_container.js
@@ -6,6 +6,8 @@ import Account from '../components/account';
 import {
   followAccount,
   unfollowAccount,
+  subscribeAccount,
+  unsubscribeAccount,
   blockAccount,
   unblockAccount,
   muteAccount,
@@ -13,10 +15,11 @@ import {
 } from '../actions/accounts';
 import { openModal } from '../actions/modal';
 import { initMuteModal } from '../actions/mutes';
-import { unfollowModal } from '../initial_state';
+import { unfollowModal, unsubscribeModal } from '../initial_state';
 
 const messages = defineMessages({
   unfollowConfirm: { id: 'confirmations.unfollow.confirm', defaultMessage: 'Unfollow' },
+  unsubscribeConfirm: { id: 'confirmations.unsubscribe.confirm', defaultMessage: 'Unsubscribe' },
 });
 
 const makeMapStateToProps = () => {
@@ -47,6 +50,22 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
     }
   },
 
+  onSubscribe (account) {
+    if (account.getIn(['relationship', 'subscribing'])) {
+      if (unsubscribeModal) {
+        dispatch(openModal('CONFIRM', {
+          message: <FormattedMessage id='confirmations.unsubscribe.message' defaultMessage='Are you sure you want to unsubscribe {name}?' values={{ name: <strong>@{account.get('acct')}</strong> }} />,
+          confirm: intl.formatMessage(messages.unsubscribeConfirm),
+          onConfirm: () => dispatch(unsubscribeAccount(account.get('id'))),
+        }));
+      } else {
+        dispatch(unsubscribeAccount(account.get('id')));
+      }
+    } else {
+      dispatch(subscribeAccount(account.get('id')));
+    }
+  },
+
   onBlock (account) {
     if (account.getIn(['relationship', 'blocking'])) {
       dispatch(unblockAccount(account.get('id')));
diff --git a/app/javascript/mastodon/features/account/components/header.js b/app/javascript/mastodon/features/account/components/header.js
index 8bd7f2db5f..6241f65edb 100644
--- a/app/javascript/mastodon/features/account/components/header.js
+++ b/app/javascript/mastodon/features/account/components/header.js
@@ -3,6 +3,7 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
 import PropTypes from 'prop-types';
 import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
 import Button from 'mastodon/components/button';
+import IconButton from 'mastodon/components/icon_button';
 import ImmutablePureComponent from 'react-immutable-pure-component';
 import { autoPlayGif, me, isStaff } from 'mastodon/initial_state';
 import classNames from 'classnames';
@@ -15,6 +16,8 @@ import DropdownMenuContainer from 'mastodon/containers/dropdown_menu_container';
 const messages = defineMessages({
   unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
   follow: { id: 'account.follow', defaultMessage: 'Follow' },
+  unsubscribe: { id: 'account.unsubscribe', defaultMessage: 'Unsubscribe' },
+  subscribe: { id: 'account.subscribe', defaultMessage: 'Subscribe' },
   cancel_follow_request: { id: 'account.cancel_follow_request', defaultMessage: 'Cancel follow request' },
   requested: { id: 'account.requested', defaultMessage: 'Awaiting approval. Click to cancel follow request' },
   unblock: { id: 'account.unblock', defaultMessage: 'Unblock @{name}' },
@@ -63,6 +66,7 @@ class Header extends ImmutablePureComponent {
     account: ImmutablePropTypes.map,
     identity_props: ImmutablePropTypes.list,
     onFollow: PropTypes.func.isRequired,
+    onSubscribe: PropTypes.func.isRequired,
     onBlock: PropTypes.func.isRequired,
     intl: PropTypes.object.isRequired,
     domain: PropTypes.string.isRequired,
@@ -250,6 +254,22 @@ class Header extends ImmutablePureComponent {
       badge = null;
     }
 
+    const following   = account.getIn(['relationship', 'following']);
+    const subscribing = account.getIn(['relationship', 'subscribing']);
+    const blockd_by   = account.getIn(['relationship', 'blocked_by']);
+    let buttons;
+
+    if(me !== account.get('id') && !blockd_by) {
+      let following_buttons, subscribing_buttons;
+      if(!account.get('moved') || subscribing) {
+        subscribing_buttons = <IconButton icon='rss-square' title={intl.formatMessage(subscribing ? messages.unsubscribe : messages.subscribe)} onClick={this.props.onSubscribe} active={subscribing} />;
+      }
+      if(!account.get('moved') || following) {
+        following_buttons = <IconButton icon={following ? 'user-times' : 'user-plus'} title={intl.formatMessage(following ? messages.unfollow : messages.follow)} onClick={this.props.onFollow} active={following} />;
+      }
+      buttons = <span>{subscribing_buttons}{following_buttons}</span>
+    }
+
     return (
       <div className={classNames('account__header', { inactive: !!account.get('moved') })} ref={this.setRef}>
         <div className='account__header__image'>
@@ -280,6 +300,9 @@ class Header extends ImmutablePureComponent {
               <span dangerouslySetInnerHTML={displayNameHtml} /> {badge}
               <small>@{acct} {lockedIcon}</small>
             </h1>
+            <div className='account__header__tabs__name__relationship account__relationship'>
+              {buttons}
+            </div>
           </div>
 
           <div className='account__header__extra'>
@@ -325,6 +348,12 @@ class Header extends ImmutablePureComponent {
               <NavLink exact activeClassName='active' to={`/accounts/${account.get('id')}/followers`} title={intl.formatNumber(account.get('followers_count'))}>
                 <strong>{shortNumberFormat(account.get('followers_count'))}</strong> <FormattedMessage id='account.followers' defaultMessage='Followers' />
               </NavLink>
+
+              { (me === account.get('id')) && (
+                <NavLink exact activeClassName='active' to={`/accounts/${account.get('id')}/subscribing`} title={intl.formatNumber(account.get('subscribing_count'))}>
+                  <strong>{shortNumberFormat(account.get('subscribing_count'))}</strong> <FormattedMessage id='account.subscribes' defaultMessage='Subscribes' />
+                </NavLink>
+              )}
             </div>
           </div>
         </div>
diff --git a/app/javascript/mastodon/features/account_timeline/components/header.js b/app/javascript/mastodon/features/account_timeline/components/header.js
index 844b8a236a..1af0b10e98 100644
--- a/app/javascript/mastodon/features/account_timeline/components/header.js
+++ b/app/javascript/mastodon/features/account_timeline/components/header.js
@@ -13,6 +13,7 @@ export default class Header extends ImmutablePureComponent {
     account: ImmutablePropTypes.map,
     identity_proofs: ImmutablePropTypes.list,
     onFollow: PropTypes.func.isRequired,
+    onSubscribe: PropTypes.func.isRequired,
     onBlock: PropTypes.func.isRequired,
     onMention: PropTypes.func.isRequired,
     onDirect: PropTypes.func.isRequired,
@@ -35,6 +36,10 @@ export default class Header extends ImmutablePureComponent {
     this.props.onFollow(this.props.account);
   }
 
+  handleSubscribe = () => {
+    this.props.onSubscribe(this.props.account);
+  }
+
   handleBlock = () => {
     this.props.onBlock(this.props.account);
   }
@@ -98,6 +103,7 @@ export default class Header extends ImmutablePureComponent {
           account={account}
           identity_proofs={identity_proofs}
           onFollow={this.handleFollow}
+          onSubscribe={this.handleSubscribe}
           onBlock={this.handleBlock}
           onMention={this.handleMention}
           onDirect={this.handleDirect}
diff --git a/app/javascript/mastodon/features/account_timeline/containers/header_container.js b/app/javascript/mastodon/features/account_timeline/containers/header_container.js
index 8728b48068..189e05e127 100644
--- a/app/javascript/mastodon/features/account_timeline/containers/header_container.js
+++ b/app/javascript/mastodon/features/account_timeline/containers/header_container.js
@@ -5,6 +5,8 @@ import Header from '../components/header';
 import {
   followAccount,
   unfollowAccount,
+  subscribeAccount,
+  unsubscribeAccount,
   unblockAccount,
   unmuteAccount,
   pinAccount,
@@ -20,11 +22,12 @@ import { initReport } from '../../../actions/reports';
 import { openModal } from '../../../actions/modal';
 import { blockDomain, unblockDomain } from '../../../actions/domain_blocks';
 import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
-import { unfollowModal } from '../../../initial_state';
+import { unfollowModal, unsubscribeModal } from '../../../initial_state';
 import { List as ImmutableList } from 'immutable';
 
 const messages = defineMessages({
   unfollowConfirm: { id: 'confirmations.unfollow.confirm', defaultMessage: 'Unfollow' },
+  unsubscribeConfirm: { id: 'confirmations.unsubscribe.confirm', defaultMessage: 'Unsubscribe' },
   blockDomainConfirm: { id: 'confirmations.domain_block.confirm', defaultMessage: 'Hide entire domain' },
 });
 
@@ -58,6 +61,22 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
     }
   },
 
+  onSubscribe (account) {
+    if (account.getIn(['relationship', 'subscribing'])) {
+      if (unsubscribeModal) {
+        dispatch(openModal('CONFIRM', {
+          message: <FormattedMessage id='confirmations.unsubscribe.message' defaultMessage='Are you sure you want to unsubscribe {name}?' values={{ name: <strong>@{account.get('acct')}</strong> }} />,
+          confirm: intl.formatMessage(messages.unsubscribeConfirm),
+          onConfirm: () => dispatch(unsubscribeAccount(account.get('id'))),
+        }));
+      } else {
+        dispatch(unsubscribeAccount(account.get('id')));
+      }
+    } else {
+      dispatch(subscribeAccount(account.get('id')));
+    }
+  },
+
   onBlock (account) {
     if (account.getIn(['relationship', 'blocking'])) {
       dispatch(unblockAccount(account.get('id')));
diff --git a/app/javascript/mastodon/features/directory/components/account_card.js b/app/javascript/mastodon/features/directory/components/account_card.js
index 50ad744501..97a9e4305d 100644
--- a/app/javascript/mastodon/features/directory/components/account_card.js
+++ b/app/javascript/mastodon/features/directory/components/account_card.js
@@ -10,15 +10,25 @@ import Permalink from 'mastodon/components/permalink';
 import RelativeTimestamp from 'mastodon/components/relative_timestamp';
 import IconButton from 'mastodon/components/icon_button';
 import { FormattedMessage, injectIntl, defineMessages } from 'react-intl';
-import { autoPlayGif, me, unfollowModal } from 'mastodon/initial_state';
+import { autoPlayGif, me, unfollowModal, unsubscribeModal } from 'mastodon/initial_state';
 import { shortNumberFormat } from 'mastodon/utils/numbers';
-import { followAccount, unfollowAccount, blockAccount, unblockAccount, unmuteAccount } from 'mastodon/actions/accounts';
+import {
+  followAccount,
+  unfollowAccount,
+  subscribeAccount,
+  unsubscribeAccount,
+  blockAccount,
+  unblockAccount,
+  unmuteAccount
+} from 'mastodon/actions/accounts';
 import { openModal } from 'mastodon/actions/modal';
 import { initMuteModal } from 'mastodon/actions/mutes';
 
 const messages = defineMessages({
   follow: { id: 'account.follow', defaultMessage: 'Follow' },
   unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
+  unsubscribe: { id: 'account.unsubscribe', defaultMessage: 'Unsubscribe' },
+  subscribe: { id: 'account.subscribe', defaultMessage: 'Subscribe' },
   requested: { id: 'account.requested', defaultMessage: 'Awaiting approval' },
   unblock: { id: 'account.unblock', defaultMessage: 'Unblock @{name}' },
   unmute: { id: 'account.unmute', defaultMessage: 'Unmute @{name}' },
@@ -52,6 +62,22 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
     }
   },
 
+  onSubscribe (account) {
+    if (account.getIn(['relationship', 'subscribing'])) {
+      if (unsubscribeModal) {
+        dispatch(openModal('CONFIRM', {
+          message: <FormattedMessage id='confirmations.unsubscribe.message' defaultMessage='Are you sure you want to unsubscribe {name}?' values={{ name: <strong>@{account.get('acct')}</strong> }} />,
+          confirm: intl.formatMessage(messages.unsubscribeConfirm),
+          onConfirm: () => dispatch(unsubscribeAccount(account.get('id'))),
+        }));
+      } else {
+        dispatch(unsubscribeAccount(account.get('id')));
+      }
+    } else {
+      dispatch(subscribeAccount(account.get('id')));
+    }
+  },
+
   onBlock (account) {
     if (account.getIn(['relationship', 'blocking'])) {
       dispatch(unblockAccount(account.get('id')));
@@ -78,6 +104,7 @@ class AccountCard extends ImmutablePureComponent {
     account: ImmutablePropTypes.map.isRequired,
     intl: PropTypes.object.isRequired,
     onFollow: PropTypes.func.isRequired,
+    onSubscribe: PropTypes.func.isRequired,
     onBlock: PropTypes.func.isRequired,
     onMute: PropTypes.func.isRequired,
   };
@@ -123,6 +150,10 @@ class AccountCard extends ImmutablePureComponent {
     this.props.onFollow(this.props.account);
   }
 
+  handleSubscribe = () => {
+    this.props.onSubscribe(this.props.account);
+  }
+
   handleBlock = () => {
     this.props.onBlock(this.props.account);
   }
@@ -141,10 +172,11 @@ class AccountCard extends ImmutablePureComponent {
     let buttons;
 
     if (account.get('id') !== me && account.get('relationship', null) !== null) {
-      const following = account.getIn(['relationship', 'following']);
-      const requested = account.getIn(['relationship', 'requested']);
-      const blocking  = account.getIn(['relationship', 'blocking']);
-      const muting    = account.getIn(['relationship', 'muting']);
+      const following   = account.getIn(['relationship', 'following']);
+      const subscribing = account.getIn(['relationship', 'subscribing']);
+      const requested   = account.getIn(['relationship', 'requested']);
+      const blocking    = account.getIn(['relationship', 'blocking']);
+      const muting      = account.getIn(['relationship', 'muting']);
 
       if (requested) {
         buttons = <IconButton disabled icon='hourglass' title={intl.formatMessage(messages.requested)} />;
@@ -152,8 +184,15 @@ class AccountCard extends ImmutablePureComponent {
         buttons = <IconButton active icon='unlock' title={intl.formatMessage(messages.unblock, { name: account.get('username') })} onClick={this.handleBlock} />;
       } else if (muting) {
         buttons = <IconButton active icon='volume-up' title={intl.formatMessage(messages.unmute, { name: account.get('username') })} onClick={this.handleMute} />;
-      } else if (!account.get('moved') || following) {
-        buttons = <IconButton icon={following ? 'user-times' : 'user-plus'} title={intl.formatMessage(following ? messages.unfollow : messages.follow)} onClick={this.handleFollow} active={following} />;
+      } else {
+        let following_buttons, subscribing_buttons;
+        if(!account.get('moved') || subscribing) {
+          subscribing_buttons = <IconButton icon='rss-square' title={intl.formatMessage(subscribing ? messages.unsubscribe : messages.subscribe)} onClick={this.handleSubscribe} active={subscribing} />;
+        }
+        if(!account.get('moved') || following) {
+          following_buttons = <IconButton icon={following ? 'user-times' : 'user-plus'} title={intl.formatMessage(following ? messages.unfollow : messages.follow)} onClick={this.handleFollow} active={following} />;
+        }
+        buttons = <span>{subscribing_buttons}{following_buttons}</span>
       }
     }
 
diff --git a/app/javascript/mastodon/features/subscribing/index.js b/app/javascript/mastodon/features/subscribing/index.js
new file mode 100644
index 0000000000..6067e88e49
--- /dev/null
+++ b/app/javascript/mastodon/features/subscribing/index.js
@@ -0,0 +1,103 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { debounce } from 'lodash';
+import LoadingIndicator from '../../components/loading_indicator';
+import {
+  fetchAccount,
+  fetchSubscribing,
+  expandSubscribing,
+} from '../../actions/accounts';
+import { FormattedMessage } from 'react-intl';
+import AccountContainer from '../../containers/account_container';
+import Column from '../ui/components/column';
+import HeaderContainer from '../account_timeline/containers/header_container';
+import ColumnBackButton from '../../components/column_back_button';
+import ScrollableList from '../../components/scrollable_list';
+import MissingIndicator from 'mastodon/components/missing_indicator';
+
+const mapStateToProps = (state, props) => ({
+  isAccount: !!state.getIn(['accounts', props.params.accountId]),
+  accountIds: state.getIn(['user_lists', 'subscribing', props.params.accountId, 'items']),
+  hasMore: !!state.getIn(['user_lists', 'subscribing', props.params.accountId, 'next']),
+  blockedBy: state.getIn(['relationships', props.params.accountId, 'blocked_by'], false),
+});
+
+export default @connect(mapStateToProps)
+class Subscribing extends ImmutablePureComponent {
+
+  static propTypes = {
+    params: PropTypes.object.isRequired,
+    dispatch: PropTypes.func.isRequired,
+    shouldUpdateScroll: PropTypes.func,
+    accountIds: ImmutablePropTypes.list,
+    hasMore: PropTypes.bool,
+    blockedBy: PropTypes.bool,
+    isAccount: PropTypes.bool,
+    multiColumn: PropTypes.bool,
+  };
+
+  componentWillMount () {
+    if (!this.props.accountIds) {
+      this.props.dispatch(fetchAccount(this.props.params.accountId));
+      this.props.dispatch(fetchSubscribing(this.props.params.accountId));
+    }
+  }
+
+  componentWillReceiveProps (nextProps) {
+    if (nextProps.params.accountId !== this.props.params.accountId && nextProps.params.accountId) {
+      this.props.dispatch(fetchAccount(nextProps.params.accountId));
+      this.props.dispatch(fetchSubscribing(nextProps.params.accountId));
+    }
+  }
+
+  handleLoadMore = debounce(() => {
+    this.props.dispatch(expandSubscribing());
+  }, 300, { leading: true });
+
+  render () {
+    const { shouldUpdateScroll, accountIds, hasMore, blockedBy, isAccount, multiColumn } = this.props;
+
+    if (!isAccount) {
+      return (
+        <Column>
+          <MissingIndicator />
+        </Column>
+      );
+    }
+
+    if (!accountIds) {
+      return (
+        <Column>
+          <LoadingIndicator />
+        </Column>
+      );
+    }
+
+    const emptyMessage = blockedBy ? <FormattedMessage id='empty_column.account_unavailable' defaultMessage='Profile unavailable' /> : <FormattedMessage id='account.subscribes.empty' defaultMessage="This user doesn't subscribe anyone yet." />;
+
+    return (
+      <Column>
+        <ColumnBackButton multiColumn={multiColumn} />
+
+        <ScrollableList
+          scrollKey='subscribing'
+          hasMore={hasMore}
+          onLoadMore={this.handleLoadMore}
+          shouldUpdateScroll={shouldUpdateScroll}
+          prepend={<HeaderContainer accountId={this.props.params.accountId} hideTabs />}
+          alwaysPrepend
+          emptyMessage={emptyMessage}
+          bindToDocument={!multiColumn}
+        >
+          {blockedBy ? [] : accountIds.map(id =>
+            <AccountContainer key={id} id={id} withNote={false} />
+          )}
+        </ScrollableList>
+      </Column>
+    );
+  }
+
+}
diff --git a/app/javascript/mastodon/features/ui/index.js b/app/javascript/mastodon/features/ui/index.js
index f0bcde9dff..04c843534f 100644
--- a/app/javascript/mastodon/features/ui/index.js
+++ b/app/javascript/mastodon/features/ui/index.js
@@ -34,6 +34,7 @@ import {
   HomeTimeline,
   Followers,
   Following,
+  Subscribing,
   Reblogs,
   Favourites,
   DirectTimeline,
@@ -207,6 +208,7 @@ class SwitchingColumnsArea extends React.PureComponent {
           <WrappedRoute path='/accounts/:accountId/with_replies' component={AccountTimeline} content={children} componentParams={{ shouldUpdateScroll: this.shouldUpdateScroll, withReplies: true }} />
           <WrappedRoute path='/accounts/:accountId/followers' component={Followers} content={children} componentParams={{ shouldUpdateScroll: this.shouldUpdateScroll }} />
           <WrappedRoute path='/accounts/:accountId/following' component={Following} content={children} componentParams={{ shouldUpdateScroll: this.shouldUpdateScroll }} />
+          <WrappedRoute path='/accounts/:accountId/subscribing' component={Subscribing} content={children} componentParams={{ shouldUpdateScroll: this.shouldUpdateScroll }} />
           <WrappedRoute path='/accounts/:accountId/media' component={AccountGallery} content={children} componentParams={{ shouldUpdateScroll: this.shouldUpdateScroll }} />
 
           <WrappedRoute path='/follow_requests' component={FollowRequests} content={children} componentParams={{ shouldUpdateScroll: this.shouldUpdateScroll }} />
diff --git a/app/javascript/mastodon/features/ui/util/async-components.js b/app/javascript/mastodon/features/ui/util/async-components.js
index bf9e87e174..06504dc553 100644
--- a/app/javascript/mastodon/features/ui/util/async-components.js
+++ b/app/javascript/mastodon/features/ui/util/async-components.js
@@ -74,6 +74,10 @@ export function Following () {
   return import(/* webpackChunkName: "features/following" */'../../following');
 }
 
+export function Subscribing () {
+  return import(/* webpackChunkName: "features/subscribing" */'../../subscribing');
+}
+
 export function Reblogs () {
   return import(/* webpackChunkName: "features/reblogs" */'../../reblogs');
 }
diff --git a/app/javascript/mastodon/initial_state.js b/app/javascript/mastodon/initial_state.js
index 1134c55db4..e724b79592 100644
--- a/app/javascript/mastodon/initial_state.js
+++ b/app/javascript/mastodon/initial_state.js
@@ -8,6 +8,7 @@ export const autoPlayGif = getMeta('auto_play_gif');
 export const displayMedia = getMeta('display_media');
 export const expandSpoilers = getMeta('expand_spoilers');
 export const unfollowModal = getMeta('unfollow_modal');
+export const unsubscribeModal = getMeta('unsubscribe_modal');
 export const boostModal = getMeta('boost_modal');
 export const deleteModal = getMeta('delete_modal');
 export const me = getMeta('me');
diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json
index 138b67f6fb..941ba0ae96 100644
--- a/app/javascript/mastodon/locales/en.json
+++ b/app/javascript/mastodon/locales/en.json
@@ -34,10 +34,14 @@
   "account.requested": "Awaiting approval. Click to cancel follow request",
   "account.share": "Share @{name}'s profile",
   "account.show_reblogs": "Show boosts from @{name}",
+  "account.subscribe": "Subscribe",
+  "account.subscribes": "Subscribes",
+  "account.subscribes.empty": "This user doesn't subscribe anyone yet.",
   "account.unblock": "Unblock @{name}",
   "account.unblock_domain": "Unhide {domain}",
   "account.unendorse": "Don't feature on profile",
   "account.unfollow": "Unfollow",
+  "account.unsubscribe": "Unsubscribe",
   "account.unmute": "Unmute @{name}",
   "account.unmute_notifications": "Unmute notifications from @{name}",
   "alert.rate_limited.message": "Please retry after {retry_time, time, medium}.",
@@ -116,6 +120,8 @@
   "confirmations.reply.message": "Replying now will overwrite the message you are currently composing. Are you sure you want to proceed?",
   "confirmations.unfollow.confirm": "Unfollow",
   "confirmations.unfollow.message": "Are you sure you want to unfollow {name}?",
+  "confirmations.unsubscribe.confirm": "Unsubscribe",
+  "confirmations.unsubscribe.message": "Are you sure you want to unsubscribe {name}?",
   "conversation.delete": "Delete conversation",
   "conversation.mark_as_read": "Mark as read",
   "conversation.open": "View conversation",
diff --git a/app/javascript/mastodon/locales/ja.json b/app/javascript/mastodon/locales/ja.json
index 86a3a9d7a8..c3bb42a1b8 100644
--- a/app/javascript/mastodon/locales/ja.json
+++ b/app/javascript/mastodon/locales/ja.json
@@ -34,10 +34,14 @@
   "account.requested": "フォロー承認待ちです。クリックしてキャンセル",
   "account.share": "@{name}さんのプロフィールを共有する",
   "account.show_reblogs": "@{name}さんからのブーストを表示",
+  "account.subscribe": "購読",
+  "account.subscribes": "購読",
+  "account.subscribes.empty": "まだ誰も購読していません。",
   "account.unblock": "@{name}さんのブロックを解除",
   "account.unblock_domain": "{domain}の非表示を解除",
   "account.unendorse": "プロフィールから外す",
   "account.unfollow": "フォロー解除",
+  "account.unsubscribe": "購読解除",
   "account.unmute": "@{name}さんのミュートを解除",
   "account.unmute_notifications": "@{name}さんからの通知を受け取るようにする",
   "alert.rate_limited.message": "{retry_time, time, medium} 以降に再度実行してください。",
@@ -116,6 +120,8 @@
   "confirmations.reply.message": "今返信すると現在作成中のメッセージが上書きされます。本当に実行しますか?",
   "confirmations.unfollow.confirm": "フォロー解除",
   "confirmations.unfollow.message": "本当に{name}さんのフォローを解除しますか?",
+  "confirmations.unsubscribe.confirm": "購読解除",
+  "confirmations.unsubscribe.message": "本当に{name}さんの購読を解除しますか?",
   "conversation.delete": "会話を削除",
   "conversation.mark_as_read": "既読にする",
   "conversation.open": "会話を表示",
diff --git a/app/javascript/mastodon/reducers/accounts.js b/app/javascript/mastodon/reducers/accounts.js
index 530ed8e607..a5853b7893 100644
--- a/app/javascript/mastodon/reducers/accounts.js
+++ b/app/javascript/mastodon/reducers/accounts.js
@@ -8,6 +8,7 @@ const normalizeAccount = (state, account) => {
 
   delete account.followers_count;
   delete account.following_count;
+  delete account.subscribing_count;
   delete account.statuses_count;
 
   return state.set(account.id, fromJS(account));
diff --git a/app/javascript/mastodon/reducers/accounts_counters.js b/app/javascript/mastodon/reducers/accounts_counters.js
index 9ebf72af9b..9a89544ef6 100644
--- a/app/javascript/mastodon/reducers/accounts_counters.js
+++ b/app/javascript/mastodon/reducers/accounts_counters.js
@@ -8,6 +8,7 @@ import { Map as ImmutableMap, fromJS } from 'immutable';
 const normalizeAccount = (state, account) => state.set(account.id, fromJS({
   followers_count: account.followers_count,
   following_count: account.following_count,
+  subscribing_count: account.subscribing_count,
   statuses_count: account.statuses_count,
 }));
 
diff --git a/app/javascript/mastodon/reducers/relationships.js b/app/javascript/mastodon/reducers/relationships.js
index 8322780de5..3a4ed2034f 100644
--- a/app/javascript/mastodon/reducers/relationships.js
+++ b/app/javascript/mastodon/reducers/relationships.js
@@ -5,6 +5,12 @@ import {
   ACCOUNT_UNFOLLOW_SUCCESS,
   ACCOUNT_UNFOLLOW_REQUEST,
   ACCOUNT_UNFOLLOW_FAIL,
+  ACCOUNT_SUBSCRIBE_SUCCESS,
+  ACCOUNT_SUBSCRIBE_REQUEST,
+  ACCOUNT_SUBSCRIBE_FAIL,
+  ACCOUNT_UNSUBSCRIBE_SUCCESS,
+  ACCOUNT_UNSUBSCRIBE_REQUEST,
+  ACCOUNT_UNSUBSCRIBE_FAIL,
   ACCOUNT_BLOCK_SUCCESS,
   ACCOUNT_UNBLOCK_SUCCESS,
   ACCOUNT_MUTE_SUCCESS,
@@ -49,8 +55,18 @@ export default function relationships(state = initialState, action) {
     return state.setIn([action.id, 'following'], false);
   case ACCOUNT_UNFOLLOW_FAIL:
     return state.setIn([action.id, 'following'], true);
+  case ACCOUNT_SUBSCRIBE_REQUEST:
+    return state.setIn([action.id, 'subscribing'], true);
+  case ACCOUNT_SUBSCRIBE_FAIL:
+    return state.setIn([action.id, 'subscribing'], false);
+  case ACCOUNT_UNSUBSCRIBE_REQUEST:
+    return state.setIn([action.id, 'subscribing'], false);
+  case ACCOUNT_UNSUBSCRIBE_FAIL:
+    return state.setIn([action.id, 'subscribing'], true);
   case ACCOUNT_FOLLOW_SUCCESS:
   case ACCOUNT_UNFOLLOW_SUCCESS:
+  case ACCOUNT_SUBSCRIBE_SUCCESS:
+  case ACCOUNT_UNSUBSCRIBE_SUCCESS:
   case ACCOUNT_BLOCK_SUCCESS:
   case ACCOUNT_UNBLOCK_SUCCESS:
   case ACCOUNT_MUTE_SUCCESS:
diff --git a/app/javascript/mastodon/reducers/timelines.js b/app/javascript/mastodon/reducers/timelines.js
index 0d7222e10a..8f712020ae 100644
--- a/app/javascript/mastodon/reducers/timelines.js
+++ b/app/javascript/mastodon/reducers/timelines.js
@@ -14,6 +14,7 @@ import {
   ACCOUNT_BLOCK_SUCCESS,
   ACCOUNT_MUTE_SUCCESS,
   ACCOUNT_UNFOLLOW_SUCCESS,
+  ACCOUNT_UNSUBSCRIBE_SUCCESS,
 } from '../actions/accounts';
 import { Map as ImmutableMap, List as ImmutableList, fromJS } from 'immutable';
 import compareId from '../compare_id';
@@ -157,6 +158,7 @@ export default function timelines(state = initialState, action) {
   case ACCOUNT_MUTE_SUCCESS:
     return filterTimelines(state, action.relationship, action.statuses);
   case ACCOUNT_UNFOLLOW_SUCCESS:
+  case ACCOUNT_UNSUBSCRIBE_SUCCESS:
     return filterTimeline('home', state, action.relationship, action.statuses);
   case TIMELINE_SCROLL_TOP:
     return updateTop(state, action.timeline, action.top);
diff --git a/app/javascript/mastodon/reducers/user_lists.js b/app/javascript/mastodon/reducers/user_lists.js
index a7853452f0..31602bc333 100644
--- a/app/javascript/mastodon/reducers/user_lists.js
+++ b/app/javascript/mastodon/reducers/user_lists.js
@@ -6,6 +6,8 @@ import {
   FOLLOWERS_EXPAND_SUCCESS,
   FOLLOWING_FETCH_SUCCESS,
   FOLLOWING_EXPAND_SUCCESS,
+  SUBSCRIBING_FETCH_SUCCESS,
+  SUBSCRIBING_EXPAND_SUCCESS,
   FOLLOW_REQUESTS_FETCH_SUCCESS,
   FOLLOW_REQUESTS_EXPAND_SUCCESS,
   FOLLOW_REQUEST_AUTHORIZE_SUCCESS,
@@ -36,6 +38,7 @@ import { Map as ImmutableMap, List as ImmutableList } from 'immutable';
 const initialState = ImmutableMap({
   followers: ImmutableMap(),
   following: ImmutableMap(),
+  subscribing: ImmutableMap(),
   reblogged_by: ImmutableMap(),
   favourited_by: ImmutableMap(),
   follow_requests: ImmutableMap(),
@@ -72,6 +75,10 @@ export default function userLists(state = initialState, action) {
     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 SUBSCRIBING_FETCH_SUCCESS:
+    return normalizeList(state, 'subscribing', action.id, action.accounts, action.next);
+  case SUBSCRIBING_EXPAND_SUCCESS:
+    return appendToList(state, 'subscribing', action.id, action.accounts, action.next);
   case REBLOGS_FETCH_SUCCESS:
     return state.setIn(['reblogged_by', action.id], ImmutableList(action.accounts.map(item => item.id)));
   case FAVOURITES_FETCH_SUCCESS:
diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss
index ee56d3bae6..427dad4e3d 100644
--- a/app/javascript/styles/mastodon/components.scss
+++ b/app/javascript/styles/mastodon/components.scss
@@ -5625,7 +5625,7 @@ a.status-card.compact:hover {
       }
 
       &__relationship {
-        width: 23px;
+        width: 46px;
         min-height: 1px;
         flex: 0 0 auto;
       }
@@ -6385,6 +6385,7 @@ noscript {
 
     &__name {
       padding: 5px;
+      display: flex;
 
       .account-role {
         vertical-align: top;
@@ -6403,6 +6404,7 @@ noscript {
         overflow: hidden;
         white-space: nowrap;
         text-overflow: ellipsis;
+        flex: 1 1 auto;
 
         small {
           display: block;
@@ -6413,6 +6415,9 @@ noscript {
           text-overflow: ellipsis;
         }
       }
+      &__relationship {
+        flex: 0 0 auto;
+      }
     }
 
     .spacer {
diff --git a/app/lib/user_settings_decorator.rb b/app/lib/user_settings_decorator.rb
index fa8255faab..68b388c8e8 100644
--- a/app/lib/user_settings_decorator.rb
+++ b/app/lib/user_settings_decorator.rb
@@ -21,6 +21,7 @@ class UserSettingsDecorator
     user.settings['default_sensitive']   = default_sensitive_preference if change?('setting_default_sensitive')
     user.settings['default_language']    = default_language_preference if change?('setting_default_language')
     user.settings['unfollow_modal']      = unfollow_modal_preference if change?('setting_unfollow_modal')
+    user.settings['unsubscribe_modal']   = unsubscribe_modal_preference if change?('setting_unsubscribe_modal')
     user.settings['boost_modal']         = boost_modal_preference if change?('setting_boost_modal')
     user.settings['delete_modal']        = delete_modal_preference if change?('setting_delete_modal')
     user.settings['auto_play_gif']       = auto_play_gif_preference if change?('setting_auto_play_gif')
@@ -60,6 +61,10 @@ class UserSettingsDecorator
     boolean_cast_setting 'setting_unfollow_modal'
   end
 
+  def unsubscribe_modal_preference
+    boolean_cast_setting 'setting_unsubscribe_modal'
+  end
+
   def boost_modal_preference
     boolean_cast_setting 'setting_boost_modal'
   end
diff --git a/app/models/account_stat.rb b/app/models/account_stat.rb
index c84e4217c8..373fb36297 100644
--- a/app/models/account_stat.rb
+++ b/app/models/account_stat.rb
@@ -3,15 +3,16 @@
 #
 # Table name: account_stats
 #
-#  id              :bigint(8)        not null, primary key
-#  account_id      :bigint(8)        not null
-#  statuses_count  :bigint(8)        default(0), not null
-#  following_count :bigint(8)        default(0), not null
-#  followers_count :bigint(8)        default(0), not null
-#  created_at      :datetime         not null
-#  updated_at      :datetime         not null
-#  last_status_at  :datetime
-#  lock_version    :integer          default(0), not null
+#  id                :bigint(8)        not null, primary key
+#  account_id        :bigint(8)        not null
+#  statuses_count    :bigint(8)        default(0), not null
+#  following_count   :bigint(8)        default(0), not null
+#  followers_count   :bigint(8)        default(0), not null
+#  subscribing_count :bigint(8)        default(0), not null
+#  created_at        :datetime         not null
+#  updated_at        :datetime         not null
+#  last_status_at    :datetime
+#  lock_version      :integer          default(0), not null
 #
 
 class AccountStat < ApplicationRecord
diff --git a/app/models/account_subscribe.rb b/app/models/account_subscribe.rb
index 0977587864..e6663c61c7 100644
--- a/app/models/account_subscribe.rb
+++ b/app/models/account_subscribe.rb
@@ -11,6 +11,9 @@
 #
 
 class AccountSubscribe < ApplicationRecord
+  include Paginable
+  include RelationshipCacheable
+
   belongs_to :account
   belongs_to :target_account, class_name: 'Account'
   belongs_to :list, optional: true
@@ -20,4 +23,17 @@ class AccountSubscribe < ApplicationRecord
   scope :recent, -> { reorder(id: :desc) }
   scope :subscribed_lists, ->(account) { AccountSubscribe.where(target_account_id: account.id).where.not(list_id: nil).select(:list_id).uniq }
 
+  after_create :increment_cache_counters
+  after_destroy :decrement_cache_counters
+
+  private
+
+  def increment_cache_counters
+    account&.increment_count!(:subscribing_count)
+  end
+
+  def decrement_cache_counters
+    account&.decrement_count!(:subscribing_count)
+  end
+
 end
diff --git a/app/models/concerns/account_counters.rb b/app/models/concerns/account_counters.rb
index 6e25e1905e..1ba9f5b64c 100644
--- a/app/models/concerns/account_counters.rb
+++ b/app/models/concerns/account_counters.rb
@@ -14,6 +14,8 @@ module AccountCounters
            :following_count=,
            :followers_count,
            :followers_count=,
+           :subscribing_count,
+           :subscribing_count=,
            :increment_count!,
            :decrement_count!,
            :last_status_at,
diff --git a/app/models/concerns/account_interactions.rb b/app/models/concerns/account_interactions.rb
index e4b2bef32b..d4006ac767 100644
--- a/app/models/concerns/account_interactions.rb
+++ b/app/models/concerns/account_interactions.rb
@@ -16,6 +16,10 @@ module AccountInteractions
       follow_mapping(Follow.where(account_id: target_account_ids, target_account_id: account_id), :account_id)
     end
 
+    def subscribing_map(target_account_ids, account_id)
+      follow_mapping(AccountSubscribe.where(target_account_id: target_account_ids, account_id: account_id), :target_account_id)
+    end
+
     def blocking_map(target_account_ids, account_id)
       follow_mapping(Block.where(target_account_id: target_account_ids, account_id: account_id), :target_account_id)
     end
@@ -158,7 +162,11 @@ module AccountInteractions
   end
 
   def subscribe!(other_account)
-    active_subscribes.find_or_create_by!(target_account: other_account)
+    rel = active_subscribes.find_or_create_by!(target_account: other_account)
+
+    remove_potential_friendship(other_account)
+
+    rel
   end
 
   def following?(other_account)
diff --git a/app/models/concerns/status_threading_concern.rb b/app/models/concerns/status_threading_concern.rb
index a0ead1995a..a19da2c2c3 100644
--- a/app/models/concerns/status_threading_concern.rb
+++ b/app/models/concerns/status_threading_concern.rb
@@ -122,6 +122,7 @@ module StatusThreadingConcern
       blocked_by: Account.blocked_by_map(account_ids, account.id),
       muting: Account.muting_map(account_ids, account.id),
       following: Account.following_map(account_ids, account.id),
+      subscribing: Account.subscribing_map(account_ids, account.id),
       domain_blocking_by_domain: Account.domain_blocking_map_by_domain(domains, account.id),
     }
   end
diff --git a/app/models/export.rb b/app/models/export.rb
index cab01f11ad..ffda4c26d1 100644
--- a/app/models/export.rb
+++ b/app/models/export.rb
@@ -59,6 +59,10 @@ class Export
     account.following_count
   end
 
+  def total_subscribes
+    account.subscribing_count
+  end
+
   def total_lists
     account.owned_lists.count
   end
diff --git a/app/models/user.rb b/app/models/user.rb
index a43e63b2e3..01723e97d0 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -106,7 +106,7 @@ class User < ApplicationRecord
 
   has_many :session_activations, dependent: :destroy
 
-  delegate :auto_play_gif, :default_sensitive, :unfollow_modal, :boost_modal, :delete_modal,
+  delegate :auto_play_gif, :default_sensitive, :unfollow_modal, :unsubscribe_modal, :boost_modal, :delete_modal,
            :reduce_motion, :system_font_ui, :noindex, :theme, :display_media, :hide_network,
            :expand_spoilers, :default_language, :aggregate_reblogs, :show_application,
            :advanced_layout, :use_blurhash, :use_pending_items, :trends, :crop_images,
diff --git a/app/presenters/account_relationships_presenter.rb b/app/presenters/account_relationships_presenter.rb
index 08614b67c2..917bbac198 100644
--- a/app/presenters/account_relationships_presenter.rb
+++ b/app/presenters/account_relationships_presenter.rb
@@ -1,7 +1,7 @@
 # frozen_string_literal: true
 
 class AccountRelationshipsPresenter
-  attr_reader :following, :followed_by, :blocking, :blocked_by,
+  attr_reader :following, :followed_by, :subscribing, :blocking, :blocked_by,
               :muting, :requested, :domain_blocking,
               :endorsed
 
@@ -11,6 +11,7 @@ class AccountRelationshipsPresenter
 
     @following       = cached[:following].merge(Account.following_map(@uncached_account_ids, @current_account_id))
     @followed_by     = cached[:followed_by].merge(Account.followed_by_map(@uncached_account_ids, @current_account_id))
+    @subscribing     = cached[:subscribing].merge(Account.subscribing_map(@uncached_account_ids, @current_account_id))
     @blocking        = cached[:blocking].merge(Account.blocking_map(@uncached_account_ids, @current_account_id))
     @blocked_by      = cached[:blocked_by].merge(Account.blocked_by_map(@uncached_account_ids, @current_account_id))
     @muting          = cached[:muting].merge(Account.muting_map(@uncached_account_ids, @current_account_id))
@@ -22,6 +23,7 @@ class AccountRelationshipsPresenter
 
     @following.merge!(options[:following_map] || {})
     @followed_by.merge!(options[:followed_by_map] || {})
+    @subscribing.merge!(options[:subscribing_map] || {})
     @blocking.merge!(options[:blocking_map] || {})
     @blocked_by.merge!(options[:blocked_by_map] || {})
     @muting.merge!(options[:muting_map] || {})
@@ -38,6 +40,7 @@ class AccountRelationshipsPresenter
     @cached = {
       following: {},
       followed_by: {},
+      subscribing: {},
       blocking: {},
       blocked_by: {},
       muting: {},
@@ -66,6 +69,7 @@ class AccountRelationshipsPresenter
       maps_for_account = {
         following:       { account_id => following[account_id] },
         followed_by:     { account_id => followed_by[account_id] },
+        subscribing:     { account_id => subscribing[account_id] },
         blocking:        { account_id => blocking[account_id] },
         blocked_by:      { account_id => blocked_by[account_id] },
         muting:          { account_id => muting[account_id] },
diff --git a/app/serializers/initial_state_serializer.rb b/app/serializers/initial_state_serializer.rb
index 392fc891af..dde0971617 100644
--- a/app/serializers/initial_state_serializer.rb
+++ b/app/serializers/initial_state_serializer.rb
@@ -27,6 +27,7 @@ class InitialStateSerializer < ActiveModel::Serializer
     if object.current_account
       store[:me]                = object.current_account.id.to_s
       store[:unfollow_modal]    = object.current_account.user.setting_unfollow_modal
+      store[:unsubscribe_modal] = object.current_account.user.setting_unsubscribe_modal
       store[:boost_modal]       = object.current_account.user.setting_boost_modal
       store[:delete_modal]      = object.current_account.user.setting_delete_modal
       store[:auto_play_gif]     = object.current_account.user.setting_auto_play_gif
diff --git a/app/serializers/rest/account_serializer.rb b/app/serializers/rest/account_serializer.rb
index fd361a34cb..3684647b0d 100644
--- a/app/serializers/rest/account_serializer.rb
+++ b/app/serializers/rest/account_serializer.rb
@@ -5,7 +5,7 @@ class REST::AccountSerializer < ActiveModel::Serializer
 
   attributes :id, :username, :acct, :display_name, :locked, :bot, :discoverable, :group, :created_at,
              :note, :url, :avatar, :avatar_static, :header, :header_static,
-             :followers_count, :following_count, :statuses_count, :last_status_at
+             :followers_count, :following_count, :subscribing_count, :statuses_count, :last_status_at
 
   has_one :moved_to_account, key: :moved, serializer: REST::AccountSerializer, if: :moved_and_not_nested?
   has_many :emojis, serializer: REST::CustomEmojiSerializer
diff --git a/app/serializers/rest/relationship_serializer.rb b/app/serializers/rest/relationship_serializer.rb
index 1a3fd915cb..5b05e60b17 100644
--- a/app/serializers/rest/relationship_serializer.rb
+++ b/app/serializers/rest/relationship_serializer.rb
@@ -1,7 +1,7 @@
 # frozen_string_literal: true
 
 class REST::RelationshipSerializer < ActiveModel::Serializer
-  attributes :id, :following, :showing_reblogs, :followed_by, :blocking, :blocked_by,
+  attributes :id, :following, :showing_reblogs, :followed_by, :subscribing, :blocking, :blocked_by,
              :muting, :muting_notifications, :requested, :domain_blocking,
              :endorsed
 
@@ -23,6 +23,10 @@ class REST::RelationshipSerializer < ActiveModel::Serializer
     instance_options[:relationships].followed_by[object.id] || false
   end
 
+  def subscribing
+    instance_options[:relationships].subscribing[object.id] ? true : false
+  end
+
   def blocking
     instance_options[:relationships].blocking[object.id] || false
   end
diff --git a/app/services/account_subscribe_service.rb b/app/services/account_subscribe_service.rb
index 8e9b0adf32..05e10953e9 100644
--- a/app/services/account_subscribe_service.rb
+++ b/app/services/account_subscribe_service.rb
@@ -6,7 +6,8 @@ class AccountSubscribeService < BaseService
   # @param [String, Account] uri User URI to subscribe in the form of username@domain (or account record)
   def call(source_account, target_account)
     begin
-      target_account = ResolveAccountService.new.call(target_account, skip_webfinger: false)
+      target_account = ResolveAccountService.new.call(target_account, skip_webfinger: true)
+      target_account ||= ResolveAccountService.new.call(target_account, skip_webfinger: false)
     rescue
       target_account = nil
     end
@@ -14,9 +15,7 @@ class AccountSubscribeService < BaseService
     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) || (!target_account.local? && target_account.ostatus?) || source_account.domain_blocking?(target_account.domain)
 
-    if source_account.following?(target_account)
-      return
-    elsif source_account.subscribing?(target_account)
+    if source_account.subscribing?(target_account)
       return
     end
 
diff --git a/app/services/follow_service.rb b/app/services/follow_service.rb
index 91c8e2e6d3..dc47804c05 100644
--- a/app/services/follow_service.rb
+++ b/app/services/follow_service.rb
@@ -54,7 +54,6 @@ class FollowService < BaseService
   def direct_follow(source_account, target_account, reblogs: true)
     follow = source_account.follow!(target_account, reblogs: reblogs)
 
-    UnsubscribeAccountService.new.call(source_account, target_account) if source_account.subscribing?(target_account)
     LocalNotificationWorker.perform_async(target_account.id, follow.id, follow.class.name)
     MergeWorker.perform_async(target_account.id, source_account.id)
 
diff --git a/app/services/search_service.rb b/app/services/search_service.rb
index 090fd409b6..bde6f4f5b5 100644
--- a/app/services/search_service.rb
+++ b/app/services/search_service.rb
@@ -119,6 +119,7 @@ class SearchService < BaseService
       blocked_by: Account.blocked_by_map(account_ids, account.id),
       muting: Account.muting_map(account_ids, account.id),
       following: Account.following_map(account_ids, account.id),
+      subscribing: Account.subscribing_map(account_ids, account.id),
       domain_blocking_by_domain: Account.domain_blocking_map_by_domain(domains, account.id),
     }
   end
diff --git a/app/services/suspend_account_service.rb b/app/services/suspend_account_service.rb
index ecc893931d..4ff9111264 100644
--- a/app/services/suspend_account_service.rb
+++ b/app/services/suspend_account_service.rb
@@ -20,6 +20,7 @@ class SuspendAccountService < BaseService
     notifications
     owned_lists
     passive_relationships
+    passive_subscribes
     report_notes
     scheduled_statuses
     status_pins
@@ -113,19 +114,20 @@ class SuspendAccountService < BaseService
 
     return unless @options[:reserve_username]
 
-    @account.silenced_at      = nil
-    @account.suspended_at     = @options[:suspended_at] || Time.now.utc
-    @account.locked           = false
-    @account.memorial         = false
-    @account.discoverable     = false
-    @account.display_name     = ''
-    @account.note             = ''
-    @account.fields           = []
-    @account.statuses_count   = 0
-    @account.followers_count  = 0
-    @account.following_count  = 0
-    @account.moved_to_account = nil
-    @account.trust_level      = :untrusted
+    @account.silenced_at       = nil
+    @account.suspended_at      = @options[:suspended_at] || Time.now.utc
+    @account.locked            = false
+    @account.memorial          = false
+    @account.discoverable      = false
+    @account.display_name      = ''
+    @account.note              = ''
+    @account.fields            = []
+    @account.statuses_count    = 0
+    @account.followers_count   = 0
+    @account.following_count   = 0
+    @account.subscribing_count = 0
+    @account.moved_to_account  = nil
+    @account.trust_level       = :untrusted
     @account.avatar.destroy
     @account.header.destroy
     @account.save!
diff --git a/app/views/settings/preferences/appearance/show.html.haml b/app/views/settings/preferences/appearance/show.html.haml
index d2b05513e3..a5ebf908dc 100644
--- a/app/views/settings/preferences/appearance/show.html.haml
+++ b/app/views/settings/preferences/appearance/show.html.haml
@@ -43,6 +43,7 @@
 
   .fields-group
     = f.input :setting_unfollow_modal, as: :boolean, wrapper: :with_label
+    = f.input :setting_unsubscribe_modal, as: :boolean, wrapper: :with_label
     = f.input :setting_boost_modal, as: :boolean, wrapper: :with_label
     = f.input :setting_delete_modal, as: :boolean, wrapper: :with_label
 
diff --git a/config/locales/simple_form.en.yml b/config/locales/simple_form.en.yml
index 4d07bd6ee4..4f0d2b8878 100644
--- a/config/locales/simple_form.en.yml
+++ b/config/locales/simple_form.en.yml
@@ -141,6 +141,7 @@ en:
         setting_theme: Site theme
         setting_trends: Show today's trends
         setting_unfollow_modal: Show confirmation dialog before unfollowing someone
+        setting_unsubscribe_modal: Show confirmation dialog before unsubscribing someone
         setting_use_blurhash: Show colorful gradients for hidden media
         setting_use_pending_items: Slow mode
         severity: Severity
diff --git a/config/locales/simple_form.ja.yml b/config/locales/simple_form.ja.yml
index 271d6496c6..165a0da033 100644
--- a/config/locales/simple_form.ja.yml
+++ b/config/locales/simple_form.ja.yml
@@ -141,6 +141,7 @@ ja:
         setting_theme: サイトテーマ
         setting_trends: 本日のトレンドタグを表示する
         setting_unfollow_modal: フォローを解除する前に確認ダイアログを表示する
+        setting_unsubscribe_modal: 購読を解除する前に確認ダイアログを表示する
         setting_use_blurhash: 非表示のメディアを色付きのぼかしで表示する
         setting_use_pending_items: 手動更新モード
         severity: 重大性
diff --git a/config/routes.rb b/config/routes.rb
index 7b75652c19..b514f7c4f4 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -383,6 +383,7 @@ Rails.application.routes.draw do
         patch :update_credentials, to: 'credentials#update'
         resource :search, only: :show, controller: :search
         resources :relationships, only: :index
+        resources :subscribing, only: :index, controller: 'subscribing_accounts'
       end
 
       resources :accounts, only: [:create, :show] do
@@ -396,6 +397,8 @@ Rails.application.routes.draw do
         member do
           post :follow
           post :unfollow
+          post :subscribe
+          post :unsubscribe
           post :block
           post :unblock
           post :mute
@@ -417,7 +420,6 @@ Rails.application.routes.draw do
       resources :featured_tags, only: [:index, :create, :destroy]
       resources :favourite_tags, only: [:index, :create, :show, :update, :destroy]
       resources :follow_tags, only: [:index, :create, :show, :update, :destroy]
-      resources :account_subscribes, only: [:index, :create, :show, :update, :destroy]
       resources :domain_subscribes, only: [:index, :create, :show, :update, :destroy]
       resources :keyword_subscribes, only: [:index, :create, :show, :update, :destroy]
 
diff --git a/config/settings.yml b/config/settings.yml
index 0024736435..f31e7c90cb 100644
--- a/config/settings.yml
+++ b/config/settings.yml
@@ -19,6 +19,7 @@ defaults: &defaults
   default_sensitive: false
   hide_network: false
   unfollow_modal: false
+  unsubscribe_modal: false
   boost_modal: false
   delete_modal: true
   auto_play_gif: false
diff --git a/db/migrate/20191214025518_add_subscribing_count_to_account_stat.rb b/db/migrate/20191214025518_add_subscribing_count_to_account_stat.rb
new file mode 100644
index 0000000000..f5135f3ca2
--- /dev/null
+++ b/db/migrate/20191214025518_add_subscribing_count_to_account_stat.rb
@@ -0,0 +1,5 @@
+class AddSubscribingCountToAccountStat < ActiveRecord::Migration[5.2]
+  def change
+    add_column :account_stats, :subscribing_count, :bigint, null: false, default: 0
+  end
+end
diff --git a/db/schema.rb b/db/schema.rb
index 0ceb16e18d..cc8a6eae1a 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -98,6 +98,7 @@ ActiveRecord::Schema.define(version: 2020_01_19_112504) do
     t.datetime "updated_at", null: false
     t.datetime "last_status_at"
     t.integer "lock_version", default: 0, null: false
+    t.bigint "subscribing_count", default: 0, null: false
     t.index ["account_id"], name: "index_account_stats_on_account_id", unique: true
   end
 
diff --git a/lib/mastodon/cache_cli.rb b/lib/mastodon/cache_cli.rb
index 803404c34f..3613bb1511 100644
--- a/lib/mastodon/cache_cli.rb
+++ b/lib/mastodon/cache_cli.rb
@@ -32,10 +32,11 @@ module Mastodon
       case type
       when 'accounts'
         processed, = parallelize_with_progress(Account.local.includes(:account_stat)) do |account|
-          account_stat                 = account.account_stat
-          account_stat.following_count = account.active_relationships.count
-          account_stat.followers_count = account.passive_relationships.count
-          account_stat.statuses_count  = account.statuses.where.not(visibility: :direct).count
+          account_stat                   = account.account_stat
+          account_stat.following_count   = account.active_relationships.count
+          account_stat.followers_count   = account.passive_relationships.count
+          account_stat.subscribing_count = account.active_subscribes.count
+          account_stat.statuses_count    = account.statuses.where.not(visibility: :direct).count
 
           account_stat.save if account_stat.changed?
         end
diff --git a/spec/lib/settings/scoped_settings_spec.rb b/spec/lib/settings/scoped_settings_spec.rb
index 7566685b4a..7c6fa688b7 100644
--- a/spec/lib/settings/scoped_settings_spec.rb
+++ b/spec/lib/settings/scoped_settings_spec.rb
@@ -6,7 +6,7 @@ RSpec.describe Settings::ScopedSettings do
   let(:object)         { Fabricate(:user) }
   let(:scoped_setting) { described_class.new(object) }
   let(:val)            { 'whatever' }
-  let(:methods)        { %i(auto_play_gif default_sensitive unfollow_modal boost_modal delete_modal reduce_motion system_font_ui noindex theme) }
+  let(:methods)        { %i(auto_play_gif default_sensitive unfollow_modal unsubscribe_modal boost_modal delete_modal reduce_motion system_font_ui noindex theme) }
 
   describe '.initialize' do
     it 'sets @object' do
diff --git a/spec/lib/user_settings_decorator_spec.rb b/spec/lib/user_settings_decorator_spec.rb
index 462c5b1249..af5ca29f19 100644
--- a/spec/lib/user_settings_decorator_spec.rb
+++ b/spec/lib/user_settings_decorator_spec.rb
@@ -42,6 +42,13 @@ describe UserSettingsDecorator do
       expect(user.settings['unfollow_modal']).to eq false
     end
 
+    it 'updates the user settings value for unsubscribe modal' do
+      values = { 'setting_unsubscribe_modal' => '0' }
+
+      settings.update(values)
+      expect(user.settings['unsubscribe_modal']).to eq false
+    end
+
     it 'updates the user settings value for boost modal' do
       values = { 'setting_boost_modal' => '1' }
 
-- 
GitLab