about summary refs log tree commit diff
path: root/app
diff options
context:
space:
mode:
Diffstat (limited to 'app')
-rw-r--r--app/controllers/activitypub/outboxes_controller.rb2
-rw-r--r--app/controllers/api/v1/accounts/familiar_followers_controller.rb25
-rw-r--r--app/controllers/api/v1/accounts/statuses_controller.rb41
-rw-r--r--app/controllers/follower_accounts_controller.rb6
-rw-r--r--app/controllers/following_accounts_controller.rb6
-rw-r--r--app/controllers/settings/preferences_controller.rb1
-rw-r--r--app/controllers/settings/profiles_controller.rb2
-rw-r--r--app/javascript/flavours/glitch/components/scrollable_list.js2
-rw-r--r--app/javascript/flavours/glitch/containers/media_container.js4
-rw-r--r--app/javascript/flavours/glitch/features/directory/components/account_card.js204
-rw-r--r--app/javascript/flavours/glitch/features/directory/index.js10
-rw-r--r--app/javascript/flavours/glitch/features/report/category.js2
-rw-r--r--app/javascript/flavours/glitch/features/video/index.js4
-rw-r--r--app/javascript/flavours/glitch/styles/admin.scss50
-rw-r--r--app/javascript/flavours/glitch/styles/components/directory.scss139
-rw-r--r--app/javascript/flavours/glitch/styles/components/index.scss2
-rw-r--r--app/javascript/flavours/glitch/styles/components/single_column.scss12
-rw-r--r--app/javascript/flavours/glitch/styles/containers.scss24
-rw-r--r--app/javascript/flavours/glitch/styles/polls.scss2
-rw-r--r--app/javascript/flavours/glitch/styles/rtl.scss5
-rw-r--r--app/javascript/mastodon/components/scrollable_list.js2
-rw-r--r--app/javascript/mastodon/containers/media_container.js4
-rw-r--r--app/javascript/mastodon/features/directory/components/account_card.js204
-rw-r--r--app/javascript/mastodon/features/directory/index.js10
-rw-r--r--app/javascript/mastodon/features/explore/suggestions.js8
-rw-r--r--app/javascript/mastodon/features/report/category.js2
-rw-r--r--app/javascript/mastodon/features/video/index.js4
-rw-r--r--app/javascript/mastodon/locales/en.json2
-rw-r--r--app/javascript/styles/mastodon-light/diff.scss13
-rw-r--r--app/javascript/styles/mastodon/admin.scss50
-rw-r--r--app/javascript/styles/mastodon/components.scss155
-rw-r--r--app/javascript/styles/mastodon/containers.scss24
-rw-r--r--app/javascript/styles/mastodon/polls.scss2
-rw-r--r--app/javascript/styles/mastodon/rtl.scss5
-rw-r--r--app/lib/user_settings_decorator.rb5
-rw-r--r--app/models/account.rb4
-rw-r--r--app/models/account_statuses_filter.rb134
-rw-r--r--app/models/report.rb2
-rw-r--r--app/models/status.rb22
-rw-r--r--app/models/user.rb6
-rw-r--r--app/policies/user_policy.rb4
-rw-r--r--app/presenters/familiar_followers_presenter.rb17
-rw-r--r--app/serializers/rest/familiar_followers_serializer.rb11
-rw-r--r--app/views/about/_registration.html.haml4
-rw-r--r--app/views/directories/index.html.haml57
-rw-r--r--app/views/follower_accounts/index.html.haml2
-rw-r--r--app/views/following_accounts/index.html.haml2
-rw-r--r--app/views/settings/preferences/other/show.html.haml3
-rw-r--r--app/views/settings/profiles/show.html.haml5
49 files changed, 549 insertions, 757 deletions
diff --git a/app/controllers/activitypub/outboxes_controller.rb b/app/controllers/activitypub/outboxes_controller.rb
index b2aab56a5..cd3992502 100644
--- a/app/controllers/activitypub/outboxes_controller.rb
+++ b/app/controllers/activitypub/outboxes_controller.rb
@@ -62,7 +62,7 @@ class ActivityPub::OutboxesController < ActivityPub::BaseController
     return unless page_requested?
 
     @statuses = cache_collection_paginated_by_id(
-      @account.statuses.permitted_for(@account, signed_request_account),
+      AccountStatusesFilter.new(@account, signed_request_account).results,
       Status,
       LIMIT,
       params_slice(:max_id, :min_id, :since_id)
diff --git a/app/controllers/api/v1/accounts/familiar_followers_controller.rb b/app/controllers/api/v1/accounts/familiar_followers_controller.rb
new file mode 100644
index 000000000..b0bd8018a
--- /dev/null
+++ b/app/controllers/api/v1/accounts/familiar_followers_controller.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+class Api::V1::Accounts::FamiliarFollowersController < Api::BaseController
+  before_action -> { doorkeeper_authorize! :read, :'read:follows' }
+  before_action :require_user!
+  before_action :set_accounts
+
+  def index
+    render json: familiar_followers.accounts, each_serializer: REST::FamiliarFollowersSerializer
+  end
+
+  private
+
+  def set_accounts
+    @accounts = Account.without_suspended.where(id: account_ids).select('id, hide_collections').index_by(&:id).values_at(*account_ids).compact
+  end
+
+  def familiar_followers
+    FamiliarFollowersPresenter.new(@accounts, current_user.account_id)
+  end
+
+  def account_ids
+    Array(params[:id]).map(&:to_i)
+  end
+end
diff --git a/app/controllers/api/v1/accounts/statuses_controller.rb b/app/controllers/api/v1/accounts/statuses_controller.rb
index 2c027ea76..38c9f5a20 100644
--- a/app/controllers/api/v1/accounts/statuses_controller.rb
+++ b/app/controllers/api/v1/accounts/statuses_controller.rb
@@ -22,53 +22,16 @@ class Api::V1::Accounts::StatusesController < Api::BaseController
   end
 
   def cached_account_statuses
-    statuses = truthy_param?(:pinned) ? pinned_scope : permitted_account_statuses
-
-    statuses.merge!(only_media_scope) if truthy_param?(:only_media)
-    statuses.merge!(no_replies_scope) if truthy_param?(:exclude_replies)
-    statuses.merge!(no_reblogs_scope) if truthy_param?(:exclude_reblogs)
-    statuses.merge!(hashtag_scope)    if params[:tagged].present?
-
     cache_collection_paginated_by_id(
-      statuses,
+      AccountStatusesFilter.new(@account, current_account, params).results,
       Status,
       limit_param(DEFAULT_STATUSES_LIMIT),
       params_slice(:max_id, :since_id, :min_id)
     )
   end
 
-  def permitted_account_statuses
-    @account.statuses.permitted_for(@account, current_account)
-  end
-
-  def only_media_scope
-    Status.joins(:media_attachments).merge(@account.media_attachments.reorder(nil)).group(:id)
-  end
-
-  def pinned_scope
-    @account.pinned_statuses.permitted_for(@account, current_account)
-  end
-
-  def no_replies_scope
-    Status.without_replies
-  end
-
-  def no_reblogs_scope
-    Status.without_reblogs
-  end
-
-  def hashtag_scope
-    tag = Tag.find_normalized(params[:tagged])
-
-    if tag
-      Status.tagged_with(tag.id)
-    else
-      Status.none
-    end
-  end
-
   def pagination_params(core_params)
-    params.slice(:limit, :only_media, :exclude_replies).permit(:limit, :only_media, :exclude_replies).merge(core_params)
+    params.slice(:limit, *AccountStatusesFilter::KEYS).permit(:limit, *AccountStatusesFilter::KEYS).merge(core_params)
   end
 
   def insert_pagination_headers
diff --git a/app/controllers/follower_accounts_controller.rb b/app/controllers/follower_accounts_controller.rb
index d519138cd..f898994ac 100644
--- a/app/controllers/follower_accounts_controller.rb
+++ b/app/controllers/follower_accounts_controller.rb
@@ -16,13 +16,13 @@ class FollowerAccountsController < ApplicationController
         use_pack 'public'
         expires_in 0, public: true unless user_signed_in?
 
-        next if @account.user_hides_network?
+        next if @account.hide_collections?
 
         follows
       end
 
       format.json do
-        raise Mastodon::NotPermittedError if page_requested? && @account.user_hides_network?
+        raise Mastodon::NotPermittedError if page_requested? && @account.hide_collections?
 
         expires_in(page_requested? ? 0 : 3.minutes, public: public_fetch_mode?)
 
@@ -83,7 +83,7 @@ class FollowerAccountsController < ApplicationController
   end
 
   def restrict_fields_to
-    if page_requested? || !@account.user_hides_network?
+    if page_requested? || !@account.hide_collections?
       # Return all fields
     else
       %i(id type total_items)
diff --git a/app/controllers/following_accounts_controller.rb b/app/controllers/following_accounts_controller.rb
index 4b4978fb9..bc291c962 100644
--- a/app/controllers/following_accounts_controller.rb
+++ b/app/controllers/following_accounts_controller.rb
@@ -16,13 +16,13 @@ class FollowingAccountsController < ApplicationController
         use_pack 'public'
         expires_in 0, public: true unless user_signed_in?
 
-        next if @account.user_hides_network?
+        next if @account.hide_collections?
 
         follows
       end
 
       format.json do
-        raise Mastodon::NotPermittedError if page_requested? && @account.user_hides_network?
+        raise Mastodon::NotPermittedError if page_requested? && @account.hide_collections?
 
         expires_in(page_requested? ? 0 : 3.minutes, public: public_fetch_mode?)
 
@@ -83,7 +83,7 @@ class FollowingAccountsController < ApplicationController
   end
 
   def restrict_fields_to
-    if page_requested? || !@account.user_hides_network?
+    if page_requested? || !@account.hide_collections?
       # Return all fields
     else
       %i(id type total_items)
diff --git a/app/controllers/settings/preferences_controller.rb b/app/controllers/settings/preferences_controller.rb
index dfe2ae2e5..1fddd087b 100644
--- a/app/controllers/settings/preferences_controller.rb
+++ b/app/controllers/settings/preferences_controller.rb
@@ -48,7 +48,6 @@ class Settings::PreferencesController < Settings::BaseController
       :setting_system_font_ui,
       :setting_system_emoji_font,
       :setting_noindex,
-      :setting_hide_network,
       :setting_hide_followers_count,
       :setting_aggregate_reblogs,
       :setting_show_application,
diff --git a/app/controllers/settings/profiles_controller.rb b/app/controllers/settings/profiles_controller.rb
index 0c15447a6..be5b4f302 100644
--- a/app/controllers/settings/profiles_controller.rb
+++ b/app/controllers/settings/profiles_controller.rb
@@ -20,7 +20,7 @@ class Settings::ProfilesController < Settings::BaseController
   private
 
   def account_params
-    params.require(:account).permit(:display_name, :note, :avatar, :header, :locked, :bot, :discoverable, fields_attributes: [:name, :value])
+    params.require(:account).permit(:display_name, :note, :avatar, :header, :locked, :bot, :discoverable, :hide_collections, fields_attributes: [:name, :value])
   end
 
   def set_account
diff --git a/app/javascript/flavours/glitch/components/scrollable_list.js b/app/javascript/flavours/glitch/components/scrollable_list.js
index 16f13afa4..50bfacc6a 100644
--- a/app/javascript/flavours/glitch/components/scrollable_list.js
+++ b/app/javascript/flavours/glitch/components/scrollable_list.js
@@ -144,7 +144,7 @@ class ScrollableList extends PureComponent {
     this.attachIntersectionObserver();
     attachFullscreenListener(this.onFullScreenChange);
 
-    // Handle initial scroll posiiton
+    // Handle initial scroll position
     this.handleScroll();
   }
 
diff --git a/app/javascript/flavours/glitch/containers/media_container.js b/app/javascript/flavours/glitch/containers/media_container.js
index 1ddbc706b..11c15d7c6 100644
--- a/app/javascript/flavours/glitch/containers/media_container.js
+++ b/app/javascript/flavours/glitch/containers/media_container.js
@@ -43,7 +43,7 @@ export default class MediaContainer extends PureComponent {
 
   handleOpenVideo = (options) => {
     const { components } = this.props;
-    const { media } = JSON.parse(components[options.componetIndex].getAttribute('data-props'));
+    const { media } = JSON.parse(components[options.componentIndex].getAttribute('data-props'));
     const mediaList = fromJS(media);
 
     document.body.classList.add('with-modals--active');
@@ -87,7 +87,7 @@ export default class MediaContainer extends PureComponent {
               ...(hashtag ? { hashtag: fromJS(hashtag) } : {}),
 
               ...(componentName === 'Video' ? {
-                componetIndex: i,
+                componentIndex: i,
                 onOpenVideo: this.handleOpenVideo,
               } : {
                 onOpenMedia: this.handleOpenMedia,
diff --git a/app/javascript/flavours/glitch/features/directory/components/account_card.js b/app/javascript/flavours/glitch/features/directory/components/account_card.js
index 2a3fd1ecf..c9ef5850c 100644
--- a/app/javascript/flavours/glitch/features/directory/components/account_card.js
+++ b/app/javascript/flavours/glitch/features/directory/components/account_card.js
@@ -7,31 +7,28 @@ import { makeGetAccount } from 'flavours/glitch/selectors';
 import Avatar from 'flavours/glitch/components/avatar';
 import DisplayName from 'flavours/glitch/components/display_name';
 import Permalink from 'flavours/glitch/components/permalink';
-import RelativeTimestamp from 'flavours/glitch/components/relative_timestamp';
-import IconButton from 'flavours/glitch/components/icon_button';
+import Button from 'flavours/glitch/components/button';
 import { FormattedMessage, injectIntl, defineMessages } from 'react-intl';
 import { autoPlayGif, me, unfollowModal } from 'flavours/glitch/util/initial_state';
 import ShortNumber from 'flavours/glitch/components/short_number';
 import {
   followAccount,
   unfollowAccount,
-  blockAccount,
   unblockAccount,
   unmuteAccount,
 } from 'flavours/glitch/actions/accounts';
 import { openModal } from 'flavours/glitch/actions/modal';
-import { initMuteModal } from 'flavours/glitch/actions/mutes';
+import classNames from 'classnames';
 
 const messages = defineMessages({
-  follow: { id: 'account.follow', defaultMessage: 'Follow' },
   unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
-  requested: { id: 'account.requested', defaultMessage: 'Awaiting approval' },
-  unblock: { id: 'account.unblock', defaultMessage: 'Unblock @{name}' },
-  unmute: { id: 'account.unmute', defaultMessage: 'Unmute @{name}' },
-  unfollowConfirm: {
-    id: 'confirmations.unfollow.confirm',
-    defaultMessage: 'Unfollow',
-  },
+  follow: { id: 'account.follow', defaultMessage: 'Follow' },
+  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_short', defaultMessage: 'Unblock' },
+  unmute: { id: 'account.unmute_short', defaultMessage: 'Unmute' },
+  unfollowConfirm: { id: 'confirmations.unfollow.confirm', defaultMessage: 'Unfollow' },
+  edit_profile: { id: 'account.edit_profile', defaultMessage: 'Edit profile' },
 });
 
 const makeMapStateToProps = () => {
@@ -75,18 +72,15 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
   onBlock(account) {
     if (account.getIn(['relationship', 'blocking'])) {
       dispatch(unblockAccount(account.get('id')));
-    } else {
-      dispatch(blockAccount(account.get('id')));
     }
   },
 
   onMute(account) {
     if (account.getIn(['relationship', 'muting'])) {
       dispatch(unmuteAccount(account.get('id')));
-    } else {
-      dispatch(initMuteModal(account));
     }
   },
+
 });
 
 export default
@@ -138,130 +132,92 @@ class AccountCard extends ImmutablePureComponent {
 
   handleMute = () => {
     this.props.onMute(this.props.account);
-  };
+  }
+
+  handleEditProfile = () => {
+    window.open('/settings/profile', '_blank');
+  }
 
   render() {
     const { account, intl } = this.props;
 
-    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']);
-
-      if (requested) {
-        buttons = (
-          <IconButton
-            disabled
-            icon='hourglass'
-            title={intl.formatMessage(messages.requested)}
-          />
-        );
-      } else if (blocking) {
-        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}
-          />
-        );
+    let actionBtn;
+
+    if (me !== account.get('id')) {
+      if (!account.get('relationship')) { // Wait until the relationship is loaded
+        actionBtn = '';
+      } else if (account.getIn(['relationship', 'requested'])) {
+        actionBtn = <Button className={classNames('logo-button')} text={intl.formatMessage(messages.cancel_follow_request)} title={intl.formatMessage(messages.requested)} onClick={this.handleFollow} />;
+      } else if (account.getIn(['relationship', 'muting'])) {
+        actionBtn = <Button className='logo-button' text={intl.formatMessage(messages.unmute)} onClick={this.handleMute} />;
+      } else if (!account.getIn(['relationship', 'blocking'])) {
+        actionBtn = <Button disabled={account.getIn(['relationship', 'blocked_by'])} className={classNames('logo-button', { 'button--destructive': account.getIn(['relationship', 'following']) })} text={intl.formatMessage(account.getIn(['relationship', 'following']) ? messages.unfollow : messages.follow)} onClick={this.handleFollow} />;
+      } else if (account.getIn(['relationship', 'blocking'])) {
+        actionBtn = <Button className='logo-button' text={intl.formatMessage(messages.unblock)} onClick={this.handleBlock} />;
       }
+    } else {
+      actionBtn = <Button className='logo-button' text={intl.formatMessage(messages.edit_profile)} onClick={this.handleEditProfile} />;
     }
 
     return (
-      <div className='directory__card'>
-        <div className='directory__card__img'>
-          <img
-            src={
-              autoPlayGif ? account.get('header') : account.get('header_static')
-            }
-            alt=''
-          />
-        </div>
+      <div className='account-card'>
+        <Permalink href={account.get('url')} to={`/@${account.get('acct')}`} className='account-card__permalink'>
+          <div className='account-card__header'>
+            <img
+              src={
+                autoPlayGif ? account.get('header') : account.get('header_static')
+              }
+              alt=''
+            />
+          </div>
 
-        <div className='directory__card__bar'>
-          <Permalink
-            className='directory__card__bar__name'
-            href={account.get('url')}
-            to={`/@${account.get('acct')}`}
-          >
-            <Avatar account={account} size={48} />
+          <div className='account-card__title'>
+            <div className='account-card__title__avatar'><Avatar account={account} size={56} /></div>
             <DisplayName account={account} />
-          </Permalink>
-
-          <div className='directory__card__bar__relationship account__relationship'>
-            {buttons}
           </div>
-        </div>
+        </Permalink>
 
-        <div className='directory__card__extra' onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave}>
+        {account.get('note').length > 0 && (
           <div
-            className='account__header__content translate'
+            className='account-card__bio translate'
+            onMouseEnter={this.handleMouseEnter}
+            onMouseLeave={this.handleMouseLeave}
             dangerouslySetInnerHTML={{ __html: account.get('note_emojified') }}
           />
-        </div>
-
-        <div className='directory__card__extra'>
-          <div className='accounts-table__count'>
-            <ShortNumber value={account.get('statuses_count')} />
-            <small>
-              <FormattedMessage id='account.posts' defaultMessage='Toots' />
-            </small>
+        )}
+
+        <div className='account-card__actions'>
+          <div className='account-card__counters'>
+            <div className='account-card__counters__item'>
+              <ShortNumber value={account.get('statuses_count')} />
+              <small>
+                <FormattedMessage id='account.posts' defaultMessage='Toots' />
+              </small>
+            </div>
+
+            <div className='account-card__counters__item'>
+              {account.get('followers_count') < 0 ? '-' : <ShortNumber value={account.get('followers_count')} />}{' '}
+              <small>
+                <FormattedMessage
+                  id='account.followers'
+                  defaultMessage='Followers'
+                />
+              </small>
+            </div>
+
+            <div className='account-card__counters__item'>
+              <ShortNumber value={account.get('following_count')} />{' '}
+              <small>
+                <FormattedMessage
+                  id='account.following'
+                  defaultMessage='Following'
+                />
+              </small>
+            </div>
           </div>
-          <div className='accounts-table__count'>
-            {account.get('followers_count') < 0 ? '-' : <ShortNumber value={account.get('followers_count')} />}{' '}
-            <small>
-              <FormattedMessage
-                id='account.followers'
-                defaultMessage='Followers'
-              />
-            </small>
-          </div>
-          <div className='accounts-table__count'>
-            {account.get('last_status_at') === null ? (
-              <FormattedMessage
-                id='account.never_active'
-                defaultMessage='Never'
-              />
-            ) : (
-              <RelativeTimestamp timestamp={account.get('last_status_at')} />
-            )}{' '}
-            <small>
-              <FormattedMessage
-                id='account.last_status'
-                defaultMessage='Last active'
-              />
-            </small>
+
+          <div className='account-card__actions__button'>
+            {actionBtn}
           </div>
         </div>
       </div>
diff --git a/app/javascript/flavours/glitch/features/directory/index.js b/app/javascript/flavours/glitch/features/directory/index.js
index cde5926e0..87d9b3625 100644
--- a/app/javascript/flavours/glitch/features/directory/index.js
+++ b/app/javascript/flavours/glitch/features/directory/index.js
@@ -10,9 +10,9 @@ import { fetchDirectory, expandDirectory } from 'flavours/glitch/actions/directo
 import { List as ImmutableList } from 'immutable';
 import AccountCard from './components/account_card';
 import RadioButton from 'flavours/glitch/components/radio_button';
-import classNames from 'classnames';
 import LoadMore from 'flavours/glitch/components/load_more';
 import ScrollContainer from 'flavours/glitch/containers/scroll_container';
+import LoadingIndicator from 'flavours/glitch/components/loading_indicator';
 
 const messages = defineMessages({
   title: { id: 'column.directory', defaultMessage: 'Browse profiles' },
@@ -129,7 +129,7 @@ class Directory extends React.PureComponent {
     const pinned = !!columnId;
 
     const scrollableArea = (
-      <div className='scrollable' style={{ background: 'transparent' }}>
+      <div className='scrollable'>
         <div className='filter-form'>
           <div className='filter-form__column' role='group'>
             <RadioButton name='order' value='active' label={intl.formatMessage(messages.recentlyActive)} checked={order === 'active'} onChange={this.handleChangeOrder} />
@@ -142,8 +142,10 @@ class Directory extends React.PureComponent {
           </div>
         </div>
 
-        <div className={classNames('directory__list', { loading: isLoading })}>
-          {accountIds.map(accountId => <AccountCard id={accountId} key={accountId} />)}
+        <div className='directory__list'>
+          {isLoading ? <LoadingIndicator /> : accountIds.map(accountId => (
+            <AccountCard id={accountId} key={accountId} />
+          ))}
         </div>
 
         <LoadMore onClick={this.handleLoadMore} visible={!isLoading} />
diff --git a/app/javascript/flavours/glitch/features/report/category.js b/app/javascript/flavours/glitch/features/report/category.js
index ddbc82563..cf63533d0 100644
--- a/app/javascript/flavours/glitch/features/report/category.js
+++ b/app/javascript/flavours/glitch/features/report/category.js
@@ -8,7 +8,7 @@ const messages = defineMessages({
   dislike: { id: 'report.reasons.dislike', defaultMessage: 'I don\'t like it' },
   dislike_description: { id: 'report.reasons.dislike_description', defaultMessage: 'It is not something you want to see' },
   spam: { id: 'report.reasons.spam', defaultMessage: 'It\'s spam' },
-  spam_description: { id: 'report.reasons.spam_description', defaultMessage: 'Malicious links, fake engagement, or repetetive replies' },
+  spam_description: { id: 'report.reasons.spam_description', defaultMessage: 'Malicious links, fake engagement, or repetitive replies' },
   violation: { id: 'report.reasons.violation', defaultMessage: 'It violates server rules' },
   violation_description: { id: 'report.reasons.violation_description', defaultMessage: 'You are aware that it breaks specific rules' },
   other: { id: 'report.reasons.other', defaultMessage: 'It\'s something else' },
diff --git a/app/javascript/flavours/glitch/features/video/index.js b/app/javascript/flavours/glitch/features/video/index.js
index fcbf07ce2..53e3dfda3 100644
--- a/app/javascript/flavours/glitch/features/video/index.js
+++ b/app/javascript/flavours/glitch/features/video/index.js
@@ -123,7 +123,7 @@ class Video extends React.PureComponent {
     autoPlay: PropTypes.bool,
     volume: PropTypes.number,
     muted: PropTypes.bool,
-    componetIndex: PropTypes.number,
+    componentIndex: PropTypes.number,
   };
 
   static defaultProps = {
@@ -516,7 +516,7 @@ class Video extends React.PureComponent {
       startTime: this.video.currentTime,
       autoPlay: !this.state.paused,
       defaultVolume: this.state.volume,
-      componetIndex: this.props.componetIndex,
+      componentIndex: this.props.componentIndex,
     });
   }
 
diff --git a/app/javascript/flavours/glitch/styles/admin.scss b/app/javascript/flavours/glitch/styles/admin.scss
index 73414785c..0873ac300 100644
--- a/app/javascript/flavours/glitch/styles/admin.scss
+++ b/app/javascript/flavours/glitch/styles/admin.scss
@@ -1236,6 +1236,11 @@ a.sparkline {
   background: $ui-base-color;
   border-radius: 4px;
 
+  &__permalink {
+    color: inherit;
+    text-decoration: none;
+  }
+
   &__header {
     padding: 4px;
     border-radius: 4px;
@@ -1252,20 +1257,22 @@ a.sparkline {
   }
 
   &__title {
-    margin-top: -25px;
+    margin-top: -(15px + 8px);
     display: flex;
     align-items: flex-end;
 
     &__avatar {
-      padding: 15px;
+      padding: 14px;
 
-      img {
+      img,
+      .account__avatar {
         display: block;
         margin: 0;
         width: 56px;
         height: 56px;
-        background: darken($ui-base-color, 8%);
+        background-color: darken($ui-base-color, 8%);
         border-radius: 8px;
+        border: 1px solid $ui-base-color;
       }
     }
 
@@ -1273,30 +1280,34 @@ a.sparkline {
       color: $darker-text-color;
       padding-bottom: 15px;
       font-size: 15px;
+      line-height: 20px;
 
       bdi {
         display: block;
         color: $primary-text-color;
-        font-weight: 500;
+        font-weight: 700;
       }
     }
   }
 
   &__bio {
     padding: 0 15px;
+    margin: 8px 0;
     overflow: hidden;
     text-overflow: ellipsis;
     word-wrap: break-word;
-    max-height: 18px * 2;
+    max-height: 21px * 2;
     position: relative;
+    font-size: 15px;
+    line-height: 21px;
 
     &::after {
       display: block;
       content: "";
       width: 50px;
-      height: 18px;
+      height: 21px;
       position: absolute;
-      bottom: 0;
+      bottom: 8px;
       right: 15px;
       background: linear-gradient(to left, $ui-base-color, transparent);
       pointer-events: none;
@@ -1309,10 +1320,6 @@ a.sparkline {
 
       &:hover {
         text-decoration: underline;
-
-        .fa {
-          color: lighten($dark-text-color, 7%);
-        }
       }
 
       &.mention {
@@ -1329,12 +1336,21 @@ a.sparkline {
 
   &__actions {
     display: flex;
+    justify-content: space-between;
     align-items: center;
-    padding-top: 10px;
 
     &__button {
-      flex: 0 0 auto;
+      flex-shrink: 1;
       padding: 0 15px;
+      overflow: hidden;
+
+      .button {
+        min-width: 0;
+        white-space: nowrap;
+        text-overflow: ellipsis;
+        overflow: hidden;
+        max-width: 100%;
+      }
     }
   }
 
@@ -1343,19 +1359,23 @@ a.sparkline {
     display: grid;
     grid-auto-columns: minmax(0, 1fr);
     grid-auto-flow: column;
+    max-width: 340px;
+    min-width: 65px * 3;
 
     &__item {
-      padding: 15px;
+      padding: 15px 0;
       text-align: center;
       color: $primary-text-color;
       font-weight: 600;
       font-size: 15px;
+      line-height: 21px;
 
       small {
         display: block;
         color: $darker-text-color;
         font-weight: 400;
         font-size: 13px;
+        line-height: 18px;
       }
     }
   }
diff --git a/app/javascript/flavours/glitch/styles/components/directory.scss b/app/javascript/flavours/glitch/styles/components/directory.scss
index b0ad5a88a..b48c6c102 100644
--- a/app/javascript/flavours/glitch/styles/components/directory.scss
+++ b/app/javascript/flavours/glitch/styles/components/directory.scss
@@ -1,133 +1,17 @@
-.directory {
-  &__list {
-    width: 100%;
-    margin: 10px 0;
-    transition: opacity 100ms ease-in;
-
-    &.loading {
-      opacity: 0.7;
-    }
+.scrollable .account-card {
+  margin: 10px;
+  background: lighten($ui-base-color, 8%);
+}
 
-    @media screen and (max-width: $no-gap-breakpoint) {
-      margin: 0;
-    }
+.scrollable .account-card__title__avatar {
+  img,
+  .account__avatar {
+    border-color: lighten($ui-base-color, 8%);
   }
+}
 
-  &__card {
-    box-sizing: border-box;
-    margin-bottom: 10px;
-
-    &__img {
-      height: 125px;
-      position: relative;
-      background: darken($ui-base-color, 12%);
-      overflow: hidden;
-
-      img {
-        display: block;
-        width: 100%;
-        height: 100%;
-        margin: 0;
-        object-fit: cover;
-      }
-    }
-
-    &__bar {
-      display: flex;
-      align-items: center;
-      background: lighten($ui-base-color, 4%);
-      padding: 10px;
-
-      &__name {
-        flex: 1 1 auto;
-        display: flex;
-        align-items: center;
-        text-decoration: none;
-        overflow: hidden;
-      }
-
-      &__relationship {
-        width: 23px;
-        min-height: 1px;
-        flex: 0 0 auto;
-      }
-
-      .avatar {
-        flex: 0 0 auto;
-        width: 48px;
-        height: 48px;
-        padding-top: 2px;
-
-        img {
-          width: 100%;
-          height: 100%;
-          display: block;
-          margin: 0;
-          border-radius: 4px;
-          background: darken($ui-base-color, 8%);
-          object-fit: cover;
-        }
-      }
-
-      .display-name {
-        margin-left: 15px;
-        text-align: left;
-
-        strong {
-          font-size: 15px;
-          color: $primary-text-color;
-          font-weight: 500;
-          overflow: hidden;
-          text-overflow: ellipsis;
-        }
-
-        span {
-          display: block;
-          font-size: 14px;
-          color: $darker-text-color;
-          font-weight: 400;
-          overflow: hidden;
-          text-overflow: ellipsis;
-        }
-      }
-    }
-
-    &__extra {
-      background: $ui-base-color;
-      display: flex;
-      align-items: center;
-      justify-content: center;
-
-      .accounts-table__count {
-        width: 33.33%;
-        flex: 0 0 auto;
-        padding: 15px 0;
-      }
-
-      .account__header__content {
-        box-sizing: border-box;
-        padding: 15px 10px;
-        border-bottom: 1px solid lighten($ui-base-color, 8%);
-        width: 100%;
-        min-height: 18px + 30px;
-        white-space: nowrap;
-        overflow: hidden;
-        text-overflow: ellipsis;
-
-        p {
-          display: none;
-
-          &:first-child {
-            display: inline;
-          }
-        }
-
-        br {
-          display: none;
-        }
-      }
-    }
-  }
+.scrollable .account-card__bio::after {
+  background: linear-gradient(to left, lighten($ui-base-color, 8%), transparent);
 }
 
 .filter-form {
@@ -135,6 +19,7 @@
 
   &__column {
     padding: 10px 15px;
+    padding-bottom: 0;
   }
 
   .radio-button {
diff --git a/app/javascript/flavours/glitch/styles/components/index.scss b/app/javascript/flavours/glitch/styles/components/index.scss
index b6372c096..7364eba91 100644
--- a/app/javascript/flavours/glitch/styles/components/index.scss
+++ b/app/javascript/flavours/glitch/styles/components/index.scss
@@ -41,7 +41,7 @@
   cursor: pointer;
   display: inline-block;
   font-family: inherit;
-  font-size: 17px;
+  font-size: 15px;
   font-weight: 500;
   letter-spacing: 0;
   line-height: 22px;
diff --git a/app/javascript/flavours/glitch/styles/components/single_column.scss b/app/javascript/flavours/glitch/styles/components/single_column.scss
index edf705b5f..db510f1f4 100644
--- a/app/javascript/flavours/glitch/styles/components/single_column.scss
+++ b/app/javascript/flavours/glitch/styles/components/single_column.scss
@@ -94,17 +94,7 @@
     padding: 0;
   }
 
-  .directory__list {
-    display: grid;
-    grid-gap: 10px;
-    grid-template-columns: minmax(0, 50%) minmax(0, 50%);
-
-    @media screen and (max-width: $no-gap-breakpoint) {
-      display: block;
-    }
-  }
-
-  .directory__card {
+  .account-card {
     margin-bottom: 0;
   }
 
diff --git a/app/javascript/flavours/glitch/styles/containers.scss b/app/javascript/flavours/glitch/styles/containers.scss
index eb82157c8..98a1288eb 100644
--- a/app/javascript/flavours/glitch/styles/containers.scss
+++ b/app/javascript/flavours/glitch/styles/containers.scss
@@ -411,14 +411,6 @@
     }
   }
 
-  .directory__card {
-    border-radius: 4px;
-
-    @media screen and (max-width: $no-gap-breakpoint) {
-      border-radius: 0;
-    }
-  }
-
   .page-header {
     @media screen and (max-width: $no-gap-breakpoint) {
       border-bottom: 0;
@@ -841,19 +833,21 @@
     grid-gap: 10px;
     grid-template-columns: minmax(0, 50%) minmax(0, 50%);
 
+    .account-card {
+      display: flex;
+      flex-direction: column;
+    }
+
     @media screen and (max-width: $no-gap-breakpoint) {
       display: block;
-    }
 
-    .icon-button {
-      font-size: 18px;
+      .account-card {
+        margin-bottom: 10px;
+        display: block;
+      }
     }
   }
 
-  .directory__card {
-    margin-bottom: 0;
-  }
-
   .card-grid {
     display: flex;
     flex-wrap: wrap;
diff --git a/app/javascript/flavours/glitch/styles/polls.scss b/app/javascript/flavours/glitch/styles/polls.scss
index a2cdecf06..0847c8f4c 100644
--- a/app/javascript/flavours/glitch/styles/polls.scss
+++ b/app/javascript/flavours/glitch/styles/polls.scss
@@ -75,7 +75,7 @@
       display: none;
     }
 
-    .autossugest-input {
+    .autosuggest-input {
       flex: 1 1 auto;
     }
 
diff --git a/app/javascript/flavours/glitch/styles/rtl.scss b/app/javascript/flavours/glitch/styles/rtl.scss
index afa05d93e..d0153c9f9 100644
--- a/app/javascript/flavours/glitch/styles/rtl.scss
+++ b/app/javascript/flavours/glitch/styles/rtl.scss
@@ -12,11 +12,6 @@ body.rtl {
     margin-left: 10px;
   }
 
-  .directory__card__bar .display-name {
-    margin-left: 0;
-    margin-right: 15px;
-  }
-
   .display-name {
     text-align: right;
   }
diff --git a/app/javascript/mastodon/components/scrollable_list.js b/app/javascript/mastodon/components/scrollable_list.js
index 68a178512..91d04bf4d 100644
--- a/app/javascript/mastodon/components/scrollable_list.js
+++ b/app/javascript/mastodon/components/scrollable_list.js
@@ -151,7 +151,7 @@ class ScrollableList extends PureComponent {
 
     attachFullscreenListener(this.onFullScreenChange);
 
-    // Handle initial scroll posiiton
+    // Handle initial scroll position
     this.handleScroll();
   }
 
diff --git a/app/javascript/mastodon/containers/media_container.js b/app/javascript/mastodon/containers/media_container.js
index 2f42a084f..6ee1f0bd8 100644
--- a/app/javascript/mastodon/containers/media_container.js
+++ b/app/javascript/mastodon/containers/media_container.js
@@ -43,7 +43,7 @@ export default class MediaContainer extends PureComponent {
 
   handleOpenVideo = (options) => {
     const { components } = this.props;
-    const { media } = JSON.parse(components[options.componetIndex].getAttribute('data-props'));
+    const { media } = JSON.parse(components[options.componentIndex].getAttribute('data-props'));
     const mediaList = fromJS(media);
 
     document.body.classList.add('with-modals--active');
@@ -87,7 +87,7 @@ export default class MediaContainer extends PureComponent {
               ...(hashtag ? { hashtag: fromJS(hashtag) } : {}),
 
               ...(componentName === 'Video' ? {
-                componetIndex: i,
+                componentIndex: i,
                 onOpenVideo: this.handleOpenVideo,
               } : {
                 onOpenMedia: this.handleOpenMedia,
diff --git a/app/javascript/mastodon/features/directory/components/account_card.js b/app/javascript/mastodon/features/directory/components/account_card.js
index 03e13f28e..31f59cd84 100644
--- a/app/javascript/mastodon/features/directory/components/account_card.js
+++ b/app/javascript/mastodon/features/directory/components/account_card.js
@@ -7,31 +7,28 @@ import { makeGetAccount } from 'mastodon/selectors';
 import Avatar from 'mastodon/components/avatar';
 import DisplayName from 'mastodon/components/display_name';
 import Permalink from 'mastodon/components/permalink';
-import RelativeTimestamp from 'mastodon/components/relative_timestamp';
-import IconButton from 'mastodon/components/icon_button';
+import Button from 'mastodon/components/button';
 import { FormattedMessage, injectIntl, defineMessages } from 'react-intl';
 import { autoPlayGif, me, unfollowModal } from 'mastodon/initial_state';
 import ShortNumber from 'mastodon/components/short_number';
 import {
   followAccount,
   unfollowAccount,
-  blockAccount,
   unblockAccount,
   unmuteAccount,
 } from 'mastodon/actions/accounts';
 import { openModal } from 'mastodon/actions/modal';
-import { initMuteModal } from 'mastodon/actions/mutes';
+import classNames from 'classnames';
 
 const messages = defineMessages({
-  follow: { id: 'account.follow', defaultMessage: 'Follow' },
   unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
-  requested: { id: 'account.requested', defaultMessage: 'Awaiting approval' },
-  unblock: { id: 'account.unblock', defaultMessage: 'Unblock @{name}' },
-  unmute: { id: 'account.unmute', defaultMessage: 'Unmute @{name}' },
-  unfollowConfirm: {
-    id: 'confirmations.unfollow.confirm',
-    defaultMessage: 'Unfollow',
-  },
+  follow: { id: 'account.follow', defaultMessage: 'Follow' },
+  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_short', defaultMessage: 'Unblock' },
+  unmute: { id: 'account.unmute_short', defaultMessage: 'Unmute' },
+  unfollowConfirm: { id: 'confirmations.unfollow.confirm', defaultMessage: 'Unfollow' },
+  edit_profile: { id: 'account.edit_profile', defaultMessage: 'Edit profile' },
 });
 
 const makeMapStateToProps = () => {
@@ -75,18 +72,15 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
   onBlock(account) {
     if (account.getIn(['relationship', 'blocking'])) {
       dispatch(unblockAccount(account.get('id')));
-    } else {
-      dispatch(blockAccount(account.get('id')));
     }
   },
 
   onMute(account) {
     if (account.getIn(['relationship', 'muting'])) {
       dispatch(unmuteAccount(account.get('id')));
-    } else {
-      dispatch(initMuteModal(account));
     }
   },
+
 });
 
 export default
@@ -138,130 +132,92 @@ class AccountCard extends ImmutablePureComponent {
 
   handleMute = () => {
     this.props.onMute(this.props.account);
-  };
+  }
+
+  handleEditProfile = () => {
+    window.open('/settings/profile', '_blank');
+  }
 
   render() {
     const { account, intl } = this.props;
 
-    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']);
-
-      if (requested) {
-        buttons = (
-          <IconButton
-            disabled
-            icon='hourglass'
-            title={intl.formatMessage(messages.requested)}
-          />
-        );
-      } else if (blocking) {
-        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}
-          />
-        );
+    let actionBtn;
+
+    if (me !== account.get('id')) {
+      if (!account.get('relationship')) { // Wait until the relationship is loaded
+        actionBtn = '';
+      } else if (account.getIn(['relationship', 'requested'])) {
+        actionBtn = <Button className={classNames('logo-button')} text={intl.formatMessage(messages.cancel_follow_request)} title={intl.formatMessage(messages.requested)} onClick={this.handleFollow} />;
+      } else if (account.getIn(['relationship', 'muting'])) {
+        actionBtn = <Button className='logo-button' text={intl.formatMessage(messages.unmute)} onClick={this.handleMute} />;
+      } else if (!account.getIn(['relationship', 'blocking'])) {
+        actionBtn = <Button disabled={account.getIn(['relationship', 'blocked_by'])} className={classNames('logo-button', { 'button--destructive': account.getIn(['relationship', 'following']) })} text={intl.formatMessage(account.getIn(['relationship', 'following']) ? messages.unfollow : messages.follow)} onClick={this.handleFollow} />;
+      } else if (account.getIn(['relationship', 'blocking'])) {
+        actionBtn = <Button className='logo-button' text={intl.formatMessage(messages.unblock)} onClick={this.handleBlock} />;
       }
+    } else {
+      actionBtn = <Button className='logo-button' text={intl.formatMessage(messages.edit_profile)} onClick={this.handleEditProfile} />;
     }
 
     return (
-      <div className='directory__card'>
-        <div className='directory__card__img'>
-          <img
-            src={
-              autoPlayGif ? account.get('header') : account.get('header_static')
-            }
-            alt=''
-          />
-        </div>
+      <div className='account-card'>
+        <Permalink href={account.get('url')} to={`/@${account.get('acct')}`} className='account-card__permalink'>
+          <div className='account-card__header'>
+            <img
+              src={
+                autoPlayGif ? account.get('header') : account.get('header_static')
+              }
+              alt=''
+            />
+          </div>
 
-        <div className='directory__card__bar'>
-          <Permalink
-            className='directory__card__bar__name'
-            href={account.get('url')}
-            to={`/@${account.get('acct')}`}
-          >
-            <Avatar account={account} size={48} />
+          <div className='account-card__title'>
+            <div className='account-card__title__avatar'><Avatar account={account} size={56} /></div>
             <DisplayName account={account} />
-          </Permalink>
-
-          <div className='directory__card__bar__relationship account__relationship'>
-            {buttons}
           </div>
-        </div>
+        </Permalink>
 
-        <div className='directory__card__extra' onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave}>
+        {account.get('note').length > 0 && (
           <div
-            className='account__header__content translate'
+            className='account-card__bio translate'
+            onMouseEnter={this.handleMouseEnter}
+            onMouseLeave={this.handleMouseLeave}
             dangerouslySetInnerHTML={{ __html: account.get('note_emojified') }}
           />
-        </div>
-
-        <div className='directory__card__extra'>
-          <div className='accounts-table__count'>
-            <ShortNumber value={account.get('statuses_count')} />
-            <small>
-              <FormattedMessage id='account.posts' defaultMessage='Toots' />
-            </small>
+        )}
+
+        <div className='account-card__actions'>
+          <div className='account-card__counters'>
+            <div className='account-card__counters__item'>
+              <ShortNumber value={account.get('statuses_count')} />
+              <small>
+                <FormattedMessage id='account.posts' defaultMessage='Toots' />
+              </small>
+            </div>
+
+            <div className='account-card__counters__item'>
+              <ShortNumber value={account.get('followers_count')} />{' '}
+              <small>
+                <FormattedMessage
+                  id='account.followers'
+                  defaultMessage='Followers'
+                />
+              </small>
+            </div>
+
+            <div className='account-card__counters__item'>
+              <ShortNumber value={account.get('following_count')} />{' '}
+              <small>
+                <FormattedMessage
+                  id='account.following'
+                  defaultMessage='Following'
+                />
+              </small>
+            </div>
           </div>
-          <div className='accounts-table__count'>
-            <ShortNumber value={account.get('followers_count')} />{' '}
-            <small>
-              <FormattedMessage
-                id='account.followers'
-                defaultMessage='Followers'
-              />
-            </small>
-          </div>
-          <div className='accounts-table__count'>
-            {account.get('last_status_at') === null ? (
-              <FormattedMessage
-                id='account.never_active'
-                defaultMessage='Never'
-              />
-            ) : (
-              <RelativeTimestamp timestamp={account.get('last_status_at')} />
-            )}{' '}
-            <small>
-              <FormattedMessage
-                id='account.last_status'
-                defaultMessage='Last active'
-              />
-            </small>
+
+          <div className='account-card__actions__button'>
+            {actionBtn}
           </div>
         </div>
       </div>
diff --git a/app/javascript/mastodon/features/directory/index.js b/app/javascript/mastodon/features/directory/index.js
index 88f20d330..94d7d1a9c 100644
--- a/app/javascript/mastodon/features/directory/index.js
+++ b/app/javascript/mastodon/features/directory/index.js
@@ -10,9 +10,9 @@ import { fetchDirectory, expandDirectory } from 'mastodon/actions/directory';
 import { List as ImmutableList } from 'immutable';
 import AccountCard from './components/account_card';
 import RadioButton from 'mastodon/components/radio_button';
-import classNames from 'classnames';
 import LoadMore from 'mastodon/components/load_more';
 import ScrollContainer from 'mastodon/containers/scroll_container';
+import LoadingIndicator from 'mastodon/components/loading_indicator';
 
 const messages = defineMessages({
   title: { id: 'column.directory', defaultMessage: 'Browse profiles' },
@@ -129,7 +129,7 @@ class Directory extends React.PureComponent {
     const pinned = !!columnId;
 
     const scrollableArea = (
-      <div className='scrollable' style={{ background: 'transparent' }}>
+      <div className='scrollable'>
         <div className='filter-form'>
           <div className='filter-form__column' role='group'>
             <RadioButton name='order' value='active' label={intl.formatMessage(messages.recentlyActive)} checked={order === 'active'} onChange={this.handleChangeOrder} />
@@ -142,8 +142,10 @@ class Directory extends React.PureComponent {
           </div>
         </div>
 
-        <div className={classNames('directory__list', { loading: isLoading })}>
-          {accountIds.map(accountId => <AccountCard id={accountId} key={accountId} />)}
+        <div className='directory__list'>
+          {isLoading ? <LoadingIndicator /> : accountIds.map(accountId => (
+            <AccountCard id={accountId} key={accountId} />
+          ))}
         </div>
 
         <LoadMore onClick={this.handleLoadMore} visible={!isLoading} />
diff --git a/app/javascript/mastodon/features/explore/suggestions.js b/app/javascript/mastodon/features/explore/suggestions.js
index c094a8d93..0c6a7ef8a 100644
--- a/app/javascript/mastodon/features/explore/suggestions.js
+++ b/app/javascript/mastodon/features/explore/suggestions.js
@@ -1,7 +1,7 @@
 import React from 'react';
 import PropTypes from 'prop-types';
 import ImmutablePropTypes from 'react-immutable-proptypes';
-import Account from 'mastodon/containers/account_container';
+import AccountCard from 'mastodon/features/directory/components/account_card';
 import LoadingIndicator from 'mastodon/components/loading_indicator';
 import { connect } from 'react-redux';
 import { fetchSuggestions } from 'mastodon/actions/suggestions';
@@ -29,9 +29,9 @@ class Suggestions extends React.PureComponent {
     const { isLoading, suggestions } = this.props;
 
     return (
-      <div className='explore__links'>
-        {isLoading ? (<LoadingIndicator />) : suggestions.map(suggestion => (
-          <Account key={suggestion.get('account')} id={suggestion.get('account')} />
+      <div className='explore__suggestions'>
+        {isLoading ? <LoadingIndicator /> : suggestions.map(suggestion => (
+          <AccountCard key={suggestion.get('account')} id={suggestion.get('account')} />
         ))}
       </div>
     );
diff --git a/app/javascript/mastodon/features/report/category.js b/app/javascript/mastodon/features/report/category.js
index 122b51c7c..a36dc81b1 100644
--- a/app/javascript/mastodon/features/report/category.js
+++ b/app/javascript/mastodon/features/report/category.js
@@ -8,7 +8,7 @@ const messages = defineMessages({
   dislike: { id: 'report.reasons.dislike', defaultMessage: 'I don\'t like it' },
   dislike_description: { id: 'report.reasons.dislike_description', defaultMessage: 'It is not something you want to see' },
   spam: { id: 'report.reasons.spam', defaultMessage: 'It\'s spam' },
-  spam_description: { id: 'report.reasons.spam_description', defaultMessage: 'Malicious links, fake engagement, or repetetive replies' },
+  spam_description: { id: 'report.reasons.spam_description', defaultMessage: 'Malicious links, fake engagement, or repetitive replies' },
   violation: { id: 'report.reasons.violation', defaultMessage: 'It violates server rules' },
   violation_description: { id: 'report.reasons.violation_description', defaultMessage: 'You are aware that it breaks specific rules' },
   other: { id: 'report.reasons.other', defaultMessage: 'It\'s something else' },
diff --git a/app/javascript/mastodon/features/video/index.js b/app/javascript/mastodon/features/video/index.js
index 70e3cd6e8..8d47e479a 100644
--- a/app/javascript/mastodon/features/video/index.js
+++ b/app/javascript/mastodon/features/video/index.js
@@ -121,7 +121,7 @@ class Video extends React.PureComponent {
     autoPlay: PropTypes.bool,
     volume: PropTypes.number,
     muted: PropTypes.bool,
-    componetIndex: PropTypes.number,
+    componentIndex: PropTypes.number,
   };
 
   static defaultProps = {
@@ -502,7 +502,7 @@ class Video extends React.PureComponent {
       startTime: this.video.currentTime,
       autoPlay: !this.state.paused,
       defaultVolume: this.state.volume,
-      componetIndex: this.props.componetIndex,
+      componentIndex: this.props.componentIndex,
     });
   }
 
diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json
index 89fd20c3f..06c2b679c 100644
--- a/app/javascript/mastodon/locales/en.json
+++ b/app/javascript/mastodon/locales/en.json
@@ -414,7 +414,7 @@
   "report.reasons.other": "It's something else",
   "report.reasons.other_description": "The issue does not fit into other categories",
   "report.reasons.spam": "It's spam",
-  "report.reasons.spam_description": "Malicious links, fake engagement, or repetetive replies",
+  "report.reasons.spam_description": "Malicious links, fake engagement, or repetitive replies",
   "report.reasons.violation": "It violates server rules",
   "report.reasons.violation_description": "You are aware that it breaks specific rules",
   "report.rules.subtitle": "Select all that apply",
diff --git a/app/javascript/styles/mastodon-light/diff.scss b/app/javascript/styles/mastodon-light/diff.scss
index 8e6b0cdd5..eb6bdea99 100644
--- a/app/javascript/styles/mastodon-light/diff.scss
+++ b/app/javascript/styles/mastodon-light/diff.scss
@@ -40,19 +40,11 @@ html {
   background: lighten($ui-base-color, 12%);
 }
 
-.filter-form,
-.directory__card__bar {
+.filter-form {
   background: $white;
   border-bottom: 1px solid lighten($ui-base-color, 8%);
 }
 
-.scrollable .directory__list {
-  width: calc(100% + 2px);
-  margin-left: -1px;
-  margin-right: -1px;
-}
-
-.directory__card,
 .table-of-contents {
   border: 1px solid lighten($ui-base-color, 8%);
 }
@@ -75,8 +67,7 @@ html {
 .column-header__back-button,
 .column-header__button,
 .column-header__button.active,
-.account__header__bar,
-.directory__card__extra {
+.account__header__bar {
   background: $white;
 }
 
diff --git a/app/javascript/styles/mastodon/admin.scss b/app/javascript/styles/mastodon/admin.scss
index 73414785c..0873ac300 100644
--- a/app/javascript/styles/mastodon/admin.scss
+++ b/app/javascript/styles/mastodon/admin.scss
@@ -1236,6 +1236,11 @@ a.sparkline {
   background: $ui-base-color;
   border-radius: 4px;
 
+  &__permalink {
+    color: inherit;
+    text-decoration: none;
+  }
+
   &__header {
     padding: 4px;
     border-radius: 4px;
@@ -1252,20 +1257,22 @@ a.sparkline {
   }
 
   &__title {
-    margin-top: -25px;
+    margin-top: -(15px + 8px);
     display: flex;
     align-items: flex-end;
 
     &__avatar {
-      padding: 15px;
+      padding: 14px;
 
-      img {
+      img,
+      .account__avatar {
         display: block;
         margin: 0;
         width: 56px;
         height: 56px;
-        background: darken($ui-base-color, 8%);
+        background-color: darken($ui-base-color, 8%);
         border-radius: 8px;
+        border: 1px solid $ui-base-color;
       }
     }
 
@@ -1273,30 +1280,34 @@ a.sparkline {
       color: $darker-text-color;
       padding-bottom: 15px;
       font-size: 15px;
+      line-height: 20px;
 
       bdi {
         display: block;
         color: $primary-text-color;
-        font-weight: 500;
+        font-weight: 700;
       }
     }
   }
 
   &__bio {
     padding: 0 15px;
+    margin: 8px 0;
     overflow: hidden;
     text-overflow: ellipsis;
     word-wrap: break-word;
-    max-height: 18px * 2;
+    max-height: 21px * 2;
     position: relative;
+    font-size: 15px;
+    line-height: 21px;
 
     &::after {
       display: block;
       content: "";
       width: 50px;
-      height: 18px;
+      height: 21px;
       position: absolute;
-      bottom: 0;
+      bottom: 8px;
       right: 15px;
       background: linear-gradient(to left, $ui-base-color, transparent);
       pointer-events: none;
@@ -1309,10 +1320,6 @@ a.sparkline {
 
       &:hover {
         text-decoration: underline;
-
-        .fa {
-          color: lighten($dark-text-color, 7%);
-        }
       }
 
       &.mention {
@@ -1329,12 +1336,21 @@ a.sparkline {
 
   &__actions {
     display: flex;
+    justify-content: space-between;
     align-items: center;
-    padding-top: 10px;
 
     &__button {
-      flex: 0 0 auto;
+      flex-shrink: 1;
       padding: 0 15px;
+      overflow: hidden;
+
+      .button {
+        min-width: 0;
+        white-space: nowrap;
+        text-overflow: ellipsis;
+        overflow: hidden;
+        max-width: 100%;
+      }
     }
   }
 
@@ -1343,19 +1359,23 @@ a.sparkline {
     display: grid;
     grid-auto-columns: minmax(0, 1fr);
     grid-auto-flow: column;
+    max-width: 340px;
+    min-width: 65px * 3;
 
     &__item {
-      padding: 15px;
+      padding: 15px 0;
       text-align: center;
       color: $primary-text-color;
       font-weight: 600;
       font-size: 15px;
+      line-height: 21px;
 
       small {
         display: block;
         color: $darker-text-color;
         font-weight: 400;
         font-size: 13px;
+        line-height: 18px;
       }
     }
   }
diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss
index 8f6d4b69a..d1d679ac2 100644
--- a/app/javascript/styles/mastodon/components.scss
+++ b/app/javascript/styles/mastodon/components.scss
@@ -50,7 +50,7 @@
   cursor: pointer;
   display: inline-block;
   font-family: inherit;
-  font-size: 17px;
+  font-size: 15px;
   font-weight: 500;
   letter-spacing: 0;
   line-height: 22px;
@@ -2333,17 +2333,7 @@ a.account__display-name {
     padding: 0;
   }
 
-  .directory__list {
-    display: grid;
-    grid-gap: 10px;
-    grid-template-columns: minmax(0, 50%) minmax(0, 50%);
-
-    @media screen and (max-width: $no-gap-breakpoint) {
-      display: block;
-    }
-  }
-
-  .directory__card {
+  .account-card {
     margin-bottom: 0;
   }
 
@@ -4315,7 +4305,7 @@ a.status-card.compact:hover {
   }
 }
 
-.upload-progess__message {
+.upload-progress__message {
   flex: 1 1 auto;
 }
 
@@ -6219,136 +6209,20 @@ a.status-card.compact:hover {
   }
 }
 
-.directory {
-  &__list {
-    width: 100%;
-    margin: 10px 0;
-    transition: opacity 100ms ease-in;
-
-    &.loading {
-      opacity: 0.7;
-    }
+.scrollable .account-card {
+  margin: 10px;
+  background: lighten($ui-base-color, 8%);
+}
 
-    @media screen and (max-width: $no-gap-breakpoint) {
-      margin: 0;
-    }
+.scrollable .account-card__title__avatar {
+  img,
+  .account__avatar {
+    border-color: lighten($ui-base-color, 8%);
   }
+}
 
-  &__card {
-    box-sizing: border-box;
-    margin-bottom: 10px;
-
-    &__img {
-      height: 125px;
-      position: relative;
-      background: darken($ui-base-color, 12%);
-      overflow: hidden;
-
-      img {
-        display: block;
-        width: 100%;
-        height: 100%;
-        margin: 0;
-        object-fit: cover;
-      }
-    }
-
-    &__bar {
-      display: flex;
-      align-items: center;
-      background: lighten($ui-base-color, 4%);
-      padding: 10px;
-
-      &__name {
-        flex: 1 1 auto;
-        display: flex;
-        align-items: center;
-        text-decoration: none;
-        overflow: hidden;
-      }
-
-      &__relationship {
-        width: 23px;
-        min-height: 1px;
-        flex: 0 0 auto;
-      }
-
-      .avatar {
-        flex: 0 0 auto;
-        width: 48px;
-        height: 48px;
-        padding-top: 2px;
-
-        img {
-          width: 100%;
-          height: 100%;
-          display: block;
-          margin: 0;
-          border-radius: 4px;
-          background: darken($ui-base-color, 8%);
-          object-fit: cover;
-        }
-      }
-
-      .display-name {
-        margin-left: 15px;
-        text-align: left;
-
-        strong {
-          font-size: 15px;
-          color: $primary-text-color;
-          font-weight: 500;
-          overflow: hidden;
-          text-overflow: ellipsis;
-        }
-
-        span {
-          display: block;
-          font-size: 14px;
-          color: $darker-text-color;
-          font-weight: 400;
-          overflow: hidden;
-          text-overflow: ellipsis;
-        }
-      }
-    }
-
-    &__extra {
-      background: $ui-base-color;
-      display: flex;
-      align-items: center;
-      justify-content: center;
-
-      .accounts-table__count {
-        width: 33.33%;
-        flex: 0 0 auto;
-        padding: 15px 0;
-      }
-
-      .account__header__content {
-        box-sizing: border-box;
-        padding: 15px 10px;
-        border-bottom: 1px solid lighten($ui-base-color, 8%);
-        width: 100%;
-        min-height: 18px + 30px;
-        white-space: nowrap;
-        overflow: hidden;
-        text-overflow: ellipsis;
-
-        p {
-          display: none;
-
-          &:first-child {
-            display: inline;
-          }
-        }
-
-        br {
-          display: none;
-        }
-      }
-    }
-  }
+.scrollable .account-card__bio::after {
+  background: linear-gradient(to left, lighten($ui-base-color, 8%), transparent);
 }
 
 .account-gallery__container {
@@ -6452,6 +6326,7 @@ a.status-card.compact:hover {
 
   &__column {
     padding: 10px 15px;
+    padding-bottom: 0;
   }
 
   .radio-button {
diff --git a/app/javascript/styles/mastodon/containers.scss b/app/javascript/styles/mastodon/containers.scss
index a180df437..81459f5ba 100644
--- a/app/javascript/styles/mastodon/containers.scss
+++ b/app/javascript/styles/mastodon/containers.scss
@@ -409,14 +409,6 @@
     }
   }
 
-  .directory__card {
-    border-radius: 4px;
-
-    @media screen and (max-width: $no-gap-breakpoint) {
-      border-radius: 0;
-    }
-  }
-
   .page-header {
     @media screen and (max-width: $no-gap-breakpoint) {
       border-bottom: 0;
@@ -835,19 +827,21 @@
     grid-gap: 10px;
     grid-template-columns: minmax(0, 50%) minmax(0, 50%);
 
+    .account-card {
+      display: flex;
+      flex-direction: column;
+    }
+
     @media screen and (max-width: $no-gap-breakpoint) {
       display: block;
-    }
 
-    .icon-button {
-      font-size: 18px;
+      .account-card {
+        margin-bottom: 10px;
+        display: block;
+      }
     }
   }
 
-  .directory__card {
-    margin-bottom: 0;
-  }
-
   .card-grid {
     display: flex;
     flex-wrap: wrap;
diff --git a/app/javascript/styles/mastodon/polls.scss b/app/javascript/styles/mastodon/polls.scss
index e33fc7983..a719044ea 100644
--- a/app/javascript/styles/mastodon/polls.scss
+++ b/app/javascript/styles/mastodon/polls.scss
@@ -69,7 +69,7 @@
       display: none;
     }
 
-    .autossugest-input {
+    .autosuggest-input {
       flex: 1 1 auto;
     }
 
diff --git a/app/javascript/styles/mastodon/rtl.scss b/app/javascript/styles/mastodon/rtl.scss
index ea7bb5113..98eb1511c 100644
--- a/app/javascript/styles/mastodon/rtl.scss
+++ b/app/javascript/styles/mastodon/rtl.scss
@@ -12,11 +12,6 @@ body.rtl {
     margin-left: 10px;
   }
 
-  .directory__card__bar .display-name {
-    margin-left: 0;
-    margin-right: 15px;
-  }
-
   .display-name,
   .announcements__item {
     text-align: right;
diff --git a/app/lib/user_settings_decorator.rb b/app/lib/user_settings_decorator.rb
index 581101782..d8015e50d 100644
--- a/app/lib/user_settings_decorator.rb
+++ b/app/lib/user_settings_decorator.rb
@@ -32,7 +32,6 @@ class UserSettingsDecorator
     user.settings['system_font_ui']      = system_font_ui_preference if change?('setting_system_font_ui')
     user.settings['system_emoji_font']   = system_emoji_font_preference if change?('setting_system_emoji_font')
     user.settings['noindex']             = noindex_preference if change?('setting_noindex')
-    user.settings['hide_followers_count']= hide_followers_count_preference if change?('setting_hide_followers_count')
     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')
@@ -110,10 +109,6 @@ class UserSettingsDecorator
     boolean_cast_setting 'setting_noindex'
   end
 
-  def hide_followers_count_preference
-    boolean_cast_setting 'setting_hide_followers_count'
-  end
-
   def flavour_preference
     settings['setting_flavour']
   end
diff --git a/app/models/account.rb b/app/models/account.rb
index 8617b389c..dfbe0b8bc 100644
--- a/app/models/account.rb
+++ b/app/models/account.rb
@@ -351,11 +351,11 @@ class Account < ApplicationRecord
   end
 
   def hides_followers?
-    hide_collections? || user_hides_network?
+    hide_collections?
   end
 
   def hides_following?
-    hide_collections? || user_hides_network?
+    hide_collections?
   end
 
   def object_type
diff --git a/app/models/account_statuses_filter.rb b/app/models/account_statuses_filter.rb
new file mode 100644
index 000000000..556aee032
--- /dev/null
+++ b/app/models/account_statuses_filter.rb
@@ -0,0 +1,134 @@
+# frozen_string_literal: true
+
+class AccountStatusesFilter
+  KEYS = %i(
+    pinned
+    tagged
+    only_media
+    exclude_replies
+    exclude_reblogs
+  ).freeze
+
+  attr_reader :params, :account, :current_account
+
+  def initialize(account, current_account, params = {})
+    @account         = account
+    @current_account = current_account
+    @params          = params
+  end
+
+  def results
+    scope = initial_scope
+
+    scope.merge!(pinned_scope)     if pinned?
+    scope.merge!(only_media_scope) if only_media?
+    scope.merge!(no_replies_scope) if exclude_replies?
+    scope.merge!(no_reblogs_scope) if exclude_reblogs?
+    scope.merge!(hashtag_scope)    if tagged?
+
+    scope
+  end
+
+  private
+
+  def initial_scope
+    if suspended?
+      Status.none
+    elsif anonymous?
+      account.statuses.not_local_only.where(visibility: %i(public unlisted))
+    elsif author?
+      account.statuses.all # NOTE: #merge! does not work without the #all
+    elsif blocked?
+      Status.none
+    else
+      filtered_scope
+    end
+  end
+
+  def filtered_scope
+    scope = account.statuses.left_outer_joins(:mentions)
+
+    scope.merge!(scope.where(visibility: follower? ? %i(public unlisted private) : %i(public unlisted)).or(scope.where(mentions: { account_id: current_account.id })).group(Status.arel_table[:id]))
+    scope.merge!(filtered_reblogs_scope) if reblogs_may_occur?
+
+    scope
+  end
+
+  def filtered_reblogs_scope
+    Status.left_outer_joins(:reblog).where(reblog_of_id: nil).or(Status.where.not(reblogs_statuses: { account_id: current_account.excluded_from_timeline_account_ids }))
+  end
+
+  def only_media_scope
+    Status.joins(:media_attachments).merge(account.media_attachments.reorder(nil)).group(Status.arel_table[:id])
+  end
+
+  def no_replies_scope
+    Status.without_replies
+  end
+
+  def no_reblogs_scope
+    Status.without_reblogs
+  end
+
+  def pinned_scope
+    account.pinned_statuses.group(Status.arel_table[:id], StatusPin.arel_table[:created_at])
+  end
+
+  def hashtag_scope
+    tag = Tag.find_normalized(params[:tagged])
+
+    if tag
+      Status.tagged_with(tag.id)
+    else
+      Status.none
+    end
+  end
+
+  def suspended?
+    account.suspended?
+  end
+
+  def anonymous?
+    current_account.nil?
+  end
+
+  def author?
+    current_account.id == account.id
+  end
+
+  def blocked?
+    account.blocking?(current_account) || (current_account.domain.present? && account.domain_blocking?(current_account.domain))
+  end
+
+  def follower?
+    current_account.following?(account)
+  end
+
+  def reblogs_may_occur?
+    !exclude_reblogs? && !only_media? && !tagged?
+  end
+
+  def pinned?
+    truthy_param?(:pinned)
+  end
+
+  def only_media?
+    truthy_param?(:only_media)
+  end
+
+  def exclude_replies?
+    truthy_param?(:exclude_replies)
+  end
+
+  def exclude_reblogs?
+    truthy_param?(:exclude_reblogs)
+  end
+
+  def tagged?
+    params[:tagged].present?
+  end
+
+  def truthy_param?(key)
+    ActiveModel::Type::Boolean.new.cast(params[key])
+  end
+end
diff --git a/app/models/report.rb b/app/models/report.rb
index 3dd8a6fdd..8ba2dd8fd 100644
--- a/app/models/report.rb
+++ b/app/models/report.rb
@@ -129,6 +129,6 @@ class Report < ApplicationRecord
   def validate_rule_ids
     return unless violation?
 
-    errors.add(:rule_ids, I18n.t('reports.errors.invalid_rules')) unless rules.size == rule_ids.size
+    errors.add(:rule_ids, I18n.t('reports.errors.invalid_rules')) unless rules.size == rule_ids&.size
   end
 end
diff --git a/app/models/status.rb b/app/models/status.rb
index 06141f410..6a848baee 100644
--- a/app/models/status.rb
+++ b/app/models/status.rb
@@ -399,28 +399,6 @@ class Status < ApplicationRecord
       end
     end
 
-    def permitted_for(target_account, account)
-      visibility = [:public, :unlisted]
-
-      if account.nil?
-        where(visibility: visibility).not_local_only
-      elsif target_account.blocking?(account) || (account.domain.present? && target_account.domain_blocking?(account.domain)) # get rid of blocked peeps
-        none
-      elsif account.id == target_account.id # author can see own stuff
-        all
-      else
-        # followers can see followers-only stuff, but also things they are mentioned in.
-        # non-followers can see everything that isn't private/direct, but can see stuff they are mentioned in.
-        visibility.push(:private) if account.following?(target_account)
-
-        scope = left_outer_joins(:reblog)
-
-        scope.where(visibility: visibility)
-             .or(scope.where(id: account.mentions.select(:status_id)))
-             .merge(scope.where(reblog_of_id: nil).or(scope.where.not(reblogs_statuses: { account_id: account.excluded_from_timeline_account_ids })))
-      end
-    end
-
     def from_text(text)
       return [] if text.blank?
 
diff --git a/app/models/user.rb b/app/models/user.rb
index 77685ad02..f657f1b27 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -126,7 +126,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_media, :hide_network, :hide_followers_count,
+           :reduce_motion, :system_font_ui, :noindex, :flavour, :skin, :display_media, :hide_followers_count,
            :expand_spoilers, :default_language, :aggregate_reblogs, :show_application,
            :advanced_layout, :use_blurhash, :use_pending_items, :trends, :crop_images,
            :disable_swiping, :default_content_type, :system_emoji_font,
@@ -281,10 +281,6 @@ class User < ApplicationRecord
     settings.notification_emails['trending_status']
   end
 
-  def hides_network?
-    @hides_network ||= settings.hide_network
-  end
-
   def aggregates_reblogs?
     @aggregates_reblogs ||= settings.aggregate_reblogs
   end
diff --git a/app/policies/user_policy.rb b/app/policies/user_policy.rb
index 6695a0ddf..92e2c4f4b 100644
--- a/app/policies/user_policy.rb
+++ b/app/policies/user_policy.rb
@@ -42,7 +42,7 @@ class UserPolicy < ApplicationPolicy
   end
 
   def promote?
-    admin? && promoteable?
+    admin? && promotable?
   end
 
   def demote?
@@ -51,7 +51,7 @@ class UserPolicy < ApplicationPolicy
 
   private
 
-  def promoteable?
+  def promotable?
     record.approved? && (!record.staff? || !record.admin?)
   end
 
diff --git a/app/presenters/familiar_followers_presenter.rb b/app/presenters/familiar_followers_presenter.rb
new file mode 100644
index 000000000..c1d944b80
--- /dev/null
+++ b/app/presenters/familiar_followers_presenter.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+class FamiliarFollowersPresenter
+  class Result < ActiveModelSerializers::Model
+    attributes :id, :accounts
+  end
+
+  def initialize(accounts, current_account_id)
+    @accounts           = accounts
+    @current_account_id = current_account_id
+  end
+
+  def accounts
+    map = Follow.includes(account: :account_stat).where(target_account_id: @accounts.map(&:id)).where(account_id: Follow.where(account_id: @current_account_id).joins(:target_account).merge(Account.where(hide_collections: [nil, false])).select(:target_account_id)).group_by(&:target_account_id)
+    @accounts.map { |account| Result.new(id: account.id, accounts: (account.hide_collections? ? [] : (map[account.id] || [])).map(&:account)) }
+  end
+end
diff --git a/app/serializers/rest/familiar_followers_serializer.rb b/app/serializers/rest/familiar_followers_serializer.rb
new file mode 100644
index 000000000..0a7e923f8
--- /dev/null
+++ b/app/serializers/rest/familiar_followers_serializer.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+class REST::FamiliarFollowersSerializer < ActiveModel::Serializer
+  attribute :id
+
+  has_many :accounts, serializer: REST::AccountSerializer
+
+  def id
+    object.id.to_s
+  end
+end
diff --git a/app/views/about/_registration.html.haml b/app/views/about/_registration.html.haml
index b452e4936..5db620b2d 100644
--- a/app/views/about/_registration.html.haml
+++ b/app/views/about/_registration.html.haml
@@ -10,8 +10,8 @@
         = account_fields.input :username, wrapper: :with_label, label: false, required: true, input_html: { 'aria-label' => t('simple_form.labels.defaults.username'), :autocomplete => 'off', placeholder: t('simple_form.labels.defaults.username'), pattern: '[a-zA-Z0-9_]+', maxlength: 30 }, append: "@#{site_hostname}", hint: false, disabled: disabled
 
       = f.input :email, placeholder: t('simple_form.labels.defaults.email'), required: true, input_html: { 'aria-label' => t('simple_form.labels.defaults.email'), :autocomplete => 'off' }, hint: false, disabled: disabled
-      = f.input :password, placeholder: t('simple_form.labels.defaults.password'), required: true, input_html: { 'aria-label' => t('simple_form.labels.defaults.password'), :autocomplete => 'off', :minlength => User.password_length.first, :maxlength => User.password_length.last }, hint: false, disabled: disabled
-      = f.input :password_confirmation, placeholder: t('simple_form.labels.defaults.confirm_password'), required: true, input_html: { 'aria-label' => t('simple_form.labels.defaults.confirm_password'), :autocomplete => 'off' }, hint: false, disabled: disabled
+      = f.input :password, placeholder: t('simple_form.labels.defaults.password'), required: true, input_html: { 'aria-label' => t('simple_form.labels.defaults.password'), :autocomplete => 'new-password', :minlength => User.password_length.first, :maxlength => User.password_length.last }, hint: false, disabled: disabled
+      = f.input :password_confirmation, placeholder: t('simple_form.labels.defaults.confirm_password'), required: true, input_html: { 'aria-label' => t('simple_form.labels.defaults.confirm_password'), :autocomplete => 'new-password' }, hint: false, disabled: disabled
 
       = f.input :confirm_password, as: :string, placeholder: t('simple_form.labels.defaults.honeypot', label: t('simple_form.labels.defaults.password')), required: false, input_html: { 'aria-label' => t('simple_form.labels.defaults.honeypot', label: t('simple_form.labels.defaults.password')), :autocomplete => 'off' }, hint: false, disabled: disabled
       = f.input :website, as: :url, placeholder: t('simple_form.labels.defaults.honeypot', label: 'Website'), required: false, input_html: { 'aria-label' => t('simple_form.labels.defaults.honeypot', label: 'Website'), :autocomplete => 'off' }, hint: false, disabled: disabled
diff --git a/app/views/directories/index.html.haml b/app/views/directories/index.html.haml
index d5509f946..2ac700fe6 100644
--- a/app/views/directories/index.html.haml
+++ b/app/views/directories/index.html.haml
@@ -19,37 +19,36 @@
 - else
   .directory__list
     - @accounts.each do |account|
-      .directory__card
-        .directory__card__img
-          = image_tag account.header.url, alt: ''
-        .directory__card__bar
-          = link_to TagManager.instance.url_for(account), class: 'directory__card__bar__name' do
-            .avatar
-              = image_tag account.avatar.url, alt: '', class: 'u-photo'
-
+      .account-card
+        = link_to TagManager.instance.url_for(account), class: 'account-card__permalink' do
+          .account-card__header
+            = image_tag account.header.url, alt: ''
+          .account-card__title
+            .account-card__title__avatar
+              = image_tag account.avatar.url, alt: ''
             .display-name
               %bdi
                 %strong.emojify.p-name= display_name(account, custom_emojify: true)
-              %span= acct(account)
-          .directory__card__bar__relationship.account__relationship
-            = minimal_account_action_button(account)
-
-        .directory__card__extra
-          .account__header__content.emojify= Formatter.instance.simplified_format(account, custom_emojify: true)
-
-        .directory__card__extra
-          .accounts-table__count
-            = friendly_number_to_human account.statuses_count
-            %small= t('accounts.posts', count: account.statuses_count).downcase
-          .accounts-table__count
-            = hide_followers_count?(account) ? '-' : (friendly_number_to_human account.followers_count)
-            %small= t('accounts.followers', count: account.followers_count).downcase
-          .accounts-table__count
-            - if account.last_status_at.present?
-              %time.time-ago{ datetime: account.last_status_at.to_date.iso8601, title: l(account.last_status_at.to_date) }= l account.last_status_at.to_date
-            - else
-              = t('accounts.never_active')
-
-            %small= t('accounts.last_active')
+              %span
+                = acct(account)
+                = fa_icon('lock') if account.locked?
+        - if account.note.present?
+          .account-card__bio.emojify
+            = Formatter.instance.simplified_format(account, custom_emojify: true)
+        - else
+          .flex-spacer
+        .account-card__actions
+          .account-card__counters
+            .account-card__counters__item
+              = friendly_number_to_human account.statuses_count
+              %small= t('accounts.posts', count: account.statuses_count).downcase
+            .account-card__counters__item
+              = hide_followers_count?(account) ? '-' : (friendly_number_to_human account.followers_count)
+              %small= t('accounts.followers', count: account.followers_count).downcase
+            .account-card__counters__item
+              = friendly_number_to_human account.following_count
+              %small= t('accounts.following', count: account.following_count).downcase
+          .account-card__actions__button
+            = account_action_button(account)
 
   = paginate @accounts
diff --git a/app/views/follower_accounts/index.html.haml b/app/views/follower_accounts/index.html.haml
index 645dd2de1..92de35a9f 100644
--- a/app/views/follower_accounts/index.html.haml
+++ b/app/views/follower_accounts/index.html.haml
@@ -7,7 +7,7 @@
 
 = render 'accounts/header', account: @account
 
-- if @account.user_hides_network?
+- if @account.hide_collections?
   .nothing-here= t('accounts.network_hidden')
 - elsif user_signed_in? && @account.blocking?(current_account)
   .nothing-here= t('accounts.unavailable')
diff --git a/app/views/following_accounts/index.html.haml b/app/views/following_accounts/index.html.haml
index 17fe79018..9bb1a9edd 100644
--- a/app/views/following_accounts/index.html.haml
+++ b/app/views/following_accounts/index.html.haml
@@ -7,7 +7,7 @@
 
 = render 'accounts/header', account: @account
 
-- if @account.user_hides_network?
+- if @account.hide_collections?
   .nothing-here= t('accounts.network_hidden')
 - elsif user_signed_in? && @account.blocking?(current_account)
   .nothing-here= t('accounts.unavailable')
diff --git a/app/views/settings/preferences/other/show.html.haml b/app/views/settings/preferences/other/show.html.haml
index 8159efb55..cf604d043 100644
--- a/app/views/settings/preferences/other/show.html.haml
+++ b/app/views/settings/preferences/other/show.html.haml
@@ -11,9 +11,6 @@
     = f.input :setting_noindex, as: :boolean, wrapper: :with_label
 
   .fields-group
-    = f.input :setting_hide_network, as: :boolean, wrapper: :with_label
-
-  .fields-group
     = f.input :setting_aggregate_reblogs, as: :boolean, wrapper: :with_label, recommended: true
 
   - unless Setting.hide_followers_count
diff --git a/app/views/settings/profiles/show.html.haml b/app/views/settings/profiles/show.html.haml
index 21948b200..b84d06c27 100644
--- a/app/views/settings/profiles/show.html.haml
+++ b/app/views/settings/profiles/show.html.haml
@@ -30,7 +30,10 @@
     = f.input :bot, as: :boolean, wrapper: :with_label, hint: t('simple_form.hints.defaults.bot')
 
   .fields-group
-    = f.input :discoverable, as: :boolean, wrapper: :with_label, hint: t(Setting.profile_directory ? 'simple_form.hints.defaults.discoverable' : 'simple_form.hints.defaults.discoverable_no_directory'), recommended: true
+    = f.input :discoverable, as: :boolean, wrapper: :with_label, hint: t('simple_form.hints.defaults.discoverable'), recommended: true
+
+  .fields-group
+    = f.input :hide_collections, as: :boolean, wrapper: :with_label, label: t('simple_form.labels.defaults.setting_hide_network'), hint: t('simple_form.hints.defaults.setting_hide_network')
 
   %hr.spacer/