diff --git a/.github/workflows/test-ruby.yml b/.github/workflows/test-ruby.yml
index dd71fd253b..ef898968d0 100644
--- a/.github/workflows/test-ruby.yml
+++ b/.github/workflows/test-ruby.yml
@@ -132,15 +132,17 @@ jobs:
           additional-system-dependencies: ffmpeg libpam-dev
 
       - name: Load database schema
-        run: './bin/rails db:create db:schema:load db:seed'
+        run: |
+          bin/rails db:setup
+          bin/flatware fan bin/rails db:test:prepare
 
-      - run: bin/rspec
+      - run: bin/flatware rspec -r ./spec/flatware_helper.rb
 
       - name: Upload coverage reports to Codecov
         if: matrix.ruby-version == '.ruby-version'
         uses: codecov/codecov-action@v4
         with:
-          files: coverage/lcov/mastodon.lcov
+          files: coverage/lcov/*.lcov
         env:
           CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
 
diff --git a/Gemfile b/Gemfile
index f2d7d098d5..be3f9e6f98 100644
--- a/Gemfile
+++ b/Gemfile
@@ -100,8 +100,6 @@ gem 'json-ld'
 gem 'json-ld-preloaded', '~> 3.2'
 gem 'rdf-normalize', '~> 0.5'
 
-gem 'private_address_check', '~> 0.5'
-
 gem 'opentelemetry-api', '~> 1.2.5'
 
 group :opentelemetry do
@@ -123,6 +121,9 @@ group :opentelemetry do
 end
 
 group :test do
+  # Enable usage of all available CPUs/cores during spec runs
+  gem 'flatware-rspec'
+
   # Adds RSpec Error/Warning annotations to GitHub PRs on the Files tab
   gem 'rspec-github', '~> 2.4', require: false
 
diff --git a/Gemfile.lock b/Gemfile.lock
index 5d735eb753..02437eab6b 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -101,16 +101,16 @@ GEM
     awrence (1.2.1)
     aws-eventstream (1.3.0)
     aws-partitions (1.947.0)
-    aws-sdk-core (3.198.0)
+    aws-sdk-core (3.199.0)
       aws-eventstream (~> 1, >= 1.3.0)
       aws-partitions (~> 1, >= 1.651.0)
       aws-sigv4 (~> 1.8)
       jmespath (~> 1, >= 1.6.1)
-    aws-sdk-kms (1.86.0)
-      aws-sdk-core (~> 3, >= 3.198.0)
+    aws-sdk-kms (1.87.0)
+      aws-sdk-core (~> 3, >= 3.199.0)
       aws-sigv4 (~> 1.1)
-    aws-sdk-s3 (1.153.0)
-      aws-sdk-core (~> 3, >= 3.198.0)
+    aws-sdk-s3 (1.154.0)
+      aws-sdk-core (~> 3, >= 3.199.0)
       aws-sdk-kms (~> 1)
       aws-sigv4 (~> 1.8)
     aws-sigv4 (1.8.0)
@@ -159,7 +159,7 @@ GEM
     case_transform (0.2)
       activesupport
     cbor (0.5.9.8)
-    charlock_holmes (0.7.7)
+    charlock_holmes (0.7.8)
     chewy (7.6.0)
       activesupport (>= 5.2)
       elasticsearch (>= 7.14.0, < 8)
@@ -264,6 +264,11 @@ GEM
     ffi-compiler (1.3.2)
       ffi (>= 1.15.5)
       rake
+    flatware (2.3.2)
+      thor (< 2.0)
+    flatware-rspec (2.3.2)
+      flatware (= 2.3.2)
+      rspec (>= 3.6)
     fog-core (2.4.0)
       builder
       excon (~> 0.71)
@@ -595,7 +600,6 @@ GEM
       actionmailer (>= 3)
       net-smtp
       premailer (~> 1.7, >= 1.7.9)
-    private_address_check (0.5.0)
     propshaft (0.9.0)
       actionpack (>= 7.0.0)
       activesupport (>= 7.0.0)
@@ -701,6 +705,10 @@ GEM
       chunky_png (~> 1.0)
       rqrcode_core (~> 1.0)
     rqrcode_core (1.2.0)
+    rspec (3.13.0)
+      rspec-core (~> 3.13.0)
+      rspec-expectations (~> 3.13.0)
+      rspec-mocks (~> 3.13.0)
     rspec-core (3.13.0)
       rspec-support (~> 3.13.0)
     rspec-expectations (3.13.1)
@@ -933,6 +941,7 @@ DEPENDENCIES
   faker (~> 3.2)
   fast_blank (~> 1.0)
   fastimage
+  flatware-rspec
   fog-core (<= 2.4.0)
   fog-openstack (~> 1.0)
   fuubar (~> 2.5)
@@ -994,7 +1003,6 @@ DEPENDENCIES
   pg (~> 1.5)
   pghero
   premailer-rails
-  private_address_check (~> 0.5)
   propshaft
   public_suffix (~> 6.0)
   puma (~> 6.3)
diff --git a/app/javascript/flavours/glitch/actions/directory.js b/app/javascript/flavours/glitch/actions/directory.js
deleted file mode 100644
index 7a0748029d..0000000000
--- a/app/javascript/flavours/glitch/actions/directory.js
+++ /dev/null
@@ -1,62 +0,0 @@
-import api from '../api';
-
-import { fetchRelationships } from './accounts';
-import { importFetchedAccounts } from './importer';
-
-export const DIRECTORY_FETCH_REQUEST = 'DIRECTORY_FETCH_REQUEST';
-export const DIRECTORY_FETCH_SUCCESS = 'DIRECTORY_FETCH_SUCCESS';
-export const DIRECTORY_FETCH_FAIL    = 'DIRECTORY_FETCH_FAIL';
-
-export const DIRECTORY_EXPAND_REQUEST = 'DIRECTORY_EXPAND_REQUEST';
-export const DIRECTORY_EXPAND_SUCCESS = 'DIRECTORY_EXPAND_SUCCESS';
-export const DIRECTORY_EXPAND_FAIL    = 'DIRECTORY_EXPAND_FAIL';
-
-export const fetchDirectory = params => (dispatch) => {
-  dispatch(fetchDirectoryRequest());
-
-  api().get('/api/v1/directory', { params: { ...params, limit: 20 } }).then(({ data }) => {
-    dispatch(importFetchedAccounts(data));
-    dispatch(fetchDirectorySuccess(data));
-    dispatch(fetchRelationships(data.map(x => x.id)));
-  }).catch(error => dispatch(fetchDirectoryFail(error)));
-};
-
-export const fetchDirectoryRequest = () => ({
-  type: DIRECTORY_FETCH_REQUEST,
-});
-
-export const fetchDirectorySuccess = accounts => ({
-  type: DIRECTORY_FETCH_SUCCESS,
-  accounts,
-});
-
-export const fetchDirectoryFail = error => ({
-  type: DIRECTORY_FETCH_FAIL,
-  error,
-});
-
-export const expandDirectory = params => (dispatch, getState) => {
-  dispatch(expandDirectoryRequest());
-
-  const loadedItems = getState().getIn(['user_lists', 'directory', 'items']).size;
-
-  api().get('/api/v1/directory', { params: { ...params, offset: loadedItems, limit: 20 } }).then(({ data }) => {
-    dispatch(importFetchedAccounts(data));
-    dispatch(expandDirectorySuccess(data));
-    dispatch(fetchRelationships(data.map(x => x.id)));
-  }).catch(error => dispatch(expandDirectoryFail(error)));
-};
-
-export const expandDirectoryRequest = () => ({
-  type: DIRECTORY_EXPAND_REQUEST,
-});
-
-export const expandDirectorySuccess = accounts => ({
-  type: DIRECTORY_EXPAND_SUCCESS,
-  accounts,
-});
-
-export const expandDirectoryFail = error => ({
-  type: DIRECTORY_EXPAND_FAIL,
-  error,
-});
diff --git a/app/javascript/flavours/glitch/actions/directory.ts b/app/javascript/flavours/glitch/actions/directory.ts
new file mode 100644
index 0000000000..3e0f1356b3
--- /dev/null
+++ b/app/javascript/flavours/glitch/actions/directory.ts
@@ -0,0 +1,37 @@
+import type { List as ImmutableList } from 'immutable';
+
+import { apiGetDirectory } from 'flavours/glitch/api/directory';
+import { createDataLoadingThunk } from 'flavours/glitch/store/typed_functions';
+
+import { fetchRelationships } from './accounts';
+import { importFetchedAccounts } from './importer';
+
+export const fetchDirectory = createDataLoadingThunk(
+  'directory/fetch',
+  async (params: Parameters<typeof apiGetDirectory>[0]) =>
+    apiGetDirectory(params),
+  (data, { dispatch }) => {
+    dispatch(importFetchedAccounts(data));
+    dispatch(fetchRelationships(data.map((x) => x.id)));
+
+    return { accounts: data };
+  },
+);
+
+export const expandDirectory = createDataLoadingThunk(
+  'directory/expand',
+  async (params: Parameters<typeof apiGetDirectory>[0], { getState }) => {
+    const loadedItems = getState().user_lists.getIn([
+      'directory',
+      'items',
+    ]) as ImmutableList<unknown>;
+
+    return apiGetDirectory({ ...params, offset: loadedItems.size }, 20);
+  },
+  (data, { dispatch }) => {
+    dispatch(importFetchedAccounts(data));
+    dispatch(fetchRelationships(data.map((x) => x.id)));
+
+    return { accounts: data };
+  },
+);
diff --git a/app/javascript/flavours/glitch/actions/importer/index.js b/app/javascript/flavours/glitch/actions/importer/index.js
index 63a28eb0ed..7341ba8550 100644
--- a/app/javascript/flavours/glitch/actions/importer/index.js
+++ b/app/javascript/flavours/glitch/actions/importer/index.js
@@ -76,8 +76,8 @@ export function importFetchedStatuses(statuses) {
         pushUnique(polls, normalizePoll(status.poll, getState().getIn(['polls', status.poll.id])));
       }
 
-      if (status.card?.author_account) {
-        pushUnique(accounts, status.card.author_account);
+      if (status.card) {
+        status.card.authors.forEach(author => author.account && pushUnique(accounts, author.account));
       }
     }
 
diff --git a/app/javascript/flavours/glitch/actions/importer/normalizer.js b/app/javascript/flavours/glitch/actions/importer/normalizer.js
index 8f5bda89b5..5f10c8d889 100644
--- a/app/javascript/flavours/glitch/actions/importer/normalizer.js
+++ b/app/javascript/flavours/glitch/actions/importer/normalizer.js
@@ -36,8 +36,15 @@ export function normalizeStatus(status, normalOldStatus, settings) {
     normalStatus.poll = status.poll.id;
   }
 
-  if (status.card?.author_account) {
-    normalStatus.card = { ...status.card, author_account: status.card.author_account.id };
+  if (status.card) {
+    normalStatus.card = {
+      ...status.card,
+      authors: status.card.authors.map(author => ({
+        ...author,
+        accountId: author.account?.id,
+        account: undefined,
+      })),
+    };
   }
 
   if (status.filtered) {
diff --git a/app/javascript/flavours/glitch/actions/trends.js b/app/javascript/flavours/glitch/actions/trends.js
index 01089fccbb..0bdf17a5d2 100644
--- a/app/javascript/flavours/glitch/actions/trends.js
+++ b/app/javascript/flavours/glitch/actions/trends.js
@@ -51,7 +51,7 @@ export const fetchTrendingLinks = () => (dispatch) => {
   api()
     .get('/api/v1/trends/links', { params: { limit: 20 } })
     .then(({ data }) => {
-      dispatch(importFetchedAccounts(data.map(link => link.author_account).filter(account => !!account)));
+      dispatch(importFetchedAccounts(data.flatMap(link => link.authors.map(author => author.account)).filter(account => !!account)));
       dispatch(fetchTrendingLinksSuccess(data));
     })
     .catch(err => dispatch(fetchTrendingLinksFail(err)));
diff --git a/app/javascript/flavours/glitch/api/directory.ts b/app/javascript/flavours/glitch/api/directory.ts
new file mode 100644
index 0000000000..72743a2584
--- /dev/null
+++ b/app/javascript/flavours/glitch/api/directory.ts
@@ -0,0 +1,15 @@
+import { apiRequestGet } from 'flavours/glitch/api';
+import type { ApiAccountJSON } from 'flavours/glitch/api_types/accounts';
+
+export const apiGetDirectory = (
+  params: {
+    order: string;
+    local: boolean;
+    offset?: number;
+  },
+  limit = 20,
+) =>
+  apiRequestGet<ApiAccountJSON[]>('v1/directory', {
+    ...params,
+    limit,
+  });
diff --git a/app/javascript/flavours/glitch/api_types/statuses.ts b/app/javascript/flavours/glitch/api_types/statuses.ts
index d63441873d..261d600305 100644
--- a/app/javascript/flavours/glitch/api_types/statuses.ts
+++ b/app/javascript/flavours/glitch/api_types/statuses.ts
@@ -30,6 +30,12 @@ export interface ApiMentionJSON {
   acct: string;
 }
 
+export interface ApiPreviewCardAuthorJSON {
+  name: string;
+  url: string;
+  account?: ApiAccountJSON;
+}
+
 export interface ApiPreviewCardJSON {
   url: string;
   title: string;
@@ -48,6 +54,7 @@ export interface ApiPreviewCardJSON {
   embed_url: string;
   blurhash: string;
   published_at: string;
+  authors: ApiPreviewCardAuthorJSON[];
 }
 
 export interface ApiStatusJSON {
diff --git a/app/javascript/flavours/glitch/components/account_bio.tsx b/app/javascript/flavours/glitch/components/account_bio.tsx
new file mode 100644
index 0000000000..567a2374c2
--- /dev/null
+++ b/app/javascript/flavours/glitch/components/account_bio.tsx
@@ -0,0 +1,20 @@
+import { useLinks } from 'flavours/glitch/hooks/useLinks';
+
+export const AccountBio: React.FC<{
+  note: string;
+  className: string;
+}> = ({ note, className }) => {
+  const handleClick = useLinks();
+
+  if (note.length === 0 || note === '<p></p>') {
+    return null;
+  }
+
+  return (
+    <div
+      className={`${className} translate`}
+      dangerouslySetInnerHTML={{ __html: note }}
+      onClickCapture={handleClick}
+    />
+  );
+};
diff --git a/app/javascript/flavours/glitch/components/account_fields.tsx b/app/javascript/flavours/glitch/components/account_fields.tsx
new file mode 100644
index 0000000000..768eb1fa4b
--- /dev/null
+++ b/app/javascript/flavours/glitch/components/account_fields.tsx
@@ -0,0 +1,42 @@
+import classNames from 'classnames';
+
+import CheckIcon from '@/material-icons/400-24px/check.svg?react';
+import { Icon } from 'flavours/glitch/components/icon';
+import { useLinks } from 'flavours/glitch/hooks/useLinks';
+import type { Account } from 'flavours/glitch/models/account';
+
+export const AccountFields: React.FC<{
+  fields: Account['fields'];
+  limit: number;
+}> = ({ fields, limit = -1 }) => {
+  const handleClick = useLinks();
+
+  if (fields.size === 0) {
+    return null;
+  }
+
+  return (
+    <div className='account-fields' onClickCapture={handleClick}>
+      {fields.take(limit).map((pair, i) => (
+        <dl
+          key={i}
+          className={classNames({ verified: pair.get('verified_at') })}
+        >
+          <dt
+            dangerouslySetInnerHTML={{ __html: pair.get('name_emojified') }}
+            className='translate'
+          />
+
+          <dd className='translate' title={pair.get('value_plain') ?? ''}>
+            {pair.get('verified_at') && (
+              <Icon id='check' icon={CheckIcon} className='verified__mark' />
+            )}
+            <span
+              dangerouslySetInnerHTML={{ __html: pair.get('value_emojified') }}
+            />
+          </dd>
+        </dl>
+      ))}
+    </div>
+  );
+};
diff --git a/app/javascript/flavours/glitch/components/column_header.jsx b/app/javascript/flavours/glitch/components/column_header.jsx
deleted file mode 100644
index 210ec396fa..0000000000
--- a/app/javascript/flavours/glitch/components/column_header.jsx
+++ /dev/null
@@ -1,233 +0,0 @@
-import PropTypes from 'prop-types';
-import { PureComponent, useCallback } from 'react';
-
-import { FormattedMessage, injectIntl, defineMessages, useIntl } from 'react-intl';
-
-import classNames from 'classnames';
-import { withRouter } from 'react-router-dom';
-
-import AddIcon from '@/material-icons/400-24px/add.svg?react';
-import ArrowBackIcon from '@/material-icons/400-24px/arrow_back.svg?react';
-import ChevronLeftIcon from '@/material-icons/400-24px/chevron_left.svg?react';
-import ChevronRightIcon from '@/material-icons/400-24px/chevron_right.svg?react';
-import CloseIcon from '@/material-icons/400-24px/close.svg?react';
-import SettingsIcon from '@/material-icons/400-24px/settings.svg?react';
-import { Icon }  from 'flavours/glitch/components/icon';
-import { ButtonInTabsBar } from 'flavours/glitch/features/ui/util/columns_context';
-import { identityContextPropShape, withIdentity } from 'flavours/glitch/identity_context';
-import { WithRouterPropTypes } from 'flavours/glitch/utils/react_router';
-
-
-import { useAppHistory } from './router';
-
-const messages = defineMessages({
-  show: { id: 'column_header.show_settings', defaultMessage: 'Show settings' },
-  hide: { id: 'column_header.hide_settings', defaultMessage: 'Hide settings' },
-  moveLeft: { id: 'column_header.moveLeft_settings', defaultMessage: 'Move column to the left' },
-  moveRight: { id: 'column_header.moveRight_settings', defaultMessage: 'Move column to the right' },
-  back: { id: 'column_back_button.label', defaultMessage: 'Back' },
-});
-
-const BackButton = ({ onlyIcon }) => {
-  const history = useAppHistory();
-  const intl = useIntl();
-
-  const handleBackClick = useCallback(() => {
-    if (history.location?.state?.fromMastodon) {
-      history.goBack();
-    } else {
-      history.push('/');
-    }
-  }, [history]);
-
-  return (
-    <button onClick={handleBackClick} className={classNames('column-header__back-button', { 'compact': onlyIcon })} aria-label={intl.formatMessage(messages.back)}>
-      <Icon id='chevron-left' icon={ArrowBackIcon} className='column-back-button__icon' />
-      {!onlyIcon && <FormattedMessage id='column_back_button.label' defaultMessage='Back' />}
-    </button>
-  );
-};
-
-BackButton.propTypes = {
-  onlyIcon: PropTypes.bool,
-};
-
-class ColumnHeader extends PureComponent {
-  static propTypes = {
-    identity: identityContextPropShape,
-    intl: PropTypes.object.isRequired,
-    title: PropTypes.node,
-    icon: PropTypes.string,
-    iconComponent: PropTypes.func,
-    active: PropTypes.bool,
-    multiColumn: PropTypes.bool,
-    extraButton: PropTypes.node,
-    showBackButton: PropTypes.bool,
-    children: PropTypes.node,
-    pinned: PropTypes.bool,
-    placeholder: PropTypes.bool,
-    onPin: PropTypes.func,
-    onMove: PropTypes.func,
-    onClick: PropTypes.func,
-    appendContent: PropTypes.node,
-    collapseIssues: PropTypes.bool,
-    ...WithRouterPropTypes,
-  };
-
-  state = {
-    collapsed: true,
-    animating: false,
-  };
-
-  handleToggleClick = (e) => {
-    e.stopPropagation();
-    this.setState({ collapsed: !this.state.collapsed, animating: true });
-  };
-
-  handleTitleClick = () => {
-    this.props.onClick?.();
-  };
-
-  handleMoveLeft = () => {
-    this.props.onMove(-1);
-  };
-
-  handleMoveRight = () => {
-    this.props.onMove(1);
-  };
-
-  handleTransitionEnd = () => {
-    this.setState({ animating: false });
-  };
-
-  handlePin = () => {
-    if (!this.props.pinned) {
-      this.props.history.replace('/');
-    }
-
-    this.props.onPin();
-  };
-
-  render () {
-    const { title, icon, iconComponent, active, children, pinned, multiColumn, extraButton, showBackButton, intl: { formatMessage }, placeholder, appendContent, collapseIssues, history } = this.props;
-    const { collapsed, animating } = this.state;
-
-    const wrapperClassName = classNames('column-header__wrapper', {
-      'active': active,
-    });
-
-    const buttonClassName = classNames('column-header', {
-      'active': active,
-    });
-
-    const collapsibleClassName = classNames('column-header__collapsible', {
-      'collapsed': collapsed,
-      'animating': animating,
-    });
-
-    const collapsibleButtonClassName = classNames('column-header__button', {
-      'active': !collapsed,
-    });
-
-    let extraContent, pinButton, moveButtons, backButton, collapseButton;
-
-    if (children) {
-      extraContent = (
-        <div key='extra-content' className='column-header__collapsible__extra'>
-          {children}
-        </div>
-      );
-    }
-
-    if (multiColumn && pinned) {
-      pinButton = <button className='text-btn column-header__setting-btn' onClick={this.handlePin}><Icon id='times' icon={CloseIcon} /> <FormattedMessage id='column_header.unpin' defaultMessage='Unpin' /></button>;
-
-      moveButtons = (
-        <div className='column-header__setting-arrows'>
-          <button title={formatMessage(messages.moveLeft)} aria-label={formatMessage(messages.moveLeft)} className='icon-button column-header__setting-btn' onClick={this.handleMoveLeft}><Icon id='chevron-left' icon={ChevronLeftIcon} /></button>
-          <button title={formatMessage(messages.moveRight)} aria-label={formatMessage(messages.moveRight)} className='icon-button column-header__setting-btn' onClick={this.handleMoveRight}><Icon id='chevron-right' icon={ChevronRightIcon} /></button>
-        </div>
-      );
-    } else if (multiColumn && this.props.onPin) {
-      pinButton = <button className='text-btn column-header__setting-btn' onClick={this.handlePin}><Icon id='plus' icon={AddIcon} /> <FormattedMessage id='column_header.pin' defaultMessage='Pin' /></button>;
-    }
-
-    if (history && !pinned && ((multiColumn && history.location?.state?.fromMastodon) || showBackButton)) {
-      backButton = <BackButton onlyIcon={!!title} />;
-    }
-
-    const collapsedContent = [
-      extraContent,
-    ];
-
-    if (multiColumn) {
-      collapsedContent.push(
-        <div key='buttons' className='column-header__advanced-buttons'>
-          {pinButton}
-          {moveButtons}
-        </div>
-      );
-    }
-
-    if (this.props.identity.signedIn && (children || (multiColumn && this.props.onPin))) {
-      collapseButton = (
-        <button
-          className={collapsibleButtonClassName}
-          title={formatMessage(collapsed ? messages.show : messages.hide)}
-          aria-label={formatMessage(collapsed ? messages.show : messages.hide)}
-          onClick={this.handleToggleClick}
-        >
-          <i className='icon-with-badge'>
-            <Icon id='sliders' icon={SettingsIcon} />
-            {collapseIssues && <i className='icon-with-badge__issue-badge' />}
-          </i>
-        </button>
-      );
-    }
-
-    const hasTitle = (icon || iconComponent) && title;
-
-    const component = (
-      <div className={wrapperClassName}>
-        <h1 className={buttonClassName}>
-          {hasTitle && (
-            <>
-              {backButton}
-
-              <button onClick={this.handleTitleClick} className='column-header__title'>
-                {!backButton && <Icon id={icon} icon={iconComponent} className='column-header__icon' />}
-                {title}
-              </button>
-            </>
-          )}
-
-          {!hasTitle && backButton}
-
-          <div className='column-header__buttons'>
-            {extraButton}
-            {collapseButton}
-          </div>
-        </h1>
-
-        <div className={collapsibleClassName} tabIndex={collapsed ? -1 : null} onTransitionEnd={this.handleTransitionEnd}>
-          <div className='column-header__collapsible-inner'>
-            {(!collapsed || animating) && collapsedContent}
-          </div>
-        </div>
-
-        {appendContent}
-      </div>
-    );
-
-    if (placeholder) {
-      return component;
-    } else {
-      return (<ButtonInTabsBar>
-        {component}
-      </ButtonInTabsBar>);
-    }
-  }
-
-}
-
-export default injectIntl(withIdentity(withRouter(ColumnHeader)));
diff --git a/app/javascript/flavours/glitch/components/column_header.tsx b/app/javascript/flavours/glitch/components/column_header.tsx
new file mode 100644
index 0000000000..9bd1559904
--- /dev/null
+++ b/app/javascript/flavours/glitch/components/column_header.tsx
@@ -0,0 +1,301 @@
+import { useCallback, useState } from 'react';
+
+import { FormattedMessage, defineMessages, useIntl } from 'react-intl';
+
+import classNames from 'classnames';
+
+import AddIcon from '@/material-icons/400-24px/add.svg?react';
+import ArrowBackIcon from '@/material-icons/400-24px/arrow_back.svg?react';
+import ChevronLeftIcon from '@/material-icons/400-24px/chevron_left.svg?react';
+import ChevronRightIcon from '@/material-icons/400-24px/chevron_right.svg?react';
+import CloseIcon from '@/material-icons/400-24px/close.svg?react';
+import SettingsIcon from '@/material-icons/400-24px/settings.svg?react';
+import type { IconProp } from 'flavours/glitch/components/icon';
+import { Icon } from 'flavours/glitch/components/icon';
+import { ButtonInTabsBar } from 'flavours/glitch/features/ui/util/columns_context';
+import { useIdentity } from 'flavours/glitch/identity_context';
+
+import { useAppHistory } from './router';
+
+const messages = defineMessages({
+  show: { id: 'column_header.show_settings', defaultMessage: 'Show settings' },
+  hide: { id: 'column_header.hide_settings', defaultMessage: 'Hide settings' },
+  moveLeft: {
+    id: 'column_header.moveLeft_settings',
+    defaultMessage: 'Move column to the left',
+  },
+  moveRight: {
+    id: 'column_header.moveRight_settings',
+    defaultMessage: 'Move column to the right',
+  },
+  back: { id: 'column_back_button.label', defaultMessage: 'Back' },
+});
+
+const BackButton: React.FC<{
+  onlyIcon: boolean;
+}> = ({ onlyIcon }) => {
+  const history = useAppHistory();
+  const intl = useIntl();
+
+  const handleBackClick = useCallback(() => {
+    if (history.location.state?.fromMastodon) {
+      history.goBack();
+    } else {
+      history.push('/');
+    }
+  }, [history]);
+
+  return (
+    <button
+      onClick={handleBackClick}
+      className={classNames('column-header__back-button', {
+        compact: onlyIcon,
+      })}
+      aria-label={intl.formatMessage(messages.back)}
+    >
+      <Icon
+        id='chevron-left'
+        icon={ArrowBackIcon}
+        className='column-back-button__icon'
+      />
+      {!onlyIcon && (
+        <FormattedMessage id='column_back_button.label' defaultMessage='Back' />
+      )}
+    </button>
+  );
+};
+
+export interface Props {
+  title?: string;
+  icon?: string;
+  iconComponent?: IconProp;
+  active?: boolean;
+  children?: React.ReactNode;
+  pinned?: boolean;
+  multiColumn?: boolean;
+  extraButton?: React.ReactNode;
+  showBackButton?: boolean;
+  placeholder?: boolean;
+  appendContent?: React.ReactNode;
+  collapseIssues?: boolean;
+  onClick?: () => void;
+  onMove?: (arg0: number) => void;
+  onPin?: () => void;
+}
+
+export const ColumnHeader: React.FC<Props> = ({
+  title,
+  icon,
+  iconComponent,
+  active,
+  children,
+  pinned,
+  multiColumn,
+  extraButton,
+  showBackButton,
+  placeholder,
+  appendContent,
+  collapseIssues,
+  onClick,
+  onMove,
+  onPin,
+}) => {
+  const intl = useIntl();
+  const { signedIn } = useIdentity();
+  const history = useAppHistory();
+  const [collapsed, setCollapsed] = useState(true);
+  const [animating, setAnimating] = useState(false);
+
+  const handleToggleClick = useCallback(
+    (e: React.MouseEvent) => {
+      e.stopPropagation();
+      setCollapsed((value) => !value);
+      setAnimating(true);
+    },
+    [setCollapsed, setAnimating],
+  );
+
+  const handleTitleClick = useCallback(() => {
+    onClick?.();
+  }, [onClick]);
+
+  const handleMoveLeft = useCallback(() => {
+    onMove?.(-1);
+  }, [onMove]);
+
+  const handleMoveRight = useCallback(() => {
+    onMove?.(1);
+  }, [onMove]);
+
+  const handleTransitionEnd = useCallback(() => {
+    setAnimating(false);
+  }, [setAnimating]);
+
+  const handlePin = useCallback(() => {
+    if (!pinned) {
+      history.replace('/');
+    }
+
+    onPin?.();
+  }, [history, pinned, onPin]);
+
+  const wrapperClassName = classNames('column-header__wrapper', {
+    active,
+  });
+
+  const buttonClassName = classNames('column-header', {
+    active,
+  });
+
+  const collapsibleClassName = classNames('column-header__collapsible', {
+    collapsed,
+    animating,
+  });
+
+  const collapsibleButtonClassName = classNames('column-header__button', {
+    active: !collapsed,
+  });
+
+  let extraContent, pinButton, moveButtons, backButton, collapseButton;
+
+  if (children) {
+    extraContent = (
+      <div key='extra-content' className='column-header__collapsible__extra'>
+        {children}
+      </div>
+    );
+  }
+
+  if (multiColumn && pinned) {
+    pinButton = (
+      <button
+        className='text-btn column-header__setting-btn'
+        onClick={handlePin}
+      >
+        <Icon id='times' icon={CloseIcon} />{' '}
+        <FormattedMessage id='column_header.unpin' defaultMessage='Unpin' />
+      </button>
+    );
+
+    moveButtons = (
+      <div className='column-header__setting-arrows'>
+        <button
+          title={intl.formatMessage(messages.moveLeft)}
+          aria-label={intl.formatMessage(messages.moveLeft)}
+          className='icon-button column-header__setting-btn'
+          onClick={handleMoveLeft}
+        >
+          <Icon id='chevron-left' icon={ChevronLeftIcon} />
+        </button>
+        <button
+          title={intl.formatMessage(messages.moveRight)}
+          aria-label={intl.formatMessage(messages.moveRight)}
+          className='icon-button column-header__setting-btn'
+          onClick={handleMoveRight}
+        >
+          <Icon id='chevron-right' icon={ChevronRightIcon} />
+        </button>
+      </div>
+    );
+  } else if (multiColumn && onPin) {
+    pinButton = (
+      <button
+        className='text-btn column-header__setting-btn'
+        onClick={handlePin}
+      >
+        <Icon id='plus' icon={AddIcon} />{' '}
+        <FormattedMessage id='column_header.pin' defaultMessage='Pin' />
+      </button>
+    );
+  }
+
+  if (
+    !pinned &&
+    ((multiColumn && history.location.state?.fromMastodon) || showBackButton)
+  ) {
+    backButton = <BackButton onlyIcon={!!title} />;
+  }
+
+  const collapsedContent = [extraContent];
+
+  if (multiColumn) {
+    collapsedContent.push(
+      <div key='buttons' className='column-header__advanced-buttons'>
+        {pinButton}
+        {moveButtons}
+      </div>,
+    );
+  }
+
+  if (signedIn && (children || (multiColumn && onPin))) {
+    collapseButton = (
+      <button
+        className={collapsibleButtonClassName}
+        title={intl.formatMessage(collapsed ? messages.show : messages.hide)}
+        aria-label={intl.formatMessage(
+          collapsed ? messages.show : messages.hide,
+        )}
+        onClick={handleToggleClick}
+      >
+        <i className='icon-with-badge'>
+          <Icon id='sliders' icon={SettingsIcon} />
+          {collapseIssues && <i className='icon-with-badge__issue-badge' />}
+        </i>
+      </button>
+    );
+  }
+
+  const hasIcon = icon && iconComponent;
+  const hasTitle = hasIcon && title;
+
+  const component = (
+    <div className={wrapperClassName}>
+      <h1 className={buttonClassName}>
+        {hasTitle && (
+          <>
+            {backButton}
+
+            <button onClick={handleTitleClick} className='column-header__title'>
+              {!backButton && (
+                <Icon
+                  id={icon}
+                  icon={iconComponent}
+                  className='column-header__icon'
+                />
+              )}
+              {title}
+            </button>
+          </>
+        )}
+
+        {!hasTitle && backButton}
+
+        <div className='column-header__buttons'>
+          {extraButton}
+          {collapseButton}
+        </div>
+      </h1>
+
+      <div
+        className={collapsibleClassName}
+        tabIndex={collapsed ? -1 : undefined}
+        onTransitionEnd={handleTransitionEnd}
+      >
+        <div className='column-header__collapsible-inner'>
+          {(!collapsed || animating) && collapsedContent}
+        </div>
+      </div>
+
+      {appendContent}
+    </div>
+  );
+
+  if (placeholder) {
+    return component;
+  } else {
+    return <ButtonInTabsBar>{component}</ButtonInTabsBar>;
+  }
+};
+
+// eslint-disable-next-line import/no-default-export
+export default ColumnHeader;
diff --git a/app/javascript/flavours/glitch/components/follow_button.tsx b/app/javascript/flavours/glitch/components/follow_button.tsx
new file mode 100644
index 0000000000..5c9cd2c067
--- /dev/null
+++ b/app/javascript/flavours/glitch/components/follow_button.tsx
@@ -0,0 +1,114 @@
+import { useCallback, useEffect } from 'react';
+
+import { useIntl, defineMessages } from 'react-intl';
+
+import { useIdentity } from '@/flavours/glitch/identity_context';
+import {
+  fetchRelationships,
+  followAccount,
+  unfollowAccount,
+} from 'flavours/glitch/actions/accounts';
+import { openModal } from 'flavours/glitch/actions/modal';
+import { Button } from 'flavours/glitch/components/button';
+import { LoadingIndicator } from 'flavours/glitch/components/loading_indicator';
+import { me } from 'flavours/glitch/initial_state';
+import { useAppDispatch, useAppSelector } from 'flavours/glitch/store';
+
+const messages = defineMessages({
+  unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
+  follow: { id: 'account.follow', defaultMessage: 'Follow' },
+  followBack: { id: 'account.follow_back', defaultMessage: 'Follow back' },
+  cancel_follow_request: {
+    id: 'account.cancel_follow_request',
+    defaultMessage: 'Withdraw follow request',
+  },
+  edit_profile: { id: 'account.edit_profile', defaultMessage: 'Edit profile' },
+});
+
+export const FollowButton: React.FC<{
+  accountId: string;
+}> = ({ accountId }) => {
+  const intl = useIntl();
+  const dispatch = useAppDispatch();
+  const { signedIn } = useIdentity();
+  const account = useAppSelector((state) =>
+    accountId ? state.accounts.get(accountId) : undefined,
+  );
+  const relationship = useAppSelector((state) =>
+    state.relationships.get(accountId),
+  );
+  const following = relationship?.following || relationship?.requested;
+
+  useEffect(() => {
+    if (accountId && signedIn) {
+      dispatch(fetchRelationships([accountId]));
+    }
+  }, [dispatch, accountId, signedIn]);
+
+  const handleClick = useCallback(() => {
+    if (!signedIn) {
+      dispatch(
+        openModal({
+          modalType: 'INTERACTION',
+          modalProps: {
+            type: 'follow',
+            accountId: accountId,
+            url: account?.url,
+          },
+        }),
+      );
+    }
+
+    if (!relationship) return;
+
+    if (accountId === me) {
+      return;
+    } else if (relationship.following || relationship.requested) {
+      dispatch(unfollowAccount(accountId));
+    } else {
+      dispatch(followAccount(accountId));
+    }
+  }, [dispatch, accountId, relationship, account, signedIn]);
+
+  let label;
+
+  if (!signedIn) {
+    label = intl.formatMessage(messages.follow);
+  } else if (accountId === me) {
+    label = intl.formatMessage(messages.edit_profile);
+  } else if (!relationship) {
+    label = <LoadingIndicator />;
+  } else if (relationship.requested) {
+    label = intl.formatMessage(messages.cancel_follow_request);
+  } else if (!relationship.following && relationship.followed_by) {
+    label = intl.formatMessage(messages.followBack);
+  } else if (relationship.following) {
+    label = intl.formatMessage(messages.unfollow);
+  } else {
+    label = intl.formatMessage(messages.follow);
+  }
+
+  if (accountId === me) {
+    return (
+      <a
+        href='/settings/profile'
+        target='_blank'
+        rel='noreferrer noopener'
+        className='button button-secondary'
+      >
+        {label}
+      </a>
+    );
+  }
+
+  return (
+    <Button
+      onClick={handleClick}
+      disabled={relationship?.blocked_by || relationship?.blocking}
+      secondary={following}
+      className={following ? 'button--destructive' : undefined}
+    >
+      {label}
+    </Button>
+  );
+};
diff --git a/app/javascript/flavours/glitch/components/hover_card_account.tsx b/app/javascript/flavours/glitch/components/hover_card_account.tsx
new file mode 100644
index 0000000000..a62128e17b
--- /dev/null
+++ b/app/javascript/flavours/glitch/components/hover_card_account.tsx
@@ -0,0 +1,78 @@
+import { useEffect, forwardRef } from 'react';
+
+import classNames from 'classnames';
+
+import { fetchAccount } from 'flavours/glitch/actions/accounts';
+import { AccountBio } from 'flavours/glitch/components/account_bio';
+import { AccountFields } from 'flavours/glitch/components/account_fields';
+import { Avatar } from 'flavours/glitch/components/avatar';
+import { FollowersCounter } from 'flavours/glitch/components/counters';
+import { DisplayName } from 'flavours/glitch/components/display_name';
+import { FollowButton } from 'flavours/glitch/components/follow_button';
+import { LoadingIndicator } from 'flavours/glitch/components/loading_indicator';
+import { Permalink } from 'flavours/glitch/components/permalink';
+import { ShortNumber } from 'flavours/glitch/components/short_number';
+import { domain } from 'flavours/glitch/initial_state';
+import { useAppSelector, useAppDispatch } from 'flavours/glitch/store';
+
+export const HoverCardAccount = forwardRef<
+  HTMLDivElement,
+  { accountId: string }
+>(({ accountId }, ref) => {
+  const dispatch = useAppDispatch();
+
+  const account = useAppSelector((state) =>
+    accountId ? state.accounts.get(accountId) : undefined,
+  );
+
+  useEffect(() => {
+    if (accountId && !account) {
+      dispatch(fetchAccount(accountId));
+    }
+  }, [dispatch, accountId, account]);
+
+  return (
+    <div
+      ref={ref}
+      id='hover-card'
+      role='tooltip'
+      className={classNames('hover-card dropdown-animation', {
+        'hover-card--loading': !account,
+      })}
+    >
+      {account ? (
+        <>
+          <Permalink
+            to={`/@${account.acct}`}
+            href={account.get('url')}
+            className='hover-card__name'
+          >
+            <Avatar account={account} size={46} />
+            <DisplayName account={account} localDomain={domain} />
+          </Permalink>
+
+          <div className='hover-card__text-row'>
+            <AccountBio
+              note={account.note_emojified}
+              className='hover-card__bio'
+            />
+            <AccountFields fields={account.fields} limit={2} />
+          </div>
+
+          <div className='hover-card__number'>
+            <ShortNumber
+              value={account.followers_count}
+              renderer={FollowersCounter}
+            />
+          </div>
+
+          <FollowButton accountId={accountId} />
+        </>
+      ) : (
+        <LoadingIndicator />
+      )}
+    </div>
+  );
+});
+
+HoverCardAccount.displayName = 'HoverCardAccount';
diff --git a/app/javascript/flavours/glitch/components/hover_card_controller.tsx b/app/javascript/flavours/glitch/components/hover_card_controller.tsx
new file mode 100644
index 0000000000..6e11d28381
--- /dev/null
+++ b/app/javascript/flavours/glitch/components/hover_card_controller.tsx
@@ -0,0 +1,117 @@
+import { useEffect, useRef, useState, useCallback } from 'react';
+
+import { useLocation } from 'react-router-dom';
+
+import Overlay from 'react-overlays/Overlay';
+import type {
+  OffsetValue,
+  UsePopperOptions,
+} from 'react-overlays/esm/usePopper';
+
+import { HoverCardAccount } from 'flavours/glitch/components/hover_card_account';
+import { useTimeout } from 'flavours/glitch/hooks/useTimeout';
+
+const offset = [-12, 4] as OffsetValue;
+const enterDelay = 650;
+const leaveDelay = 250;
+const popperConfig = { strategy: 'fixed' } as UsePopperOptions;
+
+const isHoverCardAnchor = (element: HTMLElement) =>
+  element.matches('[data-hover-card-account]');
+
+export const HoverCardController: React.FC = () => {
+  const [open, setOpen] = useState(false);
+  const [accountId, setAccountId] = useState<string | undefined>();
+  const [anchor, setAnchor] = useState<HTMLElement | null>(null);
+  const cardRef = useRef<HTMLDivElement>(null);
+  const [setLeaveTimeout, cancelLeaveTimeout] = useTimeout();
+  const [setEnterTimeout, cancelEnterTimeout] = useTimeout();
+  const location = useLocation();
+
+  const handleAnchorMouseEnter = useCallback(
+    (e: MouseEvent) => {
+      const { target } = e;
+
+      if (target instanceof HTMLElement && isHoverCardAnchor(target)) {
+        cancelLeaveTimeout();
+
+        setEnterTimeout(() => {
+          target.setAttribute('aria-describedby', 'hover-card');
+          setAnchor(target);
+          setOpen(true);
+          setAccountId(
+            target.getAttribute('data-hover-card-account') ?? undefined,
+          );
+        }, enterDelay);
+      }
+
+      if (target === cardRef.current?.parentNode) {
+        cancelLeaveTimeout();
+      }
+    },
+    [cancelLeaveTimeout, setEnterTimeout, setOpen, setAccountId, setAnchor],
+  );
+
+  const handleAnchorMouseLeave = useCallback(
+    (e: MouseEvent) => {
+      if (e.target === anchor || e.target === cardRef.current?.parentNode) {
+        cancelEnterTimeout();
+
+        setLeaveTimeout(() => {
+          anchor?.removeAttribute('aria-describedby');
+          setOpen(false);
+          setAnchor(null);
+        }, leaveDelay);
+      }
+    },
+    [cancelEnterTimeout, setLeaveTimeout, setOpen, setAnchor, anchor],
+  );
+
+  const handleClose = useCallback(() => {
+    cancelEnterTimeout();
+    cancelLeaveTimeout();
+    setOpen(false);
+    setAnchor(null);
+  }, [cancelEnterTimeout, cancelLeaveTimeout, setOpen, setAnchor]);
+
+  useEffect(() => {
+    handleClose();
+  }, [handleClose, location]);
+
+  useEffect(() => {
+    document.body.addEventListener('mouseenter', handleAnchorMouseEnter, {
+      passive: true,
+      capture: true,
+    });
+    document.body.addEventListener('mouseleave', handleAnchorMouseLeave, {
+      passive: true,
+      capture: true,
+    });
+
+    return () => {
+      document.body.removeEventListener('mouseenter', handleAnchorMouseEnter);
+      document.body.removeEventListener('mouseleave', handleAnchorMouseLeave);
+    };
+  }, [handleAnchorMouseEnter, handleAnchorMouseLeave]);
+
+  if (!accountId) return null;
+
+  return (
+    <Overlay
+      rootClose
+      onHide={handleClose}
+      show={open}
+      target={anchor}
+      placement='bottom-start'
+      flip
+      offset={offset}
+      popperConfig={popperConfig}
+    >
+      {({ props }) => (
+        <div {...props} className='hover-card-controller'>
+          <HoverCardAccount accountId={accountId} ref={cardRef} />
+        </div>
+      )}
+    </Overlay>
+  );
+};
diff --git a/app/javascript/flavours/glitch/components/status_content.jsx b/app/javascript/flavours/glitch/components/status_content.jsx
index 24da69cdf2..c28f85eb72 100644
--- a/app/javascript/flavours/glitch/components/status_content.jsx
+++ b/app/javascript/flavours/glitch/components/status_content.jsx
@@ -181,7 +181,8 @@ class StatusContent extends PureComponent {
 
       if (mention) {
         link.addEventListener('click', this.onMentionClick.bind(this, mention), false);
-        link.setAttribute('title', `@${mention.get('acct')}`);
+        link.removeAttribute('title');
+        link.setAttribute('data-hover-card-account', mention.get('id'));
         if (rewriteMentions !== 'no') {
           while (link.firstChild) link.removeChild(link.firstChild);
           link.appendChild(document.createTextNode('@'));
diff --git a/app/javascript/flavours/glitch/components/status_header.jsx b/app/javascript/flavours/glitch/components/status_header.jsx
index 692dca5c7b..ee4573659c 100644
--- a/app/javascript/flavours/glitch/components/status_header.jsx
+++ b/app/javascript/flavours/glitch/components/status_header.jsx
@@ -51,6 +51,7 @@ export default class StatusHeader extends PureComponent {
         target='_blank'
         onClick={this.handleAccountClick}
         rel='noopener noreferrer'
+        data-hover-card-account={status.getIn(['account', 'id'])}
       >
         <div className='status__avatar'>
           {statusAvatar}
diff --git a/app/javascript/flavours/glitch/components/status_prepend.jsx b/app/javascript/flavours/glitch/components/status_prepend.jsx
index 41902e60ba..e3bb554e2a 100644
--- a/app/javascript/flavours/glitch/components/status_prepend.jsx
+++ b/app/javascript/flavours/glitch/components/status_prepend.jsx
@@ -38,6 +38,7 @@ export default class StatusPrepend extends PureComponent {
         onClick={this.handleClick}
         href={account.get('url')}
         className='status__display-name'
+        data-hover-card-account={account.get('id')}
       >
         <bdi>
           <strong
diff --git a/app/javascript/flavours/glitch/features/directory/components/account_card.jsx b/app/javascript/flavours/glitch/features/directory/components/account_card.jsx
deleted file mode 100644
index ed4736d7e6..0000000000
--- a/app/javascript/flavours/glitch/features/directory/components/account_card.jsx
+++ /dev/null
@@ -1,247 +0,0 @@
-import PropTypes from 'prop-types';
-
-import { FormattedMessage, injectIntl, defineMessages } from 'react-intl';
-
-import classNames from 'classnames';
-
-import ImmutablePropTypes from 'react-immutable-proptypes';
-import ImmutablePureComponent from 'react-immutable-pure-component';
-import { connect } from 'react-redux';
-
-import {
-  followAccount,
-  unfollowAccount,
-  unblockAccount,
-  unmuteAccount,
-} from 'flavours/glitch/actions/accounts';
-import { openModal } from 'flavours/glitch/actions/modal';
-import { Avatar } from 'flavours/glitch/components/avatar';
-import { Button } from 'flavours/glitch/components/button';
-import { DisplayName } from 'flavours/glitch/components/display_name';
-import { IconButton } from 'flavours/glitch/components/icon_button';
-import { Permalink } from 'flavours/glitch/components/permalink';
-import { ShortNumber } from 'flavours/glitch/components/short_number';
-import { autoPlayGif, me } from 'flavours/glitch/initial_state';
-import { makeGetAccount } from 'flavours/glitch/selectors';
-
-const messages = defineMessages({
-  unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
-  follow: { id: 'account.follow', defaultMessage: 'Follow' },
-  cancel_follow_request: { id: 'account.cancel_follow_request', defaultMessage: 'Withdraw follow request' },
-  cancelFollowRequestConfirm: { id: 'confirmations.cancel_follow_request.confirm', defaultMessage: 'Withdraw 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' },
-  dismissSuggestion: { id: 'suggestions.dismiss', defaultMessage: 'Dismiss suggestion' },
-});
-
-const makeMapStateToProps = () => {
-  const getAccount = makeGetAccount();
-
-  const mapStateToProps = (state, { id }) => ({
-    account: getAccount(state, id),
-  });
-
-  return mapStateToProps;
-};
-
-const mapDispatchToProps = (dispatch, { intl }) => ({
-  onFollow(account) {
-    if (account.getIn(['relationship', 'following'])) {
-      dispatch(
-        openModal({
-          modalType: 'CONFIRM',
-          modalProps: {
-            message: (
-              <FormattedMessage
-                id='confirmations.unfollow.message'
-                defaultMessage='Are you sure you want to unfollow {name}?'
-                values={{ name: <strong>@{account.get('acct')}</strong> }}
-              />
-            ),
-            confirm: intl.formatMessage(messages.unfollowConfirm),
-            onConfirm: () => dispatch(unfollowAccount(account.get('id'))),
-          } }),
-      );
-    } else if (account.getIn(['relationship', 'requested'])) {
-      dispatch(openModal({
-        modalType: 'CONFIRM',
-        modalProps: {
-          message: <FormattedMessage id='confirmations.cancel_follow_request.message' defaultMessage='Are you sure you want to withdraw your request to follow {name}?' values={{ name: <strong>@{account.get('acct')}</strong> }} />,
-          confirm: intl.formatMessage(messages.cancelFollowRequestConfirm),
-          onConfirm: () => dispatch(unfollowAccount(account.get('id'))),
-        },
-      }));
-    } else {
-      dispatch(followAccount(account.get('id')));
-    }
-  },
-
-  onBlock(account) {
-    if (account.getIn(['relationship', 'blocking'])) {
-      dispatch(unblockAccount(account.get('id')));
-    }
-  },
-
-  onMute(account) {
-    if (account.getIn(['relationship', 'muting'])) {
-      dispatch(unmuteAccount(account.get('id')));
-    }
-  },
-
-});
-
-class AccountCard extends ImmutablePureComponent {
-
-  static propTypes = {
-    account: ImmutablePropTypes.record.isRequired,
-    intl: PropTypes.object.isRequired,
-    onFollow: PropTypes.func.isRequired,
-    onBlock: PropTypes.func.isRequired,
-    onMute: PropTypes.func.isRequired,
-    onDismiss: PropTypes.func,
-  };
-
-  handleMouseEnter = ({ currentTarget }) => {
-    if (autoPlayGif) {
-      return;
-    }
-
-    const emojis = currentTarget.querySelectorAll('.custom-emoji');
-
-    for (var i = 0; i < emojis.length; i++) {
-      let emoji = emojis[i];
-      emoji.src = emoji.getAttribute('data-original');
-    }
-  };
-
-  handleMouseLeave = ({ currentTarget }) => {
-    if (autoPlayGif) {
-      return;
-    }
-
-    const emojis = currentTarget.querySelectorAll('.custom-emoji');
-
-    for (var i = 0; i < emojis.length; i++) {
-      let emoji = emojis[i];
-      emoji.src = emoji.getAttribute('data-static');
-    }
-  };
-
-  handleFollow = () => {
-    this.props.onFollow(this.props.account);
-  };
-
-  handleBlock = () => {
-    this.props.onBlock(this.props.account);
-  };
-
-  handleMute = () => {
-    this.props.onMute(this.props.account);
-  };
-
-  handleEditProfile = () => {
-    window.open('/settings/profile', '_blank');
-  };
-
-  handleDismiss = (e) => {
-    const { account, onDismiss } = this.props;
-    onDismiss(account.get('id'));
-
-    e.preventDefault();
-    e.stopPropagation();
-  };
-
-  render() {
-    const { account, intl } = this.props;
-
-    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  text={intl.formatMessage(messages.cancel_follow_request)} title={intl.formatMessage(messages.requested)} onClick={this.handleFollow} />;
-      } else if (account.getIn(['relationship', 'muting'])) {
-        actionBtn = <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({ '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  text={intl.formatMessage(messages.unblock)} onClick={this.handleBlock} />;
-      }
-    } else {
-      actionBtn = <Button  text={intl.formatMessage(messages.edit_profile)} onClick={this.handleEditProfile} />;
-    }
-
-    return (
-      <div className='account-card'>
-        <Permalink href={account.get('url')} to={`/@${account.get('acct')}`} className='account-card__permalink'>
-          <div className='account-card__header'>
-            {this.props.onDismiss && <IconButton className='media-modal__close' title={intl.formatMessage(messages.dismissSuggestion)} icon='times' onClick={this.handleDismiss} size={20} />}
-
-            <img
-              src={
-                autoPlayGif ? account.get('header') : account.get('header_static')
-              }
-              alt=''
-            />
-          </div>
-
-          <div className='account-card__title'>
-            <div className='account-card__title__avatar'><Avatar account={account} size={56} /></div>
-            <DisplayName account={account} />
-          </div>
-        </Permalink>
-
-        {account.get('note').length > 0 && (
-          <div
-            className='account-card__bio translate'
-            onMouseEnter={this.handleMouseEnter}
-            onMouseLeave={this.handleMouseLeave}
-            dangerouslySetInnerHTML={{ __html: account.get('note_emojified') }}
-          />
-        )}
-
-        <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='Posts' />
-              </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='account-card__actions__button'>
-            {actionBtn}
-          </div>
-        </div>
-      </div>
-    );
-  }
-
-}
-
-export default injectIntl(connect(makeMapStateToProps, mapDispatchToProps)(AccountCard));
diff --git a/app/javascript/flavours/glitch/features/directory/components/account_card.tsx b/app/javascript/flavours/glitch/features/directory/components/account_card.tsx
new file mode 100644
index 0000000000..907dbba9fd
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/directory/components/account_card.tsx
@@ -0,0 +1,273 @@
+import type { MouseEventHandler } from 'react';
+import { useCallback } from 'react';
+
+import { FormattedMessage, defineMessages, useIntl } from 'react-intl';
+
+import classNames from 'classnames';
+
+import {
+  followAccount,
+  unfollowAccount,
+  unblockAccount,
+  unmuteAccount,
+} from 'flavours/glitch/actions/accounts';
+import { openModal } from 'flavours/glitch/actions/modal';
+import { Avatar } from 'flavours/glitch/components/avatar';
+import { Button } from 'flavours/glitch/components/button';
+import { DisplayName } from 'flavours/glitch/components/display_name';
+import { Permalink } from 'flavours/glitch/components/permalink';
+import { ShortNumber } from 'flavours/glitch/components/short_number';
+import { autoPlayGif, me } from 'flavours/glitch/initial_state';
+import type { Account } from 'flavours/glitch/models/account';
+import { makeGetAccount } from 'flavours/glitch/selectors';
+import { useAppDispatch, useAppSelector } from 'flavours/glitch/store';
+
+const messages = defineMessages({
+  unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
+  follow: { id: 'account.follow', defaultMessage: 'Follow' },
+  cancel_follow_request: {
+    id: 'account.cancel_follow_request',
+    defaultMessage: 'Withdraw follow request',
+  },
+  cancelFollowRequestConfirm: {
+    id: 'confirmations.cancel_follow_request.confirm',
+    defaultMessage: 'Withdraw 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 getAccount = makeGetAccount();
+
+export const AccountCard: React.FC<{ accountId: string }> = ({ accountId }) => {
+  const intl = useIntl();
+  const account = useAppSelector((s) => getAccount(s, accountId));
+  const dispatch = useAppDispatch();
+
+  const handleMouseEnter = useCallback<MouseEventHandler>(
+    ({ currentTarget }) => {
+      if (autoPlayGif) {
+        return;
+      }
+      const emojis =
+        currentTarget.querySelectorAll<HTMLImageElement>('.custom-emoji');
+
+      emojis.forEach((emoji) => {
+        const original = emoji.getAttribute('data-original');
+        if (original) emoji.src = original;
+      });
+    },
+    [],
+  );
+
+  const handleMouseLeave = useCallback<MouseEventHandler>(
+    ({ currentTarget }) => {
+      if (autoPlayGif) {
+        return;
+      }
+
+      const emojis =
+        currentTarget.querySelectorAll<HTMLImageElement>('.custom-emoji');
+
+      emojis.forEach((emoji) => {
+        const staticUrl = emoji.getAttribute('data-static');
+        if (staticUrl) emoji.src = staticUrl;
+      });
+    },
+    [],
+  );
+
+  const handleFollow = useCallback(() => {
+    if (!account) return;
+
+    if (account.getIn(['relationship', 'following'])) {
+      dispatch(
+        openModal({
+          modalType: 'CONFIRM',
+          modalProps: {
+            message: (
+              <FormattedMessage
+                id='confirmations.unfollow.message'
+                defaultMessage='Are you sure you want to unfollow {name}?'
+                values={{ name: <strong>@{account.get('acct')}</strong> }}
+              />
+            ),
+            confirm: intl.formatMessage(messages.unfollowConfirm),
+            onConfirm: () => {
+              dispatch(unfollowAccount(account.get('id')));
+            },
+          },
+        }),
+      );
+    } else if (account.getIn(['relationship', 'requested'])) {
+      dispatch(
+        openModal({
+          modalType: 'CONFIRM',
+          modalProps: {
+            message: (
+              <FormattedMessage
+                id='confirmations.cancel_follow_request.message'
+                defaultMessage='Are you sure you want to withdraw your request to follow {name}?'
+                values={{ name: <strong>@{account.get('acct')}</strong> }}
+              />
+            ),
+            confirm: intl.formatMessage(messages.cancelFollowRequestConfirm),
+            onConfirm: () => {
+              dispatch(unfollowAccount(account.get('id')));
+            },
+          },
+        }),
+      );
+    } else {
+      dispatch(followAccount(account.get('id')));
+    }
+  }, [account, dispatch, intl]);
+
+  const handleBlock = useCallback(() => {
+    if (account?.relationship?.blocking) {
+      dispatch(unblockAccount(account.get('id')));
+    }
+  }, [account, dispatch]);
+
+  const handleMute = useCallback(() => {
+    if (account?.relationship?.muting) {
+      dispatch(unmuteAccount(account.get('id')));
+    }
+  }, [account, dispatch]);
+
+  const handleEditProfile = useCallback(() => {
+    window.open('/settings/profile', '_blank');
+  }, []);
+
+  if (!account) return null;
+
+  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
+          text={intl.formatMessage(messages.cancel_follow_request)}
+          title={intl.formatMessage(messages.requested)}
+          onClick={handleFollow}
+        />
+      );
+    } else if (account.getIn(['relationship', 'muting'])) {
+      actionBtn = (
+        <Button
+          text={intl.formatMessage(messages.unmute)}
+          onClick={handleMute}
+        />
+      );
+    } else if (!account.getIn(['relationship', 'blocking'])) {
+      actionBtn = (
+        <Button
+          disabled={account.relationship?.blocked_by}
+          className={classNames({
+            'button--destructive': account.getIn(['relationship', 'following']),
+          })}
+          text={intl.formatMessage(
+            account.getIn(['relationship', 'following'])
+              ? messages.unfollow
+              : messages.follow,
+          )}
+          onClick={handleFollow}
+        />
+      );
+    } else if (account.getIn(['relationship', 'blocking'])) {
+      actionBtn = (
+        <Button
+          text={intl.formatMessage(messages.unblock)}
+          onClick={handleBlock}
+        />
+      );
+    }
+  } else {
+    actionBtn = (
+      <Button
+        text={intl.formatMessage(messages.edit_profile)}
+        onClick={handleEditProfile}
+      />
+    );
+  }
+
+  return (
+    <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='account-card__title'>
+          <div className='account-card__title__avatar'>
+            <Avatar account={account as Account} size={56} />
+          </div>
+          <DisplayName account={account as Account} />
+        </div>
+      </Permalink>
+
+      {account.get('note').length > 0 && (
+        <div
+          className='account-card__bio translate'
+          onMouseEnter={handleMouseEnter}
+          onMouseLeave={handleMouseLeave}
+          dangerouslySetInnerHTML={{ __html: account.get('note_emojified') }}
+        />
+      )}
+
+      <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='Posts' />
+            </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='account-card__actions__button'>{actionBtn}</div>
+      </div>
+    </div>
+  );
+};
diff --git a/app/javascript/flavours/glitch/features/directory/index.jsx b/app/javascript/flavours/glitch/features/directory/index.jsx
deleted file mode 100644
index 293b89272a..0000000000
--- a/app/javascript/flavours/glitch/features/directory/index.jsx
+++ /dev/null
@@ -1,181 +0,0 @@
-import PropTypes from 'prop-types';
-import { PureComponent } from 'react';
-
-import { defineMessages, injectIntl } from 'react-intl';
-
-import { Helmet } from 'react-helmet';
-
-import { List as ImmutableList } from 'immutable';
-import ImmutablePropTypes from 'react-immutable-proptypes';
-import { connect } from 'react-redux';
-
-import PeopleIcon from '@/material-icons/400-24px/group.svg?react';
-import { addColumn, removeColumn, moveColumn, changeColumnParams } from 'flavours/glitch/actions/columns';
-import { fetchDirectory, expandDirectory } from 'flavours/glitch/actions/directory';
-import Column from 'flavours/glitch/components/column';
-import ColumnHeader from 'flavours/glitch/components/column_header';
-import { LoadMore } from 'flavours/glitch/components/load_more';
-import { LoadingIndicator } from 'flavours/glitch/components/loading_indicator';
-import { RadioButton } from 'flavours/glitch/components/radio_button';
-import ScrollContainer from 'flavours/glitch/containers/scroll_container';
-
-import AccountCard from './components/account_card';
-
-const messages = defineMessages({
-  title: { id: 'column.directory', defaultMessage: 'Browse profiles' },
-  recentlyActive: { id: 'directory.recently_active', defaultMessage: 'Recently active' },
-  newArrivals: { id: 'directory.new_arrivals', defaultMessage: 'New arrivals' },
-  local: { id: 'directory.local', defaultMessage: 'From {domain} only' },
-  federated: { id: 'directory.federated', defaultMessage: 'From known fediverse' },
-});
-
-const mapStateToProps = state => ({
-  accountIds: state.getIn(['user_lists', 'directory', 'items'], ImmutableList()),
-  isLoading: state.getIn(['user_lists', 'directory', 'isLoading'], true),
-  domain: state.getIn(['meta', 'domain']),
-});
-
-class Directory extends PureComponent {
-
-  static propTypes = {
-    isLoading: PropTypes.bool,
-    accountIds: ImmutablePropTypes.list.isRequired,
-    dispatch: PropTypes.func.isRequired,
-    columnId: PropTypes.string,
-    intl: PropTypes.object.isRequired,
-    multiColumn: PropTypes.bool,
-    domain: PropTypes.string.isRequired,
-    params: PropTypes.shape({
-      order: PropTypes.string,
-      local: PropTypes.bool,
-    }),
-  };
-
-  state = {
-    order: null,
-    local: null,
-  };
-
-  handlePin = () => {
-    const { columnId, dispatch } = this.props;
-
-    if (columnId) {
-      dispatch(removeColumn(columnId));
-    } else {
-      dispatch(addColumn('DIRECTORY', this.getParams(this.props, this.state)));
-    }
-  };
-
-  getParams = (props, state) => ({
-    order: state.order === null ? (props.params.order || 'active') : state.order,
-    local: state.local === null ? (props.params.local || false) : state.local,
-  });
-
-  handleMove = dir => {
-    const { columnId, dispatch } = this.props;
-    dispatch(moveColumn(columnId, dir));
-  };
-
-  handleHeaderClick = () => {
-    this.column.scrollTop();
-  };
-
-  componentDidMount () {
-    const { dispatch } = this.props;
-    dispatch(fetchDirectory(this.getParams(this.props, this.state)));
-  }
-
-  componentDidUpdate (prevProps, prevState) {
-    const { dispatch } = this.props;
-    const paramsOld = this.getParams(prevProps, prevState);
-    const paramsNew = this.getParams(this.props, this.state);
-
-    if (paramsOld.order !== paramsNew.order || paramsOld.local !== paramsNew.local) {
-      dispatch(fetchDirectory(paramsNew));
-    }
-  }
-
-  setRef = c => {
-    this.column = c;
-  };
-
-  handleChangeOrder = e => {
-    const { dispatch, columnId } = this.props;
-
-    if (columnId) {
-      dispatch(changeColumnParams(columnId, ['order'], e.target.value));
-    } else {
-      this.setState({ order: e.target.value });
-    }
-  };
-
-  handleChangeLocal = e => {
-    const { dispatch, columnId } = this.props;
-
-    if (columnId) {
-      dispatch(changeColumnParams(columnId, ['local'], e.target.value === '1'));
-    } else {
-      this.setState({ local: e.target.value === '1' });
-    }
-  };
-
-  handleLoadMore = () => {
-    const { dispatch } = this.props;
-    dispatch(expandDirectory(this.getParams(this.props, this.state)));
-  };
-
-  render () {
-    const { isLoading, accountIds, intl, columnId, multiColumn, domain } = this.props;
-    const { order, local }  = this.getParams(this.props, this.state);
-    const pinned = !!columnId;
-
-    const scrollableArea = (
-      <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} />
-            <RadioButton name='order' value='new' label={intl.formatMessage(messages.newArrivals)} checked={order === 'new'} onChange={this.handleChangeOrder} />
-          </div>
-
-          <div className='filter-form__column' role='group'>
-            <RadioButton name='local' value='1' label={intl.formatMessage(messages.local, { domain })} checked={local} onChange={this.handleChangeLocal} />
-            <RadioButton name='local' value='0' label={intl.formatMessage(messages.federated)} checked={!local} onChange={this.handleChangeLocal} />
-          </div>
-        </div>
-
-        <div className='directory__list'>
-          {isLoading ? <LoadingIndicator /> : accountIds.map(accountId => (
-            <AccountCard id={accountId} key={accountId} />
-          ))}
-        </div>
-
-        <LoadMore onClick={this.handleLoadMore} visible={!isLoading} />
-      </div>
-    );
-
-    return (
-      <Column bindToDocument={!multiColumn} ref={this.setRef} label={intl.formatMessage(messages.title)}>
-        <ColumnHeader
-          icon='address-book-o'
-          iconComponent={PeopleIcon}
-          title={intl.formatMessage(messages.title)}
-          onPin={this.handlePin}
-          onMove={this.handleMove}
-          onClick={this.handleHeaderClick}
-          pinned={pinned}
-          multiColumn={multiColumn}
-        />
-
-        {multiColumn && !pinned ? <ScrollContainer scrollKey='directory'>{scrollableArea}</ScrollContainer> : scrollableArea}
-
-        <Helmet>
-          <title>{intl.formatMessage(messages.title)}</title>
-          <meta name='robots' content='noindex' />
-        </Helmet>
-      </Column>
-    );
-  }
-
-}
-
-export default connect(mapStateToProps)(injectIntl(Directory));
diff --git a/app/javascript/flavours/glitch/features/directory/index.tsx b/app/javascript/flavours/glitch/features/directory/index.tsx
new file mode 100644
index 0000000000..d58ef2eab0
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/directory/index.tsx
@@ -0,0 +1,219 @@
+import type { ChangeEventHandler } from 'react';
+import { useCallback, useEffect, useRef, useState } from 'react';
+
+import { defineMessages, useIntl } from 'react-intl';
+
+import { Helmet } from 'react-helmet';
+
+import { List as ImmutableList } from 'immutable';
+
+import PeopleIcon from '@/material-icons/400-24px/group.svg?react';
+import {
+  addColumn,
+  removeColumn,
+  moveColumn,
+  changeColumnParams,
+} from 'flavours/glitch/actions/columns';
+import {
+  fetchDirectory,
+  expandDirectory,
+} from 'flavours/glitch/actions/directory';
+import Column from 'flavours/glitch/components/column';
+import { ColumnHeader } from 'flavours/glitch/components/column_header';
+import { LoadMore } from 'flavours/glitch/components/load_more';
+import { LoadingIndicator } from 'flavours/glitch/components/loading_indicator';
+import { RadioButton } from 'flavours/glitch/components/radio_button';
+import ScrollContainer from 'flavours/glitch/containers/scroll_container';
+import { useAppDispatch, useAppSelector } from 'flavours/glitch/store';
+
+import { AccountCard } from './components/account_card';
+
+const messages = defineMessages({
+  title: { id: 'column.directory', defaultMessage: 'Browse profiles' },
+  recentlyActive: {
+    id: 'directory.recently_active',
+    defaultMessage: 'Recently active',
+  },
+  newArrivals: { id: 'directory.new_arrivals', defaultMessage: 'New arrivals' },
+  local: { id: 'directory.local', defaultMessage: 'From {domain} only' },
+  federated: {
+    id: 'directory.federated',
+    defaultMessage: 'From known fediverse',
+  },
+});
+
+export const Directory: React.FC<{
+  columnId?: string;
+  multiColumn?: boolean;
+  params?: { order: string; local?: boolean };
+}> = ({ columnId, multiColumn, params }) => {
+  const intl = useIntl();
+  const dispatch = useAppDispatch();
+
+  const [state, setState] = useState<{
+    order: string | null;
+    local: boolean | null;
+  }>({
+    order: null,
+    local: null,
+  });
+
+  const column = useRef<Column>(null);
+
+  const order = state.order ?? params?.order ?? 'active';
+  const local = state.local ?? params?.local ?? false;
+
+  const handlePin = useCallback(() => {
+    if (columnId) {
+      dispatch(removeColumn(columnId));
+    } else {
+      dispatch(addColumn('DIRECTORY', { order, local }));
+    }
+  }, [dispatch, columnId, order, local]);
+
+  const domain = useAppSelector((s) => s.meta.get('domain') as string);
+  const accountIds = useAppSelector(
+    (state) =>
+      state.user_lists.getIn(
+        ['directory', 'items'],
+        ImmutableList(),
+      ) as ImmutableList<string>,
+  );
+  const isLoading = useAppSelector(
+    (state) =>
+      state.user_lists.getIn(['directory', 'isLoading'], true) as boolean,
+  );
+
+  useEffect(() => {
+    void dispatch(fetchDirectory({ order, local }));
+  }, [dispatch, order, local]);
+
+  const handleMove = useCallback(
+    (dir: number) => {
+      dispatch(moveColumn(columnId, dir));
+    },
+    [dispatch, columnId],
+  );
+
+  const handleHeaderClick = useCallback(() => {
+    column.current?.scrollTop();
+  }, []);
+
+  const handleChangeOrder = useCallback<ChangeEventHandler<HTMLInputElement>>(
+    (e) => {
+      if (columnId) {
+        dispatch(changeColumnParams(columnId, ['order'], e.target.value));
+      } else {
+        setState((s) => ({ order: e.target.value, local: s.local }));
+      }
+    },
+    [dispatch, columnId],
+  );
+
+  const handleChangeLocal = useCallback<ChangeEventHandler<HTMLInputElement>>(
+    (e) => {
+      if (columnId) {
+        dispatch(
+          changeColumnParams(columnId, ['local'], e.target.value === '1'),
+        );
+      } else {
+        setState((s) => ({ local: e.target.value === '1', order: s.order }));
+      }
+    },
+    [dispatch, columnId],
+  );
+
+  const handleLoadMore = useCallback(() => {
+    void dispatch(expandDirectory({ order, local }));
+  }, [dispatch, order, local]);
+
+  const pinned = !!columnId;
+
+  const scrollableArea = (
+    <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={handleChangeOrder}
+          />
+          <RadioButton
+            name='order'
+            value='new'
+            label={intl.formatMessage(messages.newArrivals)}
+            checked={order === 'new'}
+            onChange={handleChangeOrder}
+          />
+        </div>
+
+        <div className='filter-form__column' role='group'>
+          <RadioButton
+            name='local'
+            value='1'
+            label={intl.formatMessage(messages.local, { domain })}
+            checked={local}
+            onChange={handleChangeLocal}
+          />
+          <RadioButton
+            name='local'
+            value='0'
+            label={intl.formatMessage(messages.federated)}
+            checked={!local}
+            onChange={handleChangeLocal}
+          />
+        </div>
+      </div>
+
+      <div className='directory__list'>
+        {isLoading ? (
+          <LoadingIndicator />
+        ) : (
+          accountIds.map((accountId) => (
+            <AccountCard accountId={accountId} key={accountId} />
+          ))
+        )}
+      </div>
+
+      <LoadMore onClick={handleLoadMore} visible={!isLoading} />
+    </div>
+  );
+
+  return (
+    <Column
+      bindToDocument={!multiColumn}
+      ref={column}
+      label={intl.formatMessage(messages.title)}
+    >
+      <ColumnHeader
+        icon='address-book-o'
+        iconComponent={PeopleIcon}
+        title={intl.formatMessage(messages.title)}
+        onPin={handlePin}
+        onMove={handleMove}
+        onClick={handleHeaderClick}
+        pinned={pinned}
+        multiColumn={multiColumn}
+      />
+
+      {multiColumn && !pinned ? (
+        // @ts-expect-error ScrollContainer is not properly typed yet
+        <ScrollContainer scrollKey='directory'>
+          {scrollableArea}
+        </ScrollContainer>
+      ) : (
+        scrollableArea
+      )}
+
+      <Helmet>
+        <title>{intl.formatMessage(messages.title)}</title>
+        <meta name='robots' content='noindex' />
+      </Helmet>
+    </Column>
+  );
+};
+
+// eslint-disable-next-line import/no-default-export -- Needed because this is called as an async components
+export default Directory;
diff --git a/app/javascript/flavours/glitch/features/explore/components/author_link.jsx b/app/javascript/flavours/glitch/features/explore/components/author_link.jsx
index 94061c0092..83c2c2db40 100644
--- a/app/javascript/flavours/glitch/features/explore/components/author_link.jsx
+++ b/app/javascript/flavours/glitch/features/explore/components/author_link.jsx
@@ -7,8 +7,12 @@ import { useAppSelector } from 'flavours/glitch/store';
 export const AuthorLink = ({ accountId }) => {
   const account = useAppSelector(state => state.getIn(['accounts', accountId]));
 
+  if (!account) {
+    return null;
+  }
+
   return (
-    <Permalink href={account.get('url')} to={`/@${account.get('acct')}`} className='story__details__shared__author-link'>
+    <Permalink href={account.get('url')} to={`/@${account.get('acct')}`} className='story__details__shared__author-link' data-hover-card-account={accountId}>
       <Avatar account={account} size={16} />
       <bdi dangerouslySetInnerHTML={{ __html: account.get('display_name_html') }} />
     </Permalink>
diff --git a/app/javascript/flavours/glitch/features/explore/components/card.jsx b/app/javascript/flavours/glitch/features/explore/components/card.jsx
index 85faa92e57..4612d25e21 100644
--- a/app/javascript/flavours/glitch/features/explore/components/card.jsx
+++ b/app/javascript/flavours/glitch/features/explore/components/card.jsx
@@ -8,34 +8,21 @@ import { Link } from 'react-router-dom';
 import { useDispatch, useSelector } from 'react-redux';
 
 import CloseIcon from '@/material-icons/400-24px/close.svg?react';
-import { followAccount, unfollowAccount } from 'flavours/glitch/actions/accounts';
 import { dismissSuggestion } from 'flavours/glitch/actions/suggestions';
 import { Avatar } from 'flavours/glitch/components/avatar';
-import { Button } from 'flavours/glitch/components/button';
 import { DisplayName } from 'flavours/glitch/components/display_name';
+import { FollowButton } from 'flavours/glitch/components/follow_button';
 import { IconButton } from 'flavours/glitch/components/icon_button';
 import { domain } from 'flavours/glitch/initial_state';
 
 const messages = defineMessages({
-  follow: { id: 'account.follow', defaultMessage: 'Follow' },
-  unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
   dismiss: { id: 'follow_suggestions.dismiss', defaultMessage: "Don't show again" },
 });
 
 export const Card = ({ id, source }) => {
   const intl = useIntl();
   const account = useSelector(state => state.getIn(['accounts', id]));
-  const relationship = useSelector(state => state.getIn(['relationships', id]));
   const dispatch = useDispatch();
-  const following = relationship?.get('following') ?? relationship?.get('requested');
-
-  const handleFollow = useCallback(() => {
-    if (following) {
-      dispatch(unfollowAccount(id));
-    } else {
-      dispatch(followAccount(id));
-    }
-  }, [id, following, dispatch]);
 
   const handleDismiss = useCallback(() => {
     dispatch(dismissSuggestion(id));
@@ -74,7 +61,7 @@ export const Card = ({ id, source }) => {
           <div className='explore__suggestions__card__body__main__name-button'>
             <Link className='explore__suggestions__card__body__main__name-button__name' to={`/@${account.get('acct')}`}><DisplayName account={account} /></Link>
             <IconButton iconComponent={CloseIcon} onClick={handleDismiss} title={intl.formatMessage(messages.dismiss)} />
-            <Button text={intl.formatMessage(following ? messages.unfollow : messages.follow)} secondary={following} onClick={handleFollow} />
+            <FollowButton accountId={account.get('id')} />
           </div>
         </div>
       </div>
diff --git a/app/javascript/flavours/glitch/features/explore/links.jsx b/app/javascript/flavours/glitch/features/explore/links.jsx
index dc15030f72..b5eb9c9d4f 100644
--- a/app/javascript/flavours/glitch/features/explore/links.jsx
+++ b/app/javascript/flavours/glitch/features/explore/links.jsx
@@ -75,7 +75,7 @@ class Links extends PureComponent {
             publisher={link.get('provider_name')}
             publishedAt={link.get('published_at')}
             author={link.get('author_name')}
-            authorAccount={link.getIn(['author_account', 'id'])}
+            authorAccount={link.getIn(['authors', 0, 'account', 'id'])}
             sharedTimes={link.getIn(['history', 0, 'accounts']) * 1 + link.getIn(['history', 1, 'accounts']) * 1}
             thumbnail={link.get('image')}
             thumbnailDescription={link.get('image_description')}
diff --git a/app/javascript/flavours/glitch/features/home_timeline/components/inline_follow_suggestions.jsx b/app/javascript/flavours/glitch/features/home_timeline/components/inline_follow_suggestions.jsx
index 97b64a09b1..4e727a63ed 100644
--- a/app/javascript/flavours/glitch/features/home_timeline/components/inline_follow_suggestions.jsx
+++ b/app/javascript/flavours/glitch/features/home_timeline/components/inline_follow_suggestions.jsx
@@ -12,12 +12,11 @@ import ChevronLeftIcon from '@/material-icons/400-24px/chevron_left.svg?react';
 import ChevronRightIcon from '@/material-icons/400-24px/chevron_right.svg?react';
 import CloseIcon from '@/material-icons/400-24px/close.svg?react';
 import InfoIcon from '@/material-icons/400-24px/info.svg?react';
-import { followAccount, unfollowAccount } from 'flavours/glitch/actions/accounts';
 import { changeSetting } from 'flavours/glitch/actions/settings';
 import { fetchSuggestions, dismissSuggestion } from 'flavours/glitch/actions/suggestions';
 import { Avatar } from 'flavours/glitch/components/avatar';
-import { Button } from 'flavours/glitch/components/button';
 import { DisplayName } from 'flavours/glitch/components/display_name';
+import { FollowButton } from 'flavours/glitch/components/follow_button';
 import { Icon } from 'flavours/glitch/components/icon';
 import { IconButton } from 'flavours/glitch/components/icon_button';
 import { VerifiedBadge } from 'flavours/glitch/components/verified_badge';
@@ -79,18 +78,8 @@ Source.propTypes = {
 const Card = ({ id, sources }) => {
   const intl = useIntl();
   const account = useSelector(state => state.getIn(['accounts', id]));
-  const relationship = useSelector(state => state.getIn(['relationships', id]));
   const firstVerifiedField = account.get('fields').find(item => !!item.get('verified_at'));
   const dispatch = useDispatch();
-  const following = relationship?.get('following') ?? relationship?.get('requested');
-
-  const handleFollow = useCallback(() => {
-    if (following) {
-      dispatch(unfollowAccount(id));
-    } else {
-      dispatch(followAccount(id));
-    }
-  }, [id, following, dispatch]);
 
   const handleDismiss = useCallback(() => {
     dispatch(dismissSuggestion(id));
@@ -109,7 +98,7 @@ const Card = ({ id, sources }) => {
         {firstVerifiedField ? <VerifiedBadge link={firstVerifiedField.get('value')} /> : <Source id={sources.get(0)} />}
       </div>
 
-      <Button text={intl.formatMessage(following ? messages.unfollow : messages.follow)} secondary={following} onClick={handleFollow} />
+      <FollowButton accountId={id} />
     </div>
   );
 };
diff --git a/app/javascript/flavours/glitch/features/notifications/components/notification.jsx b/app/javascript/flavours/glitch/features/notifications/components/notification.jsx
index 29593c06d8..0751258be9 100644
--- a/app/javascript/flavours/glitch/features/notifications/components/notification.jsx
+++ b/app/javascript/flavours/glitch/features/notifications/components/notification.jsx
@@ -389,6 +389,7 @@ class Notification extends ImmutablePureComponent {
           title={targetAccount.get('acct')}
           to={`/@${targetAccount.get('acct')}`}
           dangerouslySetInnerHTML={targetDisplayNameHtml}
+          data-hover-card-account={targetAccount.get('id')}
         />
       </bdi>
     );
@@ -423,6 +424,7 @@ class Notification extends ImmutablePureComponent {
           title={account.get('acct')}
           to={`/@${account.get('acct')}`}
           dangerouslySetInnerHTML={displayNameHtml}
+          data-hover-card-account={account.get('id')}
         />
       </bdi>
     );
diff --git a/app/javascript/flavours/glitch/features/status/components/card.jsx b/app/javascript/flavours/glitch/features/status/components/card.jsx
index c9b0f7ebaf..0d315cbbf1 100644
--- a/app/javascript/flavours/glitch/features/status/components/card.jsx
+++ b/app/javascript/flavours/glitch/features/status/components/card.jsx
@@ -127,7 +127,7 @@ export default class Card extends PureComponent {
     const interactive = card.get('type') === 'video';
     const language    = card.get('language') || '';
     const largeImage  = (card.get('image')?.length > 0 && card.get('width') > card.get('height')) || interactive;
-    const showAuthor  = !!card.get('author_account');
+    const showAuthor  = !!card.getIn(['authors', 0, 'accountId']);
 
     const description = (
       <div className='status-card__content'>
@@ -233,7 +233,7 @@ export default class Card extends PureComponent {
           {description}
         </a>
 
-        {showAuthor && <MoreFromAuthor accountId={card.get('author_account')} />}
+        {showAuthor && <MoreFromAuthor accountId={card.getIn(['authors', 0, 'accountId'])} />}
       </>
     );
   }
diff --git a/app/javascript/flavours/glitch/features/status/components/detailed_status.jsx b/app/javascript/flavours/glitch/features/status/components/detailed_status.jsx
index a5704665fd..2db9fa6d3a 100644
--- a/app/javascript/flavours/glitch/features/status/components/detailed_status.jsx
+++ b/app/javascript/flavours/glitch/features/status/components/detailed_status.jsx
@@ -285,7 +285,7 @@ class DetailedStatus extends ImmutablePureComponent {
     return (
       <div style={outerStyle}>
         <div ref={this.setRef} className={classNames('detailed-status', `detailed-status-${status.get('visibility')}`, { compact })} data-status-by={status.getIn(['account', 'acct'])}>
-          <a href={status.getIn(['account', 'url'])} onClick={this.handleAccountClick} className='detailed-status__display-name'>
+          <a href={status.getIn(['account', 'url'])} data-hover-card-account={status.getIn(['account', 'id'])} onClick={this.handleAccountClick} className='detailed-status__display-name'>
             <div className='detailed-status__display-avatar'><Avatar account={status.get('account')} size={48} /></div>
             <DisplayName account={status.get('account')} localDomain={this.props.domain} />
           </a>
diff --git a/app/javascript/flavours/glitch/features/ui/components/column_loading.tsx b/app/javascript/flavours/glitch/features/ui/components/column_loading.tsx
index 42174838cf..aa6d105dcc 100644
--- a/app/javascript/flavours/glitch/features/ui/components/column_loading.tsx
+++ b/app/javascript/flavours/glitch/features/ui/components/column_loading.tsx
@@ -1,11 +1,8 @@
-import Column from '../../../components/column';
-import ColumnHeader from '../../../components/column_header';
+import Column from 'flavours/glitch/components/column';
+import { ColumnHeader } from 'flavours/glitch/components/column_header';
+import type { Props as ColumnHeaderProps } from 'flavours/glitch/components/column_header';
 
-interface Props {
-  multiColumn?: boolean;
-}
-
-export const ColumnLoading: React.FC<Props> = (otherProps) => (
+export const ColumnLoading: React.FC<ColumnHeaderProps> = (otherProps) => (
   <Column>
     <ColumnHeader {...otherProps} />
     <div className='scrollable' />
diff --git a/app/javascript/flavours/glitch/features/ui/index.jsx b/app/javascript/flavours/glitch/features/ui/index.jsx
index 4a7b9ebfde..9fb4a784b3 100644
--- a/app/javascript/flavours/glitch/features/ui/index.jsx
+++ b/app/javascript/flavours/glitch/features/ui/index.jsx
@@ -15,6 +15,7 @@ import { HotKeys } from 'react-hotkeys';
 import { changeLayout } from 'flavours/glitch/actions/app';
 import { synchronouslySubmitMarkers, submitMarkers, fetchMarkers } from 'flavours/glitch/actions/markers';
 import { INTRODUCTION_VERSION } from 'flavours/glitch/actions/onboarding';
+import { HoverCardController } from 'flavours/glitch/components/hover_card_controller';
 import { Permalink } from 'flavours/glitch/components/permalink';
 import { PictureInPicture } from 'flavours/glitch/features/picture_in_picture';
 import { identityContextPropShape, withIdentity } from 'flavours/glitch/identity_context';
@@ -648,6 +649,7 @@ class UI extends PureComponent {
 
           {layout !== 'mobile' && <PictureInPicture />}
           <NotificationsContainer />
+          {/* Temporarily disabled while upstream improves the issue */ null && <HoverCardController />}
           <LoadingBarContainer className='loading-bar' />
           <ModalContainer />
           <UploadArea active={draggingOver} onClose={this.closeUploadModal} />
diff --git a/app/javascript/flavours/glitch/hooks/useLinks.ts b/app/javascript/flavours/glitch/hooks/useLinks.ts
new file mode 100644
index 0000000000..bb988665fd
--- /dev/null
+++ b/app/javascript/flavours/glitch/hooks/useLinks.ts
@@ -0,0 +1,61 @@
+import { useCallback } from 'react';
+
+import { useHistory } from 'react-router-dom';
+
+import { openURL } from 'flavours/glitch/actions/search';
+import { useAppDispatch } from 'flavours/glitch/store';
+
+const isMentionClick = (element: HTMLAnchorElement) =>
+  element.classList.contains('mention');
+
+const isHashtagClick = (element: HTMLAnchorElement) =>
+  element.textContent?.[0] === '#' ||
+  element.previousSibling?.textContent?.endsWith('#');
+
+export const useLinks = () => {
+  const history = useHistory();
+  const dispatch = useAppDispatch();
+
+  const handleHashtagClick = useCallback(
+    (element: HTMLAnchorElement) => {
+      const { textContent } = element;
+
+      if (!textContent) return;
+
+      history.push(`/tags/${textContent.replace(/^#/, '')}`);
+    },
+    [history],
+  );
+
+  const handleMentionClick = useCallback(
+    (element: HTMLAnchorElement) => {
+      dispatch(
+        openURL(element.href, history, () => {
+          window.location.href = element.href;
+        }),
+      );
+    },
+    [dispatch, history],
+  );
+
+  const handleClick = useCallback(
+    (e: React.MouseEvent) => {
+      const target = (e.target as HTMLElement).closest('a');
+
+      if (!target || e.button !== 0 || e.ctrlKey || e.metaKey) {
+        return;
+      }
+
+      if (isMentionClick(target)) {
+        e.preventDefault();
+        handleMentionClick(target);
+      } else if (isHashtagClick(target)) {
+        e.preventDefault();
+        handleHashtagClick(target);
+      }
+    },
+    [handleMentionClick, handleHashtagClick],
+  );
+
+  return handleClick;
+};
diff --git a/app/javascript/flavours/glitch/hooks/useTimeout.ts b/app/javascript/flavours/glitch/hooks/useTimeout.ts
new file mode 100644
index 0000000000..f1814ae8e3
--- /dev/null
+++ b/app/javascript/flavours/glitch/hooks/useTimeout.ts
@@ -0,0 +1,29 @@
+import { useRef, useCallback, useEffect } from 'react';
+
+export const useTimeout = () => {
+  const timeoutRef = useRef<ReturnType<typeof setTimeout>>();
+
+  const set = useCallback((callback: () => void, delay: number) => {
+    if (timeoutRef.current) {
+      clearTimeout(timeoutRef.current);
+    }
+
+    timeoutRef.current = setTimeout(callback, delay);
+  }, []);
+
+  const cancel = useCallback(() => {
+    if (timeoutRef.current) {
+      clearTimeout(timeoutRef.current);
+      timeoutRef.current = undefined;
+    }
+  }, []);
+
+  useEffect(
+    () => () => {
+      cancel();
+    },
+    [cancel],
+  );
+
+  return [set, cancel] as const;
+};
diff --git a/app/javascript/flavours/glitch/locales/en.json b/app/javascript/flavours/glitch/locales/en.json
index 76f7ed535e..5ab92732ce 100644
--- a/app/javascript/flavours/glitch/locales/en.json
+++ b/app/javascript/flavours/glitch/locales/en.json
@@ -155,6 +155,5 @@
   "status.in_reply_to": "This toot is a reply",
   "status.is_poll": "This toot is a poll",
   "status.local_only": "Only visible from your instance",
-  "status.uncollapse": "Uncollapse",
-  "suggestions.dismiss": "Dismiss suggestion"
+  "status.uncollapse": "Uncollapse"
 }
diff --git a/app/javascript/flavours/glitch/reducers/user_lists.js b/app/javascript/flavours/glitch/reducers/user_lists.js
index 3eb80da437..0ea3debc6c 100644
--- a/app/javascript/flavours/glitch/reducers/user_lists.js
+++ b/app/javascript/flavours/glitch/reducers/user_lists.js
@@ -1,12 +1,8 @@
 import { Map as ImmutableMap, List as ImmutableList, fromJS } from 'immutable';
 
 import {
-  DIRECTORY_FETCH_REQUEST,
-  DIRECTORY_FETCH_SUCCESS,
-  DIRECTORY_FETCH_FAIL,
-  DIRECTORY_EXPAND_REQUEST,
-  DIRECTORY_EXPAND_SUCCESS,
-  DIRECTORY_EXPAND_FAIL,
+  expandDirectory,
+  fetchDirectory
 } from 'flavours/glitch/actions/directory';
 import {
   FEATURED_TAGS_FETCH_REQUEST,
@@ -117,6 +113,7 @@ const normalizeFeaturedTags = (state, path, featuredTags, accountId) => {
   }));
 };
 
+/** @type {import('@reduxjs/toolkit').Reducer<typeof initialState>} */
 export default function userLists(state = initialState, action) {
   switch(action.type) {
   case FOLLOWERS_FETCH_SUCCESS:
@@ -194,16 +191,6 @@ export default function userLists(state = initialState, action) {
   case MUTES_FETCH_FAIL:
   case MUTES_EXPAND_FAIL:
     return state.setIn(['mutes', 'isLoading'], false);
-  case DIRECTORY_FETCH_SUCCESS:
-    return normalizeList(state, ['directory'], action.accounts, action.next);
-  case DIRECTORY_EXPAND_SUCCESS:
-    return appendToList(state, ['directory'], action.accounts, action.next);
-  case DIRECTORY_FETCH_REQUEST:
-  case DIRECTORY_EXPAND_REQUEST:
-    return state.setIn(['directory', 'isLoading'], true);
-  case DIRECTORY_FETCH_FAIL:
-  case DIRECTORY_EXPAND_FAIL:
-    return state.setIn(['directory', 'isLoading'], false);
   case FEATURED_TAGS_FETCH_SUCCESS:
     return normalizeFeaturedTags(state, ['featured_tags', action.id], action.tags, action.id);
   case FEATURED_TAGS_FETCH_REQUEST:
@@ -211,6 +198,17 @@ export default function userLists(state = initialState, action) {
   case FEATURED_TAGS_FETCH_FAIL:
     return state.setIn(['featured_tags', action.id, 'isLoading'], false);
   default:
-    return state;
+    if(fetchDirectory.fulfilled.match(action))
+      return normalizeList(state, ['directory'], action.payload.accounts, undefined);
+    else if( expandDirectory.fulfilled.match(action))
+      return appendToList(state, ['directory'], action.payload.accounts, undefined);
+    else if(fetchDirectory.pending.match(action) ||
+     expandDirectory.pending.match(action))
+      return state.setIn(['directory', 'isLoading'], true);
+    else if(fetchDirectory.rejected.match(action) ||
+     expandDirectory.rejected.match(action))
+      return state.setIn(['directory', 'isLoading'], false);
+    else
+      return state;
   }
 }
diff --git a/app/javascript/flavours/glitch/styles/components.scss b/app/javascript/flavours/glitch/styles/components.scss
index 9980f87adc..11f06dd010 100644
--- a/app/javascript/flavours/glitch/styles/components.scss
+++ b/app/javascript/flavours/glitch/styles/components.scss
@@ -120,8 +120,27 @@
       text-decoration: none;
     }
 
-    &:disabled {
-      opacity: 0.5;
+    &.button--destructive {
+      &:active,
+      &:focus,
+      &:hover {
+        border-color: $ui-button-destructive-focus-background-color;
+        color: $ui-button-destructive-focus-background-color;
+      }
+    }
+
+    &:disabled,
+    &.disabled {
+      opacity: 0.7;
+      border-color: $ui-primary-color;
+      color: $ui-primary-color;
+
+      &:active,
+      &:focus,
+      &:hover {
+        border-color: $ui-primary-color;
+        color: $ui-primary-color;
+      }
     }
   }
 
@@ -2629,7 +2648,7 @@ a.account__display-name {
 }
 
 .dropdown-animation {
-  animation: dropdown 150ms cubic-bezier(0.1, 0.7, 0.1, 1);
+  animation: dropdown 250ms cubic-bezier(0.1, 0.7, 0.1, 1);
 
   @keyframes dropdown {
     from {
@@ -10908,3 +10927,156 @@ noscript {
     }
   }
 }
+
+.hover-card-controller[data-popper-reference-hidden='true'] {
+  opacity: 0;
+  pointer-events: none;
+}
+
+.hover-card {
+  box-shadow: var(--dropdown-shadow);
+  background: var(--modal-background-color);
+  backdrop-filter: var(--background-filter);
+  border: 1px solid var(--modal-border-color);
+  border-radius: 8px;
+  padding: 16px;
+  width: 270px;
+  display: flex;
+  flex-direction: column;
+  gap: 12px;
+
+  &--loading {
+    position: relative;
+    min-height: 100px;
+  }
+
+  &__name {
+    display: flex;
+    gap: 12px;
+    text-decoration: none;
+    color: inherit;
+  }
+
+  &__number {
+    font-size: 15px;
+    line-height: 22px;
+    color: $secondary-text-color;
+
+    strong {
+      font-weight: 700;
+    }
+  }
+
+  &__text-row {
+    display: flex;
+    flex-direction: column;
+    gap: 8px;
+  }
+
+  &__bio {
+    color: $secondary-text-color;
+    font-size: 14px;
+    line-height: 20px;
+    display: -webkit-box;
+    -webkit-line-clamp: 2;
+    -webkit-box-orient: vertical;
+    max-height: 2 * 20px;
+    overflow: hidden;
+
+    p {
+      margin-bottom: 0;
+    }
+
+    a {
+      color: inherit;
+      text-decoration: underline;
+
+      &:hover,
+      &:focus,
+      &:active {
+        text-decoration: none;
+      }
+    }
+  }
+
+  .display-name {
+    font-size: 15px;
+    line-height: 22px;
+
+    bdi {
+      font-weight: 500;
+      color: $primary-text-color;
+    }
+
+    &__account {
+      display: block;
+      color: $dark-text-color;
+    }
+  }
+
+  .account-fields {
+    color: $secondary-text-color;
+    font-size: 14px;
+    line-height: 20px;
+
+    a {
+      color: inherit;
+      text-decoration: none;
+
+      &:focus,
+      &:hover,
+      &:active {
+        text-decoration: underline;
+      }
+    }
+
+    dl {
+      display: flex;
+      align-items: center;
+      gap: 4px;
+
+      dt {
+        flex: 0 0 auto;
+        color: $dark-text-color;
+        min-width: 0;
+        overflow: hidden;
+        white-space: nowrap;
+        text-overflow: ellipsis;
+      }
+
+      dd {
+        flex: 1 1 auto;
+        font-weight: 500;
+        min-width: 0;
+        overflow: hidden;
+        white-space: nowrap;
+        text-overflow: ellipsis;
+      }
+
+      &.verified {
+        dd {
+          display: flex;
+          align-items: center;
+          gap: 4px;
+          overflow: hidden;
+          white-space: nowrap;
+          color: $valid-value-color;
+
+          & > span {
+            overflow: hidden;
+            text-overflow: ellipsis;
+          }
+
+          a {
+            font-weight: 500;
+          }
+
+          .icon {
+            width: 16px;
+            height: 16px;
+          }
+        }
+      }
+    }
+  }
+}
diff --git a/app/javascript/flavours/glitch/styles/mastodon-light/variables.scss b/app/javascript/flavours/glitch/styles/mastodon-light/variables.scss
index 09a75a834b..9f571b3f26 100644
--- a/app/javascript/flavours/glitch/styles/mastodon-light/variables.scss
+++ b/app/javascript/flavours/glitch/styles/mastodon-light/variables.scss
@@ -56,11 +56,13 @@ $account-background-color: $white !default;
 
 $emojis-requiring-inversion: 'chains';
 
-.theme-mastodon-light {
+body {
   --dropdown-border-color: #d9e1e8;
   --dropdown-background-color: #fff;
+  --modal-border-color: #d9e1e8;
+  --modal-background-color: var(--background-color-tint);
   --background-border-color: #d9e1e8;
   --background-color: #fff;
-  --background-color-tint: rgba(255, 255, 255, 90%);
+  --background-color-tint: rgba(255, 255, 255, 80%);
   --background-filter: blur(10px);
 }
diff --git a/app/javascript/hooks/useLinks.ts b/app/javascript/hooks/useLinks.ts
new file mode 100644
index 0000000000..f08b9500da
--- /dev/null
+++ b/app/javascript/hooks/useLinks.ts
@@ -0,0 +1,61 @@
+import { useCallback } from 'react';
+
+import { useHistory } from 'react-router-dom';
+
+import { openURL } from 'mastodon/actions/search';
+import { useAppDispatch } from 'mastodon/store';
+
+const isMentionClick = (element: HTMLAnchorElement) =>
+  element.classList.contains('mention');
+
+const isHashtagClick = (element: HTMLAnchorElement) =>
+  element.textContent?.[0] === '#' ||
+  element.previousSibling?.textContent?.endsWith('#');
+
+export const useLinks = () => {
+  const history = useHistory();
+  const dispatch = useAppDispatch();
+
+  const handleHashtagClick = useCallback(
+    (element: HTMLAnchorElement) => {
+      const { textContent } = element;
+
+      if (!textContent) return;
+
+      history.push(`/tags/${textContent.replace(/^#/, '')}`);
+    },
+    [history],
+  );
+
+  const handleMentionClick = useCallback(
+    (element: HTMLAnchorElement) => {
+      dispatch(
+        openURL(element.href, history, () => {
+          window.location.href = element.href;
+        }),
+      );
+    },
+    [dispatch, history],
+  );
+
+  const handleClick = useCallback(
+    (e: React.MouseEvent) => {
+      const target = (e.target as HTMLElement).closest('a');
+
+      if (!target || e.button !== 0 || e.ctrlKey || e.metaKey) {
+        return;
+      }
+
+      if (isMentionClick(target)) {
+        e.preventDefault();
+        handleMentionClick(target);
+      } else if (isHashtagClick(target)) {
+        e.preventDefault();
+        handleHashtagClick(target);
+      }
+    },
+    [handleMentionClick, handleHashtagClick],
+  );
+
+  return handleClick;
+};
diff --git a/app/javascript/hooks/useTimeout.ts b/app/javascript/hooks/useTimeout.ts
new file mode 100644
index 0000000000..f1814ae8e3
--- /dev/null
+++ b/app/javascript/hooks/useTimeout.ts
@@ -0,0 +1,29 @@
+import { useRef, useCallback, useEffect } from 'react';
+
+export const useTimeout = () => {
+  const timeoutRef = useRef<ReturnType<typeof setTimeout>>();
+
+  const set = useCallback((callback: () => void, delay: number) => {
+    if (timeoutRef.current) {
+      clearTimeout(timeoutRef.current);
+    }
+
+    timeoutRef.current = setTimeout(callback, delay);
+  }, []);
+
+  const cancel = useCallback(() => {
+    if (timeoutRef.current) {
+      clearTimeout(timeoutRef.current);
+      timeoutRef.current = undefined;
+    }
+  }, []);
+
+  useEffect(
+    () => () => {
+      cancel();
+    },
+    [cancel],
+  );
+
+  return [set, cancel] as const;
+};
diff --git a/app/javascript/mastodon/actions/directory.js b/app/javascript/mastodon/actions/directory.js
deleted file mode 100644
index 7a0748029d..0000000000
--- a/app/javascript/mastodon/actions/directory.js
+++ /dev/null
@@ -1,62 +0,0 @@
-import api from '../api';
-
-import { fetchRelationships } from './accounts';
-import { importFetchedAccounts } from './importer';
-
-export const DIRECTORY_FETCH_REQUEST = 'DIRECTORY_FETCH_REQUEST';
-export const DIRECTORY_FETCH_SUCCESS = 'DIRECTORY_FETCH_SUCCESS';
-export const DIRECTORY_FETCH_FAIL    = 'DIRECTORY_FETCH_FAIL';
-
-export const DIRECTORY_EXPAND_REQUEST = 'DIRECTORY_EXPAND_REQUEST';
-export const DIRECTORY_EXPAND_SUCCESS = 'DIRECTORY_EXPAND_SUCCESS';
-export const DIRECTORY_EXPAND_FAIL    = 'DIRECTORY_EXPAND_FAIL';
-
-export const fetchDirectory = params => (dispatch) => {
-  dispatch(fetchDirectoryRequest());
-
-  api().get('/api/v1/directory', { params: { ...params, limit: 20 } }).then(({ data }) => {
-    dispatch(importFetchedAccounts(data));
-    dispatch(fetchDirectorySuccess(data));
-    dispatch(fetchRelationships(data.map(x => x.id)));
-  }).catch(error => dispatch(fetchDirectoryFail(error)));
-};
-
-export const fetchDirectoryRequest = () => ({
-  type: DIRECTORY_FETCH_REQUEST,
-});
-
-export const fetchDirectorySuccess = accounts => ({
-  type: DIRECTORY_FETCH_SUCCESS,
-  accounts,
-});
-
-export const fetchDirectoryFail = error => ({
-  type: DIRECTORY_FETCH_FAIL,
-  error,
-});
-
-export const expandDirectory = params => (dispatch, getState) => {
-  dispatch(expandDirectoryRequest());
-
-  const loadedItems = getState().getIn(['user_lists', 'directory', 'items']).size;
-
-  api().get('/api/v1/directory', { params: { ...params, offset: loadedItems, limit: 20 } }).then(({ data }) => {
-    dispatch(importFetchedAccounts(data));
-    dispatch(expandDirectorySuccess(data));
-    dispatch(fetchRelationships(data.map(x => x.id)));
-  }).catch(error => dispatch(expandDirectoryFail(error)));
-};
-
-export const expandDirectoryRequest = () => ({
-  type: DIRECTORY_EXPAND_REQUEST,
-});
-
-export const expandDirectorySuccess = accounts => ({
-  type: DIRECTORY_EXPAND_SUCCESS,
-  accounts,
-});
-
-export const expandDirectoryFail = error => ({
-  type: DIRECTORY_EXPAND_FAIL,
-  error,
-});
diff --git a/app/javascript/mastodon/actions/directory.ts b/app/javascript/mastodon/actions/directory.ts
new file mode 100644
index 0000000000..34ac309c66
--- /dev/null
+++ b/app/javascript/mastodon/actions/directory.ts
@@ -0,0 +1,37 @@
+import type { List as ImmutableList } from 'immutable';
+
+import { apiGetDirectory } from 'mastodon/api/directory';
+import { createDataLoadingThunk } from 'mastodon/store/typed_functions';
+
+import { fetchRelationships } from './accounts';
+import { importFetchedAccounts } from './importer';
+
+export const fetchDirectory = createDataLoadingThunk(
+  'directory/fetch',
+  async (params: Parameters<typeof apiGetDirectory>[0]) =>
+    apiGetDirectory(params),
+  (data, { dispatch }) => {
+    dispatch(importFetchedAccounts(data));
+    dispatch(fetchRelationships(data.map((x) => x.id)));
+
+    return { accounts: data };
+  },
+);
+
+export const expandDirectory = createDataLoadingThunk(
+  'directory/expand',
+  async (params: Parameters<typeof apiGetDirectory>[0], { getState }) => {
+    const loadedItems = getState().user_lists.getIn([
+      'directory',
+      'items',
+    ]) as ImmutableList<unknown>;
+
+    return apiGetDirectory({ ...params, offset: loadedItems.size }, 20);
+  },
+  (data, { dispatch }) => {
+    dispatch(importFetchedAccounts(data));
+    dispatch(fetchRelationships(data.map((x) => x.id)));
+
+    return { accounts: data };
+  },
+);
diff --git a/app/javascript/mastodon/actions/importer/index.js b/app/javascript/mastodon/actions/importer/index.js
index d906bdfb14..516a7a7973 100644
--- a/app/javascript/mastodon/actions/importer/index.js
+++ b/app/javascript/mastodon/actions/importer/index.js
@@ -76,8 +76,8 @@ export function importFetchedStatuses(statuses) {
         pushUnique(polls, normalizePoll(status.poll, getState().getIn(['polls', status.poll.id])));
       }
 
-      if (status.card?.author_account) {
-        pushUnique(accounts, status.card.author_account);
+      if (status.card) {
+        status.card.authors.forEach(author => author.account && pushUnique(accounts, author.account));
       }
     }
 
diff --git a/app/javascript/mastodon/actions/importer/normalizer.js b/app/javascript/mastodon/actions/importer/normalizer.js
index be76b0f391..c09a3f442c 100644
--- a/app/javascript/mastodon/actions/importer/normalizer.js
+++ b/app/javascript/mastodon/actions/importer/normalizer.js
@@ -36,8 +36,15 @@ export function normalizeStatus(status, normalOldStatus) {
     normalStatus.poll = status.poll.id;
   }
 
-  if (status.card?.author_account) {
-    normalStatus.card = { ...status.card, author_account: status.card.author_account.id };
+  if (status.card) {
+    normalStatus.card = {
+      ...status.card,
+      authors: status.card.authors.map(author => ({
+        ...author,
+        accountId: author.account?.id,
+        account: undefined,
+      })),
+    };
   }
 
   if (status.filtered) {
diff --git a/app/javascript/mastodon/actions/trends.js b/app/javascript/mastodon/actions/trends.js
index 01089fccbb..0bdf17a5d2 100644
--- a/app/javascript/mastodon/actions/trends.js
+++ b/app/javascript/mastodon/actions/trends.js
@@ -51,7 +51,7 @@ export const fetchTrendingLinks = () => (dispatch) => {
   api()
     .get('/api/v1/trends/links', { params: { limit: 20 } })
     .then(({ data }) => {
-      dispatch(importFetchedAccounts(data.map(link => link.author_account).filter(account => !!account)));
+      dispatch(importFetchedAccounts(data.flatMap(link => link.authors.map(author => author.account)).filter(account => !!account)));
       dispatch(fetchTrendingLinksSuccess(data));
     })
     .catch(err => dispatch(fetchTrendingLinksFail(err)));
diff --git a/app/javascript/mastodon/api/directory.ts b/app/javascript/mastodon/api/directory.ts
new file mode 100644
index 0000000000..cd39f8f269
--- /dev/null
+++ b/app/javascript/mastodon/api/directory.ts
@@ -0,0 +1,15 @@
+import { apiRequestGet } from 'mastodon/api';
+import type { ApiAccountJSON } from 'mastodon/api_types/accounts';
+
+export const apiGetDirectory = (
+  params: {
+    order: string;
+    local: boolean;
+    offset?: number;
+  },
+  limit = 20,
+) =>
+  apiRequestGet<ApiAccountJSON[]>('v1/directory', {
+    ...params,
+    limit,
+  });
diff --git a/app/javascript/mastodon/api_types/statuses.ts b/app/javascript/mastodon/api_types/statuses.ts
index c7dd33b5da..db4e20506f 100644
--- a/app/javascript/mastodon/api_types/statuses.ts
+++ b/app/javascript/mastodon/api_types/statuses.ts
@@ -30,6 +30,12 @@ export interface ApiMentionJSON {
   acct: string;
 }
 
+export interface ApiPreviewCardAuthorJSON {
+  name: string;
+  url: string;
+  account?: ApiAccountJSON;
+}
+
 export interface ApiPreviewCardJSON {
   url: string;
   title: string;
@@ -48,6 +54,7 @@ export interface ApiPreviewCardJSON {
   embed_url: string;
   blurhash: string;
   published_at: string;
+  authors: ApiPreviewCardAuthorJSON[];
 }
 
 export interface ApiStatusJSON {
diff --git a/app/javascript/mastodon/components/account_bio.tsx b/app/javascript/mastodon/components/account_bio.tsx
new file mode 100644
index 0000000000..9d523c7402
--- /dev/null
+++ b/app/javascript/mastodon/components/account_bio.tsx
@@ -0,0 +1,20 @@
+import { useLinks } from 'mastodon/../hooks/useLinks';
+
+export const AccountBio: React.FC<{
+  note: string;
+  className: string;
+}> = ({ note, className }) => {
+  const handleClick = useLinks();
+
+  if (note.length === 0 || note === '<p></p>') {
+    return null;
+  }
+
+  return (
+    <div
+      className={`${className} translate`}
+      dangerouslySetInnerHTML={{ __html: note }}
+      onClickCapture={handleClick}
+    />
+  );
+};
diff --git a/app/javascript/mastodon/components/account_fields.tsx b/app/javascript/mastodon/components/account_fields.tsx
new file mode 100644
index 0000000000..e297f99e3a
--- /dev/null
+++ b/app/javascript/mastodon/components/account_fields.tsx
@@ -0,0 +1,42 @@
+import classNames from 'classnames';
+
+import CheckIcon from '@/material-icons/400-24px/check.svg?react';
+import { useLinks } from 'mastodon/../hooks/useLinks';
+import { Icon } from 'mastodon/components/icon';
+import type { Account } from 'mastodon/models/account';
+
+export const AccountFields: React.FC<{
+  fields: Account['fields'];
+  limit: number;
+}> = ({ fields, limit = -1 }) => {
+  const handleClick = useLinks();
+
+  if (fields.size === 0) {
+    return null;
+  }
+
+  return (
+    <div className='account-fields' onClickCapture={handleClick}>
+      {fields.take(limit).map((pair, i) => (
+        <dl
+          key={i}
+          className={classNames({ verified: pair.get('verified_at') })}
+        >
+          <dt
+            dangerouslySetInnerHTML={{ __html: pair.get('name_emojified') }}
+            className='translate'
+          />
+
+          <dd className='translate' title={pair.get('value_plain') ?? ''}>
+            {pair.get('verified_at') && (
+              <Icon id='check' icon={CheckIcon} className='verified__mark' />
+            )}
+            <span
+              dangerouslySetInnerHTML={{ __html: pair.get('value_emojified') }}
+            />
+          </dd>
+        </dl>
+      ))}
+    </div>
+  );
+};
diff --git a/app/javascript/mastodon/components/column_header.jsx b/app/javascript/mastodon/components/column_header.jsx
deleted file mode 100644
index 42183f336d..0000000000
--- a/app/javascript/mastodon/components/column_header.jsx
+++ /dev/null
@@ -1,233 +0,0 @@
-import PropTypes from 'prop-types';
-import { PureComponent, useCallback } from 'react';
-
-import { FormattedMessage, injectIntl, defineMessages, useIntl } from 'react-intl';
-
-import classNames from 'classnames';
-import { withRouter } from 'react-router-dom';
-
-import AddIcon from '@/material-icons/400-24px/add.svg?react';
-import ArrowBackIcon from '@/material-icons/400-24px/arrow_back.svg?react';
-import ChevronLeftIcon from '@/material-icons/400-24px/chevron_left.svg?react';
-import ChevronRightIcon from '@/material-icons/400-24px/chevron_right.svg?react';
-import CloseIcon from '@/material-icons/400-24px/close.svg?react';
-import SettingsIcon from '@/material-icons/400-24px/settings.svg?react';
-import { Icon }  from 'mastodon/components/icon';
-import { ButtonInTabsBar } from 'mastodon/features/ui/util/columns_context';
-import { identityContextPropShape, withIdentity } from 'mastodon/identity_context';
-import { WithRouterPropTypes } from 'mastodon/utils/react_router';
-
-
-import { useAppHistory } from './router';
-
-const messages = defineMessages({
-  show: { id: 'column_header.show_settings', defaultMessage: 'Show settings' },
-  hide: { id: 'column_header.hide_settings', defaultMessage: 'Hide settings' },
-  moveLeft: { id: 'column_header.moveLeft_settings', defaultMessage: 'Move column to the left' },
-  moveRight: { id: 'column_header.moveRight_settings', defaultMessage: 'Move column to the right' },
-  back: { id: 'column_back_button.label', defaultMessage: 'Back' },
-});
-
-const BackButton = ({ onlyIcon }) => {
-  const history = useAppHistory();
-  const intl = useIntl();
-
-  const handleBackClick = useCallback(() => {
-    if (history.location?.state?.fromMastodon) {
-      history.goBack();
-    } else {
-      history.push('/');
-    }
-  }, [history]);
-
-  return (
-    <button onClick={handleBackClick} className={classNames('column-header__back-button', { 'compact': onlyIcon })} aria-label={intl.formatMessage(messages.back)}>
-      <Icon id='chevron-left' icon={ArrowBackIcon} className='column-back-button__icon' />
-      {!onlyIcon && <FormattedMessage id='column_back_button.label' defaultMessage='Back' />}
-    </button>
-  );
-};
-
-BackButton.propTypes = {
-  onlyIcon: PropTypes.bool,
-};
-
-class ColumnHeader extends PureComponent {
-  static propTypes = {
-    identity: identityContextPropShape,
-    intl: PropTypes.object.isRequired,
-    title: PropTypes.node,
-    icon: PropTypes.string,
-    iconComponent: PropTypes.func,
-    active: PropTypes.bool,
-    multiColumn: PropTypes.bool,
-    extraButton: PropTypes.node,
-    showBackButton: PropTypes.bool,
-    children: PropTypes.node,
-    pinned: PropTypes.bool,
-    placeholder: PropTypes.bool,
-    onPin: PropTypes.func,
-    onMove: PropTypes.func,
-    onClick: PropTypes.func,
-    appendContent: PropTypes.node,
-    collapseIssues: PropTypes.bool,
-    ...WithRouterPropTypes,
-  };
-
-  state = {
-    collapsed: true,
-    animating: false,
-  };
-
-  handleToggleClick = (e) => {
-    e.stopPropagation();
-    this.setState({ collapsed: !this.state.collapsed, animating: true });
-  };
-
-  handleTitleClick = () => {
-    this.props.onClick?.();
-  };
-
-  handleMoveLeft = () => {
-    this.props.onMove(-1);
-  };
-
-  handleMoveRight = () => {
-    this.props.onMove(1);
-  };
-
-  handleTransitionEnd = () => {
-    this.setState({ animating: false });
-  };
-
-  handlePin = () => {
-    if (!this.props.pinned) {
-      this.props.history.replace('/');
-    }
-
-    this.props.onPin();
-  };
-
-  render () {
-    const { title, icon, iconComponent, active, children, pinned, multiColumn, extraButton, showBackButton, intl: { formatMessage }, placeholder, appendContent, collapseIssues, history } = this.props;
-    const { collapsed, animating } = this.state;
-
-    const wrapperClassName = classNames('column-header__wrapper', {
-      'active': active,
-    });
-
-    const buttonClassName = classNames('column-header', {
-      'active': active,
-    });
-
-    const collapsibleClassName = classNames('column-header__collapsible', {
-      'collapsed': collapsed,
-      'animating': animating,
-    });
-
-    const collapsibleButtonClassName = classNames('column-header__button', {
-      'active': !collapsed,
-    });
-
-    let extraContent, pinButton, moveButtons, backButton, collapseButton;
-
-    if (children) {
-      extraContent = (
-        <div key='extra-content' className='column-header__collapsible__extra'>
-          {children}
-        </div>
-      );
-    }
-
-    if (multiColumn && pinned) {
-      pinButton = <button className='text-btn column-header__setting-btn' onClick={this.handlePin}><Icon id='times' icon={CloseIcon} /> <FormattedMessage id='column_header.unpin' defaultMessage='Unpin' /></button>;
-
-      moveButtons = (
-        <div className='column-header__setting-arrows'>
-          <button title={formatMessage(messages.moveLeft)} aria-label={formatMessage(messages.moveLeft)} className='icon-button column-header__setting-btn' onClick={this.handleMoveLeft}><Icon id='chevron-left' icon={ChevronLeftIcon} /></button>
-          <button title={formatMessage(messages.moveRight)} aria-label={formatMessage(messages.moveRight)} className='icon-button column-header__setting-btn' onClick={this.handleMoveRight}><Icon id='chevron-right' icon={ChevronRightIcon} /></button>
-        </div>
-      );
-    } else if (multiColumn && this.props.onPin) {
-      pinButton = <button className='text-btn column-header__setting-btn' onClick={this.handlePin}><Icon id='plus' icon={AddIcon} /> <FormattedMessage id='column_header.pin' defaultMessage='Pin' /></button>;
-    }
-
-    if (history && !pinned && ((multiColumn && history.location?.state?.fromMastodon) || showBackButton)) {
-      backButton = <BackButton onlyIcon={!!title} />;
-    }
-
-    const collapsedContent = [
-      extraContent,
-    ];
-
-    if (multiColumn) {
-      collapsedContent.push(
-        <div key='buttons' className='column-header__advanced-buttons'>
-          {pinButton}
-          {moveButtons}
-        </div>
-      );
-    }
-
-    if (this.props.identity.signedIn && (children || (multiColumn && this.props.onPin))) {
-      collapseButton = (
-        <button
-          className={collapsibleButtonClassName}
-          title={formatMessage(collapsed ? messages.show : messages.hide)}
-          aria-label={formatMessage(collapsed ? messages.show : messages.hide)}
-          onClick={this.handleToggleClick}
-        >
-          <i className='icon-with-badge'>
-            <Icon id='sliders' icon={SettingsIcon} />
-            {collapseIssues && <i className='icon-with-badge__issue-badge' />}
-          </i>
-        </button>
-      );
-    }
-
-    const hasTitle = (icon || iconComponent) && title;
-
-    const component = (
-      <div className={wrapperClassName}>
-        <h1 className={buttonClassName}>
-          {hasTitle && (
-            <>
-              {backButton}
-
-              <button onClick={this.handleTitleClick} className='column-header__title'>
-                {!backButton && <Icon id={icon} icon={iconComponent} className='column-header__icon' />}
-                {title}
-              </button>
-            </>
-          )}
-
-          {!hasTitle && backButton}
-
-          <div className='column-header__buttons'>
-            {extraButton}
-            {collapseButton}
-          </div>
-        </h1>
-
-        <div className={collapsibleClassName} tabIndex={collapsed ? -1 : null} onTransitionEnd={this.handleTransitionEnd}>
-          <div className='column-header__collapsible-inner'>
-            {(!collapsed || animating) && collapsedContent}
-          </div>
-        </div>
-
-        {appendContent}
-      </div>
-    );
-
-    if (placeholder) {
-      return component;
-    } else {
-      return (<ButtonInTabsBar>
-        {component}
-      </ButtonInTabsBar>);
-    }
-  }
-
-}
-
-export default injectIntl(withIdentity(withRouter(ColumnHeader)));
diff --git a/app/javascript/mastodon/components/column_header.tsx b/app/javascript/mastodon/components/column_header.tsx
new file mode 100644
index 0000000000..ec946cab3e
--- /dev/null
+++ b/app/javascript/mastodon/components/column_header.tsx
@@ -0,0 +1,301 @@
+import { useCallback, useState } from 'react';
+
+import { FormattedMessage, defineMessages, useIntl } from 'react-intl';
+
+import classNames from 'classnames';
+
+import AddIcon from '@/material-icons/400-24px/add.svg?react';
+import ArrowBackIcon from '@/material-icons/400-24px/arrow_back.svg?react';
+import ChevronLeftIcon from '@/material-icons/400-24px/chevron_left.svg?react';
+import ChevronRightIcon from '@/material-icons/400-24px/chevron_right.svg?react';
+import CloseIcon from '@/material-icons/400-24px/close.svg?react';
+import SettingsIcon from '@/material-icons/400-24px/settings.svg?react';
+import type { IconProp } from 'mastodon/components/icon';
+import { Icon } from 'mastodon/components/icon';
+import { ButtonInTabsBar } from 'mastodon/features/ui/util/columns_context';
+import { useIdentity } from 'mastodon/identity_context';
+
+import { useAppHistory } from './router';
+
+const messages = defineMessages({
+  show: { id: 'column_header.show_settings', defaultMessage: 'Show settings' },
+  hide: { id: 'column_header.hide_settings', defaultMessage: 'Hide settings' },
+  moveLeft: {
+    id: 'column_header.moveLeft_settings',
+    defaultMessage: 'Move column to the left',
+  },
+  moveRight: {
+    id: 'column_header.moveRight_settings',
+    defaultMessage: 'Move column to the right',
+  },
+  back: { id: 'column_back_button.label', defaultMessage: 'Back' },
+});
+
+const BackButton: React.FC<{
+  onlyIcon: boolean;
+}> = ({ onlyIcon }) => {
+  const history = useAppHistory();
+  const intl = useIntl();
+
+  const handleBackClick = useCallback(() => {
+    if (history.location.state?.fromMastodon) {
+      history.goBack();
+    } else {
+      history.push('/');
+    }
+  }, [history]);
+
+  return (
+    <button
+      onClick={handleBackClick}
+      className={classNames('column-header__back-button', {
+        compact: onlyIcon,
+      })}
+      aria-label={intl.formatMessage(messages.back)}
+    >
+      <Icon
+        id='chevron-left'
+        icon={ArrowBackIcon}
+        className='column-back-button__icon'
+      />
+      {!onlyIcon && (
+        <FormattedMessage id='column_back_button.label' defaultMessage='Back' />
+      )}
+    </button>
+  );
+};
+
+export interface Props {
+  title?: string;
+  icon?: string;
+  iconComponent?: IconProp;
+  active?: boolean;
+  children?: React.ReactNode;
+  pinned?: boolean;
+  multiColumn?: boolean;
+  extraButton?: React.ReactNode;
+  showBackButton?: boolean;
+  placeholder?: boolean;
+  appendContent?: React.ReactNode;
+  collapseIssues?: boolean;
+  onClick?: () => void;
+  onMove?: (arg0: number) => void;
+  onPin?: () => void;
+}
+
+export const ColumnHeader: React.FC<Props> = ({
+  title,
+  icon,
+  iconComponent,
+  active,
+  children,
+  pinned,
+  multiColumn,
+  extraButton,
+  showBackButton,
+  placeholder,
+  appendContent,
+  collapseIssues,
+  onClick,
+  onMove,
+  onPin,
+}) => {
+  const intl = useIntl();
+  const { signedIn } = useIdentity();
+  const history = useAppHistory();
+  const [collapsed, setCollapsed] = useState(true);
+  const [animating, setAnimating] = useState(false);
+
+  const handleToggleClick = useCallback(
+    (e: React.MouseEvent) => {
+      e.stopPropagation();
+      setCollapsed((value) => !value);
+      setAnimating(true);
+    },
+    [setCollapsed, setAnimating],
+  );
+
+  const handleTitleClick = useCallback(() => {
+    onClick?.();
+  }, [onClick]);
+
+  const handleMoveLeft = useCallback(() => {
+    onMove?.(-1);
+  }, [onMove]);
+
+  const handleMoveRight = useCallback(() => {
+    onMove?.(1);
+  }, [onMove]);
+
+  const handleTransitionEnd = useCallback(() => {
+    setAnimating(false);
+  }, [setAnimating]);
+
+  const handlePin = useCallback(() => {
+    if (!pinned) {
+      history.replace('/');
+    }
+
+    onPin?.();
+  }, [history, pinned, onPin]);
+
+  const wrapperClassName = classNames('column-header__wrapper', {
+    active,
+  });
+
+  const buttonClassName = classNames('column-header', {
+    active,
+  });
+
+  const collapsibleClassName = classNames('column-header__collapsible', {
+    collapsed,
+    animating,
+  });
+
+  const collapsibleButtonClassName = classNames('column-header__button', {
+    active: !collapsed,
+  });
+
+  let extraContent, pinButton, moveButtons, backButton, collapseButton;
+
+  if (children) {
+    extraContent = (
+      <div key='extra-content' className='column-header__collapsible__extra'>
+        {children}
+      </div>
+    );
+  }
+
+  if (multiColumn && pinned) {
+    pinButton = (
+      <button
+        className='text-btn column-header__setting-btn'
+        onClick={handlePin}
+      >
+        <Icon id='times' icon={CloseIcon} />{' '}
+        <FormattedMessage id='column_header.unpin' defaultMessage='Unpin' />
+      </button>
+    );
+
+    moveButtons = (
+      <div className='column-header__setting-arrows'>
+        <button
+          title={intl.formatMessage(messages.moveLeft)}
+          aria-label={intl.formatMessage(messages.moveLeft)}
+          className='icon-button column-header__setting-btn'
+          onClick={handleMoveLeft}
+        >
+          <Icon id='chevron-left' icon={ChevronLeftIcon} />
+        </button>
+        <button
+          title={intl.formatMessage(messages.moveRight)}
+          aria-label={intl.formatMessage(messages.moveRight)}
+          className='icon-button column-header__setting-btn'
+          onClick={handleMoveRight}
+        >
+          <Icon id='chevron-right' icon={ChevronRightIcon} />
+        </button>
+      </div>
+    );
+  } else if (multiColumn && onPin) {
+    pinButton = (
+      <button
+        className='text-btn column-header__setting-btn'
+        onClick={handlePin}
+      >
+        <Icon id='plus' icon={AddIcon} />{' '}
+        <FormattedMessage id='column_header.pin' defaultMessage='Pin' />
+      </button>
+    );
+  }
+
+  if (
+    !pinned &&
+    ((multiColumn && history.location.state?.fromMastodon) || showBackButton)
+  ) {
+    backButton = <BackButton onlyIcon={!!title} />;
+  }
+
+  const collapsedContent = [extraContent];
+
+  if (multiColumn) {
+    collapsedContent.push(
+      <div key='buttons' className='column-header__advanced-buttons'>
+        {pinButton}
+        {moveButtons}
+      </div>,
+    );
+  }
+
+  if (signedIn && (children || (multiColumn && onPin))) {
+    collapseButton = (
+      <button
+        className={collapsibleButtonClassName}
+        title={intl.formatMessage(collapsed ? messages.show : messages.hide)}
+        aria-label={intl.formatMessage(
+          collapsed ? messages.show : messages.hide,
+        )}
+        onClick={handleToggleClick}
+      >
+        <i className='icon-with-badge'>
+          <Icon id='sliders' icon={SettingsIcon} />
+          {collapseIssues && <i className='icon-with-badge__issue-badge' />}
+        </i>
+      </button>
+    );
+  }
+
+  const hasIcon = icon && iconComponent;
+  const hasTitle = hasIcon && title;
+
+  const component = (
+    <div className={wrapperClassName}>
+      <h1 className={buttonClassName}>
+        {hasTitle && (
+          <>
+            {backButton}
+
+            <button onClick={handleTitleClick} className='column-header__title'>
+              {!backButton && (
+                <Icon
+                  id={icon}
+                  icon={iconComponent}
+                  className='column-header__icon'
+                />
+              )}
+              {title}
+            </button>
+          </>
+        )}
+
+        {!hasTitle && backButton}
+
+        <div className='column-header__buttons'>
+          {extraButton}
+          {collapseButton}
+        </div>
+      </h1>
+
+      <div
+        className={collapsibleClassName}
+        tabIndex={collapsed ? -1 : undefined}
+        onTransitionEnd={handleTransitionEnd}
+      >
+        <div className='column-header__collapsible-inner'>
+          {(!collapsed || animating) && collapsedContent}
+        </div>
+      </div>
+
+      {appendContent}
+    </div>
+  );
+
+  if (placeholder) {
+    return component;
+  } else {
+    return <ButtonInTabsBar>{component}</ButtonInTabsBar>;
+  }
+};
+
+// eslint-disable-next-line import/no-default-export
+export default ColumnHeader;
diff --git a/app/javascript/mastodon/components/follow_button.tsx b/app/javascript/mastodon/components/follow_button.tsx
new file mode 100644
index 0000000000..db59942882
--- /dev/null
+++ b/app/javascript/mastodon/components/follow_button.tsx
@@ -0,0 +1,117 @@
+import { useCallback, useEffect } from 'react';
+
+import { useIntl, defineMessages } from 'react-intl';
+
+import { useIdentity } from '@/mastodon/identity_context';
+import {
+  fetchRelationships,
+  followAccount,
+  unfollowAccount,
+} from 'mastodon/actions/accounts';
+import { openModal } from 'mastodon/actions/modal';
+import { Button } from 'mastodon/components/button';
+import { LoadingIndicator } from 'mastodon/components/loading_indicator';
+import { me } from 'mastodon/initial_state';
+import { useAppDispatch, useAppSelector } from 'mastodon/store';
+
+const messages = defineMessages({
+  unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
+  follow: { id: 'account.follow', defaultMessage: 'Follow' },
+  followBack: { id: 'account.follow_back', defaultMessage: 'Follow back' },
+  mutual: { id: 'account.mutual', defaultMessage: 'Mutual' },
+  cancel_follow_request: {
+    id: 'account.cancel_follow_request',
+    defaultMessage: 'Withdraw follow request',
+  },
+  edit_profile: { id: 'account.edit_profile', defaultMessage: 'Edit profile' },
+});
+
+export const FollowButton: React.FC<{
+  accountId: string;
+}> = ({ accountId }) => {
+  const intl = useIntl();
+  const dispatch = useAppDispatch();
+  const { signedIn } = useIdentity();
+  const account = useAppSelector((state) =>
+    accountId ? state.accounts.get(accountId) : undefined,
+  );
+  const relationship = useAppSelector((state) =>
+    state.relationships.get(accountId),
+  );
+  const following = relationship?.following || relationship?.requested;
+
+  useEffect(() => {
+    if (accountId && signedIn) {
+      dispatch(fetchRelationships([accountId]));
+    }
+  }, [dispatch, accountId, signedIn]);
+
+  const handleClick = useCallback(() => {
+    if (!signedIn) {
+      dispatch(
+        openModal({
+          modalType: 'INTERACTION',
+          modalProps: {
+            type: 'follow',
+            accountId: accountId,
+            url: account?.url,
+          },
+        }),
+      );
+    }
+
+    if (!relationship) return;
+
+    if (accountId === me) {
+      return;
+    } else if (relationship.following || relationship.requested) {
+      dispatch(unfollowAccount(accountId));
+    } else {
+      dispatch(followAccount(accountId));
+    }
+  }, [dispatch, accountId, relationship, account, signedIn]);
+
+  let label;
+
+  if (!signedIn) {
+    label = intl.formatMessage(messages.follow);
+  } else if (accountId === me) {
+    label = intl.formatMessage(messages.edit_profile);
+  } else if (!relationship) {
+    label = <LoadingIndicator />;
+  } else if (relationship.requested) {
+    label = intl.formatMessage(messages.cancel_follow_request);
+  } else if (relationship.following && relationship.followed_by) {
+    label = intl.formatMessage(messages.mutual);
+  } else if (!relationship.following && relationship.followed_by) {
+    label = intl.formatMessage(messages.followBack);
+  } else if (relationship.following) {
+    label = intl.formatMessage(messages.unfollow);
+  } else {
+    label = intl.formatMessage(messages.follow);
+  }
+
+  if (accountId === me) {
+    return (
+      <a
+        href='/settings/profile'
+        target='_blank'
+        rel='noreferrer noopener'
+        className='button button-secondary'
+      >
+        {label}
+      </a>
+    );
+  }
+
+  return (
+    <Button
+      onClick={handleClick}
+      disabled={relationship?.blocked_by || relationship?.blocking}
+      secondary={following}
+      className={following ? 'button--destructive' : undefined}
+    >
+      {label}
+    </Button>
+  );
+};
diff --git a/app/javascript/mastodon/components/hover_card_account.tsx b/app/javascript/mastodon/components/hover_card_account.tsx
new file mode 100644
index 0000000000..59f9577838
--- /dev/null
+++ b/app/javascript/mastodon/components/hover_card_account.tsx
@@ -0,0 +1,74 @@
+import { useEffect, forwardRef } from 'react';
+
+import classNames from 'classnames';
+import { Link } from 'react-router-dom';
+
+import { fetchAccount } from 'mastodon/actions/accounts';
+import { AccountBio } from 'mastodon/components/account_bio';
+import { AccountFields } from 'mastodon/components/account_fields';
+import { Avatar } from 'mastodon/components/avatar';
+import { FollowersCounter } from 'mastodon/components/counters';
+import { DisplayName } from 'mastodon/components/display_name';
+import { FollowButton } from 'mastodon/components/follow_button';
+import { LoadingIndicator } from 'mastodon/components/loading_indicator';
+import { ShortNumber } from 'mastodon/components/short_number';
+import { domain } from 'mastodon/initial_state';
+import { useAppSelector, useAppDispatch } from 'mastodon/store';
+
+export const HoverCardAccount = forwardRef<
+  HTMLDivElement,
+  { accountId: string }
+>(({ accountId }, ref) => {
+  const dispatch = useAppDispatch();
+
+  const account = useAppSelector((state) =>
+    accountId ? state.accounts.get(accountId) : undefined,
+  );
+
+  useEffect(() => {
+    if (accountId && !account) {
+      dispatch(fetchAccount(accountId));
+    }
+  }, [dispatch, accountId, account]);
+
+  return (
+    <div
+      ref={ref}
+      id='hover-card'
+      role='tooltip'
+      className={classNames('hover-card dropdown-animation', {
+        'hover-card--loading': !account,
+      })}
+    >
+      {account ? (
+        <>
+          <Link to={`/@${account.acct}`} className='hover-card__name'>
+            <Avatar account={account} size={46} />
+            <DisplayName account={account} localDomain={domain} />
+          </Link>
+
+          <div className='hover-card__text-row'>
+            <AccountBio
+              note={account.note_emojified}
+              className='hover-card__bio'
+            />
+            <AccountFields fields={account.fields} limit={2} />
+          </div>
+
+          <div className='hover-card__number'>
+            <ShortNumber
+              value={account.followers_count}
+              renderer={FollowersCounter}
+            />
+          </div>
+
+          <FollowButton accountId={accountId} />
+        </>
+      ) : (
+        <LoadingIndicator />
+      )}
+    </div>
+  );
+});
+
+HoverCardAccount.displayName = 'HoverCardAccount';
diff --git a/app/javascript/mastodon/components/hover_card_controller.tsx b/app/javascript/mastodon/components/hover_card_controller.tsx
new file mode 100644
index 0000000000..0130390ef8
--- /dev/null
+++ b/app/javascript/mastodon/components/hover_card_controller.tsx
@@ -0,0 +1,117 @@
+import { useEffect, useRef, useState, useCallback } from 'react';
+
+import { useLocation } from 'react-router-dom';
+
+import Overlay from 'react-overlays/Overlay';
+import type {
+  OffsetValue,
+  UsePopperOptions,
+} from 'react-overlays/esm/usePopper';
+
+import { useTimeout } from 'mastodon/../hooks/useTimeout';
+import { HoverCardAccount } from 'mastodon/components/hover_card_account';
+
+const offset = [-12, 4] as OffsetValue;
+const enterDelay = 650;
+const leaveDelay = 250;
+const popperConfig = { strategy: 'fixed' } as UsePopperOptions;
+
+const isHoverCardAnchor = (element: HTMLElement) =>
+  element.matches('[data-hover-card-account]');
+
+export const HoverCardController: React.FC = () => {
+  const [open, setOpen] = useState(false);
+  const [accountId, setAccountId] = useState<string | undefined>();
+  const [anchor, setAnchor] = useState<HTMLElement | null>(null);
+  const cardRef = useRef<HTMLDivElement>(null);
+  const [setLeaveTimeout, cancelLeaveTimeout] = useTimeout();
+  const [setEnterTimeout, cancelEnterTimeout] = useTimeout();
+  const location = useLocation();
+
+  const handleAnchorMouseEnter = useCallback(
+    (e: MouseEvent) => {
+      const { target } = e;
+
+      if (target instanceof HTMLElement && isHoverCardAnchor(target)) {
+        cancelLeaveTimeout();
+
+        setEnterTimeout(() => {
+          target.setAttribute('aria-describedby', 'hover-card');
+          setAnchor(target);
+          setOpen(true);
+          setAccountId(
+            target.getAttribute('data-hover-card-account') ?? undefined,
+          );
+        }, enterDelay);
+      }
+
+      if (target === cardRef.current?.parentNode) {
+        cancelLeaveTimeout();
+      }
+    },
+    [cancelLeaveTimeout, setEnterTimeout, setOpen, setAccountId, setAnchor],
+  );
+
+  const handleAnchorMouseLeave = useCallback(
+    (e: MouseEvent) => {
+      if (e.target === anchor || e.target === cardRef.current?.parentNode) {
+        cancelEnterTimeout();
+
+        setLeaveTimeout(() => {
+          anchor?.removeAttribute('aria-describedby');
+          setOpen(false);
+          setAnchor(null);
+        }, leaveDelay);
+      }
+    },
+    [cancelEnterTimeout, setLeaveTimeout, setOpen, setAnchor, anchor],
+  );
+
+  const handleClose = useCallback(() => {
+    cancelEnterTimeout();
+    cancelLeaveTimeout();
+    setOpen(false);
+    setAnchor(null);
+  }, [cancelEnterTimeout, cancelLeaveTimeout, setOpen, setAnchor]);
+
+  useEffect(() => {
+    handleClose();
+  }, [handleClose, location]);
+
+  useEffect(() => {
+    document.body.addEventListener('mouseenter', handleAnchorMouseEnter, {
+      passive: true,
+      capture: true,
+    });
+    document.body.addEventListener('mouseleave', handleAnchorMouseLeave, {
+      passive: true,
+      capture: true,
+    });
+
+    return () => {
+      document.body.removeEventListener('mouseenter', handleAnchorMouseEnter);
+      document.body.removeEventListener('mouseleave', handleAnchorMouseLeave);
+    };
+  }, [handleAnchorMouseEnter, handleAnchorMouseLeave]);
+
+  if (!accountId) return null;
+
+  return (
+    <Overlay
+      rootClose
+      onHide={handleClose}
+      show={open}
+      target={anchor}
+      placement='bottom-start'
+      flip
+      offset={offset}
+      popperConfig={popperConfig}
+    >
+      {({ props }) => (
+        <div {...props} className='hover-card-controller'>
+          <HoverCardAccount accountId={accountId} ref={cardRef} />
+        </div>
+      )}
+    </Overlay>
+  );
+};
diff --git a/app/javascript/mastodon/components/status.jsx b/app/javascript/mastodon/components/status.jsx
index 7b97e45766..dce48d7036 100644
--- a/app/javascript/mastodon/components/status.jsx
+++ b/app/javascript/mastodon/components/status.jsx
@@ -425,7 +425,7 @@ class Status extends ImmutablePureComponent {
       prepend = (
         <div className='status__prepend'>
           <div className='status__prepend-icon-wrapper'><Icon id='retweet' icon={RepeatIcon} className='status__prepend-icon' /></div>
-          <FormattedMessage id='status.reblogged_by' defaultMessage='{name} boosted' values={{ name: <a onClick={this.handlePrependAccountClick} data-id={status.getIn(['account', 'id'])} href={`/@${status.getIn(['account', 'acct'])}`} className='status__display-name muted'><bdi><strong dangerouslySetInnerHTML={display_name_html} /></bdi></a> }} />
+          <FormattedMessage id='status.reblogged_by' defaultMessage='{name} boosted' values={{ name: <a onClick={this.handlePrependAccountClick} data-id={status.getIn(['account', 'id'])} data-hover-card-account={status.getIn(['account', 'id'])} href={`/@${status.getIn(['account', 'acct'])}`} className='status__display-name muted'><bdi><strong dangerouslySetInnerHTML={display_name_html} /></bdi></a> }} />
         </div>
       );
 
@@ -446,7 +446,7 @@ class Status extends ImmutablePureComponent {
       prepend = (
         <div className='status__prepend'>
           <div className='status__prepend-icon-wrapper'><Icon id='reply' icon={ReplyIcon} className='status__prepend-icon' /></div>
-          <FormattedMessage id='status.replied_to' defaultMessage='Replied to {name}' values={{ name: <a onClick={this.handlePrependAccountClick} data-id={status.getIn(['account', 'id'])} href={`/@${status.getIn(['account', 'acct'])}`} className='status__display-name muted'><bdi><strong dangerouslySetInnerHTML={display_name_html} /></bdi></a> }} />
+          <FormattedMessage id='status.replied_to' defaultMessage='Replied to {name}' values={{ name: <a onClick={this.handlePrependAccountClick} data-id={status.getIn(['account', 'id'])} data-hover-card-account={status.getIn(['account', 'id'])} href={`/@${status.getIn(['account', 'acct'])}`} className='status__display-name muted'><bdi><strong dangerouslySetInnerHTML={display_name_html} /></bdi></a> }} />
         </div>
       );
     }
@@ -562,7 +562,7 @@ class Status extends ImmutablePureComponent {
                 <RelativeTimestamp timestamp={status.get('created_at')} />{status.get('edited_at') && <abbr title={intl.formatMessage(messages.edited, { date: intl.formatDate(status.get('edited_at'), { year: 'numeric', month: 'short', day: '2-digit', hour: '2-digit', minute: '2-digit' }) })}> *</abbr>}
               </a>
 
-              <a onClick={this.handleAccountClick} href={`/@${status.getIn(['account', 'acct'])}`} title={status.getIn(['account', 'acct'])} className='status__display-name' target='_blank' rel='noopener noreferrer'>
+              <a onClick={this.handleAccountClick} href={`/@${status.getIn(['account', 'acct'])}`} data-hover-card-account={status.getIn(['account', 'id'])} className='status__display-name' target='_blank' rel='noopener noreferrer'>
                 <div className='status__avatar'>
                   {statusAvatar}
                 </div>
diff --git a/app/javascript/mastodon/components/status_content.jsx b/app/javascript/mastodon/components/status_content.jsx
index 24483cf512..82135b85ca 100644
--- a/app/javascript/mastodon/components/status_content.jsx
+++ b/app/javascript/mastodon/components/status_content.jsx
@@ -116,8 +116,9 @@ class StatusContent extends PureComponent {
 
       if (mention) {
         link.addEventListener('click', this.onMentionClick.bind(this, mention), false);
-        link.setAttribute('title', `@${mention.get('acct')}`);
+        link.removeAttribute('title');
         link.setAttribute('href', `/@${mention.get('acct')}`);
+        link.setAttribute('data-hover-card-account', mention.get('id'));
       } else if (link.textContent[0] === '#' || (link.previousSibling && link.previousSibling.textContent && link.previousSibling.textContent[link.previousSibling.textContent.length - 1] === '#')) {
         link.addEventListener('click', this.onHashtagClick.bind(this, link.text), false);
         link.setAttribute('href', `/tags/${link.text.replace(/^#/, '')}`);
diff --git a/app/javascript/mastodon/features/directory/components/account_card.jsx b/app/javascript/mastodon/features/directory/components/account_card.jsx
deleted file mode 100644
index 9c5e688120..0000000000
--- a/app/javascript/mastodon/features/directory/components/account_card.jsx
+++ /dev/null
@@ -1,234 +0,0 @@
-import PropTypes from 'prop-types';
-
-import { FormattedMessage, injectIntl, defineMessages } from 'react-intl';
-
-import classNames from 'classnames';
-import { Link } from 'react-router-dom';
-
-import ImmutablePropTypes from 'react-immutable-proptypes';
-import ImmutablePureComponent from 'react-immutable-pure-component';
-import { connect } from 'react-redux';
-
-import {
-  followAccount,
-  unfollowAccount,
-  unblockAccount,
-  unmuteAccount,
-} from 'mastodon/actions/accounts';
-import { openModal } from 'mastodon/actions/modal';
-import { Avatar } from 'mastodon/components/avatar';
-import { Button } from 'mastodon/components/button';
-import { DisplayName } from 'mastodon/components/display_name';
-import { ShortNumber } from 'mastodon/components/short_number';
-import { autoPlayGif, me } from 'mastodon/initial_state';
-import { makeGetAccount } from 'mastodon/selectors';
-
-const messages = defineMessages({
-  unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
-  follow: { id: 'account.follow', defaultMessage: 'Follow' },
-  cancel_follow_request: { id: 'account.cancel_follow_request', defaultMessage: 'Withdraw follow request' },
-  cancelFollowRequestConfirm: { id: 'confirmations.cancel_follow_request.confirm', defaultMessage: 'Withdraw 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 = () => {
-  const getAccount = makeGetAccount();
-
-  const mapStateToProps = (state, { id }) => ({
-    account: getAccount(state, id),
-  });
-
-  return mapStateToProps;
-};
-
-const mapDispatchToProps = (dispatch, { intl }) => ({
-  onFollow(account) {
-    if (account.getIn(['relationship', 'following'])) {
-      dispatch(
-        openModal({
-          modalType: 'CONFIRM',
-          modalProps: {
-            message: (
-              <FormattedMessage
-                id='confirmations.unfollow.message'
-                defaultMessage='Are you sure you want to unfollow {name}?'
-                values={{ name: <strong>@{account.get('acct')}</strong> }}
-              />
-            ),
-            confirm: intl.formatMessage(messages.unfollowConfirm),
-            onConfirm: () => dispatch(unfollowAccount(account.get('id'))),
-          } }),
-      );
-    } else if (account.getIn(['relationship', 'requested'])) {
-      dispatch(openModal({
-        modalType: 'CONFIRM',
-        modalProps: {
-          message: <FormattedMessage id='confirmations.cancel_follow_request.message' defaultMessage='Are you sure you want to withdraw your request to follow {name}?' values={{ name: <strong>@{account.get('acct')}</strong> }} />,
-          confirm: intl.formatMessage(messages.cancelFollowRequestConfirm),
-          onConfirm: () => dispatch(unfollowAccount(account.get('id'))),
-        },
-      }));
-    } else {
-      dispatch(followAccount(account.get('id')));
-    }
-  },
-
-  onBlock(account) {
-    if (account.getIn(['relationship', 'blocking'])) {
-      dispatch(unblockAccount(account.get('id')));
-    }
-  },
-
-  onMute(account) {
-    if (account.getIn(['relationship', 'muting'])) {
-      dispatch(unmuteAccount(account.get('id')));
-    }
-  },
-
-});
-
-class AccountCard extends ImmutablePureComponent {
-
-  static propTypes = {
-    account: ImmutablePropTypes.record.isRequired,
-    intl: PropTypes.object.isRequired,
-    onFollow: PropTypes.func.isRequired,
-    onBlock: PropTypes.func.isRequired,
-    onMute: PropTypes.func.isRequired,
-  };
-
-  handleMouseEnter = ({ currentTarget }) => {
-    if (autoPlayGif) {
-      return;
-    }
-
-    const emojis = currentTarget.querySelectorAll('.custom-emoji');
-
-    for (var i = 0; i < emojis.length; i++) {
-      let emoji = emojis[i];
-      emoji.src = emoji.getAttribute('data-original');
-    }
-  };
-
-  handleMouseLeave = ({ currentTarget }) => {
-    if (autoPlayGif) {
-      return;
-    }
-
-    const emojis = currentTarget.querySelectorAll('.custom-emoji');
-
-    for (var i = 0; i < emojis.length; i++) {
-      let emoji = emojis[i];
-      emoji.src = emoji.getAttribute('data-static');
-    }
-  };
-
-  handleFollow = () => {
-    this.props.onFollow(this.props.account);
-  };
-
-  handleBlock = () => {
-    this.props.onBlock(this.props.account);
-  };
-
-  handleMute = () => {
-    this.props.onMute(this.props.account);
-  };
-
-  handleEditProfile = () => {
-    window.open('/settings/profile', '_blank');
-  };
-
-  render() {
-    const { account, intl } = this.props;
-
-    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  text={intl.formatMessage(messages.cancel_follow_request)} title={intl.formatMessage(messages.requested)} onClick={this.handleFollow} />;
-      } else if (account.getIn(['relationship', 'muting'])) {
-        actionBtn = <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({ '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  text={intl.formatMessage(messages.unblock)} onClick={this.handleBlock} />;
-      }
-    } else {
-      actionBtn = <Button  text={intl.formatMessage(messages.edit_profile)} onClick={this.handleEditProfile} />;
-    }
-
-    return (
-      <div className='account-card'>
-        <Link 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='account-card__title'>
-            <div className='account-card__title__avatar'><Avatar account={account} size={56} /></div>
-            <DisplayName account={account} />
-          </div>
-        </Link>
-
-        {account.get('note').length > 0 && (
-          <div
-            className='account-card__bio translate'
-            onMouseEnter={this.handleMouseEnter}
-            onMouseLeave={this.handleMouseLeave}
-            dangerouslySetInnerHTML={{ __html: account.get('note_emojified') }}
-          />
-        )}
-
-        <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='Posts' />
-              </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='account-card__actions__button'>
-            {actionBtn}
-          </div>
-        </div>
-      </div>
-    );
-  }
-
-}
-
-export default injectIntl(connect(makeMapStateToProps, mapDispatchToProps)(AccountCard));
diff --git a/app/javascript/mastodon/features/directory/components/account_card.tsx b/app/javascript/mastodon/features/directory/components/account_card.tsx
new file mode 100644
index 0000000000..7201f6135b
--- /dev/null
+++ b/app/javascript/mastodon/features/directory/components/account_card.tsx
@@ -0,0 +1,269 @@
+import type { MouseEventHandler } from 'react';
+import { useCallback } from 'react';
+
+import { FormattedMessage, defineMessages, useIntl } from 'react-intl';
+
+import classNames from 'classnames';
+import { Link } from 'react-router-dom';
+
+import {
+  followAccount,
+  unfollowAccount,
+  unblockAccount,
+  unmuteAccount,
+} from 'mastodon/actions/accounts';
+import { openModal } from 'mastodon/actions/modal';
+import { Avatar } from 'mastodon/components/avatar';
+import { Button } from 'mastodon/components/button';
+import { DisplayName } from 'mastodon/components/display_name';
+import { ShortNumber } from 'mastodon/components/short_number';
+import { autoPlayGif, me } from 'mastodon/initial_state';
+import type { Account } from 'mastodon/models/account';
+import { makeGetAccount } from 'mastodon/selectors';
+import { useAppDispatch, useAppSelector } from 'mastodon/store';
+
+const messages = defineMessages({
+  unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
+  follow: { id: 'account.follow', defaultMessage: 'Follow' },
+  cancel_follow_request: {
+    id: 'account.cancel_follow_request',
+    defaultMessage: 'Withdraw follow request',
+  },
+  cancelFollowRequestConfirm: {
+    id: 'confirmations.cancel_follow_request.confirm',
+    defaultMessage: 'Withdraw 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 getAccount = makeGetAccount();
+
+export const AccountCard: React.FC<{ accountId: string }> = ({ accountId }) => {
+  const intl = useIntl();
+  const account = useAppSelector((s) => getAccount(s, accountId));
+  const dispatch = useAppDispatch();
+
+  const handleMouseEnter = useCallback<MouseEventHandler>(
+    ({ currentTarget }) => {
+      if (autoPlayGif) {
+        return;
+      }
+      const emojis =
+        currentTarget.querySelectorAll<HTMLImageElement>('.custom-emoji');
+
+      emojis.forEach((emoji) => {
+        const original = emoji.getAttribute('data-original');
+        if (original) emoji.src = original;
+      });
+    },
+    [],
+  );
+
+  const handleMouseLeave = useCallback<MouseEventHandler>(
+    ({ currentTarget }) => {
+      if (autoPlayGif) {
+        return;
+      }
+
+      const emojis =
+        currentTarget.querySelectorAll<HTMLImageElement>('.custom-emoji');
+
+      emojis.forEach((emoji) => {
+        const staticUrl = emoji.getAttribute('data-static');
+        if (staticUrl) emoji.src = staticUrl;
+      });
+    },
+    [],
+  );
+
+  const handleFollow = useCallback(() => {
+    if (!account) return;
+
+    if (account.getIn(['relationship', 'following'])) {
+      dispatch(
+        openModal({
+          modalType: 'CONFIRM',
+          modalProps: {
+            message: (
+              <FormattedMessage
+                id='confirmations.unfollow.message'
+                defaultMessage='Are you sure you want to unfollow {name}?'
+                values={{ name: <strong>@{account.get('acct')}</strong> }}
+              />
+            ),
+            confirm: intl.formatMessage(messages.unfollowConfirm),
+            onConfirm: () => {
+              dispatch(unfollowAccount(account.get('id')));
+            },
+          },
+        }),
+      );
+    } else if (account.getIn(['relationship', 'requested'])) {
+      dispatch(
+        openModal({
+          modalType: 'CONFIRM',
+          modalProps: {
+            message: (
+              <FormattedMessage
+                id='confirmations.cancel_follow_request.message'
+                defaultMessage='Are you sure you want to withdraw your request to follow {name}?'
+                values={{ name: <strong>@{account.get('acct')}</strong> }}
+              />
+            ),
+            confirm: intl.formatMessage(messages.cancelFollowRequestConfirm),
+            onConfirm: () => {
+              dispatch(unfollowAccount(account.get('id')));
+            },
+          },
+        }),
+      );
+    } else {
+      dispatch(followAccount(account.get('id')));
+    }
+  }, [account, dispatch, intl]);
+
+  const handleBlock = useCallback(() => {
+    if (account?.relationship?.blocking) {
+      dispatch(unblockAccount(account.get('id')));
+    }
+  }, [account, dispatch]);
+
+  const handleMute = useCallback(() => {
+    if (account?.relationship?.muting) {
+      dispatch(unmuteAccount(account.get('id')));
+    }
+  }, [account, dispatch]);
+
+  const handleEditProfile = useCallback(() => {
+    window.open('/settings/profile', '_blank');
+  }, []);
+
+  if (!account) return null;
+
+  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
+          text={intl.formatMessage(messages.cancel_follow_request)}
+          title={intl.formatMessage(messages.requested)}
+          onClick={handleFollow}
+        />
+      );
+    } else if (account.getIn(['relationship', 'muting'])) {
+      actionBtn = (
+        <Button
+          text={intl.formatMessage(messages.unmute)}
+          onClick={handleMute}
+        />
+      );
+    } else if (!account.getIn(['relationship', 'blocking'])) {
+      actionBtn = (
+        <Button
+          disabled={account.relationship?.blocked_by}
+          className={classNames({
+            'button--destructive': account.getIn(['relationship', 'following']),
+          })}
+          text={intl.formatMessage(
+            account.getIn(['relationship', 'following'])
+              ? messages.unfollow
+              : messages.follow,
+          )}
+          onClick={handleFollow}
+        />
+      );
+    } else if (account.getIn(['relationship', 'blocking'])) {
+      actionBtn = (
+        <Button
+          text={intl.formatMessage(messages.unblock)}
+          onClick={handleBlock}
+        />
+      );
+    }
+  } else {
+    actionBtn = (
+      <Button
+        text={intl.formatMessage(messages.edit_profile)}
+        onClick={handleEditProfile}
+      />
+    );
+  }
+
+  return (
+    <div className='account-card'>
+      <Link 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='account-card__title'>
+          <div className='account-card__title__avatar'>
+            <Avatar account={account as Account} size={56} />
+          </div>
+          <DisplayName account={account as Account} />
+        </div>
+      </Link>
+
+      {account.get('note').length > 0 && (
+        <div
+          className='account-card__bio translate'
+          onMouseEnter={handleMouseEnter}
+          onMouseLeave={handleMouseLeave}
+          dangerouslySetInnerHTML={{ __html: account.get('note_emojified') }}
+        />
+      )}
+
+      <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='Posts' />
+            </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='account-card__actions__button'>{actionBtn}</div>
+      </div>
+    </div>
+  );
+};
diff --git a/app/javascript/mastodon/features/directory/index.jsx b/app/javascript/mastodon/features/directory/index.jsx
deleted file mode 100644
index 0d3408146e..0000000000
--- a/app/javascript/mastodon/features/directory/index.jsx
+++ /dev/null
@@ -1,181 +0,0 @@
-import PropTypes from 'prop-types';
-import { PureComponent } from 'react';
-
-import { defineMessages, injectIntl } from 'react-intl';
-
-import { Helmet } from 'react-helmet';
-
-import { List as ImmutableList } from 'immutable';
-import ImmutablePropTypes from 'react-immutable-proptypes';
-import { connect } from 'react-redux';
-
-import PeopleIcon from '@/material-icons/400-24px/group.svg?react';
-import { addColumn, removeColumn, moveColumn, changeColumnParams } from 'mastodon/actions/columns';
-import { fetchDirectory, expandDirectory } from 'mastodon/actions/directory';
-import Column from 'mastodon/components/column';
-import ColumnHeader from 'mastodon/components/column_header';
-import { LoadMore } from 'mastodon/components/load_more';
-import { LoadingIndicator } from 'mastodon/components/loading_indicator';
-import { RadioButton } from 'mastodon/components/radio_button';
-import ScrollContainer from 'mastodon/containers/scroll_container';
-
-import AccountCard from './components/account_card';
-
-const messages = defineMessages({
-  title: { id: 'column.directory', defaultMessage: 'Browse profiles' },
-  recentlyActive: { id: 'directory.recently_active', defaultMessage: 'Recently active' },
-  newArrivals: { id: 'directory.new_arrivals', defaultMessage: 'New arrivals' },
-  local: { id: 'directory.local', defaultMessage: 'From {domain} only' },
-  federated: { id: 'directory.federated', defaultMessage: 'From known fediverse' },
-});
-
-const mapStateToProps = state => ({
-  accountIds: state.getIn(['user_lists', 'directory', 'items'], ImmutableList()),
-  isLoading: state.getIn(['user_lists', 'directory', 'isLoading'], true),
-  domain: state.getIn(['meta', 'domain']),
-});
-
-class Directory extends PureComponent {
-
-  static propTypes = {
-    isLoading: PropTypes.bool,
-    accountIds: ImmutablePropTypes.list.isRequired,
-    dispatch: PropTypes.func.isRequired,
-    columnId: PropTypes.string,
-    intl: PropTypes.object.isRequired,
-    multiColumn: PropTypes.bool,
-    domain: PropTypes.string.isRequired,
-    params: PropTypes.shape({
-      order: PropTypes.string,
-      local: PropTypes.bool,
-    }),
-  };
-
-  state = {
-    order: null,
-    local: null,
-  };
-
-  handlePin = () => {
-    const { columnId, dispatch } = this.props;
-
-    if (columnId) {
-      dispatch(removeColumn(columnId));
-    } else {
-      dispatch(addColumn('DIRECTORY', this.getParams(this.props, this.state)));
-    }
-  };
-
-  getParams = (props, state) => ({
-    order: state.order === null ? (props.params.order || 'active') : state.order,
-    local: state.local === null ? (props.params.local || false) : state.local,
-  });
-
-  handleMove = dir => {
-    const { columnId, dispatch } = this.props;
-    dispatch(moveColumn(columnId, dir));
-  };
-
-  handleHeaderClick = () => {
-    this.column.scrollTop();
-  };
-
-  componentDidMount () {
-    const { dispatch } = this.props;
-    dispatch(fetchDirectory(this.getParams(this.props, this.state)));
-  }
-
-  componentDidUpdate (prevProps, prevState) {
-    const { dispatch } = this.props;
-    const paramsOld = this.getParams(prevProps, prevState);
-    const paramsNew = this.getParams(this.props, this.state);
-
-    if (paramsOld.order !== paramsNew.order || paramsOld.local !== paramsNew.local) {
-      dispatch(fetchDirectory(paramsNew));
-    }
-  }
-
-  setRef = c => {
-    this.column = c;
-  };
-
-  handleChangeOrder = e => {
-    const { dispatch, columnId } = this.props;
-
-    if (columnId) {
-      dispatch(changeColumnParams(columnId, ['order'], e.target.value));
-    } else {
-      this.setState({ order: e.target.value });
-    }
-  };
-
-  handleChangeLocal = e => {
-    const { dispatch, columnId } = this.props;
-
-    if (columnId) {
-      dispatch(changeColumnParams(columnId, ['local'], e.target.value === '1'));
-    } else {
-      this.setState({ local: e.target.value === '1' });
-    }
-  };
-
-  handleLoadMore = () => {
-    const { dispatch } = this.props;
-    dispatch(expandDirectory(this.getParams(this.props, this.state)));
-  };
-
-  render () {
-    const { isLoading, accountIds, intl, columnId, multiColumn, domain } = this.props;
-    const { order, local }  = this.getParams(this.props, this.state);
-    const pinned = !!columnId;
-
-    const scrollableArea = (
-      <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} />
-            <RadioButton name='order' value='new' label={intl.formatMessage(messages.newArrivals)} checked={order === 'new'} onChange={this.handleChangeOrder} />
-          </div>
-
-          <div className='filter-form__column' role='group'>
-            <RadioButton name='local' value='1' label={intl.formatMessage(messages.local, { domain })} checked={local} onChange={this.handleChangeLocal} />
-            <RadioButton name='local' value='0' label={intl.formatMessage(messages.federated)} checked={!local} onChange={this.handleChangeLocal} />
-          </div>
-        </div>
-
-        <div className='directory__list'>
-          {isLoading ? <LoadingIndicator /> : accountIds.map(accountId => (
-            <AccountCard id={accountId} key={accountId} />
-          ))}
-        </div>
-
-        <LoadMore onClick={this.handleLoadMore} visible={!isLoading} />
-      </div>
-    );
-
-    return (
-      <Column bindToDocument={!multiColumn} ref={this.setRef} label={intl.formatMessage(messages.title)}>
-        <ColumnHeader
-          icon='address-book-o'
-          iconComponent={PeopleIcon}
-          title={intl.formatMessage(messages.title)}
-          onPin={this.handlePin}
-          onMove={this.handleMove}
-          onClick={this.handleHeaderClick}
-          pinned={pinned}
-          multiColumn={multiColumn}
-        />
-
-        {multiColumn && !pinned ? <ScrollContainer scrollKey='directory'>{scrollableArea}</ScrollContainer> : scrollableArea}
-
-        <Helmet>
-          <title>{intl.formatMessage(messages.title)}</title>
-          <meta name='robots' content='noindex' />
-        </Helmet>
-      </Column>
-    );
-  }
-
-}
-
-export default connect(mapStateToProps)(injectIntl(Directory));
diff --git a/app/javascript/mastodon/features/directory/index.tsx b/app/javascript/mastodon/features/directory/index.tsx
new file mode 100644
index 0000000000..51d283a482
--- /dev/null
+++ b/app/javascript/mastodon/features/directory/index.tsx
@@ -0,0 +1,216 @@
+import type { ChangeEventHandler } from 'react';
+import { useCallback, useEffect, useRef, useState } from 'react';
+
+import { defineMessages, useIntl } from 'react-intl';
+
+import { Helmet } from 'react-helmet';
+
+import { List as ImmutableList } from 'immutable';
+
+import PeopleIcon from '@/material-icons/400-24px/group.svg?react';
+import {
+  addColumn,
+  removeColumn,
+  moveColumn,
+  changeColumnParams,
+} from 'mastodon/actions/columns';
+import { fetchDirectory, expandDirectory } from 'mastodon/actions/directory';
+import Column from 'mastodon/components/column';
+import { ColumnHeader } from 'mastodon/components/column_header';
+import { LoadMore } from 'mastodon/components/load_more';
+import { LoadingIndicator } from 'mastodon/components/loading_indicator';
+import { RadioButton } from 'mastodon/components/radio_button';
+import ScrollContainer from 'mastodon/containers/scroll_container';
+import { useAppDispatch, useAppSelector } from 'mastodon/store';
+
+import { AccountCard } from './components/account_card';
+
+const messages = defineMessages({
+  title: { id: 'column.directory', defaultMessage: 'Browse profiles' },
+  recentlyActive: {
+    id: 'directory.recently_active',
+    defaultMessage: 'Recently active',
+  },
+  newArrivals: { id: 'directory.new_arrivals', defaultMessage: 'New arrivals' },
+  local: { id: 'directory.local', defaultMessage: 'From {domain} only' },
+  federated: {
+    id: 'directory.federated',
+    defaultMessage: 'From known fediverse',
+  },
+});
+
+export const Directory: React.FC<{
+  columnId?: string;
+  multiColumn?: boolean;
+  params?: { order: string; local?: boolean };
+}> = ({ columnId, multiColumn, params }) => {
+  const intl = useIntl();
+  const dispatch = useAppDispatch();
+
+  const [state, setState] = useState<{
+    order: string | null;
+    local: boolean | null;
+  }>({
+    order: null,
+    local: null,
+  });
+
+  const column = useRef<Column>(null);
+
+  const order = state.order ?? params?.order ?? 'active';
+  const local = state.local ?? params?.local ?? false;
+
+  const handlePin = useCallback(() => {
+    if (columnId) {
+      dispatch(removeColumn(columnId));
+    } else {
+      dispatch(addColumn('DIRECTORY', { order, local }));
+    }
+  }, [dispatch, columnId, order, local]);
+
+  const domain = useAppSelector((s) => s.meta.get('domain') as string);
+  const accountIds = useAppSelector(
+    (state) =>
+      state.user_lists.getIn(
+        ['directory', 'items'],
+        ImmutableList(),
+      ) as ImmutableList<string>,
+  );
+  const isLoading = useAppSelector(
+    (state) =>
+      state.user_lists.getIn(['directory', 'isLoading'], true) as boolean,
+  );
+
+  useEffect(() => {
+    void dispatch(fetchDirectory({ order, local }));
+  }, [dispatch, order, local]);
+
+  const handleMove = useCallback(
+    (dir: number) => {
+      dispatch(moveColumn(columnId, dir));
+    },
+    [dispatch, columnId],
+  );
+
+  const handleHeaderClick = useCallback(() => {
+    column.current?.scrollTop();
+  }, []);
+
+  const handleChangeOrder = useCallback<ChangeEventHandler<HTMLInputElement>>(
+    (e) => {
+      if (columnId) {
+        dispatch(changeColumnParams(columnId, ['order'], e.target.value));
+      } else {
+        setState((s) => ({ order: e.target.value, local: s.local }));
+      }
+    },
+    [dispatch, columnId],
+  );
+
+  const handleChangeLocal = useCallback<ChangeEventHandler<HTMLInputElement>>(
+    (e) => {
+      if (columnId) {
+        dispatch(
+          changeColumnParams(columnId, ['local'], e.target.value === '1'),
+        );
+      } else {
+        setState((s) => ({ local: e.target.value === '1', order: s.order }));
+      }
+    },
+    [dispatch, columnId],
+  );
+
+  const handleLoadMore = useCallback(() => {
+    void dispatch(expandDirectory({ order, local }));
+  }, [dispatch, order, local]);
+
+  const pinned = !!columnId;
+
+  const scrollableArea = (
+    <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={handleChangeOrder}
+          />
+          <RadioButton
+            name='order'
+            value='new'
+            label={intl.formatMessage(messages.newArrivals)}
+            checked={order === 'new'}
+            onChange={handleChangeOrder}
+          />
+        </div>
+
+        <div className='filter-form__column' role='group'>
+          <RadioButton
+            name='local'
+            value='1'
+            label={intl.formatMessage(messages.local, { domain })}
+            checked={local}
+            onChange={handleChangeLocal}
+          />
+          <RadioButton
+            name='local'
+            value='0'
+            label={intl.formatMessage(messages.federated)}
+            checked={!local}
+            onChange={handleChangeLocal}
+          />
+        </div>
+      </div>
+
+      <div className='directory__list'>
+        {isLoading ? (
+          <LoadingIndicator />
+        ) : (
+          accountIds.map((accountId) => (
+            <AccountCard accountId={accountId} key={accountId} />
+          ))
+        )}
+      </div>
+
+      <LoadMore onClick={handleLoadMore} visible={!isLoading} />
+    </div>
+  );
+
+  return (
+    <Column
+      bindToDocument={!multiColumn}
+      ref={column}
+      label={intl.formatMessage(messages.title)}
+    >
+      <ColumnHeader
+        icon='address-book-o'
+        iconComponent={PeopleIcon}
+        title={intl.formatMessage(messages.title)}
+        onPin={handlePin}
+        onMove={handleMove}
+        onClick={handleHeaderClick}
+        pinned={pinned}
+        multiColumn={multiColumn}
+      />
+
+      {multiColumn && !pinned ? (
+        // @ts-expect-error ScrollContainer is not properly typed yet
+        <ScrollContainer scrollKey='directory'>
+          {scrollableArea}
+        </ScrollContainer>
+      ) : (
+        scrollableArea
+      )}
+
+      <Helmet>
+        <title>{intl.formatMessage(messages.title)}</title>
+        <meta name='robots' content='noindex' />
+      </Helmet>
+    </Column>
+  );
+};
+
+// eslint-disable-next-line import/no-default-export -- Needed because this is called as an async components
+export default Directory;
diff --git a/app/javascript/mastodon/features/explore/components/author_link.jsx b/app/javascript/mastodon/features/explore/components/author_link.jsx
index b9dec3367e..764ae75341 100644
--- a/app/javascript/mastodon/features/explore/components/author_link.jsx
+++ b/app/javascript/mastodon/features/explore/components/author_link.jsx
@@ -8,8 +8,12 @@ import { useAppSelector } from 'mastodon/store';
 export const AuthorLink = ({ accountId }) => {
   const account = useAppSelector(state => state.getIn(['accounts', accountId]));
 
+  if (!account) {
+    return null;
+  }
+
   return (
-    <Link to={`/@${account.get('acct')}`} className='story__details__shared__author-link'>
+    <Link to={`/@${account.get('acct')}`} className='story__details__shared__author-link' data-hover-card-account={accountId}>
       <Avatar account={account} size={16} />
       <bdi dangerouslySetInnerHTML={{ __html: account.get('display_name_html') }} />
     </Link>
diff --git a/app/javascript/mastodon/features/explore/components/card.jsx b/app/javascript/mastodon/features/explore/components/card.jsx
index 316203060a..1908648510 100644
--- a/app/javascript/mastodon/features/explore/components/card.jsx
+++ b/app/javascript/mastodon/features/explore/components/card.jsx
@@ -8,34 +8,21 @@ import { Link } from 'react-router-dom';
 import { useDispatch, useSelector } from 'react-redux';
 
 import CloseIcon from '@/material-icons/400-24px/close.svg?react';
-import { followAccount, unfollowAccount } from 'mastodon/actions/accounts';
 import { dismissSuggestion } from 'mastodon/actions/suggestions';
 import { Avatar } from 'mastodon/components/avatar';
-import { Button } from 'mastodon/components/button';
 import { DisplayName } from 'mastodon/components/display_name';
+import { FollowButton } from 'mastodon/components/follow_button';
 import { IconButton } from 'mastodon/components/icon_button';
 import { domain } from 'mastodon/initial_state';
 
 const messages = defineMessages({
-  follow: { id: 'account.follow', defaultMessage: 'Follow' },
-  unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
   dismiss: { id: 'follow_suggestions.dismiss', defaultMessage: "Don't show again" },
 });
 
 export const Card = ({ id, source }) => {
   const intl = useIntl();
   const account = useSelector(state => state.getIn(['accounts', id]));
-  const relationship = useSelector(state => state.getIn(['relationships', id]));
   const dispatch = useDispatch();
-  const following = relationship?.get('following') ?? relationship?.get('requested');
-
-  const handleFollow = useCallback(() => {
-    if (following) {
-      dispatch(unfollowAccount(id));
-    } else {
-      dispatch(followAccount(id));
-    }
-  }, [id, following, dispatch]);
 
   const handleDismiss = useCallback(() => {
     dispatch(dismissSuggestion(id));
@@ -74,7 +61,7 @@ export const Card = ({ id, source }) => {
           <div className='explore__suggestions__card__body__main__name-button'>
             <Link className='explore__suggestions__card__body__main__name-button__name' to={`/@${account.get('acct')}`}><DisplayName account={account} /></Link>
             <IconButton iconComponent={CloseIcon} onClick={handleDismiss} title={intl.formatMessage(messages.dismiss)} />
-            <Button text={intl.formatMessage(following ? messages.unfollow : messages.follow)} secondary={following} onClick={handleFollow} />
+            <FollowButton accountId={account.get('id')} />
           </div>
         </div>
       </div>
diff --git a/app/javascript/mastodon/features/explore/links.jsx b/app/javascript/mastodon/features/explore/links.jsx
index 93fd1fb6dd..035e5aaad8 100644
--- a/app/javascript/mastodon/features/explore/links.jsx
+++ b/app/javascript/mastodon/features/explore/links.jsx
@@ -75,7 +75,7 @@ class Links extends PureComponent {
             publisher={link.get('provider_name')}
             publishedAt={link.get('published_at')}
             author={link.get('author_name')}
-            authorAccount={link.getIn(['author_account', 'id'])}
+            authorAccount={link.getIn(['authors', 0, 'account', 'id'])}
             sharedTimes={link.getIn(['history', 0, 'accounts']) * 1 + link.getIn(['history', 1, 'accounts']) * 1}
             thumbnail={link.get('image')}
             thumbnailDescription={link.get('image_description')}
diff --git a/app/javascript/mastodon/features/home_timeline/components/inline_follow_suggestions.jsx b/app/javascript/mastodon/features/home_timeline/components/inline_follow_suggestions.jsx
index c39b43bade..1b8040e55b 100644
--- a/app/javascript/mastodon/features/home_timeline/components/inline_follow_suggestions.jsx
+++ b/app/javascript/mastodon/features/home_timeline/components/inline_follow_suggestions.jsx
@@ -12,12 +12,11 @@ import ChevronLeftIcon from '@/material-icons/400-24px/chevron_left.svg?react';
 import ChevronRightIcon from '@/material-icons/400-24px/chevron_right.svg?react';
 import CloseIcon from '@/material-icons/400-24px/close.svg?react';
 import InfoIcon from '@/material-icons/400-24px/info.svg?react';
-import { followAccount, unfollowAccount } from 'mastodon/actions/accounts';
 import { changeSetting } from 'mastodon/actions/settings';
 import { fetchSuggestions, dismissSuggestion } from 'mastodon/actions/suggestions';
 import { Avatar } from 'mastodon/components/avatar';
-import { Button } from 'mastodon/components/button';
 import { DisplayName } from 'mastodon/components/display_name';
+import { FollowButton } from 'mastodon/components/follow_button';
 import { Icon } from 'mastodon/components/icon';
 import { IconButton } from 'mastodon/components/icon_button';
 import { VerifiedBadge } from 'mastodon/components/verified_badge';
@@ -79,18 +78,8 @@ Source.propTypes = {
 const Card = ({ id, sources }) => {
   const intl = useIntl();
   const account = useSelector(state => state.getIn(['accounts', id]));
-  const relationship = useSelector(state => state.getIn(['relationships', id]));
   const firstVerifiedField = account.get('fields').find(item => !!item.get('verified_at'));
   const dispatch = useDispatch();
-  const following = relationship?.get('following') ?? relationship?.get('requested');
-
-  const handleFollow = useCallback(() => {
-    if (following) {
-      dispatch(unfollowAccount(id));
-    } else {
-      dispatch(followAccount(id));
-    }
-  }, [id, following, dispatch]);
 
   const handleDismiss = useCallback(() => {
     dispatch(dismissSuggestion(id));
@@ -109,7 +98,7 @@ const Card = ({ id, sources }) => {
         {firstVerifiedField ? <VerifiedBadge link={firstVerifiedField.get('value')} /> : <Source id={sources.get(0)} />}
       </div>
 
-      <Button text={intl.formatMessage(following ? messages.unfollow : messages.follow)} secondary={following} onClick={handleFollow} />
+      <FollowButton accountId={id} />
     </div>
   );
 };
diff --git a/app/javascript/mastodon/features/notifications/components/notification.jsx b/app/javascript/mastodon/features/notifications/components/notification.jsx
index 69084c2111..272893042d 100644
--- a/app/javascript/mastodon/features/notifications/components/notification.jsx
+++ b/app/javascript/mastodon/features/notifications/components/notification.jsx
@@ -435,7 +435,7 @@ class Notification extends ImmutablePureComponent {
 
     const targetAccount = report.get('target_account');
     const targetDisplayNameHtml = { __html: targetAccount.get('display_name_html') };
-    const targetLink = <bdi><Link className='notification__display-name' title={targetAccount.get('acct')} to={`/@${targetAccount.get('acct')}`} dangerouslySetInnerHTML={targetDisplayNameHtml} /></bdi>;
+    const targetLink = <bdi><Link className='notification__display-name' data-hover-card-account={targetAccount.get('id')} to={`/@${targetAccount.get('acct')}`} dangerouslySetInnerHTML={targetDisplayNameHtml} /></bdi>;
 
     return (
       <HotKeys handlers={this.getHandlers()}>
@@ -458,7 +458,7 @@ class Notification extends ImmutablePureComponent {
     const { notification } = this.props;
     const account          = notification.get('account');
     const displayNameHtml  = { __html: account.get('display_name_html') };
-    const link             = <bdi><Link className='notification__display-name' href={`/@${account.get('acct')}`} title={account.get('acct')} to={`/@${account.get('acct')}`} dangerouslySetInnerHTML={displayNameHtml} /></bdi>;
+    const link             = <bdi><Link className='notification__display-name' href={`/@${account.get('acct')}`} data-hover-card-account={account.get('id')} to={`/@${account.get('acct')}`} dangerouslySetInnerHTML={displayNameHtml} /></bdi>;
 
     switch(notification.get('type')) {
     case 'follow':
diff --git a/app/javascript/mastodon/features/status/components/card.jsx b/app/javascript/mastodon/features/status/components/card.jsx
index f562e53f0b..f0ae40cbc4 100644
--- a/app/javascript/mastodon/features/status/components/card.jsx
+++ b/app/javascript/mastodon/features/status/components/card.jsx
@@ -138,7 +138,7 @@ export default class Card extends PureComponent {
     const interactive = card.get('type') === 'video';
     const language    = card.get('language') || '';
     const largeImage  = (card.get('image')?.length > 0 && card.get('width') > card.get('height')) || interactive;
-    const showAuthor  = !!card.get('author_account');
+    const showAuthor  = !!card.getIn(['authors', 0, 'accountId']);
 
     const description = (
       <div className='status-card__content'>
@@ -244,7 +244,7 @@ export default class Card extends PureComponent {
           {description}
         </a>
 
-        {showAuthor && <MoreFromAuthor accountId={card.get('author_account')} />}
+        {showAuthor && <MoreFromAuthor accountId={card.getIn(['authors', 0, 'accountId'])} />}
       </>
     );
   }
diff --git a/app/javascript/mastodon/features/status/components/detailed_status.jsx b/app/javascript/mastodon/features/status/components/detailed_status.jsx
index 8843619bc9..bc81fd2dfb 100644
--- a/app/javascript/mastodon/features/status/components/detailed_status.jsx
+++ b/app/javascript/mastodon/features/status/components/detailed_status.jsx
@@ -272,7 +272,7 @@ class DetailedStatus extends ImmutablePureComponent {
               <FormattedMessage id='status.direct_indicator' defaultMessage='Private mention' />
             </div>
           )}
-          <a href={`/@${status.getIn(['account', 'acct'])}`} onClick={this.handleAccountClick} className='detailed-status__display-name'>
+          <a href={`/@${status.getIn(['account', 'acct'])}`} data-hover-card-account={status.getIn(['account', 'id'])} onClick={this.handleAccountClick} className='detailed-status__display-name'>
             <div className='detailed-status__display-avatar'><Avatar account={status.get('account')} size={46} /></div>
             <DisplayName account={status.get('account')} localDomain={this.props.domain} />
           </a>
diff --git a/app/javascript/mastodon/features/ui/components/column_loading.tsx b/app/javascript/mastodon/features/ui/components/column_loading.tsx
index 42174838cf..d9563dda7a 100644
--- a/app/javascript/mastodon/features/ui/components/column_loading.tsx
+++ b/app/javascript/mastodon/features/ui/components/column_loading.tsx
@@ -1,11 +1,8 @@
-import Column from '../../../components/column';
-import ColumnHeader from '../../../components/column_header';
+import Column from 'mastodon/components/column';
+import { ColumnHeader } from 'mastodon/components/column_header';
+import type { Props as ColumnHeaderProps } from 'mastodon/components/column_header';
 
-interface Props {
-  multiColumn?: boolean;
-}
-
-export const ColumnLoading: React.FC<Props> = (otherProps) => (
+export const ColumnLoading: React.FC<ColumnHeaderProps> = (otherProps) => (
   <Column>
     <ColumnHeader {...otherProps} />
     <div className='scrollable' />
diff --git a/app/javascript/mastodon/features/ui/index.jsx b/app/javascript/mastodon/features/ui/index.jsx
index 7742f64860..b58e191ed8 100644
--- a/app/javascript/mastodon/features/ui/index.jsx
+++ b/app/javascript/mastodon/features/ui/index.jsx
@@ -14,6 +14,7 @@ import { HotKeys } from 'react-hotkeys';
 import { focusApp, unfocusApp, changeLayout } from 'mastodon/actions/app';
 import { synchronouslySubmitMarkers, submitMarkers, fetchMarkers } from 'mastodon/actions/markers';
 import { INTRODUCTION_VERSION } from 'mastodon/actions/onboarding';
+import { HoverCardController } from 'mastodon/components/hover_card_controller';
 import { PictureInPicture } from 'mastodon/features/picture_in_picture';
 import { identityContextPropShape, withIdentity } from 'mastodon/identity_context';
 import { layoutFromWindow } from 'mastodon/is_mobile';
@@ -585,6 +586,7 @@ class UI extends PureComponent {
 
           {layout !== 'mobile' && <PictureInPicture />}
           <NotificationsContainer />
+          <HoverCardController />
           <LoadingBarContainer className='loading-bar' />
           <ModalContainer />
           <UploadArea active={draggingOver} onClose={this.closeUploadModal} />
diff --git a/app/javascript/mastodon/locales/af.json b/app/javascript/mastodon/locales/af.json
index e4f7f12b0e..77e15eb2c6 100644
--- a/app/javascript/mastodon/locales/af.json
+++ b/app/javascript/mastodon/locales/af.json
@@ -50,7 +50,6 @@
   "account.requested_follow": "{name} het versoek om jou te volg",
   "account.share": "Deel @{name} se profiel",
   "account.show_reblogs": "Wys aangestuurde plasings van @{name}",
-  "account.statuses_counter": "{count, plural, one {{counter} Plaas} other {{counter} Plasings}}",
   "account.unblock": "Deblokkeer @{name}",
   "account.unblock_domain": "Deblokkeer domein {domain}",
   "account.unblock_short": "Deblokkeer",
diff --git a/app/javascript/mastodon/locales/an.json b/app/javascript/mastodon/locales/an.json
index af5f8426d0..752b6c3564 100644
--- a/app/javascript/mastodon/locales/an.json
+++ b/app/javascript/mastodon/locales/an.json
@@ -31,9 +31,7 @@
   "account.follow": "Seguir",
   "account.followers": "Seguidores",
   "account.followers.empty": "Encara no sigue dengún a este usuario.",
-  "account.followers_counter": "{count, plural, one {{counter} Seguidor} other {{counter} Seguidores}}",
   "account.following": "Seguindo",
-  "account.following_counter": "{count, plural, one {{counter} Following} other {{counter} Seguindo}}",
   "account.follows.empty": "Este usuario encara no sigue a dengún.",
   "account.go_to_profile": "Ir ta lo perfil",
   "account.hide_reblogs": "Amagar retutz de @{name}",
@@ -54,7 +52,6 @@
   "account.requested_follow": "{name} ha demandau seguir-te",
   "account.share": "Compartir lo perfil de @{name}",
   "account.show_reblogs": "Amostrar retutz de @{name}",
-  "account.statuses_counter": "{count, plural, one {{counter} Publicación} other {{counter} Publicaciones}}",
   "account.unblock": "Desblocar a @{name}",
   "account.unblock_domain": "Amostrar a {domain}",
   "account.unblock_short": "Desblocar",
diff --git a/app/javascript/mastodon/locales/ar.json b/app/javascript/mastodon/locales/ar.json
index b5ce0ae861..a9e1bdb270 100644
--- a/app/javascript/mastodon/locales/ar.json
+++ b/app/javascript/mastodon/locales/ar.json
@@ -35,9 +35,7 @@
   "account.follow_back": "رد المتابعة",
   "account.followers": "مُتابِعون",
   "account.followers.empty": "لا أحدَ يُتابع هذا المُستخدم إلى حد الآن.",
-  "account.followers_counter": "{count, plural, zero{لا مُتابع} one {مُتابعٌ واحِد} two {مُتابعانِ اِثنان} few {{counter} مُتابِعين} many {{counter} مُتابِعًا} other {{counter} مُتابع}}",
   "account.following": "الاشتراكات",
-  "account.following_counter": "{count, plural, zero{لا يُتابِع أحدًا} one {يُتابِعُ واحد} two{يُتابِعُ اِثنان} few{يُتابِعُ {counter}} many{يُتابِعُ {counter}} other {يُتابِعُ {counter}}}",
   "account.follows.empty": "لا يُتابع هذا المُستخدمُ أيَّ أحدٍ حتى الآن.",
   "account.go_to_profile": "اذهب إلى الملف الشخصي",
   "account.hide_reblogs": "إخفاء المعاد نشرها مِن @{name}",
@@ -63,7 +61,6 @@
   "account.requested_follow": "لقد طلب {name} متابعتك",
   "account.share": "شارِك الملف التعريفي لـ @{name}",
   "account.show_reblogs": "اعرض إعادات نشر @{name}",
-  "account.statuses_counter": "{count, plural, zero {لَا منشورات} one {منشور واحد} two {منشوران إثنان} few {{counter} منشورات} many {{counter} منشورًا} other {{counter} منشور}}",
   "account.unblock": "إلغاء الحَظر عن @{name}",
   "account.unblock_domain": "إلغاء الحَظر عن النِّطاق {domain}",
   "account.unblock_short": "ألغ الحجب",
diff --git a/app/javascript/mastodon/locales/ast.json b/app/javascript/mastodon/locales/ast.json
index 80e0aa6cbf..3f32a8bf15 100644
--- a/app/javascript/mastodon/locales/ast.json
+++ b/app/javascript/mastodon/locales/ast.json
@@ -32,7 +32,6 @@
   "account.followers": "Siguidores",
   "account.followers.empty": "Naide sigue a esti perfil.",
   "account.following": "Siguiendo",
-  "account.following_counter": "{count, plural,one {Sigue a {counter}} other {Sigue a {counter}}}",
   "account.follows.empty": "Esti perfil nun sigue a naide.",
   "account.go_to_profile": "Dir al perfil",
   "account.hide_reblogs": "Anubrir los artículos compartíos de @{name}",
@@ -49,7 +48,6 @@
   "account.report": "Informar de: @{name}",
   "account.requested_follow": "{name} solicitó siguite",
   "account.show_reblogs": "Amosar los artículos compartíos de @{name}",
-  "account.statuses_counter": "{count, plural, one {{counter} artículu} other {{counter} artículos}}",
   "account.unblock": "Desbloquiar a @{name}",
   "account.unblock_domain": "Desbloquiar el dominiu «{domain}»",
   "account.unblock_short": "Desbloquiar",
diff --git a/app/javascript/mastodon/locales/be.json b/app/javascript/mastodon/locales/be.json
index 03164c4290..643725270c 100644
--- a/app/javascript/mastodon/locales/be.json
+++ b/app/javascript/mastodon/locales/be.json
@@ -35,9 +35,7 @@
   "account.follow_back": "Падпісацца ў адказ",
   "account.followers": "Падпісчыкі",
   "account.followers.empty": "Ніхто пакуль не падпісаны на гэтага карыстальніка.",
-  "account.followers_counter": "{count, plural, one {{counter} падпісчык} few {{counter} падпісчыкі} many {{counter} падпісчыкаў} other {{counter} падпісчыка}}",
   "account.following": "Падпіскі",
-  "account.following_counter": "{count, plural, one {{counter} падпіска} few {{counter} падпіскі} many {{counter} падпісак} other {{counter} падпіскі}}",
   "account.follows.empty": "Карыстальнік ні на каго не падпісаны.",
   "account.go_to_profile": "Перайсці да профілю",
   "account.hide_reblogs": "Схаваць пашырэнні ад @{name}",
@@ -63,7 +61,6 @@
   "account.requested_follow": "{name} адправіў запыт на падпіску",
   "account.share": "Абагуліць профіль @{name}",
   "account.show_reblogs": "Паказаць падштурхоўванні ад @{name}",
-  "account.statuses_counter": "{count, plural, one {{counter} допіс} few {{counter} допісы} many {{counter} допісаў} other {{counter} допісу}}",
   "account.unblock": "Разблакіраваць @{name}",
   "account.unblock_domain": "Разблакіраваць дамен {domain}",
   "account.unblock_short": "Разблакіраваць",
diff --git a/app/javascript/mastodon/locales/bg.json b/app/javascript/mastodon/locales/bg.json
index 98e84c45d7..323890ba2e 100644
--- a/app/javascript/mastodon/locales/bg.json
+++ b/app/javascript/mastodon/locales/bg.json
@@ -35,9 +35,7 @@
   "account.follow_back": "Последване взаимно",
   "account.followers": "Последователи",
   "account.followers.empty": "Още никой не следва потребителя.",
-  "account.followers_counter": "{count, plural, one {{counter} последовател} other {{counter} последователи}}",
   "account.following": "Последвано",
-  "account.following_counter": "{count, plural, one {{counter} последван} other {{counter} последвани}}",
   "account.follows.empty": "Потребителят още никого не следва.",
   "account.go_to_profile": "Към профила",
   "account.hide_reblogs": "Скриване на подсилвания от @{name}",
@@ -63,7 +61,6 @@
   "account.requested_follow": "{name} поиска да ви последва",
   "account.share": "Споделяне на профила на @{name}",
   "account.show_reblogs": "Показване на подсилвания от @{name}",
-  "account.statuses_counter": "{count, plural, one {{counter} публикация} other {{counter} публикации}}",
   "account.unblock": "Отблокиране на @{name}",
   "account.unblock_domain": "Отблокиране на домейн {domain}",
   "account.unblock_short": "Отблокиране",
diff --git a/app/javascript/mastodon/locales/bn.json b/app/javascript/mastodon/locales/bn.json
index 4c4138bcf1..a203c43f03 100644
--- a/app/javascript/mastodon/locales/bn.json
+++ b/app/javascript/mastodon/locales/bn.json
@@ -33,9 +33,7 @@
   "account.follow": "অনুসরণ",
   "account.followers": "অনুসরণকারী",
   "account.followers.empty": "এই ব্যক্তিকে এখনো কেউ অনুসরণ করে না.",
-  "account.followers_counter": "{count, plural,one {{counter} জন অনুসরণকারী } other {{counter} জন অনুসরণকারী}}",
   "account.following": "অনুসরণ করা হচ্ছে",
-  "account.following_counter": "{count, plural,one {{counter} জনকে অনুসরণ} other {{counter} জনকে অনুসরণ}}",
   "account.follows.empty": "এই সদস্য কাউকে এখনো ফলো করেন না.",
   "account.go_to_profile": "প্রোফাইলে যান",
   "account.hide_reblogs": "@{name}'র সমর্থনগুলি লুকিয়ে ফেলুন",
@@ -60,7 +58,6 @@
   "account.requested_follow": "{name} আপনাকে অনুসরণ করার জন্য অনুরোধ করেছে",
   "account.share": "@{name} র প্রোফাইল অন্যদের দেখান",
   "account.show_reblogs": "@{name} র সমর্থনগুলো দেখান",
-  "account.statuses_counter": "{count, plural,one {{counter} টুট} other {{counter} টুট}}",
   "account.unblock": "@{name} র কার্যকলাপ দেখুন",
   "account.unblock_domain": "{domain} কে আবার দেখুন",
   "account.unblock_short": "আনব্লক করুন",
diff --git a/app/javascript/mastodon/locales/br.json b/app/javascript/mastodon/locales/br.json
index 7cd49ba59d..c919d2e9de 100644
--- a/app/javascript/mastodon/locales/br.json
+++ b/app/javascript/mastodon/locales/br.json
@@ -35,9 +35,7 @@
   "account.follow_back": "Heuliañ d'ho tro",
   "account.followers": "Tud koumanantet",
   "account.followers.empty": "Den na heul an implijer·ez-mañ c'hoazh.",
-  "account.followers_counter": "{count, plural, other{{counter} Heulier·ez}}",
   "account.following": "Koumanantoù",
-  "account.following_counter": "{count, plural, one{{counter} C'houmanant} two{{counter} Goumanant} other {{counter} a Goumanant}}",
   "account.follows.empty": "An implijer·ez-mañ na heul den ebet.",
   "account.go_to_profile": "Gwelet ar profil",
   "account.hide_reblogs": "Kuzh skignadennoù gant @{name}",
@@ -62,7 +60,6 @@
   "account.requested_follow": "Gant {name} eo bet goulennet ho heuliañ",
   "account.share": "Skignañ profil @{name}",
   "account.show_reblogs": "Diskouez skignadennoù @{name}",
-  "account.statuses_counter": "{count, plural, one {{counter} C'hannad} two {{counter} Gannad} other {{counter} a Gannadoù}}",
   "account.unblock": "Diverzañ @{name}",
   "account.unblock_domain": "Diverzañ an domani {domain}",
   "account.unblock_short": "Distankañ",
diff --git a/app/javascript/mastodon/locales/ca.json b/app/javascript/mastodon/locales/ca.json
index 88dd34aff0..3123e29d8d 100644
--- a/app/javascript/mastodon/locales/ca.json
+++ b/app/javascript/mastodon/locales/ca.json
@@ -35,9 +35,9 @@
   "account.follow_back": "Segueix tu també",
   "account.followers": "Seguidors",
   "account.followers.empty": "A aquest usuari encara no el segueix ningú.",
-  "account.followers_counter": "{count, plural, one {{counter} seguidor} other {{counter} Seguidors}}",
+  "account.followers_counter": "{count, plural, one {{counter} seguidor} other {{counter} seguidors}}",
   "account.following": "Seguint",
-  "account.following_counter": "{count, plural, other {{counter} Seguint-ne}}",
+  "account.following_counter": "{count, plural, other {Seguint-ne {counter}}}",
   "account.follows.empty": "Aquest usuari encara no segueix ningú.",
   "account.go_to_profile": "Vés al perfil",
   "account.hide_reblogs": "Amaga els impulsos de @{name}",
@@ -63,7 +63,7 @@
   "account.requested_follow": "{name} ha demanat de seguir-te",
   "account.share": "Comparteix el perfil de @{name}",
   "account.show_reblogs": "Mostra els impulsos de @{name}",
-  "account.statuses_counter": "{count, plural, one {{counter} Tut} other {{counter} Tuts}}",
+  "account.statuses_counter": "{count, plural, one {{counter} publicació} other {{counter} publicacions}}",
   "account.unblock": "Desbloca @{name}",
   "account.unblock_domain": "Desbloca el domini {domain}",
   "account.unblock_short": "Desbloca",
diff --git a/app/javascript/mastodon/locales/ckb.json b/app/javascript/mastodon/locales/ckb.json
index c212b53a8b..3ebf9391d2 100644
--- a/app/javascript/mastodon/locales/ckb.json
+++ b/app/javascript/mastodon/locales/ckb.json
@@ -35,9 +35,7 @@
   "account.follow_back": "فۆڵۆو بکەنەوە",
   "account.followers": "شوێنکەوتووان",
   "account.followers.empty": "کەسێک شوێن ئەم بەکارهێنەرە نەکەوتووە",
-  "account.followers_counter": "{count, plural, one {{counter} شوێنکەوتوو} other {{counter} شوێنکەوتوو}}",
   "account.following": "بەدوادا",
-  "account.following_counter": "{count, plural, one {{counter} شوێنکەوتوو} other {{counter} شوێنکەوتوو}}",
   "account.follows.empty": "ئەم بەکارهێنەرە تا ئێستا شوێن کەس نەکەوتووە.",
   "account.go_to_profile": "بڕۆ بۆ پڕۆفایلی",
   "account.hide_reblogs": "داشاردنی بووستەکان لە @{name}",
@@ -63,7 +61,6 @@
   "account.requested_follow": "{name} داوای کردووە شوێنت بکەوێت",
   "account.share": "پرۆفایلی @{name} هاوبەش بکە",
   "account.show_reblogs": "پیشاندانی بەرزکردنەوەکان لە @{name}",
-  "account.statuses_counter": "{count, plural, one {{counter} Following} other {{counter} Following}}",
   "account.unblock": "@{name} لاببە",
   "account.unblock_domain": "کردنەوەی دۆمەینی {domain}",
   "account.unblock_short": "لابردنی بەربەست",
diff --git a/app/javascript/mastodon/locales/co.json b/app/javascript/mastodon/locales/co.json
index be4cce2692..78f8e6fd78 100644
--- a/app/javascript/mastodon/locales/co.json
+++ b/app/javascript/mastodon/locales/co.json
@@ -16,8 +16,6 @@
   "account.follow": "Siguità",
   "account.followers": "Abbunati",
   "account.followers.empty": "Nisunu hè abbunatu à st'utilizatore.",
-  "account.followers_counter": "{count, plural, one {{counter} Abbunatu} other {{counter} Abbunati}}",
-  "account.following_counter": "{count, plural, one {{counter} Abbunamentu} other {{counter} Abbunamenti}}",
   "account.follows.empty": "St'utilizatore ùn seguita nisunu.",
   "account.hide_reblogs": "Piattà spartere da @{name}",
   "account.link_verified_on": "A prupietà di stu ligame hè stata verificata u {date}",
@@ -32,7 +30,6 @@
   "account.requested": "In attesa d'apprubazione. Cliccate per annullà a dumanda",
   "account.share": "Sparte u prufile di @{name}",
   "account.show_reblogs": "Vede spartere da @{name}",
-  "account.statuses_counter": "{count, plural, one {{counter} Statutu} other {{counter} Statuti}}",
   "account.unblock": "Sbluccà @{name}",
   "account.unblock_domain": "Ùn piattà più {domain}",
   "account.unendorse": "Ùn fà figurà nant'à u prufilu",
diff --git a/app/javascript/mastodon/locales/cs.json b/app/javascript/mastodon/locales/cs.json
index d8d83ae5fa..66aa1fe0a9 100644
--- a/app/javascript/mastodon/locales/cs.json
+++ b/app/javascript/mastodon/locales/cs.json
@@ -35,9 +35,7 @@
   "account.follow_back": "Také sledovat",
   "account.followers": "Sledující",
   "account.followers.empty": "Tohoto uživatele zatím nikdo nesleduje.",
-  "account.followers_counter": "{count, plural, one {{counter} Sledující} few {{counter} Sledující} many {{counter} Sledujících} other {{counter} Sledujících}}",
   "account.following": "Sledujete",
-  "account.following_counter": "{count, plural, one {{counter} Sledovaný} few {{counter} Sledovaní} many {{counter} Sledovaných} other {{counter} Sledovaných}}",
   "account.follows.empty": "Tento uživatel zatím nikoho nesleduje.",
   "account.go_to_profile": "Přejít na profil",
   "account.hide_reblogs": "Skrýt boosty od @{name}",
@@ -63,7 +61,6 @@
   "account.requested_follow": "{name} tě požádal o sledování",
   "account.share": "Sdílet profil @{name}",
   "account.show_reblogs": "Zobrazit boosty od @{name}",
-  "account.statuses_counter": "{count, plural, one {{counter} Příspěvek} few {{counter} Příspěvky} many {{counter} Příspěvků} other {{counter} Příspěvků}}",
   "account.unblock": "Odblokovat @{name}",
   "account.unblock_domain": "Odblokovat doménu {domain}",
   "account.unblock_short": "Odblokovat",
diff --git a/app/javascript/mastodon/locales/cy.json b/app/javascript/mastodon/locales/cy.json
index 96476b1433..1c7e61832c 100644
--- a/app/javascript/mastodon/locales/cy.json
+++ b/app/javascript/mastodon/locales/cy.json
@@ -35,9 +35,7 @@
   "account.follow_back": "Dilyn yn ôl",
   "account.followers": "Dilynwyr",
   "account.followers.empty": "Does neb yn dilyn y defnyddiwr hwn eto.",
-  "account.followers_counter": "{count, plural, one {Dilynwr: {counter}} other {Dilynwyr: {counter}}}",
   "account.following": "Yn dilyn",
-  "account.following_counter": "{count, plural, one {Yn dilyn: {counter}} other {Yn dilyn: {counter}}}",
   "account.follows.empty": "Nid yw'r defnyddiwr hwn yn dilyn unrhyw un eto.",
   "account.go_to_profile": "Mynd i'r proffil",
   "account.hide_reblogs": "Cuddio hybiau gan @{name}",
@@ -63,7 +61,6 @@
   "account.requested_follow": "Mae {name} wedi gwneud cais i'ch dilyn",
   "account.share": "Rhannwch broffil @{name}",
   "account.show_reblogs": "Dangos hybiau gan @{name}",
-  "account.statuses_counter": "{count, plural, one {Postiad: {counter}} other {Postiad: {counter}}}",
   "account.unblock": "Dadflocio @{name}",
   "account.unblock_domain": "Dadflocio parth {domain}",
   "account.unblock_short": "Dadflocio",
diff --git a/app/javascript/mastodon/locales/da.json b/app/javascript/mastodon/locales/da.json
index 5ac7128a37..d8c178d295 100644
--- a/app/javascript/mastodon/locales/da.json
+++ b/app/javascript/mastodon/locales/da.json
@@ -35,9 +35,8 @@
   "account.follow_back": "Følg tilbage",
   "account.followers": "Følgere",
   "account.followers.empty": "Ingen følger denne bruger endnu.",
-  "account.followers_counter": "{count, plural, one {{counter} Følger} other {{counter} Følgere}}",
+  "account.followers_counter": "{count, plural, one {{counter} følger} other {{counter} følgere}}",
   "account.following": "Følger",
-  "account.following_counter": "{count, plural, one {{counter} Følges} other {{counter} Følges}}",
   "account.follows.empty": "Denne bruger følger ikke nogen endnu.",
   "account.go_to_profile": "Gå til profil",
   "account.hide_reblogs": "Skjul boosts fra @{name}",
@@ -63,7 +62,6 @@
   "account.requested_follow": "{name} har anmodet om at følge dig",
   "account.share": "Del @{name}s profil",
   "account.show_reblogs": "Vis fremhævelser fra @{name}",
-  "account.statuses_counter": "{count, plural, one {{counter} Indlæg} other {{counter} Indlæg}}",
   "account.unblock": "Afblokér @{name}",
   "account.unblock_domain": "Afblokér domænet {domain}",
   "account.unblock_short": "Afblokér",
diff --git a/app/javascript/mastodon/locales/de.json b/app/javascript/mastodon/locales/de.json
index 86438757a3..4a5b666d3e 100644
--- a/app/javascript/mastodon/locales/de.json
+++ b/app/javascript/mastodon/locales/de.json
@@ -37,7 +37,7 @@
   "account.followers.empty": "Diesem Profil folgt noch niemand.",
   "account.followers_counter": "{count, plural, one {{counter} Follower} other {{counter} Follower}}",
   "account.following": "Folge ich",
-  "account.following_counter": "{count, plural, one {{counter} Folge ich} other {{counter} Folge ich}}",
+  "account.following_counter": "{count, plural, one {{counter} folge ich} other {{counter} folge ich}}",
   "account.follows.empty": "Dieses Profil folgt noch niemandem.",
   "account.go_to_profile": "Profil aufrufen",
   "account.hide_reblogs": "Geteilte Beiträge von @{name} ausblenden",
diff --git a/app/javascript/mastodon/locales/el.json b/app/javascript/mastodon/locales/el.json
index 47a8df6200..5442624b36 100644
--- a/app/javascript/mastodon/locales/el.json
+++ b/app/javascript/mastodon/locales/el.json
@@ -35,9 +35,7 @@
   "account.follow_back": "Ακολούθησε και εσύ",
   "account.followers": "Ακόλουθοι",
   "account.followers.empty": "Κανείς δεν ακολουθεί αυτόν τον χρήστη ακόμα.",
-  "account.followers_counter": "{count, plural, one {{counter} Ακόλουθος} other {{counter} Ακόλουθοι}}",
   "account.following": "Ακολουθείτε",
-  "account.following_counter": "{count, plural, one {{counter} Ακολουθεί} other {{counter} Ακολουθούν}}",
   "account.follows.empty": "Αυτός ο χρήστης δεν ακολουθεί κανέναν ακόμα.",
   "account.go_to_profile": "Μετάβαση στο προφίλ",
   "account.hide_reblogs": "Απόκρυψη ενισχύσεων από @{name}",
@@ -63,7 +61,6 @@
   "account.requested_follow": "Ο/Η {name} αιτήθηκε να σε ακολουθήσει",
   "account.share": "Κοινοποίηση του προφίλ @{name}",
   "account.show_reblogs": "Εμφάνιση ενισχύσεων από @{name}",
-  "account.statuses_counter": "{count, plural, one {{counter} Ανάρτηση} other {{counter} Αναρτήσεις}}",
   "account.unblock": "Άρση αποκλεισμού @{name}",
   "account.unblock_domain": "Άρση αποκλεισμού του τομέα {domain}",
   "account.unblock_short": "Άρση αποκλεισμού",
diff --git a/app/javascript/mastodon/locales/en-GB.json b/app/javascript/mastodon/locales/en-GB.json
index 108880cc97..c4f401d86d 100644
--- a/app/javascript/mastodon/locales/en-GB.json
+++ b/app/javascript/mastodon/locales/en-GB.json
@@ -35,9 +35,7 @@
   "account.follow_back": "Follow back",
   "account.followers": "Followers",
   "account.followers.empty": "No one follows this user yet.",
-  "account.followers_counter": "{count, plural, one {{counter} Follower} other {{counter} Followers}}",
   "account.following": "Following",
-  "account.following_counter": "{count, plural, one {{counter} Following} other {{counter} Following}}",
   "account.follows.empty": "This user doesn't follow anyone yet.",
   "account.go_to_profile": "Go to profile",
   "account.hide_reblogs": "Hide boosts from @{name}",
@@ -63,7 +61,6 @@
   "account.requested_follow": "{name} has requested to follow you",
   "account.share": "Share @{name}'s profile",
   "account.show_reblogs": "Show boosts from @{name}",
-  "account.statuses_counter": "{count, plural, one {{counter} Post} other {{counter} Posts}}",
   "account.unblock": "Unblock @{name}",
   "account.unblock_domain": "Unblock domain {domain}",
   "account.unblock_short": "Unblock",
diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json
index f0c27ad706..13296e1d20 100644
--- a/app/javascript/mastodon/locales/en.json
+++ b/app/javascript/mastodon/locales/en.json
@@ -35,9 +35,9 @@
   "account.follow_back": "Follow back",
   "account.followers": "Followers",
   "account.followers.empty": "No one follows this user yet.",
-  "account.followers_counter": "{count, plural, one {{counter} Follower} other {{counter} Followers}}",
+  "account.followers_counter": "{count, plural, one {{counter} follower} other {{counter} followers}}",
   "account.following": "Following",
-  "account.following_counter": "{count, plural, one {{counter} Following} other {{counter} Following}}",
+  "account.following_counter": "{count, plural, one {{counter} following} other {{counter} following}}",
   "account.follows.empty": "This user doesn't follow anyone yet.",
   "account.go_to_profile": "Go to profile",
   "account.hide_reblogs": "Hide boosts from @{name}",
@@ -63,7 +63,7 @@
   "account.requested_follow": "{name} has requested to follow you",
   "account.share": "Share @{name}'s profile",
   "account.show_reblogs": "Show boosts from @{name}",
-  "account.statuses_counter": "{count, plural, one {{counter} Post} other {{counter} Posts}}",
+  "account.statuses_counter": "{count, plural, one {{counter} post} other {{counter} posts}}",
   "account.unblock": "Unblock @{name}",
   "account.unblock_domain": "Unblock domain {domain}",
   "account.unblock_short": "Unblock",
diff --git a/app/javascript/mastodon/locales/eo.json b/app/javascript/mastodon/locales/eo.json
index bab277b483..e7cfc03468 100644
--- a/app/javascript/mastodon/locales/eo.json
+++ b/app/javascript/mastodon/locales/eo.json
@@ -35,9 +35,7 @@
   "account.follow_back": "Sekvu reen",
   "account.followers": "Sekvantoj",
   "account.followers.empty": "Ankoraŭ neniu sekvas ĉi tiun uzanton.",
-  "account.followers_counter": "{count, plural, one{{counter} Sekvanto} other {{counter} Sekvantoj}}",
   "account.following": "Sekvatoj",
-  "account.following_counter": "{count, plural, one {{counter} Sekvato} other {{counter} Sekvatoj}}",
   "account.follows.empty": "La uzanto ankoraŭ ne sekvas iun ajn.",
   "account.go_to_profile": "Iri al profilo",
   "account.hide_reblogs": "Kaŝi diskonigojn de @{name}",
@@ -63,7 +61,6 @@
   "account.requested_follow": "{name} petis sekvi vin",
   "account.share": "Diskonigi la profilon de @{name}",
   "account.show_reblogs": "Montri diskonigojn de @{name}",
-  "account.statuses_counter": "{count, plural, one {{counter} Afiŝo} other {{counter} Afiŝoj}}",
   "account.unblock": "Malbloki @{name}",
   "account.unblock_domain": "Malbloki la domajnon {domain}",
   "account.unblock_short": "Malbloki",
diff --git a/app/javascript/mastodon/locales/es-AR.json b/app/javascript/mastodon/locales/es-AR.json
index 7da39b88cc..28e8de9237 100644
--- a/app/javascript/mastodon/locales/es-AR.json
+++ b/app/javascript/mastodon/locales/es-AR.json
@@ -37,7 +37,7 @@
   "account.followers.empty": "Todavía nadie sigue a este usuario.",
   "account.followers_counter": "{count, plural, one {{counter} seguidor} other {{counter} seguidores}}",
   "account.following": "Siguiendo",
-  "account.following_counter": "{count, plural, other {Siguiendo a {counter}}}",
+  "account.following_counter": "{count, plural, one {siguiendo a {counter}} other {siguiendo a {counter}}}",
   "account.follows.empty": "Todavía este usuario no sigue a nadie.",
   "account.go_to_profile": "Ir al perfil",
   "account.hide_reblogs": "Ocultar adhesiones de @{name}",
diff --git a/app/javascript/mastodon/locales/es-MX.json b/app/javascript/mastodon/locales/es-MX.json
index d3e02cd6e1..c10a161015 100644
--- a/app/javascript/mastodon/locales/es-MX.json
+++ b/app/javascript/mastodon/locales/es-MX.json
@@ -35,9 +35,9 @@
   "account.follow_back": "Seguir también",
   "account.followers": "Seguidores",
   "account.followers.empty": "Todavía nadie sigue a este usuario.",
-  "account.followers_counter": "{count, plural, one {{counter} Seguidor} other {{counter} Seguidores}}",
+  "account.followers_counter": "{count, plural, one {{counter} seguidor} other {{counter} seguidores}}",
   "account.following": "Siguiendo",
-  "account.following_counter": "{count, plural, other {{counter} Siguiendo}}",
+  "account.following_counter": "{count, plural, one {{counter} siguiendo} other {{counter} siguiendo}}",
   "account.follows.empty": "Este usuario todavía no sigue a nadie.",
   "account.go_to_profile": "Ir al perfil",
   "account.hide_reblogs": "Ocultar retoots de @{name}",
@@ -63,7 +63,7 @@
   "account.requested_follow": "{name} ha solicitado seguirte",
   "account.share": "Compartir el perfil de @{name}",
   "account.show_reblogs": "Mostrar retoots de @{name}",
-  "account.statuses_counter": "{count, plural, one {{counter} Toot} other {{counter} Toots}}",
+  "account.statuses_counter": "{count, plural, one {{counter} publicación} other {{counter} publicaciones}}",
   "account.unblock": "Desbloquear a @{name}",
   "account.unblock_domain": "Mostrar a {domain}",
   "account.unblock_short": "Desbloquear",
diff --git a/app/javascript/mastodon/locales/es.json b/app/javascript/mastodon/locales/es.json
index 849e0fa27f..259fc1795b 100644
--- a/app/javascript/mastodon/locales/es.json
+++ b/app/javascript/mastodon/locales/es.json
@@ -35,9 +35,9 @@
   "account.follow_back": "Seguir también",
   "account.followers": "Seguidores",
   "account.followers.empty": "Todavía nadie sigue a este usuario.",
-  "account.followers_counter": "{count, plural, one {{counter} Seguidor} other {{counter} Seguidores}}",
+  "account.followers_counter": "{count, plural, one {{counter} seguidor} other {{counter} seguidores}}",
   "account.following": "Siguiendo",
-  "account.following_counter": "{count, plural, other {Siguiendo a {counter}}}",
+  "account.following_counter": "{count, plural, one {{counter} siguiendo} other {{counter} siguiendo}}",
   "account.follows.empty": "Este usuario todavía no sigue a nadie.",
   "account.go_to_profile": "Ir al perfil",
   "account.hide_reblogs": "Ocultar impulsos de @{name}",
@@ -63,7 +63,7 @@
   "account.requested_follow": "{name} ha solicitado seguirte",
   "account.share": "Compartir el perfil de @{name}",
   "account.show_reblogs": "Mostrar impulsos de @{name}",
-  "account.statuses_counter": "{count, plural, one {{counter} Publicación} other {{counter} Publicaciones}}",
+  "account.statuses_counter": "{count, plural, one {{counter} publicación} other {{counter} publicaciones}}",
   "account.unblock": "Desbloquear a @{name}",
   "account.unblock_domain": "Desbloquear dominio {domain}",
   "account.unblock_short": "Desbloquear",
diff --git a/app/javascript/mastodon/locales/et.json b/app/javascript/mastodon/locales/et.json
index 547a0fe61f..94f5ef5d9e 100644
--- a/app/javascript/mastodon/locales/et.json
+++ b/app/javascript/mastodon/locales/et.json
@@ -35,9 +35,7 @@
   "account.follow_back": "Jälgi vastu",
   "account.followers": "Jälgijad",
   "account.followers.empty": "Keegi ei jälgi veel seda kasutajat.",
-  "account.followers_counter": "{count, plural, one {{counter} jälgija} other {{counter} jälgijat}}",
   "account.following": "Jälgib",
-  "account.following_counter": "{count, plural, one {{counter} jälgitav} other {{counter} jälgitavat}}",
   "account.follows.empty": "See kasutaja ei jälgi veel kedagi.",
   "account.go_to_profile": "Mine profiilile",
   "account.hide_reblogs": "Peida @{name} jagamised",
@@ -63,7 +61,6 @@
   "account.requested_follow": "{name} on taodelnud sinu jälgimist",
   "account.share": "Jaga @{name} profiili",
   "account.show_reblogs": "Näita @{name} jagamisi",
-  "account.statuses_counter": "{count, plural, one {{counter} postitus} other {{counter} postitust}}",
   "account.unblock": "Eemalda blokeering @{name}",
   "account.unblock_domain": "Tee {domain} nähtavaks",
   "account.unblock_short": "Eemalda blokeering",
diff --git a/app/javascript/mastodon/locales/eu.json b/app/javascript/mastodon/locales/eu.json
index 5fbac270cf..97c4250d22 100644
--- a/app/javascript/mastodon/locales/eu.json
+++ b/app/javascript/mastodon/locales/eu.json
@@ -35,9 +35,7 @@
   "account.follow_back": "Jarraitu bueltan",
   "account.followers": "Jarraitzaileak",
   "account.followers.empty": "Ez du inork erabiltzaile hau jarraitzen oraindik.",
-  "account.followers_counter": "{count, plural, one {Jarraitzaile {counter}} other {{counter} jarraitzaile}}",
   "account.following": "Jarraitzen",
-  "account.following_counter": "{count, plural, one {{counter} jarraitzen} other {{counter} jarraitzen}}",
   "account.follows.empty": "Erabiltzaile honek ez du inor jarraitzen oraindik.",
   "account.go_to_profile": "Joan profilera",
   "account.hide_reblogs": "Ezkutatu @{name} erabiltzailearen bultzadak",
@@ -63,7 +61,6 @@
   "account.requested_follow": "{name}-(e)k zu jarraitzeko eskaera egin du",
   "account.share": "Partekatu @{name} erabiltzailearen profila",
   "account.show_reblogs": "Erakutsi @{name} erabiltzailearen bultzadak",
-  "account.statuses_counter": "{count, plural, one {Bidalketa {counter}} other {{counter} bidalketa}}",
   "account.unblock": "Desblokeatu @{name}",
   "account.unblock_domain": "Berriz erakutsi {domain}",
   "account.unblock_short": "Desblokeatu",
diff --git a/app/javascript/mastodon/locales/fa.json b/app/javascript/mastodon/locales/fa.json
index 072a67421a..18f6466d48 100644
--- a/app/javascript/mastodon/locales/fa.json
+++ b/app/javascript/mastodon/locales/fa.json
@@ -35,9 +35,7 @@
   "account.follow_back": "دنبال کردن متقابل",
   "account.followers": "پی‌گیرندگان",
   "account.followers.empty": "هنوز کسی پی‌گیر این کاربر نیست.",
-  "account.followers_counter": "{count, plural, one {{counter} پی‌گیرنده} other {{counter} پی‌گیرنده}}",
   "account.following": "پی می‌گیرید",
-  "account.following_counter": "{count, plural, one {{counter} پی‌گرفته} other {{counter} پی‌گرفته}}",
   "account.follows.empty": "این کاربر هنوز پی‌گیر کسی نیست.",
   "account.go_to_profile": "رفتن به نمایه",
   "account.hide_reblogs": "نهفتن تقویت‌های ‎@{name}",
@@ -63,7 +61,6 @@
   "account.requested_follow": "{name} درخواست پی‌گیریتان را داد",
   "account.share": "هم‌رسانی نمایهٔ ‎@{name}",
   "account.show_reblogs": "نمایش تقویت‌های ‎@{name}",
-  "account.statuses_counter": "{count, plural, one {{counter} فرسته} other {{counter} فرسته}}",
   "account.unblock": "رفع مسدودیت ‎@{name}",
   "account.unblock_domain": "رفع مسدودیت دامنهٔ {domain}",
   "account.unblock_short": "رفع مسدودیت",
diff --git a/app/javascript/mastodon/locales/fi.json b/app/javascript/mastodon/locales/fi.json
index 67e2b72b86..0767dd5e37 100644
--- a/app/javascript/mastodon/locales/fi.json
+++ b/app/javascript/mastodon/locales/fi.json
@@ -699,7 +699,7 @@
   "server_banner.is_one_of_many": "{domain} on yksi monista itsenäisistä Mastodon-palvelimista, joiden välityksellä voit toimia fediversumissa.",
   "server_banner.server_stats": "Palvelimen tilastot:",
   "sign_in_banner.create_account": "Luo tili",
-  "sign_in_banner.follow_anyone": "Seuraa kenen tahansa julkaisuja fediversumissa ja näe ne kaikki aikajärjestyksessä. Ei algoritmejä, mainoksia tai klikkikalastelua.",
+  "sign_in_banner.follow_anyone": "Seuraa kenen tahansa julkaisuja fediversumissa ja näe ne kaikki aikajärjestyksessä. Ei algoritmeja, mainoksia tai klikkausten kalastelua.",
   "sign_in_banner.mastodon_is": "Mastodon on paras tapa pysyä ajan tasalla siitä, mitä ympärillä tapahtuu.",
   "sign_in_banner.sign_in": "Kirjaudu",
   "sign_in_banner.sso_redirect": "Kirjaudu tai rekisteröidy",
diff --git a/app/javascript/mastodon/locales/fo.json b/app/javascript/mastodon/locales/fo.json
index 7a317820bb..e7786f388d 100644
--- a/app/javascript/mastodon/locales/fo.json
+++ b/app/javascript/mastodon/locales/fo.json
@@ -35,9 +35,7 @@
   "account.follow_back": "Fylg aftur",
   "account.followers": "Fylgjarar",
   "account.followers.empty": "Ongar fylgjarar enn.",
-  "account.followers_counter": "{count, plural, one {{counter} Fylgjari} other {{counter} Fylgjarar}}",
   "account.following": "Fylgir",
-  "account.following_counter": "{count, plural, one {{counter} fylgir} other {{counter} fylgja}}",
   "account.follows.empty": "Hesin brúkari fylgir ongum enn.",
   "account.go_to_profile": "Far til vanga",
   "account.hide_reblogs": "Fjal lyft frá @{name}",
@@ -63,7 +61,6 @@
   "account.requested_follow": "{name} hevur biðið um at fylgja tær",
   "account.share": "Deil vanga @{name}'s",
   "account.show_reblogs": "Vís lyft frá @{name}",
-  "account.statuses_counter": "{count, plural, one {{counter} postur} other {{counter} postar}}",
   "account.unblock": "Banna ikki @{name}",
   "account.unblock_domain": "Banna ikki økisnavnið {domain}",
   "account.unblock_short": "Banna ikki",
diff --git a/app/javascript/mastodon/locales/fr-CA.json b/app/javascript/mastodon/locales/fr-CA.json
index 50b7dcf90d..4324855003 100644
--- a/app/javascript/mastodon/locales/fr-CA.json
+++ b/app/javascript/mastodon/locales/fr-CA.json
@@ -35,9 +35,7 @@
   "account.follow_back": "S'abonner en retour",
   "account.followers": "abonné·e·s",
   "account.followers.empty": "Personne ne suit ce compte pour l'instant.",
-  "account.followers_counter": "{count, plural, one {{counter} Abonné·e} other {{counter} Abonné·e·s}}",
   "account.following": "Abonné·e",
-  "account.following_counter": "{count, plural, one {{counter} Abonnement} other {{counter} Abonnements}}",
   "account.follows.empty": "Ce compte ne suit personne présentement.",
   "account.go_to_profile": "Voir ce profil",
   "account.hide_reblogs": "Masquer les boosts de @{name}",
@@ -63,7 +61,6 @@
   "account.requested_follow": "{name} a demandé à vous suivre",
   "account.share": "Partager le profil de @{name}",
   "account.show_reblogs": "Afficher les boosts de @{name}",
-  "account.statuses_counter": "{count, plural, one {{counter} Publication} other {{counter} Publications}}",
   "account.unblock": "Débloquer @{name}",
   "account.unblock_domain": "Débloquer le domaine {domain}",
   "account.unblock_short": "Débloquer",
diff --git a/app/javascript/mastodon/locales/fr.json b/app/javascript/mastodon/locales/fr.json
index 2e565c200f..cd67cda539 100644
--- a/app/javascript/mastodon/locales/fr.json
+++ b/app/javascript/mastodon/locales/fr.json
@@ -35,9 +35,7 @@
   "account.follow_back": "S'abonner en retour",
   "account.followers": "Abonné·e·s",
   "account.followers.empty": "Personne ne suit cet·te utilisateur·rice pour l’instant.",
-  "account.followers_counter": "{count, plural, one {{counter} Abonné·e} other {{counter} Abonné·e·s}}",
   "account.following": "Abonnements",
-  "account.following_counter": "{count, plural, one {{counter} Abonnement} other {{counter} Abonnements}}",
   "account.follows.empty": "Cet·te utilisateur·rice ne suit personne pour l’instant.",
   "account.go_to_profile": "Aller au profil",
   "account.hide_reblogs": "Masquer les partages de @{name}",
@@ -63,7 +61,6 @@
   "account.requested_follow": "{name} a demandé à vous suivre",
   "account.share": "Partager le profil de @{name}",
   "account.show_reblogs": "Afficher les partages de @{name}",
-  "account.statuses_counter": "{count, plural, one {{counter} Message} other {{counter} Messages}}",
   "account.unblock": "Débloquer @{name}",
   "account.unblock_domain": "Débloquer le domaine {domain}",
   "account.unblock_short": "Débloquer",
diff --git a/app/javascript/mastodon/locales/fy.json b/app/javascript/mastodon/locales/fy.json
index 11b11ff819..d787c16bf3 100644
--- a/app/javascript/mastodon/locales/fy.json
+++ b/app/javascript/mastodon/locales/fy.json
@@ -35,9 +35,7 @@
   "account.follow_back": "Weromfolgje",
   "account.followers": "Folgers",
   "account.followers.empty": "Noch net ien folget dizze brûker.",
-  "account.followers_counter": "{count, plural, one {{counter} folger} other {{counter} folgers}}",
   "account.following": "Folgjend",
-  "account.following_counter": "{count, plural, one {{counter} folgjend} other {{counter} folgjend}}",
   "account.follows.empty": "Dizze brûker folget noch net ien.",
   "account.go_to_profile": "Gean nei profyl",
   "account.hide_reblogs": "Boosts fan @{name} ferstopje",
@@ -63,7 +61,6 @@
   "account.requested_follow": "{name} hat dy in folchfersyk stjoerd",
   "account.share": "Profyl fan @{name} diele",
   "account.show_reblogs": "Boosts fan @{name} toane",
-  "account.statuses_counter": "{count, plural, one {{counter} berjocht} other {{counter} berjochten}}",
   "account.unblock": "@{name} deblokkearje",
   "account.unblock_domain": "Domein {domain} deblokkearje",
   "account.unblock_short": "Deblokkearje",
diff --git a/app/javascript/mastodon/locales/ga.json b/app/javascript/mastodon/locales/ga.json
index 97dcc752b8..edf7618148 100644
--- a/app/javascript/mastodon/locales/ga.json
+++ b/app/javascript/mastodon/locales/ga.json
@@ -31,9 +31,7 @@
   "account.follow": "Lean",
   "account.followers": "Leantóirí",
   "account.followers.empty": "Ní leanann éinne an t-úsáideoir seo fós.",
-  "account.followers_counter": "{count, plural, one {Leantóir amháin} other {{counter} Leantóir}}",
   "account.following": "Ag leanúint",
-  "account.following_counter": "{count, plural, one {Ag leanúint cúntas amháin} other {Ag leanúint {counter} cúntas}}",
   "account.follows.empty": "Ní leanann an t-úsáideoir seo duine ar bith fós.",
   "account.go_to_profile": "Téigh go dtí próifíl",
   "account.hide_reblogs": "Folaigh moltaí ó @{name}",
@@ -55,7 +53,6 @@
   "account.requested_follow": "D'iarr {name} ort do chuntas a leanúint",
   "account.share": "Roinn próifíl @{name}",
   "account.show_reblogs": "Taispeáin moltaí ó @{name}",
-  "account.statuses_counter": "{count, plural, one {Postáil amháin} other {{counter} Postáil}}",
   "account.unblock": "Bain bac de @{name}",
   "account.unblock_domain": "Bain bac den ainm fearainn {domain}",
   "account.unblock_short": "Bain bac de",
diff --git a/app/javascript/mastodon/locales/gd.json b/app/javascript/mastodon/locales/gd.json
index 714fa6e364..fec025045c 100644
--- a/app/javascript/mastodon/locales/gd.json
+++ b/app/javascript/mastodon/locales/gd.json
@@ -35,9 +35,7 @@
   "account.follow_back": "Lean air ais",
   "account.followers": "Luchd-leantainn",
   "account.followers.empty": "Chan eil neach sam bith a’ leantainn air a’ chleachdaiche seo fhathast.",
-  "account.followers_counter": "{count, plural, one {{counter} neach-leantainn} two {{counter} neach-leantainn} few {{counter} luchd-leantainn} other {{counter} luchd-leantainn}}",
   "account.following": "A’ leantainn",
-  "account.following_counter": "{count, plural, one {A’ leantainn {counter}} two {A’ leantainn {counter}} few {A’ leantainn {counter}} other {A’ leantainn {counter}}}",
   "account.follows.empty": "Chan eil an cleachdaiche seo a’ leantainn neach sam bith fhathast.",
   "account.go_to_profile": "Tadhail air a’ phròifil",
   "account.hide_reblogs": "Falaich na brosnachaidhean o @{name}",
@@ -63,7 +61,6 @@
   "account.requested_follow": "Dh’iarr {name} ’gad leantainn",
   "account.share": "Co-roinn a’ phròifil aig @{name}",
   "account.show_reblogs": "Seall na brosnachaidhean o @{name}",
-  "account.statuses_counter": "{count, plural, one {{counter} phost} two {{counter} phost} few {{counter} postaichean} other {{counter} post}}",
   "account.unblock": "Dì-bhac @{name}",
   "account.unblock_domain": "Dì-bhac an àrainn {domain}",
   "account.unblock_short": "Dì-bhac",
diff --git a/app/javascript/mastodon/locales/gl.json b/app/javascript/mastodon/locales/gl.json
index 7b77f98034..fae48ed06b 100644
--- a/app/javascript/mastodon/locales/gl.json
+++ b/app/javascript/mastodon/locales/gl.json
@@ -35,9 +35,9 @@
   "account.follow_back": "Seguir tamén",
   "account.followers": "Seguidoras",
   "account.followers.empty": "Aínda ninguén segue esta usuaria.",
-  "account.followers_counter": "{count, plural, one {{counter} Seguidora} other {{counter} Seguidoras}}",
+  "account.followers_counter": "{count, plural, one {{counter} seguidora} other {{counter} seguidoras}}",
   "account.following": "Seguindo",
-  "account.following_counter": "{count, plural, one {{counter} Seguindo} other {{counter} Seguindo}}",
+  "account.following_counter": "{count, plural, one {{counter} seguimento} other {{counter} seguimentos}}",
   "account.follows.empty": "Esta usuaria aínda non segue a ninguén.",
   "account.go_to_profile": "Ir ao perfil",
   "account.hide_reblogs": "Agochar promocións de @{name}",
@@ -63,7 +63,7 @@
   "account.requested_follow": "{name} solicitou seguirte",
   "account.share": "Compartir o perfil de @{name}",
   "account.show_reblogs": "Amosar compartidos de @{name}",
-  "account.statuses_counter": "{count, plural, one {{counter} Publicación} other {{counter} Publicacións}}",
+  "account.statuses_counter": "{count, plural, one {{counter} publicación} other {{counter} publicacións}}",
   "account.unblock": "Desbloquear @{name}",
   "account.unblock_domain": "Amosar {domain}",
   "account.unblock_short": "Desbloquear",
diff --git a/app/javascript/mastodon/locales/he.json b/app/javascript/mastodon/locales/he.json
index 1c50ba8e1f..e022eac110 100644
--- a/app/javascript/mastodon/locales/he.json
+++ b/app/javascript/mastodon/locales/he.json
@@ -35,9 +35,7 @@
   "account.follow_back": "לעקוב בחזרה",
   "account.followers": "עוקבים",
   "account.followers.empty": "אף אחד לא עוקב אחר המשתמש הזה עדיין.",
-  "account.followers_counter": "{count, plural,one {עוקב אחד} other {{counter} עוקבים}}",
   "account.following": "נעקבים",
-  "account.following_counter": "{count, plural,one {עוקב אחרי {counter}}other {עוקב אחרי {counter}}}",
   "account.follows.empty": "משתמש זה עדיין לא עוקב אחרי אף אחד.",
   "account.go_to_profile": "מעבר לפרופיל",
   "account.hide_reblogs": "להסתיר הידהודים מאת @{name}",
@@ -63,7 +61,6 @@
   "account.requested_follow": "{name} ביקשו לעקוב אחריך",
   "account.share": "שתף את הפרופיל של @{name}",
   "account.show_reblogs": "הצג הדהודים מאת @{name}",
-  "account.statuses_counter": "{count, plural, one {הודעה} two {הודעותיים} many {{count} הודעות} other {{count} הודעות}}",
   "account.unblock": "להסיר חסימה ל- @{name}",
   "account.unblock_domain": "הסירי את החסימה של קהילת {domain}",
   "account.unblock_short": "הסר חסימה",
diff --git a/app/javascript/mastodon/locales/hi.json b/app/javascript/mastodon/locales/hi.json
index a2da55da85..89c71207f0 100644
--- a/app/javascript/mastodon/locales/hi.json
+++ b/app/javascript/mastodon/locales/hi.json
@@ -35,9 +35,7 @@
   "account.follow_back": "फॉलो करें",
   "account.followers": "फॉलोवर",
   "account.followers.empty": "कोई भी इस यूज़र् को फ़ॉलो नहीं करता है",
-  "account.followers_counter": "{count, plural, one {{counter} अनुगामी} other {{counter} समर्थक}}",
   "account.following": "फॉलोइंग",
-  "account.following_counter": "{count, plural, one {{counter} निम्नलिखित} other {{counter} निम्नलिखित}}",
   "account.follows.empty": "यह यूज़र् अभी तक किसी को फॉलो नहीं करता है।",
   "account.go_to_profile": "प्रोफाइल में जाएँ",
   "account.hide_reblogs": "@{name} के बूस्ट छुपाएं",
@@ -63,7 +61,6 @@
   "account.requested_follow": "{name} ने आपको फॉलो करने के लिए अनुरोध किया है",
   "account.share": "@{name} की प्रोफाइल शेयर करे",
   "account.show_reblogs": "@{name} के बूस्ट दिखाए",
-  "account.statuses_counter": "{count, plural, one {{counter} भोंपू} other {{counter} भोंपू}}",
   "account.unblock": "@{name} को अनब्लॉक करें",
   "account.unblock_domain": "{domain} दिखाए",
   "account.unblock_short": "अनब्लॉक",
diff --git a/app/javascript/mastodon/locales/hr.json b/app/javascript/mastodon/locales/hr.json
index d952945c46..c8f6f01862 100644
--- a/app/javascript/mastodon/locales/hr.json
+++ b/app/javascript/mastodon/locales/hr.json
@@ -35,9 +35,7 @@
   "account.follow_back": "Slijedi natrag",
   "account.followers": "Pratitelji",
   "account.followers.empty": "Nitko još ne prati korisnika/cu.",
-  "account.followers_counter": "{count, plural, one {{counter} pratitelj} other {{counter} pratitelja}}",
   "account.following": "Pratim",
-  "account.following_counter": "{count, plural, one {{counter} praćeni} few{{counter} praćena} other {{counter} praćenih}}",
   "account.follows.empty": "Korisnik/ca još ne prati nikoga.",
   "account.go_to_profile": "Idi na profil",
   "account.hide_reblogs": "Sakrij boostove od @{name}",
@@ -62,7 +60,6 @@
   "account.requested_follow": "{name} zatražio/la je praćenje",
   "account.share": "Podijeli profil @{name}",
   "account.show_reblogs": "Prikaži boostove od @{name}",
-  "account.statuses_counter": "{count, plural, one {{counter} toot} other {{counter} toota}}",
   "account.unblock": "Deblokiraj @{name}",
   "account.unblock_domain": "Deblokiraj domenu {domain}",
   "account.unblock_short": "Deblokiraj",
diff --git a/app/javascript/mastodon/locales/hu.json b/app/javascript/mastodon/locales/hu.json
index 6164335da8..1fcadc8f9c 100644
--- a/app/javascript/mastodon/locales/hu.json
+++ b/app/javascript/mastodon/locales/hu.json
@@ -35,9 +35,9 @@
   "account.follow_back": "Viszontkövetés",
   "account.followers": "Követő",
   "account.followers.empty": "Ezt a felhasználót még senki sem követi.",
-  "account.followers_counter": "{count, plural, one {{counter} Követő} other {{counter} Követő}}",
+  "account.followers_counter": "{count, plural, one {{counter} követő} other {{counter} követő}}",
   "account.following": "Követve",
-  "account.following_counter": "{count, plural, one {{counter} Követett} other {{counter} Követett}}",
+  "account.following_counter": "{count, plural, one {{counter} követett} other {{counter} követett}}",
   "account.follows.empty": "Ez a felhasználó még senkit sem követ.",
   "account.go_to_profile": "Ugrás a profilhoz",
   "account.hide_reblogs": "@{name} megtolásainak elrejtése",
@@ -63,7 +63,7 @@
   "account.requested_follow": "{name} kérte, hogy követhessen",
   "account.share": "@{name} profiljának megosztása",
   "account.show_reblogs": "@{name} megtolásainak mutatása",
-  "account.statuses_counter": "{count, plural, one {{counter} Bejegyzés} other {{counter} Bejegyzés}}",
+  "account.statuses_counter": "{count, plural, one {{counter} bejegyzés} other {{counter} bejegyzés}}",
   "account.unblock": "@{name} letiltásának feloldása",
   "account.unblock_domain": "{domain} domain tiltásának feloldása",
   "account.unblock_short": "Tiltás feloldása",
diff --git a/app/javascript/mastodon/locales/hy.json b/app/javascript/mastodon/locales/hy.json
index cd29f441df..b4abe9bf09 100644
--- a/app/javascript/mastodon/locales/hy.json
+++ b/app/javascript/mastodon/locales/hy.json
@@ -28,9 +28,7 @@
   "account.follow": "Հետեւել",
   "account.followers": "Հետեւողներ",
   "account.followers.empty": "Այս օգտատիրոջը դեռ ոչ մէկ չի հետեւում։",
-  "account.followers_counter": "{count, plural, one {{counter} Հետեւորդ} other {{counter} Հետեւորդ}}",
   "account.following": "Հետեւած",
-  "account.following_counter": "{count, plural, one {{counter} Հետեւած} other {{counter} Հետեւած}}",
   "account.follows.empty": "Այս օգտատէրը դեռ ոչ մէկի չի հետեւում։",
   "account.go_to_profile": "Գնալ անձնական հաշիւ",
   "account.hide_reblogs": "Թաքցնել @{name}֊ի տարածածները",
@@ -52,7 +50,6 @@
   "account.requested_follow": "{name}-ը ցանկանում է հետեւել քեզ",
   "account.share": "Կիսուել @{name}֊ի էջով",
   "account.show_reblogs": "Ցուցադրել @{name}֊ի տարածածները",
-  "account.statuses_counter": "{count, plural, one {{counter} Գրառում} other {{counter} Գրառումներ}}",
   "account.unblock": "Ապաարգելափակել @{name}֊ին",
   "account.unblock_domain": "Ցուցադրել {domain} թաքցուած տիրոյթի գրառումները",
   "account.unblock_short": "Արգելաբացել",
diff --git a/app/javascript/mastodon/locales/ia.json b/app/javascript/mastodon/locales/ia.json
index 53cc938592..a2e64c10f1 100644
--- a/app/javascript/mastodon/locales/ia.json
+++ b/app/javascript/mastodon/locales/ia.json
@@ -699,6 +699,7 @@
   "server_banner.is_one_of_many": "{domain} es un de multe servitores independente de Mastodon que tu pote usar pro participar in le fediverso.",
   "server_banner.server_stats": "Statos del servitor:",
   "sign_in_banner.create_account": "Crear un conto",
+  "sign_in_banner.follow_anyone": "Seque quicunque in le fediverso, e tu videra toto in ordine chronologic. Sin algorithmo, sin publicitate, sin titulos de esca.",
   "sign_in_banner.mastodon_is": "Mastodon es le melior maniera de sequer lo que passa.",
   "sign_in_banner.sign_in": "Aperir session",
   "sign_in_banner.sso_redirect": "Aperir session o crear conto",
diff --git a/app/javascript/mastodon/locales/id.json b/app/javascript/mastodon/locales/id.json
index d86b5854f4..f4e5e1baea 100644
--- a/app/javascript/mastodon/locales/id.json
+++ b/app/javascript/mastodon/locales/id.json
@@ -35,9 +35,7 @@
   "account.follow_back": "Ikuti balik",
   "account.followers": "Pengikut",
   "account.followers.empty": "Pengguna ini belum ada pengikut.",
-  "account.followers_counter": "{count, plural, other {{counter} Pengikut}}",
   "account.following": "Mengikuti",
-  "account.following_counter": "{count, plural, other {{counter} Mengikuti}}",
   "account.follows.empty": "Pengguna ini belum mengikuti siapa pun.",
   "account.go_to_profile": "Buka profil",
   "account.hide_reblogs": "Sembunyikan boosts dari @{name}",
@@ -63,7 +61,6 @@
   "account.requested_follow": "{name} ingin mengikuti Anda",
   "account.share": "Bagikan profil @{name}",
   "account.show_reblogs": "Tampilkan boost dari @{name}",
-  "account.statuses_counter": "{count, plural, other {{counter} Kiriman}}",
   "account.unblock": "Buka blokir @{name}",
   "account.unblock_domain": "Buka blokir domain {domain}",
   "account.unblock_short": "Buka blokir",
diff --git a/app/javascript/mastodon/locales/ie.json b/app/javascript/mastodon/locales/ie.json
index f15b982889..c75788c430 100644
--- a/app/javascript/mastodon/locales/ie.json
+++ b/app/javascript/mastodon/locales/ie.json
@@ -35,9 +35,7 @@
   "account.follow_back": "Sequer reciprocmen",
   "account.followers": "Sequitores",
   "account.followers.empty": "Ancor nequi seque ti-ci usator.",
-  "account.followers_counter": "{count, plural, one {{counter} Sequitor} other {{counter} Sequitor}}",
   "account.following": "Sequent",
-  "account.following_counter": "{count, plural, one {{counter} Sequent} other {{counter} Sequent}}",
   "account.follows.empty": "Ti-ci usator ancor ne seque quemcunc.",
   "account.go_to_profile": "Ear a profil",
   "account.hide_reblogs": "Celar boosts de @{name}",
@@ -63,7 +61,6 @@
   "account.requested_follow": "{name} ha petit sequer te",
   "account.share": "Distribuer li profil de @{name}",
   "account.show_reblogs": "Monstrar boosts de @{name}",
-  "account.statuses_counter": "{count, plural, one {{counter} Posta} other {{counter} Postas}}",
   "account.unblock": "Desbloccar @{name}",
   "account.unblock_domain": "Desbloccar dominia {domain}",
   "account.unblock_short": "Desbloccar",
diff --git a/app/javascript/mastodon/locales/io.json b/app/javascript/mastodon/locales/io.json
index 016a111c46..6aa954ae57 100644
--- a/app/javascript/mastodon/locales/io.json
+++ b/app/javascript/mastodon/locales/io.json
@@ -33,9 +33,7 @@
   "account.follow": "Sequar",
   "account.followers": "Sequanti",
   "account.followers.empty": "Nulu sequas ca uzanto til nun.",
-  "account.followers_counter": "{count, plural, one {{counter} Sequanto} other {{counter} Sequanti}}",
   "account.following": "Sequata",
-  "account.following_counter": "{count, plural, one {{counter} Sequas} other {{counter} Sequanti}}",
   "account.follows.empty": "Ca uzanto ne sequa irgu til nun.",
   "account.go_to_profile": "Irez al profilo",
   "account.hide_reblogs": "Celez repeti de @{name}",
@@ -60,7 +58,6 @@
   "account.requested_follow": "{name} demandis sequar tu",
   "account.share": "Partigez profilo di @{name}",
   "account.show_reblogs": "Montrez repeti de @{name}",
-  "account.statuses_counter": "{count, plural, one {{counter} Posto} other {{counter} Posti}}",
   "account.unblock": "Desblokusar @{name}",
   "account.unblock_domain": "Desblokusar {domain}",
   "account.unblock_short": "Desblokusar",
diff --git a/app/javascript/mastodon/locales/is.json b/app/javascript/mastodon/locales/is.json
index 08605f5238..1a38591b85 100644
--- a/app/javascript/mastodon/locales/is.json
+++ b/app/javascript/mastodon/locales/is.json
@@ -63,7 +63,7 @@
   "account.requested_follow": "{name} hefur beðið um að fylgjast með þér",
   "account.share": "Deila notandasniði fyrir @{name}",
   "account.show_reblogs": "Sýna endurbirtingar frá @{name}",
-  "account.statuses_counter": "{count, plural, one {Færsla: {counter}} other {Færslur: {counter}}}",
+  "account.statuses_counter": "{count, plural, one {{counter} færsla} other {{counter} færslur}}",
   "account.unblock": "Aflétta útilokun af @{name}",
   "account.unblock_domain": "Aflétta útilokun lénsins {domain}",
   "account.unblock_short": "Hætta að loka á",
diff --git a/app/javascript/mastodon/locales/it.json b/app/javascript/mastodon/locales/it.json
index 3672b5fd7a..73c4f9ba60 100644
--- a/app/javascript/mastodon/locales/it.json
+++ b/app/javascript/mastodon/locales/it.json
@@ -35,9 +35,9 @@
   "account.follow_back": "Segui a tua volta",
   "account.followers": "Follower",
   "account.followers.empty": "Ancora nessuno segue questo utente.",
-  "account.followers_counter": "{count, plural, one {{counter} Follower} other {{counter} Follower}}",
+  "account.followers_counter": "{count, plural, one {{counter} seguace} other {{counter} seguaci}}",
   "account.following": "Seguiti",
-  "account.following_counter": "{count, plural, one {{counter} Seguiti} other {{counter} Seguiti}}",
+  "account.following_counter": "{count, plural, one {{counter} segui} other {{counter} segui}}",
   "account.follows.empty": "Questo utente non segue ancora nessuno.",
   "account.go_to_profile": "Vai al profilo",
   "account.hide_reblogs": "Nascondi potenziamenti da @{name}",
@@ -63,7 +63,7 @@
   "account.requested_follow": "{name} ha richiesto di seguirti",
   "account.share": "Condividi il profilo di @{name}",
   "account.show_reblogs": "Mostra potenziamenti da @{name}",
-  "account.statuses_counter": "{count, plural, one {{counter} Post} other {{counter} Post}}",
+  "account.statuses_counter": "{count, plural, one {{counter} post} other {{counter} post}}",
   "account.unblock": "Sblocca @{name}",
   "account.unblock_domain": "Sblocca il dominio {domain}",
   "account.unblock_short": "Sblocca",
diff --git a/app/javascript/mastodon/locales/ja.json b/app/javascript/mastodon/locales/ja.json
index 90a46edd5b..575c68de03 100644
--- a/app/javascript/mastodon/locales/ja.json
+++ b/app/javascript/mastodon/locales/ja.json
@@ -35,9 +35,7 @@
   "account.follow_back": "フォローバック",
   "account.followers": "フォロワー",
   "account.followers.empty": "まだ誰もフォローしていません。",
-  "account.followers_counter": "{counter} フォロワー",
   "account.following": "フォロー中",
-  "account.following_counter": "{counter} フォロー",
   "account.follows.empty": "まだ誰もフォローしていません。",
   "account.go_to_profile": "プロフィールページへ",
   "account.hide_reblogs": "@{name}さんからのブーストを非表示",
@@ -63,7 +61,6 @@
   "account.requested_follow": "{name}さんがあなたにフォローリクエストしました",
   "account.share": "@{name}さんのプロフィールを共有する",
   "account.show_reblogs": "@{name}さんからのブーストを表示",
-  "account.statuses_counter": "{counter} 投稿",
   "account.unblock": "@{name}さんのブロックを解除",
   "account.unblock_domain": "{domain}のブロックを解除",
   "account.unblock_short": "ブロック解除",
diff --git a/app/javascript/mastodon/locales/ka.json b/app/javascript/mastodon/locales/ka.json
index 7af4dccd86..b2e67e143e 100644
--- a/app/javascript/mastodon/locales/ka.json
+++ b/app/javascript/mastodon/locales/ka.json
@@ -26,7 +26,6 @@
   "account.requested": "დამტკიცების მოლოდინში. დააწკაპუნეთ რომ უარყოთ დადევნების მოთხონვა",
   "account.share": "გააზიარე @{name}-ის პროფილი",
   "account.show_reblogs": "აჩვენე ბუსტები @{name}-სგან",
-  "account.statuses_counter": "{count, plural, one {{counter} Toot} other {{counter} Toots}}",
   "account.unblock": "განბლოკე @{name}",
   "account.unblock_domain": "გამოაჩინე {domain}",
   "account.unendorse": "არ გამოირჩეს პროფილზე",
diff --git a/app/javascript/mastodon/locales/kab.json b/app/javascript/mastodon/locales/kab.json
index 5aa46bafde..de866cc1bc 100644
--- a/app/javascript/mastodon/locales/kab.json
+++ b/app/javascript/mastodon/locales/kab.json
@@ -26,9 +26,7 @@
   "account.follow": "Ḍfer",
   "account.followers": "Imeḍfaren",
   "account.followers.empty": "Ar tura, ulac yiwen i yeṭṭafaṛen amseqdac-agi.",
-  "account.followers_counter": "{count, plural, one {{count} n umeḍfar} other {{count} n imeḍfaren}}",
   "account.following": "Yeṭṭafaṛ",
-  "account.following_counter": "{count, plural, one {{counter} yettwaḍfaren} other {{counter} yettwaḍfaren}}",
   "account.follows.empty": "Ar tura, amseqdac-agi ur yeṭṭafaṛ yiwen.",
   "account.go_to_profile": "Ddu ɣer umaɣnu",
   "account.hide_reblogs": "Ffer ayen i ibeṭṭu @{name}",
@@ -51,7 +49,6 @@
   "account.requested_follow": "{name} yessuter ad k·m-yeḍfer",
   "account.share": "Bḍu amaɣnu n @{name}",
   "account.show_reblogs": "Ssken-d inebḍa n @{name}",
-  "account.statuses_counter": "{count, plural, one {{counter} n tsuffeɣt} other {{counter} n tsuffaɣ}}",
   "account.unblock": "Serreḥ i @{name}",
   "account.unblock_domain": "Ssken-d {domain}",
   "account.unblock_short": "Serreḥ",
diff --git a/app/javascript/mastodon/locales/kk.json b/app/javascript/mastodon/locales/kk.json
index bd0a806cdb..efeee16c65 100644
--- a/app/javascript/mastodon/locales/kk.json
+++ b/app/javascript/mastodon/locales/kk.json
@@ -31,9 +31,7 @@
   "account.follow": "Жазылу",
   "account.followers": "Жазылушы",
   "account.followers.empty": "Бұл қолданушыға әлі ешкім жазылмаған.",
-  "account.followers_counter": "{count, plural, one {{counter} жазылушы} other {{counter} жазылушы}}",
   "account.following": "Жазылым",
-  "account.following_counter": "{count, plural, one {{counter} жазылым} other {{counter} жазылым}}",
   "account.follows.empty": "Бұл қолданушы әлі ешкімге жазылмаған.",
   "account.go_to_profile": "Профиліне өту",
   "account.hide_reblogs": "@{name} бустарын жасыру",
@@ -52,7 +50,6 @@
   "account.requested": "Растауын күтіңіз. Жазылудан бас тарту үшін басыңыз",
   "account.share": "@{name} профилін бөлісу\"",
   "account.show_reblogs": "@{name} бөліскендерін көрсету",
-  "account.statuses_counter": "{count, plural, one {{counter} Пост} other {{counter} Пост}}",
   "account.unblock": "Бұғаттан шығару @{name}",
   "account.unblock_domain": "Бұғаттан шығару {domain}",
   "account.unendorse": "Профильде рекомендемеу",
diff --git a/app/javascript/mastodon/locales/kn.json b/app/javascript/mastodon/locales/kn.json
index ceb0f8b9b6..24592e37fc 100644
--- a/app/javascript/mastodon/locales/kn.json
+++ b/app/javascript/mastodon/locales/kn.json
@@ -16,7 +16,6 @@
   "account.posts": "ಟೂಟ್‌ಗಳು",
   "account.posts_with_replies": "Toots and replies",
   "account.requested": "Awaiting approval",
-  "account.statuses_counter": "{count, plural, one {{counter} Toot} other {{counter} Toots}}",
   "account.unblock_domain": "Unhide {domain}",
   "account_note.placeholder": "Click to add a note",
   "alert.unexpected.title": "ಅಯ್ಯೋ!",
diff --git a/app/javascript/mastodon/locales/ko.json b/app/javascript/mastodon/locales/ko.json
index c4c084d98e..90755666bb 100644
--- a/app/javascript/mastodon/locales/ko.json
+++ b/app/javascript/mastodon/locales/ko.json
@@ -35,9 +35,7 @@
   "account.follow_back": "맞팔로우 하기",
   "account.followers": "팔로워",
   "account.followers.empty": "아직 아무도 이 사용자를 팔로우하고 있지 않습니다.",
-  "account.followers_counter": "{counter} 팔로워",
   "account.following": "팔로잉",
-  "account.following_counter": "{counter} 팔로잉",
   "account.follows.empty": "이 사용자는 아직 아무도 팔로우하고 있지 않습니다.",
   "account.go_to_profile": "프로필로 이동",
   "account.hide_reblogs": "@{name}의 부스트를 숨기기",
@@ -63,7 +61,6 @@
   "account.requested_follow": "{name} 님이 팔로우 요청을 보냈습니다",
   "account.share": "@{name}의 프로필 공유",
   "account.show_reblogs": "@{name}의 부스트 보기",
-  "account.statuses_counter": "{counter} 게시물",
   "account.unblock": "차단 해제",
   "account.unblock_domain": "도메인 {domain} 차단 해제",
   "account.unblock_short": "차단 해제",
diff --git a/app/javascript/mastodon/locales/ku.json b/app/javascript/mastodon/locales/ku.json
index 83fcef26fb..5248cdfa51 100644
--- a/app/javascript/mastodon/locales/ku.json
+++ b/app/javascript/mastodon/locales/ku.json
@@ -32,9 +32,7 @@
   "account.follow": "Bişopîne",
   "account.followers": "Şopîner",
   "account.followers.empty": "Kesekî hin ev bikarhêner neşopandiye.",
-  "account.followers_counter": "{count, plural, one {{counter} Şopîner} other {{counter} Şopîner}}",
   "account.following": "Dişopîne",
-  "account.following_counter": "{count, plural, one {{counter} Dişopîne} other {{counter} Dişopîne}}",
   "account.follows.empty": "Ev bikarhêner hin kesekî heya niha neşopandiye.",
   "account.go_to_profile": "Biçe bo profîlê",
   "account.hide_reblogs": "Bilindkirinên ji @{name} veşêre",
@@ -56,7 +54,6 @@
   "account.requested_follow": "{name} dixwaze te bişopîne",
   "account.share": "Profîla @{name} parve bike",
   "account.show_reblogs": "Bilindkirinên ji @{name} nîşan bike",
-  "account.statuses_counter": "{count, plural,one {{counter} Şandî}other {{counter} Şandî}}",
   "account.unblock": "Astengê li ser @{name} rake",
   "account.unblock_domain": "Astengê li ser navperê {domain} rake",
   "account.unblock_short": "Astengiyê rake",
diff --git a/app/javascript/mastodon/locales/kw.json b/app/javascript/mastodon/locales/kw.json
index 794cbd9ede..1afcf645cf 100644
--- a/app/javascript/mastodon/locales/kw.json
+++ b/app/javascript/mastodon/locales/kw.json
@@ -17,8 +17,6 @@
   "account.follow": "Holya",
   "account.followers": "Holyoryon",
   "account.followers.empty": "Ny wra nagonan holya'n devnydhyer ma hwath.",
-  "account.followers_counter": "{count, plural, one {{counter} Holyer} other {{counter} Holyer}}",
-  "account.following_counter": "{count, plural, one {Ow holya {counter}} other {Ow holya {counter}}}",
   "account.follows.empty": "Ny wra'n devnydhyer ma holya nagonan hwath.",
   "account.hide_reblogs": "Kudha kenerthow a @{name}",
   "account.link_verified_on": "Perghenogeth an kolm ma a veu checkys dhe {date}",
@@ -33,7 +31,6 @@
   "account.requested": "Ow kortos komendyans. Klyckyewgh dhe hedhi govyn holya",
   "account.share": "Kevrenna profil @{name}",
   "account.show_reblogs": "Diskwedhes kenerthow a @{name}",
-  "account.statuses_counter": "{count, plural, one {{counter} Tout} other {{counter} Tout}}",
   "account.unblock": "Anlettya @{name}",
   "account.unblock_domain": "Anlettya gorfarth {domain}",
   "account.unendorse": "Na wra diskwedhes yn profil",
diff --git a/app/javascript/mastodon/locales/la.json b/app/javascript/mastodon/locales/la.json
index d867034f01..aa209fcc00 100644
--- a/app/javascript/mastodon/locales/la.json
+++ b/app/javascript/mastodon/locales/la.json
@@ -14,12 +14,9 @@
   "account.edit_profile": "Recolere notionem",
   "account.featured_tags.last_status_never": "Nulla contributa",
   "account.featured_tags.title": "Hashtag notātī {name}",
-  "account.followers_counter": "{count, plural, one {{counter} Sectator} other {{counter} Sectatores}}",
-  "account.following_counter": "{count, plural, one {{counter} Sequens} other {{counter} Sequentes}}",
   "account.moved_to": "{name} significavit eum suam rationem novam nunc esse:",
   "account.muted": "Confutatus",
   "account.requested_follow": "{name} postulavit ut te sequeretur",
-  "account.statuses_counter": "{count, plural, one {{counter} Nuntius} other {{counter} Nuntii}}",
   "account.unblock_short": "Solvere impedimentum",
   "account_note.placeholder": "Click to add a note",
   "admin.dashboard.retention.average": "Mediocritas",
diff --git a/app/javascript/mastodon/locales/lad.json b/app/javascript/mastodon/locales/lad.json
index bf676a6020..292f00818c 100644
--- a/app/javascript/mastodon/locales/lad.json
+++ b/app/javascript/mastodon/locales/lad.json
@@ -35,9 +35,7 @@
   "account.follow_back": "Sige tamyen",
   "account.followers": "Suivantes",
   "account.followers.empty": "Por agora dingun no sige a este utilizador.",
-  "account.followers_counter": "{count, plural, one {{counter} suivante} other {{counter} suivantes}}",
   "account.following": "Sigiendo",
-  "account.following_counter": "{count, plural, other {Sigiendo a {counter}}}",
   "account.follows.empty": "Este utilizador ainda no sige a dingun.",
   "account.go_to_profile": "Va al profil",
   "account.hide_reblogs": "Eskonde repartajasyones de @{name}",
@@ -63,7 +61,6 @@
   "account.requested_follow": "{name} tiene solisitado segirte",
   "account.share": "Partaja el profil de @{name}",
   "account.show_reblogs": "Amostra repartajasyones de @{name}",
-  "account.statuses_counter": "{count, plural, one {{counter} publikasyon} other {{counter} publikasyones}}",
   "account.unblock": "Dezbloka a @{name}",
   "account.unblock_domain": "Dezbloka domeno {domain}",
   "account.unblock_short": "Dezbloka",
diff --git a/app/javascript/mastodon/locales/lv.json b/app/javascript/mastodon/locales/lv.json
index 13ceec21c8..041072c6ad 100644
--- a/app/javascript/mastodon/locales/lv.json
+++ b/app/javascript/mastodon/locales/lv.json
@@ -35,9 +35,7 @@
   "account.follow_back": "Sekot atpakaļ",
   "account.followers": "Sekotāji",
   "account.followers.empty": "Šim lietotājam vēl nav sekotāju.",
-  "account.followers_counter": "{count, plural, zero {{counter} sekotāju} one {{counter} sekotājs} other {{counter} sekotāji}}",
   "account.following": "Seko",
-  "account.following_counter": "{count, plural, zero{{counter} sekojamo} one {{counter} sekojamais} other {{counter} sekojamie}}",
   "account.follows.empty": "Šis lietotājs pagaidām nevienam neseko.",
   "account.go_to_profile": "Doties uz profilu",
   "account.hide_reblogs": "Paslēpt @{name} pastiprinātos ierakstus",
@@ -63,7 +61,6 @@
   "account.requested_follow": "{name} nosūtīja Tev sekošanas pieprasījumu",
   "account.share": "Dalīties ar @{name} profilu",
   "account.show_reblogs": "Parādīt @{name} pastiprinātos ierakstus",
-  "account.statuses_counter": "{count, plural, zero {{counter} ierakstu} one {{counter} ieraksts} other {{counter} ieraksti}}",
   "account.unblock": "Atbloķēt @{name}",
   "account.unblock_domain": "Atbloķēt domēnu {domain}",
   "account.unblock_short": "Atbloķēt",
diff --git a/app/javascript/mastodon/locales/mk.json b/app/javascript/mastodon/locales/mk.json
index d8a470ed47..a09ad98ebf 100644
--- a/app/javascript/mastodon/locales/mk.json
+++ b/app/javascript/mastodon/locales/mk.json
@@ -38,7 +38,6 @@
   "account.requested": "Се чека одобрување. Кликни за да одкажиш барање за следење",
   "account.share": "Сподели @{name} профил",
   "account.show_reblogs": "Прикажи бустови од @{name}",
-  "account.statuses_counter": "{count, plural, one {{counter} Toot} other {{counter} Toots}}",
   "account.unblock": "Одблокирај @{name}",
   "account.unblock_domain": "Прикажи {domain}",
   "account.unendorse": "Не прикажувај на профил",
diff --git a/app/javascript/mastodon/locales/ml.json b/app/javascript/mastodon/locales/ml.json
index 8fb4e818db..d9caccef34 100644
--- a/app/javascript/mastodon/locales/ml.json
+++ b/app/javascript/mastodon/locales/ml.json
@@ -22,9 +22,7 @@
   "account.follow": "പിന്തുടരുക",
   "account.followers": "പിന്തുടരുന്നവർ",
   "account.followers.empty": "ഈ ഉപയോക്താവിനെ ആരും ഇതുവരെ പിന്തുടരുന്നില്ല.",
-  "account.followers_counter": "{count, plural, one {{counter} പിന്തുടരുന്നവർ} other {{counter} പിന്തുടരുന്നവർ}}",
   "account.following": "പിന്തുടരുന്നു",
-  "account.following_counter": "{count, plural, one {{counter} പിന്തുടരുന്നു} other {{counter} പിന്തുടരുന്നു}}",
   "account.follows.empty": "ഈ ഉപയോക്താവ് ആരേയും ഇതുവരെ പിന്തുടരുന്നില്ല.",
   "account.go_to_profile": "പ്രൊഫൈലിലേക്ക് പോകാം",
   "account.hide_reblogs": "@{name} ബൂസ്റ്റ് ചെയ്തവ മറയ്കുക",
@@ -42,7 +40,6 @@
   "account.requested": "അനുവാദത്തിനായി കാത്തിരിക്കുന്നു. പിന്തുടരാനുള്ള അപേക്ഷ റദ്ദാക്കുവാൻ ഞെക്കുക",
   "account.share": "@{name} ന്റെ പ്രൊഫൈൽ പങ്കിടുക",
   "account.show_reblogs": "@{name} ൽ നിന്നുള്ള ബൂസ്റ്റുകൾ കാണിക്കുക",
-  "account.statuses_counter": "{count, plural, one {{counter} ടൂട്ട്} other {{counter} ടൂട്ടുകൾ}}",
   "account.unblock": "@{name} തടഞ്ഞത് മാറ്റുക",
   "account.unblock_domain": "{domain} എന്ന മേഖല വെളിപ്പെടുത്തുക",
   "account.unblock_short": "അൺബ്ലോക്കു ചെയ്യുക",
diff --git a/app/javascript/mastodon/locales/mr.json b/app/javascript/mastodon/locales/mr.json
index c07294d90a..2757b96f94 100644
--- a/app/javascript/mastodon/locales/mr.json
+++ b/app/javascript/mastodon/locales/mr.json
@@ -35,9 +35,7 @@
   "account.follow_back": "आपणही अनुसरण करा",
   "account.followers": "अनुयायी",
   "account.followers.empty": "ह्या वापरकर्त्याचा आतापर्यंत कोणी अनुयायी नाही.",
-  "account.followers_counter": "{count, plural, one {{counter} Toot} other {{counter} Toots}}",
   "account.following": "अनुसरण",
-  "account.following_counter": "{count, plural, one {{counter} following} other {{counter} following}}",
   "account.follows.empty": "हा वापरकर्ता अजूनपर्यंत कोणाचा अनुयायी नाही.",
   "account.go_to_profile": "प्रोफाइल वर जा",
   "account.hide_reblogs": "@{name} पासून सर्व बूस्ट लपवा",
@@ -59,7 +57,6 @@
   "account.requested_follow": "{name} ने आपल्याला फॉलो करण्याची रिक्वेस्ट केली आहे",
   "account.share": "@{name} चे प्रोफाइल शेअर करा",
   "account.show_reblogs": "{name}चे सर्व बुस्ट्स दाखवा",
-  "account.statuses_counter": "{count, plural, one {{counter} Toot} other {{counter} Toots}}",
   "account.unblock": "@{name} ला ब्लॉक करा",
   "account.unblock_domain": "उघड करा {domain}",
   "account.unblock_short": "अनब्लॉक करा",
diff --git a/app/javascript/mastodon/locales/ms.json b/app/javascript/mastodon/locales/ms.json
index 3d7992faf7..88c093bdee 100644
--- a/app/javascript/mastodon/locales/ms.json
+++ b/app/javascript/mastodon/locales/ms.json
@@ -35,9 +35,7 @@
   "account.follow_back": "Ikut balik",
   "account.followers": "Pengikut",
   "account.followers.empty": "Belum ada yang mengikuti pengguna ini.",
-  "account.followers_counter": "{count, plural, one {{counter} Pengikut} other {{counter} Pengikut}}",
   "account.following": "Mengikuti",
-  "account.following_counter": "{count, plural, one {{counter} Diikuti} other {{counter} Diikuti}}",
   "account.follows.empty": "Pengguna ini belum mengikuti sesiapa.",
   "account.go_to_profile": "Pergi ke profil",
   "account.hide_reblogs": "Sembunyikan galakan daripada @{name}",
@@ -63,7 +61,6 @@
   "account.requested_follow": "{name} has requested to follow you",
   "account.share": "Kongsi profil @{name}",
   "account.show_reblogs": "Tunjukkan galakan daripada @{name}",
-  "account.statuses_counter": "{count, plural, other {{counter} kiriman}}",
   "account.unblock": "Nyahsekat @{name}",
   "account.unblock_domain": "Nyahsekat domain {domain}",
   "account.unblock_short": "Nyahsekat",
diff --git a/app/javascript/mastodon/locales/my.json b/app/javascript/mastodon/locales/my.json
index e3287f3f32..46c8d18069 100644
--- a/app/javascript/mastodon/locales/my.json
+++ b/app/javascript/mastodon/locales/my.json
@@ -34,9 +34,7 @@
   "account.follow": "စောင့်ကြည့်",
   "account.followers": "စောင့်ကြည့်သူများ",
   "account.followers.empty": "ဤသူကို စောင့်ကြည့်သူ မရှိသေးပါ။",
-  "account.followers_counter": "{count, plural, one {စောင့်ကြည့်သူ {counter}} other {စောင့်ကြည့်သူများ {counter}}}",
   "account.following": "စောင့်ကြည့်နေသည်",
-  "account.following_counter": "{count, plural, one {စောင့်ကြည့်ထားသူ {counter}} other {စောင့်ကြည့်ထားသူများ {counter}}}",
   "account.follows.empty": "ဤသူသည် မည်သူ့ကိုမျှ စောင့်ကြည့်ခြင်း မရှိသေးပါ။",
   "account.go_to_profile": "ပရိုဖိုင်းသို့ သွားရန်",
   "account.hide_reblogs": "@{name} ၏ မျှဝေမှုကို ဝှက်ထားရန်",
@@ -61,7 +59,6 @@
   "account.requested_follow": "{name} က သင့်ကို စောင့်ကြည့်ရန် တောင်းဆိုထားသည်",
   "account.share": "{name}၏ပရိုဖိုင်ကိုမျှဝေပါ",
   "account.show_reblogs": "@{name} မှ မျှ၀ေမှုများကို ပြပါ\n",
-  "account.statuses_counter": "{count, plural, one {{counter} ပိုစ့်များ} other {{counter} ပိုစ့်များ}}",
   "account.unblock": "{name} ကို ဘလော့ဖြုတ်မည်",
   "account.unblock_domain": " {domain} ဒိုမိန်းကိုပြန်ဖွင့်မည်",
   "account.unblock_short": "ဘလော့ဖြုတ်ရန်",
diff --git a/app/javascript/mastodon/locales/ne.json b/app/javascript/mastodon/locales/ne.json
index 500261a34b..ca23a1f781 100644
--- a/app/javascript/mastodon/locales/ne.json
+++ b/app/javascript/mastodon/locales/ne.json
@@ -39,7 +39,6 @@
   "account.requested_follow": "{name} ले तपाईंलाई फलो गर्न अनुरोध गर्नुभएको छ",
   "account.share": "@{name} को प्रोफाइल सेयर गर्नुहोस्",
   "account.show_reblogs": "@{name} को बूस्टहरू देखाउनुहोस्",
-  "account.statuses_counter": "{count, plural, one {{counter} पोस्ट} other {{counter} पोस्टहरू}}",
   "account.unblock": "@{name} लाई अनब्लक गर्नुहोस्",
   "account.unblock_domain": "{domain} डोमेनलाई अनब्लक गर्नुहोस्",
   "account.unblock_short": "अनब्लक गर्नुहोस्",
diff --git a/app/javascript/mastodon/locales/nn.json b/app/javascript/mastodon/locales/nn.json
index 93b44f29a1..0fb0edf0a0 100644
--- a/app/javascript/mastodon/locales/nn.json
+++ b/app/javascript/mastodon/locales/nn.json
@@ -35,9 +35,9 @@
   "account.follow_back": "Fylg tilbake",
   "account.followers": "Fylgjarar",
   "account.followers.empty": "Ingen fylgjer denne brukaren enno.",
-  "account.followers_counter": "{count, plural, one {{counter} fylgjar} other {{counter} fylgjarar}}",
+  "account.followers_counter": "{count, plural, one {{counter} følgjar} other {{counter} følgjarar}}",
   "account.following": "Fylgjer",
-  "account.following_counter": "{count, plural, one {Fylgjer {counter}} other {Fylgjer {counter}}}",
+  "account.following_counter": "{count, plural, one {{counter} følgjer} other {{counter} følgjer}}",
   "account.follows.empty": "Denne brukaren fylgjer ikkje nokon enno.",
   "account.go_to_profile": "Gå til profil",
   "account.hide_reblogs": "Gøym framhevingar frå @{name}",
@@ -63,7 +63,7 @@
   "account.requested_follow": "{name} har bedt om å få fylgja deg",
   "account.share": "Del @{name} sin profil",
   "account.show_reblogs": "Vis framhevingar frå @{name}",
-  "account.statuses_counter": "{count, plural, one {{counter} tut} other {{counter} tut}}",
+  "account.statuses_counter": "{count, plural, one {{counter} innlegg} other {{counter} innlegg}}",
   "account.unblock": "Stopp blokkering av @{name}",
   "account.unblock_domain": "Stopp blokkering av domenet {domain}",
   "account.unblock_short": "Stopp blokkering",
@@ -696,8 +696,11 @@
   "server_banner.about_active_users": "Personar som har brukt denne tenaren dei siste 30 dagane (Månadlege Aktive Brukarar)",
   "server_banner.active_users": "aktive brukarar",
   "server_banner.administered_by": "Administrert av:",
+  "server_banner.is_one_of_many": "{domain} er ein av dei mange uavhengige Mastodon-serverane du kan bruke til å delta i Fødiverset.",
   "server_banner.server_stats": "Tenarstatistikk:",
   "sign_in_banner.create_account": "Opprett konto",
+  "sign_in_banner.follow_anyone": "Følg kven som helst på tvers av Fødiverset og sjå alt i kronologisk rekkjefølgje. Ingen algoritmar, reklamar eller clickbait i sikte.",
+  "sign_in_banner.mastodon_is": "Mastodon er den beste måten å følgje med på det som skjer.",
   "sign_in_banner.sign_in": "Logg inn",
   "sign_in_banner.sso_redirect": "Logg inn eller registrer deg",
   "status.admin_account": "Opne moderasjonsgrensesnitt for @{name}",
diff --git a/app/javascript/mastodon/locales/no.json b/app/javascript/mastodon/locales/no.json
index 213ba8af12..2bda373404 100644
--- a/app/javascript/mastodon/locales/no.json
+++ b/app/javascript/mastodon/locales/no.json
@@ -35,9 +35,7 @@
   "account.follow_back": "Følg tilbake",
   "account.followers": "Følgere",
   "account.followers.empty": "Ingen følger denne brukeren ennå.",
-  "account.followers_counter": "{count, plural, one {{counter} følger} other {{counter} følgere}}",
   "account.following": "Følger",
-  "account.following_counter": "{count, plural, one {{counter} som følges} other {{counter} som følges}}",
   "account.follows.empty": "Denne brukeren følger ikke noen enda.",
   "account.go_to_profile": "Gå til profil",
   "account.hide_reblogs": "Skjul fremhevinger fra @{name}",
@@ -63,7 +61,6 @@
   "account.requested_follow": "{name} har bedt om å få følge deg",
   "account.share": "Del @{name} sin profil",
   "account.show_reblogs": "Vis fremhevinger fra @{name}",
-  "account.statuses_counter": "{count, plural, one {{counter} innlegg} other {{counter} innlegg}}",
   "account.unblock": "Opphev blokkering av @{name}",
   "account.unblock_domain": "Opphev blokkering av {domain}",
   "account.unblock_short": "Opphev blokkering",
diff --git a/app/javascript/mastodon/locales/oc.json b/app/javascript/mastodon/locales/oc.json
index d8e1141588..d977eed4af 100644
--- a/app/javascript/mastodon/locales/oc.json
+++ b/app/javascript/mastodon/locales/oc.json
@@ -32,9 +32,7 @@
   "account.follow_back": "Sègre en retorn",
   "account.followers": "Seguidors",
   "account.followers.empty": "Degun sèc pas aqueste utilizaire pel moment.",
-  "account.followers_counter": "{count, plural, one {{counter} Seguidor} other {{counter} Seguidors}}",
   "account.following": "Abonat",
-  "account.following_counter": "{count, plural, one {{counter} Abonaments} other {{counter} Abonaments}}",
   "account.follows.empty": "Aqueste utilizaire sèc pas degun pel moment.",
   "account.go_to_profile": "Anar al perfil",
   "account.hide_reblogs": "Rescondre los partatges de @{name}",
@@ -60,7 +58,6 @@
   "account.requested_follow": "{name} a demandat a vos sègre",
   "account.share": "Partejar lo perfil a @{name}",
   "account.show_reblogs": "Mostrar los partatges de @{name}",
-  "account.statuses_counter": "{count, plural, one {{counter} Tut} other {{counter} Tuts}}",
   "account.unblock": "Desblocar @{name}",
   "account.unblock_domain": "Desblocar {domain}",
   "account.unblock_short": "Desblocat",
diff --git a/app/javascript/mastodon/locales/pa.json b/app/javascript/mastodon/locales/pa.json
index 46924d737d..3828ff887e 100644
--- a/app/javascript/mastodon/locales/pa.json
+++ b/app/javascript/mastodon/locales/pa.json
@@ -25,9 +25,7 @@
   "account.follow_back": "ਵਾਪਸ ਫਾਲ਼ੋ ਕਰੋ",
   "account.followers": "ਫ਼ਾਲੋਅਰ",
   "account.followers.empty": "ਇਸ ਵਰਤੋਂਕਾਰ ਨੂੰ ਹਾਲੇ ਕੋਈ ਫ਼ਾਲੋ ਨਹੀਂ ਕਰਦਾ ਹੈ।",
-  "account.followers_counter": "{count, plural, one {{counter} ਫ਼ਾਲੋਅਰ} other {{counter} ਫ਼ਾਲੋਅਰ}}",
   "account.following": "ਫ਼ਾਲੋ ਕੀਤਾ",
-  "account.following_counter": "{count, plural, one {{counter} ਨੂੰ ਫ਼ਾਲੋ} other {{counter} ਨੂੰ ਫ਼ਾਲੋ}}",
   "account.follows.empty": "ਇਹ ਵਰਤੋਂਕਾਰ ਹਾਲੇ ਕਿਸੇ ਨੂੰ ਫ਼ਾਲੋ ਨਹੀਂ ਕਰਦਾ ਹੈ।",
   "account.go_to_profile": "ਪਰੋਫਾਇਲ ਉੱਤੇ ਜਾਓ",
   "account.media": "ਮੀਡੀਆ",
@@ -41,7 +39,6 @@
   "account.requested": "ਮਨਜ਼ੂਰੀ ਕੀਤੀ ਜਾ ਰਹੀ ਹੈ। ਫ਼ਾਲੋ ਬੇਨਤੀਆਂ ਨੂੰ ਰੱਦ ਕਰਨ ਲਈ ਕਲਿੱਕ ਕਰੋ",
   "account.requested_follow": "{name} ਨੇ ਤੁਹਾਨੂੰ ਫ਼ਾਲੋ ਕਰਨ ਦੀ ਬੇਨਤੀ ਕੀਤੀ ਹੈ",
   "account.share": "{name} ਦਾ ਪਰੋਫ਼ਾਇਲ ਸਾਂਝਾ ਕਰੋ",
-  "account.statuses_counter": "{count, plural, one {{counter} Toot} other {{counter} Toots}}",
   "account.unblock": "@{name} ਤੋਂ ਪਾਬੰਦੀ ਹਟਾਓ",
   "account.unblock_domain": "{domain} ਡੋਮੇਨ ਤੋਂ ਪਾਬੰਦੀ ਹਟਾਓ",
   "account.unblock_short": "ਪਾਬੰਦੀ ਹਟਾਓ",
diff --git a/app/javascript/mastodon/locales/pt-BR.json b/app/javascript/mastodon/locales/pt-BR.json
index 4d3bd2d280..34d0ba36e6 100644
--- a/app/javascript/mastodon/locales/pt-BR.json
+++ b/app/javascript/mastodon/locales/pt-BR.json
@@ -35,9 +35,7 @@
   "account.follow_back": "Seguir de volta",
   "account.followers": "Seguidores",
   "account.followers.empty": "Nada aqui.",
-  "account.followers_counter": "{count, plural, one {{counter} seguidor} other {{counter} seguidores}}",
   "account.following": "Seguindo",
-  "account.following_counter": "{count, plural, one {segue {counter}} other {segue {counter}}}",
   "account.follows.empty": "Nada aqui.",
   "account.go_to_profile": "Ir ao perfil",
   "account.hide_reblogs": "Ocultar boosts de @{name}",
@@ -63,7 +61,6 @@
   "account.requested_follow": "{name} quer te seguir",
   "account.share": "Compartilhar perfil de @{name}",
   "account.show_reblogs": "Mostrar boosts de @{name}",
-  "account.statuses_counter": "{count, plural, one {{counter} Toot} other {{counter} Toots}}",
   "account.unblock": "Desbloquear @{name}",
   "account.unblock_domain": "Desbloquear domínio {domain}",
   "account.unblock_short": "Desbloquear",
diff --git a/app/javascript/mastodon/locales/pt-PT.json b/app/javascript/mastodon/locales/pt-PT.json
index 9446d5ee25..6a6feca309 100644
--- a/app/javascript/mastodon/locales/pt-PT.json
+++ b/app/javascript/mastodon/locales/pt-PT.json
@@ -37,7 +37,7 @@
   "account.followers.empty": "Ainda ninguém segue este utilizador.",
   "account.followers_counter": "{count, plural, one {{counter} seguidor} other {{counter} seguidores}}",
   "account.following": "A seguir",
-  "account.following_counter": "{count, plural, other {A seguir {counter}}}",
+  "account.following_counter": "{count, plural, one {A seguir {counter}} other {A seguir {counter}}}",
   "account.follows.empty": "Este utilizador ainda não segue ninguém.",
   "account.go_to_profile": "Ir para o perfil",
   "account.hide_reblogs": "Esconder partilhas de @{name}",
@@ -63,7 +63,7 @@
   "account.requested_follow": "{name} pediu para segui-lo",
   "account.share": "Partilhar o perfil @{name}",
   "account.show_reblogs": "Mostrar partilhas de @{name}",
-  "account.statuses_counter": "{count, plural, one {{counter} Toot} other {{counter} Toots}}",
+  "account.statuses_counter": "{count, plural, one {{counter} publicação} other {{counter} publicações}}",
   "account.unblock": "Desbloquear @{name}",
   "account.unblock_domain": "Desbloquear o domínio {domain}",
   "account.unblock_short": "Desbloquear",
diff --git a/app/javascript/mastodon/locales/ro.json b/app/javascript/mastodon/locales/ro.json
index 3a2fab9056..35abf1b021 100644
--- a/app/javascript/mastodon/locales/ro.json
+++ b/app/javascript/mastodon/locales/ro.json
@@ -35,9 +35,7 @@
   "account.follow_back": "Urmăreşte înapoi",
   "account.followers": "Urmăritori",
   "account.followers.empty": "Acest utilizator nu are încă urmăritori.",
-  "account.followers_counter": "{count, plural, one {Un abonat} few {{counter} abonați} other {{counter} de abonați}}",
   "account.following": "Urmăriți",
-  "account.following_counter": "{count, plural, one {Un abonament} few {{counter} abonamente} other {{counter} de abonamente}}",
   "account.follows.empty": "Momentan acest utilizator nu are niciun abonament.",
   "account.go_to_profile": "Mergi la profil",
   "account.hide_reblogs": "Ascunde distribuirile de la @{name}",
@@ -62,7 +60,6 @@
   "account.requested_follow": "{name} A cerut să vă urmărească",
   "account.share": "Distribuie profilul lui @{name}",
   "account.show_reblogs": "Afișează distribuirile de la @{name}",
-  "account.statuses_counter": "{count, plural, one {{counter} Toot} other {{counter} Toots}}",
   "account.unblock": "Deblochează pe @{name}",
   "account.unblock_domain": "Deblochează domeniul {domain}",
   "account.unblock_short": "Deblochează",
diff --git a/app/javascript/mastodon/locales/ru.json b/app/javascript/mastodon/locales/ru.json
index 40ca848147..97a1f0b09c 100644
--- a/app/javascript/mastodon/locales/ru.json
+++ b/app/javascript/mastodon/locales/ru.json
@@ -35,9 +35,7 @@
   "account.follow_back": "Подписаться в ответ",
   "account.followers": "Подписчики",
   "account.followers.empty": "На этого пользователя пока никто не подписан.",
-  "account.followers_counter": "{count, plural, one {{counter} подписчик} many {{counter} подписчиков} other {{counter} подписчика}}",
   "account.following": "Подписки",
-  "account.following_counter": "{count, plural, one {{counter} подписка} many {{counter} подписок} other {{counter} подписки}}",
   "account.follows.empty": "Этот пользователь пока ни на кого не подписался.",
   "account.go_to_profile": "Перейти к профилю",
   "account.hide_reblogs": "Скрыть продвижения от @{name}",
@@ -63,7 +61,6 @@
   "account.requested_follow": "{name} отправил(а) вам запрос на подписку",
   "account.share": "Поделиться профилем @{name}",
   "account.show_reblogs": "Показывать продвижения от @{name}",
-  "account.statuses_counter": "{count, plural, one {{counter} пост} many {{counter} постов} other {{counter} поста}}",
   "account.unblock": "Разблокировать @{name}",
   "account.unblock_domain": "Разблокировать {domain}",
   "account.unblock_short": "Разблокировать",
diff --git a/app/javascript/mastodon/locales/sa.json b/app/javascript/mastodon/locales/sa.json
index 58654deb03..c3880a6b03 100644
--- a/app/javascript/mastodon/locales/sa.json
+++ b/app/javascript/mastodon/locales/sa.json
@@ -32,9 +32,7 @@
   "account.follow": "अनुस्रियताम्",
   "account.followers": "अनुसर्तारः",
   "account.followers.empty": "नाऽनुसर्तारो वर्तन्ते",
-  "account.followers_counter": "{count, plural, one {{counter} अनुसर्ता} two {{counter} अनुसर्तारौ} other {{counter} अनुसर्तारः}}",
   "account.following": "अनुसरति",
-  "account.following_counter": "{count, plural, one {{counter} अनुसृतः} two {{counter} अनुसृतौ} other {{counter} अनुसृताः}}",
   "account.follows.empty": "न कोऽप्यनुसृतो वर्तते",
   "account.go_to_profile": "प्रोफायिलं गच्छ",
   "account.hide_reblogs": "@{name} मित्रस्य प्रकाशनानि छिद्यन्ताम्",
@@ -56,7 +54,6 @@
   "account.requested_follow": "{name} त्वामनुसर्तुमयाचीत्",
   "account.share": "@{name} मित्रस्य विवरणं विभाज्यताम्",
   "account.show_reblogs": "@{name} मित्रस्य प्रकाशनानि दृश्यन्ताम्",
-  "account.statuses_counter": "{count, plural, one {{counter} पत्रम्}  two{{counter} पत्रे} other {{counter} पत्राणि}}",
   "account.unblock": "निषेधता नश्यताम् @{name}",
   "account.unblock_domain": "प्रदेशनिषेधता नश्यताम् {domain}",
   "account.unblock_short": "अनवरुन्धि",
diff --git a/app/javascript/mastodon/locales/sc.json b/app/javascript/mastodon/locales/sc.json
index a0b5b32711..8955573737 100644
--- a/app/javascript/mastodon/locales/sc.json
+++ b/app/javascript/mastodon/locales/sc.json
@@ -26,9 +26,7 @@
   "account.follow": "Sighi",
   "account.followers": "Sighiduras",
   "account.followers.empty": "Nemos sighit ancora custa persone.",
-  "account.followers_counter": "{count, plural, one {{counter} sighidura} other {{counter} sighiduras}}",
   "account.following": "Sighende",
-  "account.following_counter": "{count, plural, one {Sighende a {counter}} other {Sighende a {counter}}}",
   "account.follows.empty": "Custa persone non sighit ancora a nemos.",
   "account.hide_reblogs": "Cua is cumpartziduras de @{name}",
   "account.in_memoriam": "In memoriam.",
@@ -47,7 +45,6 @@
   "account.requested_follow": "{name} at dimandadu de ti sighire",
   "account.share": "Cumpartzi su profilu de @{name}",
   "account.show_reblogs": "Ammustra is cumpartziduras de @{name}",
-  "account.statuses_counter": "{count, plural, one {{counter} publicatzione} other {{counter} publicatziones}}",
   "account.unblock": "Isbloca a @{name}",
   "account.unblock_domain": "Isbloca su domìniu {domain}",
   "account.unendorse": "Non cussiges in su profilu",
diff --git a/app/javascript/mastodon/locales/sco.json b/app/javascript/mastodon/locales/sco.json
index 53501a5937..397f63fed4 100644
--- a/app/javascript/mastodon/locales/sco.json
+++ b/app/javascript/mastodon/locales/sco.json
@@ -31,9 +31,7 @@
   "account.follow": "Follae",
   "account.followers": "Follaers",
   "account.followers.empty": "Naebody follaes this uiser yit.",
-  "account.followers_counter": "{count, plural, one {{counter} Follaer} other {{counter} Follaers}}",
   "account.following": "Follaein",
-  "account.following_counter": "{count, plural, one {{counter} Follaein} other {{counter} Follaein}}",
   "account.follows.empty": "This uiser disnae follae oniebody yit.",
   "account.go_to_profile": "Gang tae profile",
   "account.hide_reblogs": "Dinnae shaw heezes fae @{name}",
@@ -53,7 +51,6 @@
   "account.requested": "Haudin fir approval. Chap tae cancel follae request",
   "account.share": "Share @{name}'s profile",
   "account.show_reblogs": "Shaw heezes frae @{name}",
-  "account.statuses_counter": "{count, plural, one {{counter} Post} other {{counter} Posts}}",
   "account.unblock": "Undingie @{name}",
   "account.unblock_domain": "Undingie domain {domain}",
   "account.unblock_short": "Undingie",
diff --git a/app/javascript/mastodon/locales/si.json b/app/javascript/mastodon/locales/si.json
index 22320daefc..fbfdfaa659 100644
--- a/app/javascript/mastodon/locales/si.json
+++ b/app/javascript/mastodon/locales/si.json
@@ -23,9 +23,7 @@
   "account.follow": "අනුගමනය",
   "account.followers": "අනුගාමිකයින්",
   "account.followers.empty": "කිසිවෙක් අනුගමනය කර නැත.",
-  "account.followers_counter": "{count, plural, one {අනුගාමිකයින් {counter}} other {අනුගාමිකයින් {counter}}}",
   "account.following": "අනුගමන",
-  "account.following_counter": "{count, plural, one {අනුගමන {counter}} other {අනුගමන {counter}}}",
   "account.follows.empty": "තවමත් කිසිවෙක් අනුගමනය නොකරයි.",
   "account.go_to_profile": "පැතිකඩට යන්න",
   "account.joined_short": "එක් වූ දිනය",
@@ -39,7 +37,6 @@
   "account.posts_with_replies": "ලිපි සහ පිළිතුරු",
   "account.report": "@{name} වාර්තා කරන්න",
   "account.share": "@{name} ගේ පැතිකඩ බෙදාගන්න",
-  "account.statuses_counter": "{count, plural, one {ලිපි {counter}} other {ලිපි {counter}}}",
   "account.unblock": "@{name} අනවහිර කරන්න",
   "account.unblock_domain": "{domain} වසම අනවහිර කරන්න",
   "account.unblock_short": "අනවහිර",
diff --git a/app/javascript/mastodon/locales/sk.json b/app/javascript/mastodon/locales/sk.json
index 4c152a2143..ed9c0de604 100644
--- a/app/javascript/mastodon/locales/sk.json
+++ b/app/javascript/mastodon/locales/sk.json
@@ -35,9 +35,7 @@
   "account.follow_back": "Sledovať späť",
   "account.followers": "Sledovatelia",
   "account.followers.empty": "Tento účet ešte nikto nesleduje.",
-  "account.followers_counter": "{count, plural, one {{counter} sledujúci účet} few {{counter} sledujúce účty} many {{counter} sledujúcich účtov} other {{counter} sledujúcich účtov}}",
   "account.following": "Sledovaný účet",
-  "account.following_counter": "{count, plural, one {{counter} sledovaný účet} few {{counter} sledované účty} many {{counter} sledovaných účtov} other {{counter} sledovaných účtov}}",
   "account.follows.empty": "Tento účet ešte nikoho nesleduje.",
   "account.go_to_profile": "Prejsť na profil",
   "account.hide_reblogs": "Skryť zdieľania od @{name}",
@@ -63,7 +61,6 @@
   "account.requested_follow": "{name} vás chce sledovať",
   "account.share": "Zdieľaj profil @{name}",
   "account.show_reblogs": "Zobrazovať zdieľania od @{name}",
-  "account.statuses_counter": "{count, plural, one {{counter} príspevok} few {{counter} príspevky} many {{counter} príspevkov} other {{counter} príspevkov}}",
   "account.unblock": "Odblokovať @{name}",
   "account.unblock_domain": "Odblokovať doménu {domain}",
   "account.unblock_short": "Odblokovať",
diff --git a/app/javascript/mastodon/locales/sl.json b/app/javascript/mastodon/locales/sl.json
index 195797143b..2a3d74a80f 100644
--- a/app/javascript/mastodon/locales/sl.json
+++ b/app/javascript/mastodon/locales/sl.json
@@ -35,9 +35,9 @@
   "account.follow_back": "Sledi nazaj",
   "account.followers": "Sledilci",
   "account.followers.empty": "Nihče ne sledi temu uporabniku.",
-  "account.followers_counter": "{count, plural, one {ima {counter} sledilca} two {ima {counter} sledilca} few {ima {counter} sledilce} other {ima {counter} sledilcev}}",
+  "account.followers_counter": "{count, plural, one {{counter} sledilec} two {{counter} sledilca} few {{counter} sledilci} other {{counter} sledilcev}}",
   "account.following": "Sledim",
-  "account.following_counter": "{count, plural, one {sledi {count} osebi} two {sledi {count} osebama} few {sledi {count} osebam} other {sledi {count} osebam}}",
+  "account.following_counter": "{count, plural, one {{counter} sleden} two {{counter} sledena} few {{counter} sledeni} other {{counter} sledenih}}",
   "account.follows.empty": "Ta uporabnik še ne sledi nikomur.",
   "account.go_to_profile": "Pojdi na profil",
   "account.hide_reblogs": "Skrij izpostavitve od @{name}",
@@ -63,7 +63,7 @@
   "account.requested_follow": "{name} vam želi slediti",
   "account.share": "Deli profil osebe @{name}",
   "account.show_reblogs": "Pokaži izpostavitve osebe @{name}",
-  "account.statuses_counter": "{count, plural, one {{count} objava} two {{count} objavi} few {{count} objave} other {{count} objav}}",
+  "account.statuses_counter": "{count, plural, one {{counter} objava} two {{counter} objavi} few {{counter} objave} other {{counter} objav}}",
   "account.unblock": "Odblokiraj @{name}",
   "account.unblock_domain": "Odblokiraj domeno {domain}",
   "account.unblock_short": "Odblokiraj",
diff --git a/app/javascript/mastodon/locales/sq.json b/app/javascript/mastodon/locales/sq.json
index 6903bceff6..96b7b3fefc 100644
--- a/app/javascript/mastodon/locales/sq.json
+++ b/app/javascript/mastodon/locales/sq.json
@@ -35,9 +35,9 @@
   "account.follow_back": "Ndiqe gjithashtu",
   "account.followers": "Ndjekës",
   "account.followers.empty": "Këtë përdorues ende s’e ndjek kush.",
-  "account.followers_counter": "{count, plural, one {{counter} Ndjekës} other {{counter} Ndjekës}}",
+  "account.followers_counter": "{count, plural, one {{counter} ndjekës} other {{counter} ndjekës}}",
   "account.following": "Ndjekje",
-  "account.following_counter": "{count, plural, one {{counter} i Ndjekur} other {{counter} të Ndjekur}}",
+  "account.following_counter": "{count, plural, one {{counter} i ndjekur} other {{counter} të ndjekur}}",
   "account.follows.empty": "Ky përdorues ende s’ndjek kënd.",
   "account.go_to_profile": "Kalo te profili",
   "account.hide_reblogs": "Fshih përforcime nga @{name}",
@@ -63,7 +63,7 @@
   "account.requested_follow": "{name} ka kërkuar t’ju ndjekë",
   "account.share": "Ndajeni profilin e @{name} me të tjerët",
   "account.show_reblogs": "Shfaq përforcime nga @{name}",
-  "account.statuses_counter": "{count, plural, one {{counter} Mesazh} other {{counter} Mesazhe}}",
+  "account.statuses_counter": "{count, plural, one {{counter} postim} other {{counter} postime}}",
   "account.unblock": "Zhbllokoje @{name}",
   "account.unblock_domain": "Zhblloko përkatësinë {domain}",
   "account.unblock_short": "Zhbllokoje",
diff --git a/app/javascript/mastodon/locales/sr-Latn.json b/app/javascript/mastodon/locales/sr-Latn.json
index 63b2e03c96..93c3b8fe2e 100644
--- a/app/javascript/mastodon/locales/sr-Latn.json
+++ b/app/javascript/mastodon/locales/sr-Latn.json
@@ -35,9 +35,7 @@
   "account.follow_back": "Uzvrati praćenje",
   "account.followers": "Pratioci",
   "account.followers.empty": "Još uvek niko ne prati ovog korisnika.",
-  "account.followers_counter": "{count, plural, one {{counter} pratilac} few {{counter} pratioca} other {{counter} pratilaca}}",
   "account.following": "Prati",
-  "account.following_counter": "{count, plural, one {{counter} prati} few {{counter} prati} other {{counter} prati}}",
   "account.follows.empty": "Ovaj korisnik još uvek nikog ne prati.",
   "account.go_to_profile": "Idi na profil",
   "account.hide_reblogs": "Sakrij podržavanja @{name}",
@@ -63,7 +61,6 @@
   "account.requested_follow": "{name} je zatražio da vas prati",
   "account.share": "Podeli profil korisnika @{name}",
   "account.show_reblogs": "Prikaži podržavanja od korisnika @{name}",
-  "account.statuses_counter": "{count, plural, one {{counter} objavio} few {{counter} objavio} other {{counter} objavio}}",
   "account.unblock": "Odblokiraj korisnika @{name}",
   "account.unblock_domain": "Odblokiraj domen {domain}",
   "account.unblock_short": "Odblokiraj",
@@ -696,8 +693,11 @@
   "server_banner.about_active_users": "Ljudi koji su koristili ovaj server u prethodnih 30 dana (mesečno aktivnih korisnika)",
   "server_banner.active_users": "aktivnih korisnika",
   "server_banner.administered_by": "Administrira:",
+  "server_banner.is_one_of_many": "{domain} je jedan od mnogih nezavisnih Mastodon servera koje možete koristiti za učešće u fediverzumu.",
   "server_banner.server_stats": "Statistike servera:",
   "sign_in_banner.create_account": "Napravite nalog",
+  "sign_in_banner.follow_anyone": "Pratite bilo koga širom fediverzuma i pogledajte sve hronološkim redom. Nema algoritama, reklama ili mamaca za klikove na vidiku.",
+  "sign_in_banner.mastodon_is": "Mastodon je najbolji način da budete u toku sa onim što se dešava.",
   "sign_in_banner.sign_in": "Prijavite se",
   "sign_in_banner.sso_redirect": "Prijavite se ili se registrujte",
   "status.admin_account": "Otvori moderatorsko okruženje za @{name}",
diff --git a/app/javascript/mastodon/locales/sr.json b/app/javascript/mastodon/locales/sr.json
index c6b969e982..0273002b37 100644
--- a/app/javascript/mastodon/locales/sr.json
+++ b/app/javascript/mastodon/locales/sr.json
@@ -35,9 +35,7 @@
   "account.follow_back": "Узврати праћење",
   "account.followers": "Пратиоци",
   "account.followers.empty": "Још увек нико не прати овог корисника.",
-  "account.followers_counter": "{count, plural, one {{counter} пратилац} few {{counter} пратиоца} other {{counter} пратилаца}}",
   "account.following": "Прати",
-  "account.following_counter": "{count, plural, one {{counter} прати} few {{counter} прати} other {{counter} прати}}",
   "account.follows.empty": "Овај корисник још увек никог не прати.",
   "account.go_to_profile": "Иди на профил",
   "account.hide_reblogs": "Сакриј подржавања од @{name}",
@@ -63,7 +61,6 @@
   "account.requested_follow": "{name} је затражио да вас прати",
   "account.share": "Подели профил корисника @{name}",
   "account.show_reblogs": "Прикажи подржавања од корисника @{name}",
-  "account.statuses_counter": "{count, plural, one {{counter} објавио} few {{counter} објавио} other {{counter} објавио}}",
   "account.unblock": "Одблокирај корисника @{name}",
   "account.unblock_domain": "Одблокирај домен {domain}",
   "account.unblock_short": "Одблокирај",
@@ -696,8 +693,11 @@
   "server_banner.about_active_users": "Људи који су користили овај сервер у претходних 30 дана (месечно активних корисника)",
   "server_banner.active_users": "активних корисника",
   "server_banner.administered_by": "Администрира:",
+  "server_banner.is_one_of_many": "{domain} је један од многих независних Mastodon сервера које можете користити за учешће у федиверзуму.",
   "server_banner.server_stats": "Статистике сервера:",
   "sign_in_banner.create_account": "Направите налог",
+  "sign_in_banner.follow_anyone": "Пратите било кога широм федиверзума и погледајте све хронолошким редом. Нема алгоритама, реклама или мамаца за кликове на видику.",
+  "sign_in_banner.mastodon_is": "Mastodon је најбољи начин да будете у току са оним што се дешава.",
   "sign_in_banner.sign_in": "Пријавите се",
   "sign_in_banner.sso_redirect": "Пријавите се или се региструјте",
   "status.admin_account": "Отвори модераторско окружење за @{name}",
diff --git a/app/javascript/mastodon/locales/sv.json b/app/javascript/mastodon/locales/sv.json
index ced6c36054..1833a2cfde 100644
--- a/app/javascript/mastodon/locales/sv.json
+++ b/app/javascript/mastodon/locales/sv.json
@@ -37,7 +37,6 @@
   "account.followers.empty": "Ingen följer denna användare än.",
   "account.followers_counter": "{count, plural, one {{counter} följare} other {{counter} följare}}",
   "account.following": "Följer",
-  "account.following_counter": "{count, plural, one {{counter} följd} other {{counter} följda}}",
   "account.follows.empty": "Denna användare följer inte någon än.",
   "account.go_to_profile": "Gå till profilen",
   "account.hide_reblogs": "Dölj boostar från @{name}",
diff --git a/app/javascript/mastodon/locales/szl.json b/app/javascript/mastodon/locales/szl.json
index 43cfc78d5b..34d086eb48 100644
--- a/app/javascript/mastodon/locales/szl.json
+++ b/app/javascript/mastodon/locales/szl.json
@@ -23,7 +23,6 @@
   "account.posts": "Toots",
   "account.posts_with_replies": "Toots and replies",
   "account.requested": "Awaiting approval",
-  "account.statuses_counter": "{count, plural, one {{counter} Toot} other {{counter} Toots}}",
   "account_note.placeholder": "Click to add a note",
   "column.pins": "Pinned toot",
   "community.column_settings.media_only": "Media only",
diff --git a/app/javascript/mastodon/locales/ta.json b/app/javascript/mastodon/locales/ta.json
index ac0984293a..d44ac424f4 100644
--- a/app/javascript/mastodon/locales/ta.json
+++ b/app/javascript/mastodon/locales/ta.json
@@ -24,9 +24,7 @@
   "account.follow_back": "பின்தொடரு",
   "account.followers": "பின்தொடர்பவர்கள்",
   "account.followers.empty": "இதுவரை யாரும் இந்த பயனரைப் பின்தொடரவில்லை.",
-  "account.followers_counter": "{count, plural, one {{counter} வாசகர்} other {{counter} வாசகர்கள்}}",
   "account.following": "பின்தொடரும்",
-  "account.following_counter": "{count, plural,one {{counter} சந்தா} other {{counter} சந்தாக்கள்}}",
   "account.follows.empty": "இந்த பயனர் இதுவரை யாரையும் பின்தொடரவில்லை.",
   "account.go_to_profile": "சுயவிவரத்திற்குச் செல்லவும்",
   "account.hide_reblogs": "இருந்து ஊக்கியாக மறை @{name}",
@@ -45,7 +43,6 @@
   "account.requested": "ஒப்புதலுக்காகக் காத்திருக்கிறது. பின்தொடரும் கோரிக்கையை நீக்க அழுத்தவும்",
   "account.share": "@{name} உடைய விவரத்தை பகிர்",
   "account.show_reblogs": "காட்டு boosts இருந்து @{name}",
-  "account.statuses_counter": "{count, plural, one {{counter} டூட்} other {{counter} டூட்டுகள்}}",
   "account.unblock": "@{name} மீது தடை நீக்குக",
   "account.unblock_domain": "{domain} ஐ காண்பி",
   "account.unblock_short": "தடையை நீக்கு",
diff --git a/app/javascript/mastodon/locales/tai.json b/app/javascript/mastodon/locales/tai.json
index 825cfb93bd..cad6e8eaa5 100644
--- a/app/javascript/mastodon/locales/tai.json
+++ b/app/javascript/mastodon/locales/tai.json
@@ -9,7 +9,6 @@
   "account.posts": "Huah-siann",
   "account.posts_with_replies": "Huah-siann kah huê-ìng",
   "account.requested": "Tán-thāi phue-tsún",
-  "account.statuses_counter": "{count, plural, one {{counter} Huah-siann} other {{counter} Huah-siann}}",
   "account_note.placeholder": "Tiám tsi̍t-ē ka-thiam pī-tsù",
   "column.pins": "Tah thâu-tsîng ê huah-siann",
   "community.column_settings.media_only": "Kan-na muî-thé",
diff --git a/app/javascript/mastodon/locales/te.json b/app/javascript/mastodon/locales/te.json
index 284102c381..c06472561f 100644
--- a/app/javascript/mastodon/locales/te.json
+++ b/app/javascript/mastodon/locales/te.json
@@ -25,7 +25,6 @@
   "account.requested": "ఆమోదం కోసం వేచి ఉంది. అభ్యర్థనను రద్దు చేయడానికి క్లిక్ చేయండి",
   "account.share": "@{name} యొక్క ప్రొఫైల్ను పంచుకోండి",
   "account.show_reblogs": "@{name}నుంచి బూస్ట్ లను చూపించు",
-  "account.statuses_counter": "{count, plural, one {{counter} Toot} other {{counter} Toots}}",
   "account.unblock": "@{name}పై బ్లాక్ ను తొలగించు",
   "account.unblock_domain": "{domain}ను దాచవద్దు",
   "account.unendorse": "ప్రొఫైల్లో చూపించవద్దు",
diff --git a/app/javascript/mastodon/locales/th.json b/app/javascript/mastodon/locales/th.json
index 64abb394bf..e1d556ebf0 100644
--- a/app/javascript/mastodon/locales/th.json
+++ b/app/javascript/mastodon/locales/th.json
@@ -35,9 +35,7 @@
   "account.follow_back": "ติดตามกลับ",
   "account.followers": "ผู้ติดตาม",
   "account.followers.empty": "ยังไม่มีใครติดตามผู้ใช้นี้",
-  "account.followers_counter": "{count, plural, other {{counter} ผู้ติดตาม}}",
   "account.following": "กำลังติดตาม",
-  "account.following_counter": "{count, plural, other {{counter} กำลังติดตาม}}",
   "account.follows.empty": "ผู้ใช้นี้ยังไม่ได้ติดตามใคร",
   "account.go_to_profile": "ไปยังโปรไฟล์",
   "account.hide_reblogs": "ซ่อนการดันจาก @{name}",
@@ -63,7 +61,6 @@
   "account.requested_follow": "{name} ได้ขอติดตามคุณ",
   "account.share": "แชร์โปรไฟล์ของ @{name}",
   "account.show_reblogs": "แสดงการดันจาก @{name}",
-  "account.statuses_counter": "{count, plural, other {{counter} โพสต์}}",
   "account.unblock": "เลิกปิดกั้น @{name}",
   "account.unblock_domain": "เลิกปิดกั้นโดเมน {domain}",
   "account.unblock_short": "เลิกปิดกั้น",
diff --git a/app/javascript/mastodon/locales/tr.json b/app/javascript/mastodon/locales/tr.json
index 0bb2a0e4a6..ac39a3fd7b 100644
--- a/app/javascript/mastodon/locales/tr.json
+++ b/app/javascript/mastodon/locales/tr.json
@@ -35,9 +35,9 @@
   "account.follow_back": "Geri takip et",
   "account.followers": "Takipçi",
   "account.followers.empty": "Henüz kimse bu kullanıcıyı takip etmiyor.",
-  "account.followers_counter": "{count, plural, one {{counter} Takipçi} other {{counter} Takipçi}}",
+  "account.followers_counter": "{count, plural, one {{counter} takipçi} other {{counter} takipçi}}",
   "account.following": "Takip Ediliyor",
-  "account.following_counter": "{count, plural, one {{counter} Takip Edilen} other {{counter} Takip Edilen}}",
+  "account.following_counter": "{count, plural, one {{counter} takip edilen} other {{counter} takip edilen}}",
   "account.follows.empty": "Bu kullanıcı henüz kimseyi takip etmiyor.",
   "account.go_to_profile": "Profile git",
   "account.hide_reblogs": "@{name} kişisinin boostlarını gizle",
@@ -63,7 +63,7 @@
   "account.requested_follow": "{name} size takip isteği gönderdi",
   "account.share": "@{name} adlı kişinin profilini paylaş",
   "account.show_reblogs": "@{name} kişisinin yeniden paylaşımlarını göster",
-  "account.statuses_counter": "{count, plural, one {{counter} Gönderi} other {{counter} Gönderi}}",
+  "account.statuses_counter": "{count, plural, one {{counter} gönderi} other {{counter} gönderi}}",
   "account.unblock": "@{name} adlı kişinin engelini kaldır",
   "account.unblock_domain": "{domain} alan adının engelini kaldır",
   "account.unblock_short": "Engeli kaldır",
diff --git a/app/javascript/mastodon/locales/tt.json b/app/javascript/mastodon/locales/tt.json
index 273c1a6de7..baba3190dc 100644
--- a/app/javascript/mastodon/locales/tt.json
+++ b/app/javascript/mastodon/locales/tt.json
@@ -31,9 +31,7 @@
   "account.follow": "Язылу",
   "account.followers": "Язылучы",
   "account.followers.empty": "Әле беркем дә язылмаган.",
-  "account.followers_counter": "{count, plural,one {{counter} язылучы} other {{counter} язылучы}}",
   "account.following": "Язылулар",
-  "account.following_counter": "{count, plural, one {{counter} язылу} other {{counter} язылу}}",
   "account.follows.empty": "Беркемгә дә язылмаган әле.",
   "account.go_to_profile": "Профильгә күчү",
   "account.hide_reblogs": "Скрывать көчен нче @{name}",
@@ -55,7 +53,6 @@
   "account.requested_follow": "{name} Сезгә язылу соравын җиберде",
   "account.share": "@{name} профиле белән уртаклашу",
   "account.show_reblogs": "Күрсәтергә көчәйтү нче @{name}",
-  "account.statuses_counter": "{count, plural, one {{counter} язма} other {{counter} язма}}",
   "account.unblock": "@{name} бикләвен чыгу",
   "account.unblock_domain": "{domain} бикләвен чыгу",
   "account.unblock_short": "Бикләүне чыгу",
diff --git a/app/javascript/mastodon/locales/ug.json b/app/javascript/mastodon/locales/ug.json
index 4120d4483b..e3dd0e6b11 100644
--- a/app/javascript/mastodon/locales/ug.json
+++ b/app/javascript/mastodon/locales/ug.json
@@ -6,7 +6,6 @@
   "account.posts": "Toots",
   "account.posts_with_replies": "Toots and replies",
   "account.requested": "Awaiting approval",
-  "account.statuses_counter": "{count, plural, one {{counter} Toot} other {{counter} Toots}}",
   "account_note.placeholder": "Click to add a note",
   "column.pins": "Pinned toot",
   "community.column_settings.media_only": "Media only",
diff --git a/app/javascript/mastodon/locales/uk.json b/app/javascript/mastodon/locales/uk.json
index 22cd15bd23..338b650617 100644
--- a/app/javascript/mastodon/locales/uk.json
+++ b/app/javascript/mastodon/locales/uk.json
@@ -35,9 +35,7 @@
   "account.follow_back": "Підписатися взаємно",
   "account.followers": "Підписники",
   "account.followers.empty": "Ніхто ще не підписаний на цього користувача.",
-  "account.followers_counter": "{count, plural, one {{counter} підписник} few {{counter} підписники} many {{counter} підписників} other {{counter} підписники}}",
   "account.following": "Ви стежите",
-  "account.following_counter": "{count, plural, one {{counter} підписка} few {{counter} підписки} many {{counter} підписок} other {{counter} підписки}}",
   "account.follows.empty": "Цей користувач ще ні на кого не підписався.",
   "account.go_to_profile": "Перейти до профілю",
   "account.hide_reblogs": "Сховати поширення від @{name}",
@@ -63,7 +61,6 @@
   "account.requested_follow": "{name} надсилає запит на стеження",
   "account.share": "Поділитися профілем @{name}",
   "account.show_reblogs": "Показати поширення від @{name}",
-  "account.statuses_counter": "{count, plural, one {{counter} допис} few {{counter} дописи} many {{counter} дописів} other {{counter} дописи}}",
   "account.unblock": "Розблокувати @{name}",
   "account.unblock_domain": "Розблокувати {domain}",
   "account.unblock_short": "Розблокувати",
diff --git a/app/javascript/mastodon/locales/ur.json b/app/javascript/mastodon/locales/ur.json
index 1b9f8d9691..cf53eb6fe8 100644
--- a/app/javascript/mastodon/locales/ur.json
+++ b/app/javascript/mastodon/locales/ur.json
@@ -29,9 +29,7 @@
   "account.follow_back": "اکاؤنٹ کو فالو بیک ",
   "account.followers": "پیروکار",
   "account.followers.empty": "ہنوز اس صارف کی کوئی پیروی نہیں کرتا.",
-  "account.followers_counter": "{count, plural,one {{counter} پیروکار} other {{counter} پیروکار}}",
   "account.following": "فالو کر رہے ہیں",
-  "account.following_counter": "{count, plural, one {{counter} پیروی کر رہے ہیں} other {{counter} پیروی کر رہے ہیں}}",
   "account.follows.empty": "\"یہ صارف ہنوز کسی کی پیروی نہیں کرتا ہے\".",
   "account.go_to_profile": "پروفائل پر جائیں",
   "account.hide_reblogs": "@{name} سے فروغ چھپائیں",
@@ -57,7 +55,6 @@
   "account.requested_follow": "{name} آپ کو فالو کرنا چھاتا ہے۔",
   "account.share": "@{name} کے مشخص کو بانٹیں",
   "account.show_reblogs": "@{name} کی افزائشات کو دکھائیں",
-  "account.statuses_counter": "{count, plural, one {{counter} Toot} other {{counter} Toots}}",
   "account.unblock": "@{name} کو بحال کریں",
   "account.unblock_domain": "{domain} کو نہ چھپائیں",
   "account.unblock_short": "بلاک ختم کریں",
diff --git a/app/javascript/mastodon/locales/uz.json b/app/javascript/mastodon/locales/uz.json
index 77892914a4..4824b1d332 100644
--- a/app/javascript/mastodon/locales/uz.json
+++ b/app/javascript/mastodon/locales/uz.json
@@ -31,9 +31,7 @@
   "account.follow": "Obuna bo‘lish",
   "account.followers": "Obunachilar",
   "account.followers.empty": "Bu foydalanuvchini hali hech kim kuzatmaydi.",
-  "account.followers_counter": "{count, plural, one {{counter} Muxlis} other {{counter} Muxlislar}}",
   "account.following": "Kuzatish",
-  "account.following_counter": "{count, plural, one {{counter} ga Muxlis} other {{counter} larga muxlis}}",
   "account.follows.empty": "Bu foydalanuvchi hali hech kimni kuzatmagan.",
   "account.go_to_profile": "Profilga o'tish",
   "account.hide_reblogs": "@{name} dan boostlarni yashirish",
@@ -54,7 +52,6 @@
   "account.requested_follow": "{name} sizni kuzatishni soʻradi",
   "account.share": "@{name} profilini ulashing",
   "account.show_reblogs": "@{name} dan bootlarni ko'rsatish",
-  "account.statuses_counter": "{count, plural, one {{counter} Post} other {{counter} Postlar}}",
   "account.unblock": "@{name} ni blokdan chiqarish",
   "account.unblock_domain": "{domain} domenini blokdan chiqarish",
   "account.unblock_short": "Blokdan chiqarish",
diff --git a/app/javascript/mastodon/locales/vi.json b/app/javascript/mastodon/locales/vi.json
index 18f0fec3c5..bbfecf2c8a 100644
--- a/app/javascript/mastodon/locales/vi.json
+++ b/app/javascript/mastodon/locales/vi.json
@@ -35,9 +35,7 @@
   "account.follow_back": "Theo dõi lại",
   "account.followers": "Người theo dõi",
   "account.followers.empty": "Chưa có người theo dõi nào.",
-  "account.followers_counter": "{count, plural, one {{counter} Người theo dõi} other {{counter} Người theo dõi}}",
   "account.following": "Đang theo dõi",
-  "account.following_counter": "{count, plural, one {{counter} Theo dõi} other {{counter} Theo dõi}}",
   "account.follows.empty": "Người này chưa theo dõi ai.",
   "account.go_to_profile": "Xem hồ sơ",
   "account.hide_reblogs": "Ẩn tút @{name} đăng lại",
@@ -63,7 +61,6 @@
   "account.requested_follow": "{name} yêu cầu theo dõi bạn",
   "account.share": "Chia sẻ @{name}",
   "account.show_reblogs": "Hiện tút do @{name} đăng lại",
-  "account.statuses_counter": "{count, plural, one {{counter} Tút} other {{counter} Tút}}",
   "account.unblock": "Bỏ chặn @{name}",
   "account.unblock_domain": "Bỏ ẩn {domain}",
   "account.unblock_short": "Bỏ chặn",
diff --git a/app/javascript/mastodon/locales/zgh.json b/app/javascript/mastodon/locales/zgh.json
index 1d3a22108c..b42bb7589c 100644
--- a/app/javascript/mastodon/locales/zgh.json
+++ b/app/javascript/mastodon/locales/zgh.json
@@ -19,7 +19,6 @@
   "account.posts_with_replies": "Toots and replies",
   "account.requested": "Awaiting approval",
   "account.share": "ⴱⴹⵓ ⵉⴼⵔⵙ ⵏ @{name}",
-  "account.statuses_counter": "{count, plural, one {{counter} Toot} other {{counter} Toots}}",
   "account.unfollow": "ⴽⴽⵙ ⴰⴹⴼⴼⵓⵕ",
   "account_note.placeholder": "Click to add a note",
   "bundle_column_error.retry": "ⴰⵍⵙ ⴰⵔⵎ",
diff --git a/app/javascript/mastodon/locales/zh-CN.json b/app/javascript/mastodon/locales/zh-CN.json
index 3456f99d25..f2accae0d0 100644
--- a/app/javascript/mastodon/locales/zh-CN.json
+++ b/app/javascript/mastodon/locales/zh-CN.json
@@ -35,9 +35,9 @@
   "account.follow_back": "回关",
   "account.followers": "关注者",
   "account.followers.empty": "目前无人关注此用户。",
-  "account.followers_counter": "被 {counter} 人关注",
+  "account.followers_counter": "{count, plural, other {{counter} 关注者}}",
   "account.following": "正在关注",
-  "account.following_counter": "正在关注 {counter} 人",
+  "account.following_counter": "{count, plural, other {{counter} 关注}}",
   "account.follows.empty": "此用户目前未关注任何人。",
   "account.go_to_profile": "前往个人资料页",
   "account.hide_reblogs": "隐藏来自 @{name} 的转嘟",
@@ -63,7 +63,7 @@
   "account.requested_follow": "{name} 已经向你发送了关注请求",
   "account.share": "分享 @{name} 的个人资料页",
   "account.show_reblogs": "显示来自 @{name} 的转嘟",
-  "account.statuses_counter": "{counter} 条嘟文",
+  "account.statuses_counter": "{count, plural, other {{counter} 嘟文}}",
   "account.unblock": "取消屏蔽 @{name}",
   "account.unblock_domain": "取消屏蔽 {domain} 域名",
   "account.unblock_short": "取消屏蔽",
@@ -699,6 +699,7 @@
   "server_banner.is_one_of_many": "{domain} 是可用于参与联邦宇宙的众多独立 Mastodon 服务器之一。",
   "server_banner.server_stats": "服务器统计数据:",
   "sign_in_banner.create_account": "创建账户",
+  "sign_in_banner.follow_anyone": "关注联邦宇宙中的任何人,并按时间顺序查看所有内容。没有算法、广告或诱导链接。",
   "sign_in_banner.mastodon_is": "Mastodon 是了解最新动态的最佳途径。",
   "sign_in_banner.sign_in": "登录",
   "sign_in_banner.sso_redirect": "登录或注册",
diff --git a/app/javascript/mastodon/locales/zh-HK.json b/app/javascript/mastodon/locales/zh-HK.json
index 5dff466201..09a497e889 100644
--- a/app/javascript/mastodon/locales/zh-HK.json
+++ b/app/javascript/mastodon/locales/zh-HK.json
@@ -35,9 +35,7 @@
   "account.follow_back": "追蹤對方",
   "account.followers": "追蹤者",
   "account.followers.empty": "尚未有人追蹤這位使用者。",
-  "account.followers_counter": "有 {count, plural,one {{counter} 個} other {{counter} 個}}追蹤者",
   "account.following": "正在追蹤",
-  "account.following_counter": "正在追蹤 {count, plural,one {{counter}}other {{counter} 人}}",
   "account.follows.empty": "這位使用者尚未追蹤任何人。",
   "account.go_to_profile": "前往個人檔案",
   "account.hide_reblogs": "隱藏 @{name} 的轉推",
@@ -63,7 +61,6 @@
   "account.requested_follow": "{name} 要求追蹤你",
   "account.share": "分享 @{name} 的個人檔案",
   "account.show_reblogs": "顯示 @{name} 的轉推",
-  "account.statuses_counter": "{count, plural,one {{counter} 篇}other {{counter} 篇}}帖文",
   "account.unblock": "解除封鎖 @{name}",
   "account.unblock_domain": "解除封鎖網域 {domain}",
   "account.unblock_short": "解除封鎖",
diff --git a/app/javascript/mastodon/locales/zh-TW.json b/app/javascript/mastodon/locales/zh-TW.json
index 4ab22daba5..04469a971d 100644
--- a/app/javascript/mastodon/locales/zh-TW.json
+++ b/app/javascript/mastodon/locales/zh-TW.json
@@ -35,9 +35,9 @@
   "account.follow_back": "跟隨回去",
   "account.followers": "跟隨者",
   "account.followers.empty": "尚未有人跟隨這位使用者。",
-  "account.followers_counter": "被 {count, plural, other {{counter} 人}}跟隨",
+  "account.followers_counter": "被 {count, plural, other {{count} 人}}跟隨",
   "account.following": "跟隨中",
-  "account.following_counter": "正在跟隨 {count,plural,other {{counter} 人}}",
+  "account.following_counter": "正在跟隨 {count,plural,other {{count} 人}}",
   "account.follows.empty": "這位使用者尚未跟隨任何人。",
   "account.go_to_profile": "前往個人檔案",
   "account.hide_reblogs": "隱藏來自 @{name} 的轉嘟",
@@ -63,7 +63,7 @@
   "account.requested_follow": "{name} 要求跟隨您",
   "account.share": "分享 @{name} 的個人檔案",
   "account.show_reblogs": "顯示來自 @{name} 的轉嘟",
-  "account.statuses_counter": "{count, plural,one {{counter} 則}other {{counter} 則}}嘟文",
+  "account.statuses_counter": "{count, plural, other {{count} 則嘟文}}",
   "account.unblock": "解除封鎖 @{name}",
   "account.unblock_domain": "解除封鎖網域 {domain}",
   "account.unblock_short": "解除封鎖",
diff --git a/app/javascript/mastodon/reducers/user_lists.js b/app/javascript/mastodon/reducers/user_lists.js
index 2f17fed5fd..7a4c04c5c7 100644
--- a/app/javascript/mastodon/reducers/user_lists.js
+++ b/app/javascript/mastodon/reducers/user_lists.js
@@ -1,12 +1,8 @@
 import { Map as ImmutableMap, List as ImmutableList, fromJS } from 'immutable';
 
 import {
-  DIRECTORY_FETCH_REQUEST,
-  DIRECTORY_FETCH_SUCCESS,
-  DIRECTORY_FETCH_FAIL,
-  DIRECTORY_EXPAND_REQUEST,
-  DIRECTORY_EXPAND_SUCCESS,
-  DIRECTORY_EXPAND_FAIL,
+  expandDirectory,
+  fetchDirectory
 } from 'mastodon/actions/directory';
 import {
   FEATURED_TAGS_FETCH_REQUEST,
@@ -117,6 +113,7 @@ const normalizeFeaturedTags = (state, path, featuredTags, accountId) => {
   }));
 };
 
+/** @type {import('@reduxjs/toolkit').Reducer<typeof initialState>} */
 export default function userLists(state = initialState, action) {
   switch(action.type) {
   case FOLLOWERS_FETCH_SUCCESS:
@@ -194,16 +191,6 @@ export default function userLists(state = initialState, action) {
   case MUTES_FETCH_FAIL:
   case MUTES_EXPAND_FAIL:
     return state.setIn(['mutes', 'isLoading'], false);
-  case DIRECTORY_FETCH_SUCCESS:
-    return normalizeList(state, ['directory'], action.accounts, action.next);
-  case DIRECTORY_EXPAND_SUCCESS:
-    return appendToList(state, ['directory'], action.accounts, action.next);
-  case DIRECTORY_FETCH_REQUEST:
-  case DIRECTORY_EXPAND_REQUEST:
-    return state.setIn(['directory', 'isLoading'], true);
-  case DIRECTORY_FETCH_FAIL:
-  case DIRECTORY_EXPAND_FAIL:
-    return state.setIn(['directory', 'isLoading'], false);
   case FEATURED_TAGS_FETCH_SUCCESS:
     return normalizeFeaturedTags(state, ['featured_tags', action.id], action.tags, action.id);
   case FEATURED_TAGS_FETCH_REQUEST:
@@ -211,6 +198,17 @@ export default function userLists(state = initialState, action) {
   case FEATURED_TAGS_FETCH_FAIL:
     return state.setIn(['featured_tags', action.id, 'isLoading'], false);
   default:
-    return state;
+    if(fetchDirectory.fulfilled.match(action))
+      return normalizeList(state, ['directory'], action.payload.accounts, undefined);
+    else if( expandDirectory.fulfilled.match(action))
+      return appendToList(state, ['directory'], action.payload.accounts, undefined);
+    else if(fetchDirectory.pending.match(action) ||
+     expandDirectory.pending.match(action))
+      return state.setIn(['directory', 'isLoading'], true);
+    else if(fetchDirectory.rejected.match(action) ||
+     expandDirectory.rejected.match(action))
+      return state.setIn(['directory', 'isLoading'], false);
+    else
+      return state;
   }
 }
diff --git a/app/javascript/mastodon/test_helpers.tsx b/app/javascript/mastodon/test_helpers.tsx
index 93b5a8453a..f405090730 100644
--- a/app/javascript/mastodon/test_helpers.tsx
+++ b/app/javascript/mastodon/test_helpers.tsx
@@ -2,6 +2,7 @@ import { IntlProvider } from 'react-intl';
 
 import { MemoryRouter } from 'react-router';
 
+import type { RenderOptions } from '@testing-library/react';
 // eslint-disable-next-line import/no-extraneous-dependencies
 import { render as rtlRender } from '@testing-library/react';
 
@@ -9,7 +10,11 @@ import { IdentityContext } from './identity_context';
 
 function render(
   ui: React.ReactElement,
-  { locale = 'en', signedIn = true, ...renderOptions } = {},
+  {
+    locale = 'en',
+    signedIn = true,
+    ...renderOptions
+  }: RenderOptions & { locale?: string; signedIn?: boolean } = {},
 ) {
   const fakeIdentity = {
     signedIn: signedIn,
diff --git a/app/javascript/styles/mastodon-light/variables.scss b/app/javascript/styles/mastodon-light/variables.scss
index 09a75a834b..9f571b3f26 100644
--- a/app/javascript/styles/mastodon-light/variables.scss
+++ b/app/javascript/styles/mastodon-light/variables.scss
@@ -56,11 +56,13 @@ $account-background-color: $white !default;
 
 $emojis-requiring-inversion: 'chains';
 
-.theme-mastodon-light {
+body {
   --dropdown-border-color: #d9e1e8;
   --dropdown-background-color: #fff;
+  --modal-border-color: #d9e1e8;
+  --modal-background-color: var(--background-color-tint);
   --background-border-color: #d9e1e8;
   --background-color: #fff;
-  --background-color-tint: rgba(255, 255, 255, 90%);
+  --background-color-tint: rgba(255, 255, 255, 80%);
   --background-filter: blur(10px);
 }
diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss
index 9811ee9546..61ff2a65ed 100644
--- a/app/javascript/styles/mastodon/components.scss
+++ b/app/javascript/styles/mastodon/components.scss
@@ -120,8 +120,27 @@
       text-decoration: none;
     }
 
-    &:disabled {
-      opacity: 0.5;
+    &.button--destructive {
+      &:active,
+      &:focus,
+      &:hover {
+        border-color: $ui-button-destructive-focus-background-color;
+        color: $ui-button-destructive-focus-background-color;
+      }
+    }
+
+    &:disabled,
+    &.disabled {
+      opacity: 0.7;
+      border-color: $ui-primary-color;
+      color: $ui-primary-color;
+
+      &:active,
+      &:focus,
+      &:hover {
+        border-color: $ui-primary-color;
+        color: $ui-primary-color;
+      }
     }
   }
 
@@ -2420,7 +2439,7 @@ a.account__display-name {
 }
 
 .dropdown-animation {
-  animation: dropdown 150ms cubic-bezier(0.1, 0.7, 0.1, 1);
+  animation: dropdown 250ms cubic-bezier(0.1, 0.7, 0.1, 1);
 
   @keyframes dropdown {
     from {
@@ -10325,3 +10344,156 @@ noscript {
     }
   }
 }
+
+.hover-card-controller[data-popper-reference-hidden='true'] {
+  opacity: 0;
+  pointer-events: none;
+}
+
+.hover-card {
+  box-shadow: var(--dropdown-shadow);
+  background: var(--modal-background-color);
+  backdrop-filter: var(--background-filter);
+  border: 1px solid var(--modal-border-color);
+  border-radius: 8px;
+  padding: 16px;
+  width: 270px;
+  display: flex;
+  flex-direction: column;
+  gap: 12px;
+
+  &--loading {
+    position: relative;
+    min-height: 100px;
+  }
+
+  &__name {
+    display: flex;
+    gap: 12px;
+    text-decoration: none;
+    color: inherit;
+  }
+
+  &__number {
+    font-size: 15px;
+    line-height: 22px;
+    color: $secondary-text-color;
+
+    strong {
+      font-weight: 700;
+    }
+  }
+
+  &__text-row {
+    display: flex;
+    flex-direction: column;
+    gap: 8px;
+  }
+
+  &__bio {
+    color: $secondary-text-color;
+    font-size: 14px;
+    line-height: 20px;
+    display: -webkit-box;
+    -webkit-line-clamp: 2;
+    -webkit-box-orient: vertical;
+    max-height: 2 * 20px;
+    overflow: hidden;
+
+    p {
+      margin-bottom: 0;
+    }
+
+    a {
+      color: inherit;
+      text-decoration: underline;
+
+      &:hover,
+      &:focus,
+      &:active {
+        text-decoration: none;
+      }
+    }
+  }
+
+  .display-name {
+    font-size: 15px;
+    line-height: 22px;
+
+    bdi {
+      font-weight: 500;
+      color: $primary-text-color;
+    }
+
+    &__account {
+      display: block;
+      color: $dark-text-color;
+    }
+  }
+
+  .account-fields {
+    color: $secondary-text-color;
+    font-size: 14px;
+    line-height: 20px;
+
+    a {
+      color: inherit;
+      text-decoration: none;
+
+      &:focus,
+      &:hover,
+      &:active {
+        text-decoration: underline;
+      }
+    }
+
+    dl {
+      display: flex;
+      align-items: center;
+      gap: 4px;
+
+      dt {
+        flex: 0 0 auto;
+        color: $dark-text-color;
+        min-width: 0;
+        overflow: hidden;
+        white-space: nowrap;
+        text-overflow: ellipsis;
+      }
+
+      dd {
+        flex: 1 1 auto;
+        font-weight: 500;
+        min-width: 0;
+        overflow: hidden;
+        white-space: nowrap;
+        text-overflow: ellipsis;
+      }
+
+      &.verified {
+        dd {
+          display: flex;
+          align-items: center;
+          gap: 4px;
+          overflow: hidden;
+          white-space: nowrap;
+          color: $valid-value-color;
+
+          & > span {
+            overflow: hidden;
+            text-overflow: ellipsis;
+          }
+
+          a {
+            font-weight: 500;
+          }
+
+          .icon {
+            width: 16px;
+            height: 16px;
+          }
+        }
+      }
+    }
+  }
+}
diff --git a/app/lib/private_address_check.rb b/app/lib/private_address_check.rb
new file mode 100644
index 0000000000..d00b16e66b
--- /dev/null
+++ b/app/lib/private_address_check.rb
@@ -0,0 +1,33 @@
+# frozen_string_literal: true
+
+module PrivateAddressCheck
+  module_function
+
+  CIDR_LIST = [
+    IPAddr.new('0.0.0.0/8'),       # Current network (only valid as source address)
+    IPAddr.new('100.64.0.0/10'),   # Shared Address Space
+    IPAddr.new('172.16.0.0/12'),   # Private network
+    IPAddr.new('192.0.0.0/24'),    # IETF Protocol Assignments
+    IPAddr.new('192.0.2.0/24'),    # TEST-NET-1, documentation and examples
+    IPAddr.new('192.88.99.0/24'),  # IPv6 to IPv4 relay (includes 2002::/16)
+    IPAddr.new('198.18.0.0/15'),   # Network benchmark tests
+    IPAddr.new('198.51.100.0/24'), # TEST-NET-2, documentation and examples
+    IPAddr.new('203.0.113.0/24'),  # TEST-NET-3, documentation and examples
+    IPAddr.new('224.0.0.0/4'),     # IP multicast (former Class D network)
+    IPAddr.new('240.0.0.0/4'),     # Reserved (former Class E network)
+    IPAddr.new('255.255.255.255'), # Broadcast
+    IPAddr.new('64:ff9b::/96'),    # IPv4/IPv6 translation (RFC 6052)
+    IPAddr.new('100::/64'),        # Discard prefix (RFC 6666)
+    IPAddr.new('2001::/32'),       # Teredo tunneling
+    IPAddr.new('2001:10::/28'),    # Deprecated (previously ORCHID)
+    IPAddr.new('2001:20::/28'),    # ORCHIDv2
+    IPAddr.new('2001:db8::/32'),   # Addresses used in documentation and example source code
+    IPAddr.new('2002::/16'),       # 6to4
+    IPAddr.new('fc00::/7'),        # Unique local address
+    IPAddr.new('ff00::/8'),        # Multicast
+  ].freeze
+
+  def private_address?(address)
+    address.private? || address.loopback? || address.link_local? || CIDR_LIST.any? { |cidr| cidr.include?(address) }
+  end
+end
diff --git a/app/lib/search_query_transformer.rb b/app/lib/search_query_transformer.rb
index 927495eace..606819ed40 100644
--- a/app/lib/search_query_transformer.rb
+++ b/app/lib/search_query_transformer.rb
@@ -225,7 +225,7 @@ class SearchQueryTransformer < Parslet::Transform
   end
 
   rule(clause: subtree(:clause)) do
-    prefix   = clause[:prefix][:term].to_s if clause[:prefix]
+    prefix   = clause[:prefix][:term].to_s.downcase if clause[:prefix]
     operator = clause[:operator]&.to_s
     term     = clause[:phrase] ? clause[:phrase].map { |term| term[:term].to_s }.join(' ') : clause[:term].to_s
 
diff --git a/app/lib/themes.rb b/app/lib/themes.rb
index a87323d8e9..64c2f1bfe3 100644
--- a/app/lib/themes.rb
+++ b/app/lib/themes.rb
@@ -8,7 +8,7 @@ class Themes
 
   THEME_COLORS = {
     dark: '#191b22',
-    light: '#f3f5f7',
+    light: '#ffffff',
   }.freeze
 
   def initialize
diff --git a/app/models/preview_card.rb b/app/models/preview_card.rb
index cbfc393786..eac02ac14f 100644
--- a/app/models/preview_card.rb
+++ b/app/models/preview_card.rb
@@ -128,6 +128,22 @@ class PreviewCard < ApplicationRecord
     @history ||= Trends::History.new('links', id)
   end
 
+  def authors
+    @authors ||= [PreviewCard::Author.new(self)]
+  end
+
+  class Author < ActiveModelSerializers::Model
+    attributes :name, :url, :account
+
+    def initialize(preview_card)
+      super(
+        name: preview_card.author_name,
+        url: preview_card.author_url,
+        account: preview_card.author_account,
+      )
+    end
+  end
+
   class << self
     private
 
diff --git a/app/models/site_upload.rb b/app/models/site_upload.rb
index 6431d1007d..273dd6de9f 100644
--- a/app/models/site_upload.rb
+++ b/app/models/site_upload.rb
@@ -31,17 +31,10 @@ class SiteUpload < ApplicationRecord
         [:"#{size}", { format: 'png', geometry: "#{size}x#{size}#", file_geometry_parser: FastGeometryParser }]
       end.freeze,
 
-    favicon: {
-      ico: {
-        format: 'ico',
-        geometry: '48x48#',
-        file_geometry_parser: FastGeometryParser,
-      }.freeze,
-    }.merge(
+    favicon:
       FAVICON_SIZES.to_h do |size|
         [:"#{size}", { format: 'png', geometry: "#{size}x#{size}#", file_geometry_parser: FastGeometryParser }]
-      end
-    ).freeze,
+      end.freeze,
 
     thumbnail: {
       '@1x': {
diff --git a/app/serializers/rest/preview_card_serializer.rb b/app/serializers/rest/preview_card_serializer.rb
index 7d4c99c2d1..f73a051ac0 100644
--- a/app/serializers/rest/preview_card_serializer.rb
+++ b/app/serializers/rest/preview_card_serializer.rb
@@ -1,6 +1,11 @@
 # frozen_string_literal: true
 
 class REST::PreviewCardSerializer < ActiveModel::Serializer
+  class AuthorSerializer < ActiveModel::Serializer
+    attributes :name, :url
+    has_one :account, serializer: REST::AccountSerializer
+  end
+
   include RoutingHelper
 
   attributes :url, :title, :description, :language, :type,
@@ -8,7 +13,7 @@ class REST::PreviewCardSerializer < ActiveModel::Serializer
              :provider_url, :html, :width, :height,
              :image, :image_description, :embed_url, :blurhash, :published_at
 
-  has_one :author_account, serializer: REST::AccountSerializer, if: -> { object.author_account.present? }
+  has_many :authors, serializer: AuthorSerializer
 
   def url
     object.original_url.presence || object.url
diff --git a/app/services/account_search_service.rb b/app/services/account_search_service.rb
index b86c9b9e7e..dab5f748bf 100644
--- a/app/services/account_search_service.rb
+++ b/app/services/account_search_service.rb
@@ -28,9 +28,7 @@ class AccountSearchService < BaseService
               },
 
               functions: [
-                reputation_score_function,
                 followers_score_function,
-                time_distance_function,
               ],
             },
           },
@@ -81,36 +79,12 @@ class AccountSearchService < BaseService
       }
     end
 
-    # This function deranks accounts that follow more people than follow them
-    def reputation_score_function
-      {
-        script_score: {
-          script: {
-            source: "(Math.max(doc['followers_count'].value, 0) + 0.0) / (Math.max(doc['followers_count'].value, 0) + Math.max(doc['following_count'].value, 0) + 1)",
-          },
-        },
-      }
-    end
-
     # This function promotes accounts that have more followers
     def followers_score_function
       {
         script_score: {
           script: {
-            source: "(Math.max(doc['followers_count'].value, 0) / (Math.max(doc['followers_count'].value, 0) + 1))",
-          },
-        },
-      }
-    end
-
-    # This function deranks accounts that haven't posted in a long time
-    def time_distance_function
-      {
-        gauss: {
-          last_status_at: {
-            scale: '30d',
-            offset: '30d',
-            decay: 0.3,
+            source: "Math.log10((Math.max(doc['followers_count'].value, 0) + 1))",
           },
         },
       }
@@ -126,10 +100,24 @@ class AccountSearchService < BaseService
 
     def core_query
       {
-        multi_match: {
-          query: @query,
-          type: 'bool_prefix',
-          fields: %w(username^2 username.*^2 display_name display_name.*),
+        dis_max: {
+          queries: [
+            {
+              multi_match: {
+                query: @query,
+                type: 'most_fields',
+                fields: %w(username username.*),
+              },
+            },
+
+            {
+              multi_match: {
+                query: @query,
+                type: 'most_fields',
+                fields: %w(display_name display_name.*),
+              },
+            },
+          ],
         },
       }
     end
@@ -142,7 +130,7 @@ class AccountSearchService < BaseService
       {
         multi_match: {
           query: @query,
-          type: 'most_fields',
+          type: 'best_fields',
           fields: %w(username^2 display_name^2 text text.*),
           operator: 'and',
         },
diff --git a/app/services/fetch_link_card_service.rb b/app/services/fetch_link_card_service.rb
index 900cb9863d..8bc9f912c5 100644
--- a/app/services/fetch_link_card_service.rb
+++ b/app/services/fetch_link_card_service.rb
@@ -15,6 +15,9 @@ class FetchLinkCardService < BaseService
     )
   }iox
 
+  # URL size limit to safely store in PosgreSQL's unique indexes
+  BYTESIZE_LIMIT = 2692
+
   def call(status)
     @status       = status
     @original_url = parse_urls
@@ -29,7 +32,7 @@ class FetchLinkCardService < BaseService
     end
 
     attach_card if @card&.persisted?
-  rescue HTTP::Error, OpenSSL::SSL::SSLError, Addressable::URI::InvalidURIError, Mastodon::HostValidationError, Mastodon::LengthValidationError => e
+  rescue HTTP::Error, OpenSSL::SSL::SSLError, Addressable::URI::InvalidURIError, Mastodon::HostValidationError, Mastodon::LengthValidationError, Encoding::UndefinedConversionError => e
     Rails.logger.debug { "Error fetching link #{@original_url}: #{e}" }
     nil
   end
@@ -85,7 +88,7 @@ class FetchLinkCardService < BaseService
 
   def bad_url?(uri)
     # Avoid local instance URLs and invalid URLs
-    uri.host.blank? || TagManager.instance.local_url?(uri.to_s) || !%w(http https).include?(uri.scheme)
+    uri.host.blank? || TagManager.instance.local_url?(uri.to_s) || !%w(http https).include?(uri.scheme) || uri.to_s.bytesize > BYTESIZE_LIMIT
   end
 
   def mention_link?(anchor)
diff --git a/app/views/layouts/application.html.haml b/app/views/layouts/application.html.haml
index 1b0d03958e..f022c3f516 100755
--- a/app/views/layouts/application.html.haml
+++ b/app/views/layouts/application.html.haml
@@ -11,8 +11,6 @@
     - if storage_host?
       %link{ rel: 'dns-prefetch', href: storage_host }/
 
-    %link{ rel: 'icon', href: favicon_path('ico') || '/favicon.ico', type: 'image/x-icon' }/
-
     - SiteUpload::FAVICON_SIZES.each do |size|
       %link{ rel: 'icon', sizes: "#{size}x#{size}", href: favicon_path(size.to_i) || frontend_asset_path("icons/favicon-#{size}x#{size}.png"), type: 'image/png' }/
 
diff --git a/bin/flatware b/bin/flatware
new file mode 100755
index 0000000000..337ce9277e
--- /dev/null
+++ b/bin/flatware
@@ -0,0 +1,27 @@
+#!/usr/bin/env ruby
+# frozen_string_literal: true
+
+#
+# This file was generated by Bundler.
+#
+# The application 'flatware' is installed as part of a gem, and
+# this file is here to facilitate running it.
+#
+
+ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__)
+
+bundle_binstub = File.expand_path("bundle", __dir__)
+
+if File.file?(bundle_binstub)
+  if File.read(bundle_binstub, 300).include?("This file was generated by Bundler")
+    load(bundle_binstub)
+  else
+    abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run.
+Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.")
+  end
+end
+
+require "rubygems"
+require "bundler/setup"
+
+load Gem.bin_path("flatware", "flatware")
diff --git a/config/imagemagick/policy.xml b/config/imagemagick/policy.xml
index 2730a9f84e..e2aa202f27 100644
--- a/config/imagemagick/policy.xml
+++ b/config/imagemagick/policy.xml
@@ -23,5 +23,5 @@
   <!-- Disallow any coder by default, and only enable ones required by Mastodon -->
   <policy domain="coder" rights="none" pattern="*" />
   <policy domain="coder" rights="read | write" pattern="{JPEG,PNG,GIF,WEBP,HEIC,AVIF}" />
-  <policy domain="coder" rights="write" pattern="{HISTOGRAM,RGB,INFO,ICO}" />
+  <policy domain="coder" rights="write" pattern="{HISTOGRAM,RGB,INFO}" />
 </policymap>
diff --git a/config/initializers/vips.rb b/config/initializers/vips.rb
index 25a17b2a17..a539d7035c 100644
--- a/config/initializers/vips.rb
+++ b/config/initializers/vips.rb
@@ -5,7 +5,11 @@ if Rails.configuration.x.use_vips
 
   require 'vips'
 
-  abort('Incompatible libvips version, please install libvips >= 8.13') unless Vips.at_least_libvips?(8, 13)
+  unless Vips.at_least_libvips?(8, 13)
+    abort <<~ERROR.squish
+      Incompatible libvips version (#{Vips.version_string}), please install libvips >= 8.13
+    ERROR
+  end
 
   Vips.block('VipsForeign', true)
 
@@ -25,3 +29,11 @@ if Rails.configuration.x.use_vips
 
   Vips.block_untrusted(true)
 end
+
+# In some places of the code, we rescue this exception, but we don't always
+# load libvips, so it may be an undefined constant:
+unless defined?(Vips)
+  module Vips
+    class Error < StandardError; end
+  end
+end
diff --git a/config/locales/fi.yml b/config/locales/fi.yml
index f108718e5e..be87258daf 100644
--- a/config/locales/fi.yml
+++ b/config/locales/fi.yml
@@ -293,7 +293,7 @@ fi:
       filter_by_action: Suodata tapahtuman mukaan
       filter_by_user: Suodata käyttäjän mukaan
       title: Auditointiloki
-      unavailable_instance: "(verkkotunnus ei ole saatavilla)"
+      unavailable_instance: "(verkkotunnus ei saatavilla)"
     announcements:
       destroyed_msg: Tiedote poistettu onnistuneesti!
       edit:
diff --git a/config/locales/nn.yml b/config/locales/nn.yml
index d82c92c262..2da30e6627 100644
--- a/config/locales/nn.yml
+++ b/config/locales/nn.yml
@@ -293,6 +293,7 @@ nn:
       filter_by_action: Sorter etter handling
       filter_by_user: Sorter etter brukar
       title: Revisionslogg
+      unavailable_instance: "(domenenamn er utilgjengeleg)"
     announcements:
       destroyed_msg: Kunngjøringen er slettet!
       edit:
diff --git a/config/locales/sr-Latn.yml b/config/locales/sr-Latn.yml
index 776e473ee7..f6e6d4d2a6 100644
--- a/config/locales/sr-Latn.yml
+++ b/config/locales/sr-Latn.yml
@@ -577,7 +577,7 @@ sr-Latn:
     relays:
       add_new: Dodaj novi relej
       delete: Obriši
-      description_html: "<strong>Federalni relej</strong> je posrednički server koji razmenjuje velike količine javnih truba između servera na koji je pretplaćen i na koji objavljuje.<strong>Može pomoći malim i srednjim serverima da otkriju sadržaj iz fediversa</strong>, koji inače zahteva od lokalnih korisnika da ručno pratiti ostale ljude na udaljenim serverima."
+      description_html: "<strong>Federalni relej</strong> je posrednički server koji razmenjuje velike količine javnih objava između servera na koji je pretplaćen i na koji objavljuje.<strong>Može pomoći malim i srednjim serverima da otkriju sadržaj iz fediverzuma</strong>, koji inače zahteva od lokalnih korisnika da ručno pratiti ostale ljude na udaljenim serverima."
       disable: Isključi
       disabled: Isključen
       enable: Uključi
diff --git a/config/locales/sr.yml b/config/locales/sr.yml
index 365b358d5a..9bfefde83c 100644
--- a/config/locales/sr.yml
+++ b/config/locales/sr.yml
@@ -577,7 +577,7 @@ sr:
     relays:
       add_new: Додај нови релеј
       delete: Обриши
-      description_html: "<strong>Федерални релеј</strong> је посреднички сервер који размењује велике количине јавних труба између сервера на који је претплаћен и на који објављује.<strong>Може помоћи малим и средњим серверима да открију садржај из федиверса</strong>, који иначе захтева од локалних корисника да ручно пратити остале људе на удаљеним серверима."
+      description_html: "<strong>Федерални релеј</strong> је посреднички сервер који размењује велике количине јавних објава између сервера на који је претплаћен и на који објављује.<strong>Може помоћи малим и средњим серверима да открију садржај из федиверзума</strong>, који иначе захтева од локалних корисника да ручно пратити остале људе на удаљеним серверима."
       disable: Искључи
       disabled: Искључен
       enable: Укључи
diff --git a/config/locales/zh-TW.yml b/config/locales/zh-TW.yml
index 93de27c0b2..1317d5f707 100644
--- a/config/locales/zh-TW.yml
+++ b/config/locales/zh-TW.yml
@@ -525,7 +525,7 @@ zh-TW:
       total_followed_by_us: 被我們跟隨
       total_reported: 關於他們的檢舉報告
       total_storage: 多媒體附加檔案
-      totals_time_period_hint_html: 以下顯示之總和包含所有時間的資料。
+      totals_time_period_hint_html: 以下顯示之統計包含所有時間的資料。
       unknown_instance: 此伺服器目前沒有這個網域的紀錄。
     invites:
       deactivate_all: 全部停用
diff --git a/db/migrate/20160223164502_make_uris_nullable_in_statuses.rb b/db/migrate/20160223164502_make_uris_nullable_in_statuses.rb
index fff07093c8..ebb572bd60 100644
--- a/db/migrate/20160223164502_make_uris_nullable_in_statuses.rb
+++ b/db/migrate/20160223164502_make_uris_nullable_in_statuses.rb
@@ -1,7 +1,11 @@
 # frozen_string_literal: true
 
 class MakeUrisNullableInStatuses < ActiveRecord::Migration[4.2]
-  def change
+  def up
     change_column :statuses, :uri, :string, null: true, default: nil
   end
+
+  def down
+    raise ActiveRecord::IrreversibleMigration
+  end
 end
diff --git a/db/migrate/20170322143850_change_primary_key_to_bigint_on_statuses.rb b/db/migrate/20170322143850_change_primary_key_to_bigint_on_statuses.rb
index b98fffab83..e7fcb75a44 100644
--- a/db/migrate/20170322143850_change_primary_key_to_bigint_on_statuses.rb
+++ b/db/migrate/20170322143850_change_primary_key_to_bigint_on_statuses.rb
@@ -1,7 +1,7 @@
 # frozen_string_literal: true
 
 class ChangePrimaryKeyToBigintOnStatuses < ActiveRecord::Migration[5.0]
-  def change
+  def up
     change_table(:statuses, bulk: true) do |t|
       t.change :id, :bigint
       t.change :reblog_of_id, :bigint
@@ -16,4 +16,8 @@ class ChangePrimaryKeyToBigintOnStatuses < ActiveRecord::Migration[5.0]
     change_column :statuses_tags, :status_id, :bigint
     change_column :stream_entries, :activity_id, :bigint
   end
+
+  def down
+    raise ActiveRecord::IrreversibleMigration
+  end
 end
diff --git a/db/migrate/20170520145338_change_language_filter_to_opt_out.rb b/db/migrate/20170520145338_change_language_filter_to_opt_out.rb
index f0a95819c3..0e91c46803 100644
--- a/db/migrate/20170520145338_change_language_filter_to_opt_out.rb
+++ b/db/migrate/20170520145338_change_language_filter_to_opt_out.rb
@@ -5,7 +5,7 @@ class ChangeLanguageFilterToOptOut < ActiveRecord::Migration[5.0]
     remove_index :users, :allowed_languages
 
     change_table(:users, bulk: true) do |t|
-      t.remove :allowed_languages
+      t.remove :allowed_languages, type: :string, array: true, default: [], null: false
       t.column :filtered_languages, :string, array: true, default: [], null: false
     end
 
diff --git a/db/migrate/20170609145826_remove_default_language_from_statuses.rb b/db/migrate/20170609145826_remove_default_language_from_statuses.rb
index 28b4172a8c..122c322287 100644
--- a/db/migrate/20170609145826_remove_default_language_from_statuses.rb
+++ b/db/migrate/20170609145826_remove_default_language_from_statuses.rb
@@ -1,7 +1,11 @@
 # frozen_string_literal: true
 
 class RemoveDefaultLanguageFromStatuses < ActiveRecord::Migration[5.1]
-  def change
+  def up
     change_column :statuses, :language, :string, default: nil, null: true
   end
+
+  def down
+    raise ActiveRecord::IrreversibleMigration
+  end
 end
diff --git a/lib/paperclip/color_extractor.rb b/lib/paperclip/color_extractor.rb
index 378af0961d..fba32ba4cb 100644
--- a/lib/paperclip/color_extractor.rb
+++ b/lib/paperclip/color_extractor.rb
@@ -116,34 +116,23 @@ module Paperclip
       # The number of occurrences of a color (r, g, b) is thus encoded in band `b` at pixel position `(r, g)`
       histogram = image.hist_find_ndim(bins: BINS)
 
-      # `histogram.max` returns an array of maxima with their pixel positions, but we don't know in which
-      # band they are
+      # With `bandunfold`, we get back to a (BINS*BINS)×BINS 2D image with a single band.
+      # The number of occurrences of a color (r, g, b) is thus encoded at pixel position `(r * BINS + b, g)`
+      histogram = histogram.bandunfold
+
       _, colors = histogram.max(size: 10, out_array: true, x_array: true, y_array: true)
 
-      colors['out_array'].zip(colors['x_array'], colors['y_array']).map do |v, x, y|
-        rgb_from_xyv(histogram, x, y, v)
-      end.flatten.reverse.uniq
+      colors['x_array'].zip(colors['y_array']).map do |x, y|
+        rgb_from_hist_xy(x, y)
+      end.flatten.reverse
     end
 
     # rubocop:disable Naming/MethodParameterName
-    def rgb_from_xyv(image, x, y, v)
-      pixel = image.getpoint(x, y)
-
-      # As we only have the first 2 dimensions for this maximum, we
-      # can't distinguish with different maxima with the same `r` and `g`
-      # values but different `b` values.
-      #
-      # Therefore, we return an array of maxima, which is always non-empty,
-      # but may contain multiple colors with the same values.
-
-      pixel.filter_map.with_index do |pv, z|
-        next if pv != v
-
-        r = (x + 0.5) * 256 / BINS
-        g = (y + 0.5) * 256 / BINS
-        b = (z + 0.5) * 256 / BINS
-        ColorDiff::Color::RGB.new(r, g, b)
-      end
+    def rgb_from_hist_xy(x, y)
+      r = ((x / BINS) + 0.5) * 256 / BINS
+      g = (y + 0.5) * 256 / BINS
+      b = ((x % BINS) + 0.5) * 256 / BINS
+      ColorDiff::Color::RGB.new(r, g, b)
     end
 
     def w3c_contrast(color1, color2)
diff --git a/lib/tasks/branding.rake b/lib/tasks/branding.rake
index 608fb3af9c..be72454ce2 100644
--- a/lib/tasks/branding.rake
+++ b/lib/tasks/branding.rake
@@ -42,7 +42,6 @@ namespace :branding do
     output_dest     = Rails.root.join('app', 'javascript', 'icons')
 
     rsvg_convert = Terrapin::CommandLine.new('rsvg-convert', '-w :size -h :size --keep-aspect-ratio :input -o :output')
-    convert = Terrapin::CommandLine.new('convert', ':input :output', environment: { 'MAGICK_CONFIGURE_PATH' => nil })
 
     favicon_sizes      = [16, 32, 48]
     apple_icon_sizes   = [57, 60, 72, 76, 114, 120, 144, 152, 167, 180, 1024]
@@ -56,8 +55,6 @@ namespace :branding do
       rsvg_convert.run(size: size, input: favicon_source, output: output_path)
     end
 
-    convert.run(input: favicons, output: Rails.public_path.join('favicon.ico'))
-
     apple_icon_sizes.each do |size|
       rsvg_convert.run(size: size, input: app_icon_source, output: output_dest.join("apple-touch-icon-#{size}x#{size}.png"))
     end
diff --git a/package.json b/package.json
index ccfea2bc09..9fffa2b9da 100644
--- a/package.json
+++ b/package.json
@@ -141,8 +141,9 @@
   },
   "devDependencies": {
     "@formatjs/cli": "^6.1.1",
+    "@testing-library/dom": "^10.2.0",
     "@testing-library/jest-dom": "^6.0.0",
-    "@testing-library/react": "^15.0.0",
+    "@testing-library/react": "^16.0.0",
     "@types/babel__core": "^7.20.1",
     "@types/emoji-mart": "^3.0.9",
     "@types/escape-html": "^1.0.2",
diff --git a/public/favicon.ico b/public/favicon.ico
deleted file mode 100644
index b09a98bb9b..0000000000
Binary files a/public/favicon.ico and /dev/null differ
diff --git a/spec/fixtures/requests/redirect_with_utf8_url.txt b/spec/fixtures/requests/redirect_with_utf8_url.txt
new file mode 100644
index 0000000000..08f99ee2ae
--- /dev/null
+++ b/spec/fixtures/requests/redirect_with_utf8_url.txt
@@ -0,0 +1,5 @@
+HTTP/1.1 301 Moved Permanently
+server: nginx
+date: Thu, 27 Jun 2024 11:04:53 GMT
+content-type: text/html; charset=UTF-8
+location: http://example.com/ärgerliche-umlaute.html
diff --git a/spec/flatware_helper.rb b/spec/flatware_helper.rb
new file mode 100644
index 0000000000..57a7c1f56a
--- /dev/null
+++ b/spec/flatware_helper.rb
@@ -0,0 +1,12 @@
+# frozen_string_literal: true
+
+if defined?(Flatware)
+  Flatware.configure do |config|
+    config.after_fork do |test_env_number|
+      unless ENV.fetch('DISABLE_SIMPLECOV', nil) == 'true'
+        require 'simplecov'
+        SimpleCov.at_fork.call(test_env_number) # Combines parallel coverage results
+      end
+    end
+  end
+end
diff --git a/spec/models/media_attachment_spec.rb b/spec/models/media_attachment_spec.rb
index 221645ac5a..a8f1ce7745 100644
--- a/spec/models/media_attachment_spec.rb
+++ b/spec/models/media_attachment_spec.rb
@@ -210,10 +210,14 @@ RSpec.describe MediaAttachment, :paperclip_processing do
       expect(media.file.meta['original']['duration']).to be_within(0.05).of(0.235102)
       expect(media.thumbnail.present?).to be true
 
-      # NOTE: Our libvips and ImageMagick implementations currently have different results
-      expect(media.file.meta['colors']['background']).to eq(ENV['MASTODON_USE_LIBVIPS'] ? '#268cd9' : '#3088d4')
+      expect(media.file.meta['colors']['background']).to eq(expected_background_color)
       expect(media.file_file_name).to_not eq 'boop.ogg'
     end
+
+    def expected_background_color
+      # The libvips and ImageMagick implementations produce different results
+      Rails.configuration.x.use_vips ? '#268cd9' : '#3088d4'
+    end
   end
 
   describe 'mp3 with large cover art' do
diff --git a/spec/requests/account_show_page_spec.rb b/spec/requests/account_show_page_spec.rb
index 81e965e6e6..830d778608 100644
--- a/spec/requests/account_show_page_spec.rb
+++ b/spec/requests/account_show_page_spec.rb
@@ -9,7 +9,7 @@ describe 'The account show page' do
 
     get '/@alice'
 
-    expect(head_link_icons.size).to eq(4) # One general favicon and three with sizes
+    expect(head_link_icons.size).to eq(3) # Three favicons with sizes
 
     expect(head_meta_content('og:title')).to match alice.display_name
     expect(head_meta_content('og:type')).to eq 'profile'
diff --git a/spec/services/fetch_link_card_service_spec.rb b/spec/services/fetch_link_card_service_spec.rb
index 239f84fde9..d83a527514 100644
--- a/spec/services/fetch_link_card_service_spec.rb
+++ b/spec/services/fetch_link_card_service_spec.rb
@@ -27,6 +27,7 @@ RSpec.describe FetchLinkCardService do
     stub_request(:get, 'http://example.com/koi8-r').to_return(request_fixture('koi8-r.txt'))
     stub_request(:get, 'http://example.com/windows-1251').to_return(request_fixture('windows-1251.txt'))
     stub_request(:get, 'http://example.com/low_confidence_latin1').to_return(request_fixture('low_confidence_latin1.txt'))
+    stub_request(:get, 'http://example.com/aergerliche-umlaute').to_return(request_fixture('redirect_with_utf8_url.txt'))
 
     Rails.cache.write('oembed_endpoint:example.com', oembed_cache) if oembed_cache
 
@@ -101,6 +102,14 @@ RSpec.describe FetchLinkCardService do
       end
     end
 
+    context 'with a redirect URL with faulty encoding' do
+      let(:status) { Fabricate(:status, text: 'http://example.com/aergerliche-umlaute') }
+
+      it 'does not create a preview card' do
+        expect(status.preview_card).to be_nil
+      end
+    end
+
     context 'with a 404 URL' do
       let(:status) { Fabricate(:status, text: 'http://example.com/not-found') }
 
@@ -193,6 +202,19 @@ RSpec.describe FetchLinkCardService do
       end
     end
 
+    context 'with an URL too long for PostgreSQL unique indexes' do
+      let(:url) { "http://example.com/#{'a' * 2674}" }
+      let(:status) { Fabricate(:status, text: url) }
+
+      it 'does not fetch the URL' do
+        expect(a_request(:get, url)).to_not have_been_made
+      end
+
+      it 'does not create a preview card' do
+        expect(status.preview_card).to be_nil
+      end
+    end
+
     context 'with a URL of a page with oEmbed support' do
       let(:html) { '<!doctype html><title>Hello world</title><link rel="alternate" type="application/json+oembed" href="http://example.com/oembed?url=http://example.com/html">' }
       let(:status) { Fabricate(:status, text: 'http://example.com/html') }
diff --git a/yarn.lock b/yarn.lock
index b836ad0140..8fd8f583a9 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -2768,8 +2768,9 @@ __metadata:
     "@rails/ujs": "npm:7.1.3"
     "@reduxjs/toolkit": "npm:^2.0.1"
     "@svgr/webpack": "npm:^5.5.0"
+    "@testing-library/dom": "npm:^10.2.0"
     "@testing-library/jest-dom": "npm:^6.0.0"
-    "@testing-library/react": "npm:^15.0.0"
+    "@testing-library/react": "npm:^16.0.0"
     "@types/babel__core": "npm:^7.20.1"
     "@types/emoji-mart": "npm:^3.0.9"
     "@types/escape-html": "npm:^1.0.2"
@@ -3351,9 +3352,9 @@ __metadata:
   languageName: node
   linkType: hard
 
-"@testing-library/dom@npm:^10.0.0":
-  version: 10.0.0
-  resolution: "@testing-library/dom@npm:10.0.0"
+"@testing-library/dom@npm:^10.2.0":
+  version: 10.2.0
+  resolution: "@testing-library/dom@npm:10.2.0"
   dependencies:
     "@babel/code-frame": "npm:^7.10.4"
     "@babel/runtime": "npm:^7.12.5"
@@ -3363,7 +3364,7 @@ __metadata:
     dom-accessibility-api: "npm:^0.5.9"
     lz-string: "npm:^1.5.0"
     pretty-format: "npm:^27.0.2"
-  checksum: 10c0/2d12d2a6018a6f1d15e91834180bc068932c699ff1fcbfb80aa21aba519a4f5329c861dfa852e06ee5615bcb92ef2a0f0e755e32684ea3dada63bc34248382ab
+  checksum: 10c0/de582dfbeb632436547a0ca5851b5a714a4a17f8e96ab3dc4fb4e454eef52c912b648b7cb6e9fdf477f3eeef97e698f3250f0ce50846f39d04677a44169209f2
   languageName: node
   linkType: hard
 
@@ -3400,21 +3401,23 @@ __metadata:
   languageName: node
   linkType: hard
 
-"@testing-library/react@npm:^15.0.0":
-  version: 15.0.7
-  resolution: "@testing-library/react@npm:15.0.7"
+"@testing-library/react@npm:^16.0.0":
+  version: 16.0.0
+  resolution: "@testing-library/react@npm:16.0.0"
   dependencies:
     "@babel/runtime": "npm:^7.12.5"
-    "@testing-library/dom": "npm:^10.0.0"
-    "@types/react-dom": "npm:^18.0.0"
   peerDependencies:
+    "@testing-library/dom": ^10.0.0
     "@types/react": ^18.0.0
+    "@types/react-dom": ^18.0.0
     react: ^18.0.0
     react-dom: ^18.0.0
   peerDependenciesMeta:
     "@types/react":
       optional: true
-  checksum: 10c0/ac8ee8968e81949ecb35f7ee34741c2c043f73dd7fee2247d56f6de6a30de4742af94f25264356863974e54387485b46c9448ecf3f6ca41cf4339011c369f2d4
+    "@types/react-dom":
+      optional: true
+  checksum: 10c0/297f97bf4722dad05f11d9cafd47d387dbdb096fea4b79b876c7466460f0f2e345b55b81b3e37fc81ed8185c528cb53dd8455ca1b6b019b229edf6c796f11c9f
   languageName: node
   linkType: hard
 
@@ -3433,9 +3436,9 @@ __metadata:
   linkType: hard
 
 "@types/aria-query@npm:^5.0.1":
-  version: 5.0.1
-  resolution: "@types/aria-query@npm:5.0.1"
-  checksum: 10c0/bc9e40ce37bd3a1654948778c7829bd55aea1bc5f2cd06fcf6cd650b07bb388995799e9aab6e2d93a6cf55dcba3b85c155f7ba93adefcc7c2e152fc6057061b5
+  version: 5.0.4
+  resolution: "@types/aria-query@npm:5.0.4"
+  checksum: 10c0/dc667bc6a3acc7bba2bccf8c23d56cb1f2f4defaa704cfef595437107efaa972d3b3db9ec1d66bc2711bfc35086821edd32c302bffab36f2e79b97f312069f08
   languageName: node
   linkType: hard
 
@@ -3832,7 +3835,7 @@ __metadata:
   languageName: node
   linkType: hard
 
-"@types/react-dom@npm:^18.0.0, @types/react-dom@npm:^18.2.4":
+"@types/react-dom@npm:^18.2.4":
   version: 18.3.0
   resolution: "@types/react-dom@npm:18.3.0"
   dependencies:
@@ -14123,12 +14126,12 @@ __metadata:
   linkType: hard
 
 "prom-client@npm:^15.0.0":
-  version: 15.1.2
-  resolution: "prom-client@npm:15.1.2"
+  version: 15.1.3
+  resolution: "prom-client@npm:15.1.3"
   dependencies:
     "@opentelemetry/api": "npm:^1.4.0"
     tdigest: "npm:^0.1.1"
-  checksum: 10c0/a221db148fa64e29dfd4c6cdcaaae14635495a4272b68917e2b44fcfd988bc57027d275b04489ceeea4d0c4d64d058af842c1300966d2c1ffa255f1fa6af1277
+  checksum: 10c0/816525572e5799a2d1d45af78512fb47d073c842dc899c446e94d17cfc343d04282a1627c488c7ca1bcd47f766446d3e49365ab7249f6d9c22c7664a5bce7021
   languageName: node
   linkType: hard
 
@@ -17175,22 +17178,22 @@ __metadata:
   linkType: hard
 
 "typescript@npm:5, typescript@npm:^5.0.4":
-  version: 5.4.5
-  resolution: "typescript@npm:5.4.5"
+  version: 5.5.2
+  resolution: "typescript@npm:5.5.2"
   bin:
     tsc: bin/tsc
     tsserver: bin/tsserver
-  checksum: 10c0/2954022ada340fd3d6a9e2b8e534f65d57c92d5f3989a263754a78aba549f7e6529acc1921913560a4b816c46dce7df4a4d29f9f11a3dc0d4213bb76d043251e
+  checksum: 10c0/8ca39b27b5f9bd7f32db795045933ab5247897660627251e8254180b792a395bf061ea7231947d5d7ffa5cb4cc771970fd4ef543275f9b559f08c9325cccfce3
   languageName: node
   linkType: hard
 
 "typescript@patch:typescript@npm%3A5#optional!builtin<compat/typescript>, typescript@patch:typescript@npm%3A^5.0.4#optional!builtin<compat/typescript>":
-  version: 5.4.5
-  resolution: "typescript@patch:typescript@npm%3A5.4.5#optional!builtin<compat/typescript>::version=5.4.5&hash=5adc0c"
+  version: 5.5.2
+  resolution: "typescript@patch:typescript@npm%3A5.5.2#optional!builtin<compat/typescript>::version=5.5.2&hash=379a07"
   bin:
     tsc: bin/tsc
     tsserver: bin/tsserver
-  checksum: 10c0/db2ad2a16ca829f50427eeb1da155e7a45e598eec7b086d8b4e8ba44e5a235f758e606d681c66992230d3fc3b8995865e5fd0b22a2c95486d0b3200f83072ec9
+  checksum: 10c0/a7b7ede75dc7fc32a76d0d0af6b91f5fbd8620890d84c906f663d8783bf3de6d7bd50f0430b8bb55eac88a38934af847ff709e7156e5138b95ae94cbd5f73e5b
   languageName: node
   linkType: hard