From 79013c730d8c241d54823a1b5860f403de2b9a1c Mon Sep 17 00:00:00 2001
From: Eugen Rochko <eugen@zeonfederated.com>
Date: Tue, 29 Apr 2025 14:14:22 +0200
Subject: [PATCH] Add endorsed accounts to profiles in web UI (#34568)

---
 .../mastodon/actions/accounts_typed.ts        | 21 ++++++---
 .../mastodon/actions/featured_tags.js         | 34 --------------
 .../mastodon/actions/featured_tags.ts         |  7 +++
 .../mastodon/actions/importer/accounts.ts     |  7 +++
 .../mastodon/actions/importer/index.js        |  3 +-
 app/javascript/mastodon/api/accounts.ts       | 10 ++++-
 .../features/account_featured/index.tsx       | 45 ++++++++++++++++---
 .../features/account_gallery/index.tsx        |  2 +-
 .../components/account_header.tsx             |  2 -
 .../features/account_timeline/index.jsx       |  4 +-
 .../compose/components/action_bar.tsx         |  2 -
 .../mastodon/features/followers/index.jsx     |  2 +-
 .../mastodon/features/following/index.jsx     |  2 +-
 app/javascript/mastodon/hooks/useAccountId.ts | 18 ++++----
 .../mastodon/hooks/useAccountVisibility.ts    | 12 ++---
 app/javascript/mastodon/locales/en.json       |  1 +
 app/javascript/mastodon/reducers/accounts.ts  |  2 +-
 .../mastodon/reducers/accounts_map.js         | 23 ----------
 .../mastodon/reducers/accounts_map.ts         | 38 ++++++++++++++++
 app/javascript/mastodon/reducers/index.ts     |  4 +-
 .../mastodon/reducers/user_lists.js           | 31 +++++++------
 21 files changed, 157 insertions(+), 113 deletions(-)
 delete mode 100644 app/javascript/mastodon/actions/featured_tags.js
 create mode 100644 app/javascript/mastodon/actions/featured_tags.ts
 create mode 100644 app/javascript/mastodon/actions/importer/accounts.ts
 delete mode 100644 app/javascript/mastodon/reducers/accounts_map.js
 create mode 100644 app/javascript/mastodon/reducers/accounts_map.ts

diff --git a/app/javascript/mastodon/actions/accounts_typed.ts b/app/javascript/mastodon/actions/accounts_typed.ts
index fcdec97e08..fe7c7327ce 100644
--- a/app/javascript/mastodon/actions/accounts_typed.ts
+++ b/app/javascript/mastodon/actions/accounts_typed.ts
@@ -1,18 +1,18 @@
 import { createAction } from '@reduxjs/toolkit';
 
-import { apiRemoveAccountFromFollowers } from 'mastodon/api/accounts';
-import type { ApiAccountJSON } from 'mastodon/api_types/accounts';
+import {
+  apiRemoveAccountFromFollowers,
+  apiGetEndorsedAccounts,
+} from 'mastodon/api/accounts';
 import type { ApiRelationshipJSON } from 'mastodon/api_types/relationships';
 import { createDataLoadingThunk } from 'mastodon/store/typed_functions';
 
+import { importFetchedAccounts } from './importer';
+
 export const revealAccount = createAction<{
   id: string;
 }>('accounts/revealAccount');
 
-export const importAccounts = createAction<{ accounts: ApiAccountJSON[] }>(
-  'accounts/importAccounts',
-);
-
 function actionWithSkipLoadingTrue<Args extends object>(args: Args) {
   return {
     payload: {
@@ -104,3 +104,12 @@ export const removeAccountFromFollowers = createDataLoadingThunk(
     apiRemoveAccountFromFollowers(accountId),
   (relationship) => ({ relationship }),
 );
+
+export const fetchEndorsedAccounts = createDataLoadingThunk(
+  'accounts/endorsements',
+  ({ accountId }: { accountId: string }) => apiGetEndorsedAccounts(accountId),
+  (data, { dispatch }) => {
+    dispatch(importFetchedAccounts(data));
+    return data;
+  },
+);
diff --git a/app/javascript/mastodon/actions/featured_tags.js b/app/javascript/mastodon/actions/featured_tags.js
deleted file mode 100644
index 6ee4dee2bc..0000000000
--- a/app/javascript/mastodon/actions/featured_tags.js
+++ /dev/null
@@ -1,34 +0,0 @@
-import api from '../api';
-
-export const FEATURED_TAGS_FETCH_REQUEST = 'FEATURED_TAGS_FETCH_REQUEST';
-export const FEATURED_TAGS_FETCH_SUCCESS = 'FEATURED_TAGS_FETCH_SUCCESS';
-export const FEATURED_TAGS_FETCH_FAIL    = 'FEATURED_TAGS_FETCH_FAIL';
-
-export const fetchFeaturedTags = (id) => (dispatch, getState) => {
-  if (getState().getIn(['user_lists', 'featured_tags', id, 'items'])) {
-    return;
-  }
-
-  dispatch(fetchFeaturedTagsRequest(id));
-
-  api().get(`/api/v1/accounts/${id}/featured_tags`)
-    .then(({ data }) => dispatch(fetchFeaturedTagsSuccess(id, data)))
-    .catch(err => dispatch(fetchFeaturedTagsFail(id, err)));
-};
-
-export const fetchFeaturedTagsRequest = (id) => ({
-  type: FEATURED_TAGS_FETCH_REQUEST,
-  id,
-});
-
-export const fetchFeaturedTagsSuccess = (id, tags) => ({
-  type: FEATURED_TAGS_FETCH_SUCCESS,
-  id,
-  tags,
-});
-
-export const fetchFeaturedTagsFail = (id, error) => ({
-  type: FEATURED_TAGS_FETCH_FAIL,
-  id,
-  error,
-});
diff --git a/app/javascript/mastodon/actions/featured_tags.ts b/app/javascript/mastodon/actions/featured_tags.ts
new file mode 100644
index 0000000000..12ed6282af
--- /dev/null
+++ b/app/javascript/mastodon/actions/featured_tags.ts
@@ -0,0 +1,7 @@
+import { apiGetFeaturedTags } from 'mastodon/api/accounts';
+import { createDataLoadingThunk } from 'mastodon/store/typed_functions';
+
+export const fetchFeaturedTags = createDataLoadingThunk(
+  'accounts/featured_tags',
+  ({ accountId }: { accountId: string }) => apiGetFeaturedTags(accountId),
+);
diff --git a/app/javascript/mastodon/actions/importer/accounts.ts b/app/javascript/mastodon/actions/importer/accounts.ts
new file mode 100644
index 0000000000..9eedad6da5
--- /dev/null
+++ b/app/javascript/mastodon/actions/importer/accounts.ts
@@ -0,0 +1,7 @@
+import { createAction } from '@reduxjs/toolkit';
+
+import type { ApiAccountJSON } from 'mastodon/api_types/accounts';
+
+export const importAccounts = createAction<{ accounts: ApiAccountJSON[] }>(
+  'accounts/importAccounts',
+);
diff --git a/app/javascript/mastodon/actions/importer/index.js b/app/javascript/mastodon/actions/importer/index.js
index a527043940..becbdb88c3 100644
--- a/app/javascript/mastodon/actions/importer/index.js
+++ b/app/javascript/mastodon/actions/importer/index.js
@@ -1,7 +1,6 @@
 import { createPollFromServerJSON } from 'mastodon/models/poll';
 
-import { importAccounts } from '../accounts_typed';
-
+import { importAccounts } from './accounts';
 import { normalizeStatus } from './normalizer';
 import { importPolls } from './polls';
 
diff --git a/app/javascript/mastodon/api/accounts.ts b/app/javascript/mastodon/api/accounts.ts
index c574a47459..074bcffaa1 100644
--- a/app/javascript/mastodon/api/accounts.ts
+++ b/app/javascript/mastodon/api/accounts.ts
@@ -1,5 +1,7 @@
-import { apiRequestPost } from 'mastodon/api';
+import { apiRequestPost, apiRequestGet } from 'mastodon/api';
+import type { ApiAccountJSON } from 'mastodon/api_types/accounts';
 import type { ApiRelationshipJSON } from 'mastodon/api_types/relationships';
+import type { ApiHashtagJSON } from 'mastodon/api_types/tags';
 
 export const apiSubmitAccountNote = (id: string, value: string) =>
   apiRequestPost<ApiRelationshipJSON>(`v1/accounts/${id}/note`, {
@@ -23,3 +25,9 @@ export const apiRemoveAccountFromFollowers = (id: string) =>
   apiRequestPost<ApiRelationshipJSON>(
     `v1/accounts/${id}/remove_from_followers`,
   );
+
+export const apiGetFeaturedTags = (id: string) =>
+  apiRequestGet<ApiHashtagJSON>(`v1/accounts/${id}/featured_tags`);
+
+export const apiGetEndorsedAccounts = (id: string) =>
+  apiRequestGet<ApiAccountJSON>(`v1/accounts/${id}/endorsements`);
diff --git a/app/javascript/mastodon/features/account_featured/index.tsx b/app/javascript/mastodon/features/account_featured/index.tsx
index 70e411f61a..d516bc3411 100644
--- a/app/javascript/mastodon/features/account_featured/index.tsx
+++ b/app/javascript/mastodon/features/account_featured/index.tsx
@@ -7,19 +7,21 @@ import { useParams } from 'react-router';
 import type { Map as ImmutableMap } from 'immutable';
 import { List as ImmutableList } from 'immutable';
 
+import { fetchEndorsedAccounts } from 'mastodon/actions/accounts';
 import { fetchFeaturedTags } from 'mastodon/actions/featured_tags';
 import { expandAccountFeaturedTimeline } from 'mastodon/actions/timelines';
+import { Account } from 'mastodon/components/account';
 import { ColumnBackButton } from 'mastodon/components/column_back_button';
 import { LoadingIndicator } from 'mastodon/components/loading_indicator';
 import { RemoteHint } from 'mastodon/components/remote_hint';
 import StatusContainer from 'mastodon/containers/status_container';
+import { AccountHeader } from 'mastodon/features/account_timeline/components/account_header';
+import BundleColumnError from 'mastodon/features/ui/components/bundle_column_error';
+import Column from 'mastodon/features/ui/components/column';
 import { useAccountId } from 'mastodon/hooks/useAccountId';
 import { useAccountVisibility } from 'mastodon/hooks/useAccountVisibility';
 import { useAppDispatch, useAppSelector } from 'mastodon/store';
 
-import { AccountHeader } from '../account_timeline/components/account_header';
-import Column from '../ui/components/column';
-
 import { EmptyMessage } from './components/empty_message';
 import { FeaturedTag } from './components/featured_tag';
 import type { TagMap } from './components/featured_tag';
@@ -29,7 +31,9 @@ interface Params {
   id?: string;
 }
 
-const AccountFeatured = () => {
+const AccountFeatured: React.FC<{ multiColumn: boolean }> = ({
+  multiColumn,
+}) => {
   const accountId = useAccountId();
   const { suspended, blockedBy, hidden } = useAccountVisibility(accountId);
   const forceEmptyState = suspended || blockedBy || hidden;
@@ -40,7 +44,8 @@ const AccountFeatured = () => {
   useEffect(() => {
     if (accountId) {
       void dispatch(expandAccountFeaturedTimeline(accountId));
-      dispatch(fetchFeaturedTags(accountId));
+      void dispatch(fetchFeaturedTags({ accountId }));
+      void dispatch(fetchEndorsedAccounts({ accountId }));
     }
   }, [accountId, dispatch]);
 
@@ -67,6 +72,17 @@ const AccountFeatured = () => {
         ImmutableList(),
       ) as ImmutableList<string>,
   );
+  const featuredAccountIds = useAppSelector(
+    (state) =>
+      state.user_lists.getIn(
+        ['featured_accounts', accountId, 'items'],
+        ImmutableList(),
+      ) as ImmutableList<string>,
+  );
+
+  if (accountId === null) {
+    return <BundleColumnError multiColumn={multiColumn} errorType='routing' />;
+  }
 
   if (isLoading) {
     return (
@@ -78,7 +94,11 @@ const AccountFeatured = () => {
     );
   }
 
-  if (featuredStatusIds.isEmpty() && featuredTags.isEmpty()) {
+  if (
+    featuredStatusIds.isEmpty() &&
+    featuredTags.isEmpty() &&
+    featuredAccountIds.isEmpty()
+  ) {
     return (
       <AccountFeaturedWrapper accountId={accountId}>
         <EmptyMessage
@@ -131,6 +151,19 @@ const AccountFeatured = () => {
             ))}
           </>
         )}
+        {!featuredAccountIds.isEmpty() && (
+          <>
+            <h4 className='column-subheading'>
+              <FormattedMessage
+                id='account.featured.accounts'
+                defaultMessage='Profiles'
+              />
+            </h4>
+            {featuredAccountIds.map((featuredAccountId) => (
+              <Account key={featuredAccountId} id={featuredAccountId} />
+            ))}
+          </>
+        )}
         <RemoteHint accountId={accountId} />
       </div>
     </Column>
diff --git a/app/javascript/mastodon/features/account_gallery/index.tsx b/app/javascript/mastodon/features/account_gallery/index.tsx
index 0027329c93..594f71cb23 100644
--- a/app/javascript/mastodon/features/account_gallery/index.tsx
+++ b/app/javascript/mastodon/features/account_gallery/index.tsx
@@ -147,7 +147,7 @@ export const AccountGallery: React.FC<{
     [dispatch],
   );
 
-  if (accountId && !isAccount) {
+  if (accountId === null) {
     return <BundleColumnError multiColumn={multiColumn} errorType='routing' />;
   }
 
diff --git a/app/javascript/mastodon/features/account_timeline/components/account_header.tsx b/app/javascript/mastodon/features/account_timeline/components/account_header.tsx
index 9d4825d302..b7908cc8d3 100644
--- a/app/javascript/mastodon/features/account_timeline/components/account_header.tsx
+++ b/app/javascript/mastodon/features/account_timeline/components/account_header.tsx
@@ -107,7 +107,6 @@ const messages = defineMessages({
     id: 'account.disable_notifications',
     defaultMessage: 'Stop notifying me when @{name} posts',
   },
-  pins: { id: 'navigation_bar.pins', defaultMessage: 'Pinned posts' },
   preferences: {
     id: 'navigation_bar.preferences',
     defaultMessage: 'Preferences',
@@ -451,7 +450,6 @@ export const AccountHeader: React.FC<{
         text: intl.formatMessage(messages.preferences),
         href: '/settings/preferences',
       });
-      arr.push({ text: intl.formatMessage(messages.pins), to: '/pinned' });
       arr.push(null);
       arr.push({
         text: intl.formatMessage(messages.follow_requests),
diff --git a/app/javascript/mastodon/features/account_timeline/index.jsx b/app/javascript/mastodon/features/account_timeline/index.jsx
index a5223275b3..6fc7d0a4ef 100644
--- a/app/javascript/mastodon/features/account_timeline/index.jsx
+++ b/app/javascript/mastodon/features/account_timeline/index.jsx
@@ -13,7 +13,6 @@ import { normalizeForLookup } from 'mastodon/reducers/accounts_map';
 import { getAccountHidden } from 'mastodon/selectors/accounts';
 
 import { lookupAccount, fetchAccount } from '../../actions/accounts';
-import { fetchFeaturedTags } from '../../actions/featured_tags';
 import { expandAccountFeaturedTimeline, expandAccountTimeline, connectTimeline, disconnectTimeline } from '../../actions/timelines';
 import { ColumnBackButton } from '../../components/column_back_button';
 import { LoadingIndicator } from '../../components/loading_indicator';
@@ -27,7 +26,7 @@ import { LimitedAccountHint } from './components/limited_account_hint';
 const emptyList = ImmutableList();
 
 const mapStateToProps = (state, { params: { acct, id, tagged }, withReplies = false }) => {
-  const accountId = id || state.getIn(['accounts_map', normalizeForLookup(acct)]);
+  const accountId = id || state.accounts_map[normalizeForLookup(acct)];
 
   if (accountId === null) {
     return {
@@ -86,7 +85,6 @@ class AccountTimeline extends ImmutablePureComponent {
       dispatch(expandAccountFeaturedTimeline(accountId, { tagged }));
     }
 
-    dispatch(fetchFeaturedTags(accountId));
     dispatch(expandAccountTimeline(accountId, { withReplies, tagged }));
 
     if (accountId === me) {
diff --git a/app/javascript/mastodon/features/compose/components/action_bar.tsx b/app/javascript/mastodon/features/compose/components/action_bar.tsx
index af24c565f6..55e95fb5d8 100644
--- a/app/javascript/mastodon/features/compose/components/action_bar.tsx
+++ b/app/javascript/mastodon/features/compose/components/action_bar.tsx
@@ -9,7 +9,6 @@ import { useAppDispatch } from 'mastodon/store';
 
 const messages = defineMessages({
   edit_profile: { id: 'account.edit_profile', defaultMessage: 'Edit profile' },
-  pins: { id: 'navigation_bar.pins', defaultMessage: 'Pinned posts' },
   preferences: {
     id: 'navigation_bar.preferences',
     defaultMessage: 'Preferences',
@@ -53,7 +52,6 @@ export const ActionBar: React.FC = () => {
         text: intl.formatMessage(messages.preferences),
         href: '/settings/preferences',
       },
-      { text: intl.formatMessage(messages.pins), to: '/pinned' },
       null,
       {
         text: intl.formatMessage(messages.follow_requests),
diff --git a/app/javascript/mastodon/features/followers/index.jsx b/app/javascript/mastodon/features/followers/index.jsx
index eaafb3d193..e55d3d9cd6 100644
--- a/app/javascript/mastodon/features/followers/index.jsx
+++ b/app/javascript/mastodon/features/followers/index.jsx
@@ -29,7 +29,7 @@ import { LimitedAccountHint } from '../account_timeline/components/limited_accou
 import Column from '../ui/components/column';
 
 const mapStateToProps = (state, { params: { acct, id } }) => {
-  const accountId = id || state.getIn(['accounts_map', normalizeForLookup(acct)]);
+  const accountId = id || state.accounts_map[normalizeForLookup(acct)];
 
   if (!accountId) {
     return {
diff --git a/app/javascript/mastodon/features/following/index.jsx b/app/javascript/mastodon/features/following/index.jsx
index 3200f1543b..1dc39df0ee 100644
--- a/app/javascript/mastodon/features/following/index.jsx
+++ b/app/javascript/mastodon/features/following/index.jsx
@@ -29,7 +29,7 @@ import { LimitedAccountHint } from '../account_timeline/components/limited_accou
 import Column from '../ui/components/column';
 
 const mapStateToProps = (state, { params: { acct, id } }) => {
-  const accountId = id || state.getIn(['accounts_map', normalizeForLookup(acct)]);
+  const accountId = id || state.accounts_map[normalizeForLookup(acct)];
 
   if (!accountId) {
     return {
diff --git a/app/javascript/mastodon/hooks/useAccountId.ts b/app/javascript/mastodon/hooks/useAccountId.ts
index 1cc819ca59..af1c93d17d 100644
--- a/app/javascript/mastodon/hooks/useAccountId.ts
+++ b/app/javascript/mastodon/hooks/useAccountId.ts
@@ -11,27 +11,25 @@ interface Params {
   id?: string;
 }
 
-export function useAccountId() {
+export const useAccountId = () => {
   const { acct, id } = useParams<Params>();
+  const dispatch = useAppDispatch();
   const accountId = useAppSelector(
     (state) =>
-      id ??
-      (state.accounts_map.get(normalizeForLookup(acct)) as string | undefined),
+      id ?? (acct ? state.accounts_map[normalizeForLookup(acct)] : undefined),
   );
-
   const account = useAppSelector((state) =>
     accountId ? state.accounts.get(accountId) : undefined,
   );
-  const isAccount = !!account;
+  const accountInStore = !!account;
 
-  const dispatch = useAppDispatch();
   useEffect(() => {
-    if (!accountId) {
+    if (typeof accountId === 'undefined' && acct) {
       dispatch(lookupAccount(acct));
-    } else if (!isAccount) {
+    } else if (accountId && !accountInStore) {
       dispatch(fetchAccount(accountId));
     }
-  }, [dispatch, accountId, acct, isAccount]);
+  }, [dispatch, accountId, acct, accountInStore]);
 
   return accountId;
-}
+};
diff --git a/app/javascript/mastodon/hooks/useAccountVisibility.ts b/app/javascript/mastodon/hooks/useAccountVisibility.ts
index 55651af5a0..7ef98eef69 100644
--- a/app/javascript/mastodon/hooks/useAccountVisibility.ts
+++ b/app/javascript/mastodon/hooks/useAccountVisibility.ts
@@ -1,12 +1,14 @@
 import { getAccountHidden } from 'mastodon/selectors/accounts';
 import { useAppSelector } from 'mastodon/store';
 
-export function useAccountVisibility(accountId?: string) {
-  const blockedBy = useAppSelector(
-    (state) => !!state.relationships.getIn([accountId, 'blocked_by'], false),
+export function useAccountVisibility(accountId?: string | null) {
+  const blockedBy = useAppSelector((state) =>
+    accountId
+      ? !!state.relationships.getIn([accountId, 'blocked_by'], false)
+      : false,
   );
-  const suspended = useAppSelector(
-    (state) => !!state.accounts.getIn([accountId, 'suspended'], false),
+  const suspended = useAppSelector((state) =>
+    accountId ? !!state.accounts.getIn([accountId, 'suspended'], false) : false,
   );
   const hidden = useAppSelector((state) =>
     accountId ? Boolean(getAccountHidden(state, accountId)) : false,
diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json
index e193e10a6d..3d6e166498 100644
--- a/app/javascript/mastodon/locales/en.json
+++ b/app/javascript/mastodon/locales/en.json
@@ -29,6 +29,7 @@
   "account.enable_notifications": "Notify me when @{name} posts",
   "account.endorse": "Feature on profile",
   "account.featured": "Featured",
+  "account.featured.accounts": "Profiles",
   "account.featured.hashtags": "Hashtags",
   "account.featured.posts": "Posts",
   "account.featured_tags.last_status_at": "Last post on {date}",
diff --git a/app/javascript/mastodon/reducers/accounts.ts b/app/javascript/mastodon/reducers/accounts.ts
index 2001353b2e..692c4feec4 100644
--- a/app/javascript/mastodon/reducers/accounts.ts
+++ b/app/javascript/mastodon/reducers/accounts.ts
@@ -4,9 +4,9 @@ import { Map as ImmutableMap } from 'immutable';
 import {
   followAccountSuccess,
   unfollowAccountSuccess,
-  importAccounts,
   revealAccount,
 } from 'mastodon/actions/accounts_typed';
+import { importAccounts } from 'mastodon/actions/importer/accounts';
 import type { ApiAccountJSON } from 'mastodon/api_types/accounts';
 import { me } from 'mastodon/initial_state';
 import type { Account } from 'mastodon/models/account';
diff --git a/app/javascript/mastodon/reducers/accounts_map.js b/app/javascript/mastodon/reducers/accounts_map.js
deleted file mode 100644
index d1229169cc..0000000000
--- a/app/javascript/mastodon/reducers/accounts_map.js
+++ /dev/null
@@ -1,23 +0,0 @@
-import { Map as ImmutableMap } from 'immutable';
-
-import { ACCOUNT_LOOKUP_FAIL } from '../actions/accounts';
-import { importAccounts } from '../actions/accounts_typed';
-import { domain } from '../initial_state';
-
-const pattern = new RegExp(`@${domain}$`, 'gi');
-
-export const normalizeForLookup = str =>
-  str.toLowerCase().replace(pattern, '');
-
-const initialState = ImmutableMap();
-
-export default function accountsMap(state = initialState, action) {
-  switch(action.type) {
-  case ACCOUNT_LOOKUP_FAIL:
-    return action.error?.response?.status === 404 ? state.set(normalizeForLookup(action.acct), null) : state;
-  case importAccounts.type:
-    return state.withMutations(map => action.payload.accounts.forEach(account => map.set(normalizeForLookup(account.acct), account.id)));
-  default:
-    return state;
-  }
-}
diff --git a/app/javascript/mastodon/reducers/accounts_map.ts b/app/javascript/mastodon/reducers/accounts_map.ts
new file mode 100644
index 0000000000..820082e3d8
--- /dev/null
+++ b/app/javascript/mastodon/reducers/accounts_map.ts
@@ -0,0 +1,38 @@
+import { createReducer } from '@reduxjs/toolkit';
+import type { UnknownAction } from '@reduxjs/toolkit';
+
+import type { AxiosError } from 'axios';
+
+import { ACCOUNT_LOOKUP_FAIL } from 'mastodon/actions/accounts';
+import { importAccounts } from 'mastodon/actions/importer/accounts';
+import { domain } from 'mastodon/initial_state';
+
+interface AccountLookupFailAction extends UnknownAction {
+  acct: string;
+  error?: AxiosError;
+}
+
+const pattern = new RegExp(`@${domain}$`, 'gi');
+
+export const normalizeForLookup = (str: string) =>
+  str.toLowerCase().replace(pattern, '');
+
+const initialState: Record<string, string | null> = {};
+
+export const accountsMapReducer = createReducer(initialState, (builder) => {
+  builder
+    .addCase(importAccounts, (state, action) => {
+      action.payload.accounts.forEach((account) => {
+        state[normalizeForLookup(account.acct)] = account.id;
+      });
+    })
+    .addMatcher(
+      (action: UnknownAction): action is AccountLookupFailAction =>
+        action.type === ACCOUNT_LOOKUP_FAIL,
+      (state, action) => {
+        if (action.error?.response?.status === 404) {
+          state[normalizeForLookup(action.acct)] = null;
+        }
+      },
+    );
+});
diff --git a/app/javascript/mastodon/reducers/index.ts b/app/javascript/mastodon/reducers/index.ts
index a1b349af80..0b6e66a1b2 100644
--- a/app/javascript/mastodon/reducers/index.ts
+++ b/app/javascript/mastodon/reducers/index.ts
@@ -4,7 +4,7 @@ import { loadingBarReducer } from 'react-redux-loading-bar';
 import { combineReducers } from 'redux-immutable';
 
 import { accountsReducer } from './accounts';
-import accounts_map from './accounts_map';
+import { accountsMapReducer } from './accounts_map';
 import { alertsReducer } from './alerts';
 import announcements from './announcements';
 import { composeReducer } from './compose';
@@ -49,7 +49,7 @@ const reducers = {
   user_lists,
   status_lists,
   accounts: accountsReducer,
-  accounts_map,
+  accounts_map: accountsMapReducer,
   statuses,
   relationships: relationshipsReducer,
   settings,
diff --git a/app/javascript/mastodon/reducers/user_lists.js b/app/javascript/mastodon/reducers/user_lists.js
index 7a4c04c5c7..466bfe54d6 100644
--- a/app/javascript/mastodon/reducers/user_lists.js
+++ b/app/javascript/mastodon/reducers/user_lists.js
@@ -5,9 +5,7 @@ import {
   fetchDirectory
 } from 'mastodon/actions/directory';
 import {
-  FEATURED_TAGS_FETCH_REQUEST,
-  FEATURED_TAGS_FETCH_SUCCESS,
-  FEATURED_TAGS_FETCH_FAIL,
+  fetchFeaturedTags
 } from 'mastodon/actions/featured_tags';
 
 import {
@@ -31,6 +29,7 @@ import {
   FOLLOW_REQUESTS_EXPAND_FAIL,
   authorizeFollowRequestSuccess,
   rejectFollowRequestSuccess,
+  fetchEndorsedAccounts,
 } from '../actions/accounts';
 import {
   BLOCKS_FETCH_REQUEST,
@@ -191,21 +190,27 @@ export default function userLists(state = initialState, action) {
   case MUTES_FETCH_FAIL:
   case MUTES_EXPAND_FAIL:
     return state.setIn(['mutes', 'isLoading'], false);
-  case FEATURED_TAGS_FETCH_SUCCESS:
-    return normalizeFeaturedTags(state, ['featured_tags', action.id], action.tags, action.id);
-  case FEATURED_TAGS_FETCH_REQUEST:
-    return state.setIn(['featured_tags', action.id, 'isLoading'], true);
-  case FEATURED_TAGS_FETCH_FAIL:
-    return state.setIn(['featured_tags', action.id, 'isLoading'], false);
   default:
-    if(fetchDirectory.fulfilled.match(action))
+    if (fetchEndorsedAccounts.fulfilled.match(action))
+      return normalizeList(state, ['featured_accounts', action.meta.arg.accountId], action.payload, undefined);
+    else if (fetchEndorsedAccounts.pending.match(action))
+      return state.setIn(['featured_accounts', action.meta.arg.accountId, 'isLoading'], true);
+    else if (fetchEndorsedAccounts.rejected.match(action))
+      return state.setIn(['featured_accounts', action.meta.arg.accountId, 'isLoading'], false);
+    else if (fetchFeaturedTags.fulfilled.match(action))
+      return normalizeFeaturedTags(state, ['featured_tags', action.meta.arg.accountId], action.payload, action.meta.arg.accountId);
+    else if (fetchFeaturedTags.pending.match(action))
+      return state.setIn(['featured_tags', action.meta.arg.accountId, 'isLoading'], true);
+    else if (fetchFeaturedTags.rejected.match(action))
+      return state.setIn(['featured_tags', action.meta.arg.accountId, 'isLoading'], false);
+    else if (fetchDirectory.fulfilled.match(action))
       return normalizeList(state, ['directory'], action.payload.accounts, undefined);
-    else if( expandDirectory.fulfilled.match(action))
+    else if (expandDirectory.fulfilled.match(action))
       return appendToList(state, ['directory'], action.payload.accounts, undefined);
-    else if(fetchDirectory.pending.match(action) ||
+    else if (fetchDirectory.pending.match(action) ||
      expandDirectory.pending.match(action))
       return state.setIn(['directory', 'isLoading'], true);
-    else if(fetchDirectory.rejected.match(action) ||
+    else if (fetchDirectory.rejected.match(action) ||
      expandDirectory.rejected.match(action))
       return state.setIn(['directory', 'isLoading'], false);
     else