diff --git a/app/javascript/mastodon/components/scrollable_list.jsx b/app/javascript/mastodon/components/scrollable_list.jsx
index 35cd86ea1a..22ec18afa9 100644
--- a/app/javascript/mastodon/components/scrollable_list.jsx
+++ b/app/javascript/mastodon/components/scrollable_list.jsx
@@ -81,6 +81,7 @@ class ScrollableList extends PureComponent {
     bindToDocument: PropTypes.bool,
     preventScroll: PropTypes.bool,
     footer: PropTypes.node,
+    className: PropTypes.string,
   };
 
   static defaultProps = {
@@ -325,7 +326,7 @@ class ScrollableList extends PureComponent {
   };
 
   render () {
-    const { children, scrollKey, trackScroll, showLoading, isLoading, hasMore, numPending, prepend, alwaysPrepend, append, footer, emptyMessage, onLoadMore } = this.props;
+    const { children, scrollKey, className, trackScroll, showLoading, isLoading, hasMore, numPending, prepend, alwaysPrepend, append, footer, emptyMessage, onLoadMore } = this.props;
     const { fullscreen } = this.state;
     const childrenCount = Children.count(children);
 
@@ -336,9 +337,9 @@ class ScrollableList extends PureComponent {
     if (showLoading) {
       scrollableArea = (
         <div className='scrollable scrollable--flex' ref={this.setRef}>
-          <div role='feed' className='item-list'>
-            {prepend}
-          </div>
+          {prepend}
+
+          <div role='feed' className='item-list' />
 
           <div className='scrollable__append'>
             <LoadingIndicator />
@@ -350,9 +351,9 @@ class ScrollableList extends PureComponent {
     } else if (isLoading || childrenCount > 0 || numPending > 0 || hasMore || !emptyMessage) {
       scrollableArea = (
         <div className={classNames('scrollable scrollable--flex', { fullscreen })} ref={this.setRef} onMouseMove={this.handleMouseMove}>
-          <div role='feed' className='item-list'>
-            {prepend}
+          {prepend}
 
+          <div role='feed' className={classNames('item-list', className)}>
             {loadPending}
 
             {Children.map(this.props.children, (child, index) => (
diff --git a/app/javascript/mastodon/features/account_gallery/components/media_item.tsx b/app/javascript/mastodon/features/account_gallery/components/media_item.tsx
index fef8a1300d..80704c3388 100644
--- a/app/javascript/mastodon/features/account_gallery/components/media_item.tsx
+++ b/app/javascript/mastodon/features/account_gallery/components/media_item.tsx
@@ -11,11 +11,15 @@ import { Icon } from 'mastodon/components/icon';
 import { formatTime } from 'mastodon/features/video';
 import { autoPlayGif, displayMedia, useBlurhash } from 'mastodon/initial_state';
 import type { Status, MediaAttachment } from 'mastodon/models/status';
+import { useAppSelector } from 'mastodon/store';
 
 export const MediaItem: React.FC<{
   attachment: MediaAttachment;
   onOpenMedia: (arg0: MediaAttachment) => void;
 }> = ({ attachment, onOpenMedia }) => {
+  const account = useAppSelector((state) =>
+    state.accounts.get(attachment.getIn(['status', 'account']) as string),
+  );
   const [visible, setVisible] = useState(
     (displayMedia !== 'hide_all' &&
       !attachment.getIn(['status', 'sensitive'])) ||
@@ -70,7 +74,6 @@ export const MediaItem: React.FC<{
   const lang = status.get('language') as string;
   const blurhash = attachment.get('blurhash') as string;
   const statusId = status.get('id') as string;
-  const acct = status.getIn(['account', 'acct']) as string;
   const type = attachment.get('type') as string;
 
   let thumbnail;
@@ -181,7 +184,7 @@ export const MediaItem: React.FC<{
 
       <a
         className='media-gallery__item-thumbnail'
-        href={`/@${acct}/${statusId}`}
+        href={`/@${account?.acct}/${statusId}`}
         onClick={handleClick}
         target='_blank'
         rel='noopener noreferrer'
diff --git a/app/javascript/mastodon/features/account_gallery/index.jsx b/app/javascript/mastodon/features/account_gallery/index.jsx
deleted file mode 100644
index 695a1a2ad0..0000000000
--- a/app/javascript/mastodon/features/account_gallery/index.jsx
+++ /dev/null
@@ -1,241 +0,0 @@
-import PropTypes from 'prop-types';
-
-import { FormattedMessage } from 'react-intl';
-
-import ImmutablePropTypes from 'react-immutable-proptypes';
-import ImmutablePureComponent from 'react-immutable-pure-component';
-import { connect } from 'react-redux';
-
-import { lookupAccount, fetchAccount } from 'mastodon/actions/accounts';
-import { openModal } from 'mastodon/actions/modal';
-import { ColumnBackButton } from 'mastodon/components/column_back_button';
-import { LoadMore } from 'mastodon/components/load_more';
-import { LoadingIndicator } from 'mastodon/components/loading_indicator';
-import ScrollContainer from 'mastodon/containers/scroll_container';
-import BundleColumnError from 'mastodon/features/ui/components/bundle_column_error';
-import { normalizeForLookup } from 'mastodon/reducers/accounts_map';
-import { getAccountGallery } from 'mastodon/selectors';
-
-import { expandAccountMediaTimeline } from '../../actions/timelines';
-import { AccountHeader } from '../account_timeline/components/account_header';
-import Column from '../ui/components/column';
-
-import { MediaItem } from './components/media_item';
-
-const mapStateToProps = (state, { params: { acct, id } }) => {
-  const accountId = id || state.getIn(['accounts_map', normalizeForLookup(acct)]);
-
-  if (!accountId) {
-    return {
-      isLoading: true,
-    };
-  }
-
-  return {
-    accountId,
-    isAccount: !!state.getIn(['accounts', accountId]),
-    attachments: getAccountGallery(state, accountId),
-    isLoading: state.getIn(['timelines', `account:${accountId}:media`, 'isLoading']),
-    hasMore: state.getIn(['timelines', `account:${accountId}:media`, 'hasMore']),
-    suspended: state.getIn(['accounts', accountId, 'suspended'], false),
-    blockedBy: state.getIn(['relationships', accountId, 'blocked_by'], false),
-  };
-};
-
-class LoadMoreMedia extends ImmutablePureComponent {
-
-  static propTypes = {
-    maxId: PropTypes.string,
-    onLoadMore: PropTypes.func.isRequired,
-  };
-
-  handleLoadMore = () => {
-    this.props.onLoadMore(this.props.maxId);
-  };
-
-  render () {
-    return (
-      <LoadMore
-        disabled={this.props.disabled}
-        onClick={this.handleLoadMore}
-      />
-    );
-  }
-
-}
-
-class AccountGallery extends ImmutablePureComponent {
-
-  static propTypes = {
-    params: PropTypes.shape({
-      acct: PropTypes.string,
-      id: PropTypes.string,
-    }).isRequired,
-    accountId: PropTypes.string,
-    dispatch: PropTypes.func.isRequired,
-    attachments: ImmutablePropTypes.list.isRequired,
-    isLoading: PropTypes.bool,
-    hasMore: PropTypes.bool,
-    isAccount: PropTypes.bool,
-    blockedBy: PropTypes.bool,
-    suspended: PropTypes.bool,
-    multiColumn: PropTypes.bool,
-  };
-
-  state = {
-    width: 323,
-  };
-
-  _load () {
-    const { accountId, isAccount, dispatch } = this.props;
-
-    if (!isAccount) dispatch(fetchAccount(accountId));
-    dispatch(expandAccountMediaTimeline(accountId));
-  }
-
-  componentDidMount () {
-    const { params: { acct }, accountId, dispatch } = this.props;
-
-    if (accountId) {
-      this._load();
-    } else {
-      dispatch(lookupAccount(acct));
-    }
-  }
-
-  componentDidUpdate (prevProps) {
-    const { params: { acct }, accountId, dispatch } = this.props;
-
-    if (prevProps.accountId !== accountId && accountId) {
-      this._load();
-    } else if (prevProps.params.acct !== acct) {
-      dispatch(lookupAccount(acct));
-    }
-  }
-
-  handleScrollToBottom = () => {
-    if (this.props.hasMore) {
-      this.handleLoadMore(this.props.attachments.size > 0 ? this.props.attachments.last().getIn(['status', 'id']) : undefined);
-    }
-  };
-
-  handleScroll = e => {
-    const { scrollTop, scrollHeight, clientHeight } = e.target;
-    const offset = scrollHeight - scrollTop - clientHeight;
-
-    if (150 > offset && !this.props.isLoading) {
-      this.handleScrollToBottom();
-    }
-  };
-
-  handleLoadMore = maxId => {
-    this.props.dispatch(expandAccountMediaTimeline(this.props.accountId, { maxId }));
-  };
-
-  handleLoadOlder = e => {
-    e.preventDefault();
-    this.handleScrollToBottom();
-  };
-
-  handleOpenMedia = attachment => {
-    const { dispatch } = this.props;
-    const statusId = attachment.getIn(['status', 'id']);
-    const lang = attachment.getIn(['status', 'language']);
-
-    if (attachment.get('type') === 'video') {
-      dispatch(openModal({
-        modalType: 'VIDEO',
-        modalProps: { media: attachment, statusId, lang, options: { autoPlay: true } },
-      }));
-    } else if (attachment.get('type') === 'audio') {
-      dispatch(openModal({
-        modalType: 'AUDIO',
-        modalProps: { media: attachment, statusId, lang, options: { autoPlay: true } },
-      }));
-    } else {
-      const media = attachment.getIn(['status', 'media_attachments']);
-      const index = media.findIndex(x => x.get('id') === attachment.get('id'));
-
-      dispatch(openModal({
-        modalType: 'MEDIA',
-        modalProps: { media, index, statusId, lang },
-      }));
-    }
-  };
-
-  handleRef = c => {
-    if (c) {
-      this.setState({ width: c.offsetWidth });
-    }
-  };
-
-  render () {
-    const { attachments, isLoading, hasMore, isAccount, multiColumn, blockedBy, suspended } = this.props;
-    const { width } = this.state;
-
-    if (!isAccount) {
-      return (
-        <BundleColumnError multiColumn={multiColumn} errorType='routing' />
-      );
-    }
-
-    if (!attachments && isLoading) {
-      return (
-        <Column>
-          <LoadingIndicator />
-        </Column>
-      );
-    }
-
-    let loadOlder = null;
-
-    if (hasMore && !(isLoading && attachments.size === 0)) {
-      loadOlder = <LoadMore visible={!isLoading} onClick={this.handleLoadOlder} />;
-    }
-
-    let emptyMessage;
-
-    if (suspended) {
-      emptyMessage = <FormattedMessage id='empty_column.account_suspended' defaultMessage='Account suspended' />;
-    } else if (blockedBy) {
-      emptyMessage = <FormattedMessage id='empty_column.account_unavailable' defaultMessage='Profile unavailable' />;
-    }
-
-    return (
-      <Column>
-        <ColumnBackButton />
-
-        <ScrollContainer scrollKey='account_gallery'>
-          <div className='scrollable scrollable--flex' onScroll={this.handleScroll}>
-            <AccountHeader accountId={this.props.accountId} />
-
-            {(suspended || blockedBy) ? (
-              <div className='empty-column-indicator'>
-                {emptyMessage}
-              </div>
-            ) : (
-              <div role='feed' className='account-gallery__container' ref={this.handleRef}>
-                {attachments.map((attachment, index) => attachment === null ? (
-                  <LoadMoreMedia key={'more:' + attachments.getIn(index + 1, 'id')} maxId={index > 0 ? attachments.getIn(index - 1, 'id') : null} onLoadMore={this.handleLoadMore} />
-                ) : (
-                  <MediaItem key={attachment.get('id')} attachment={attachment} displayWidth={width} onOpenMedia={this.handleOpenMedia} />
-                ))}
-
-                {loadOlder}
-              </div>
-            )}
-
-            {isLoading && attachments.size === 0 && (
-              <div className='scrollable__append'>
-                <LoadingIndicator />
-              </div>
-            )}
-          </div>
-        </ScrollContainer>
-      </Column>
-    );
-  }
-
-}
-
-export default connect(mapStateToProps)(AccountGallery);
diff --git a/app/javascript/mastodon/features/account_gallery/index.tsx b/app/javascript/mastodon/features/account_gallery/index.tsx
new file mode 100644
index 0000000000..60afdadc81
--- /dev/null
+++ b/app/javascript/mastodon/features/account_gallery/index.tsx
@@ -0,0 +1,283 @@
+import { useEffect, useCallback } from 'react';
+
+import { FormattedMessage } from 'react-intl';
+
+import { useParams } from 'react-router-dom';
+
+import { createSelector } from '@reduxjs/toolkit';
+import type { Map as ImmutableMap } from 'immutable';
+import { List as ImmutableList } from 'immutable';
+
+import { lookupAccount, fetchAccount } from 'mastodon/actions/accounts';
+import { openModal } from 'mastodon/actions/modal';
+import { expandAccountMediaTimeline } from 'mastodon/actions/timelines';
+import { ColumnBackButton } from 'mastodon/components/column_back_button';
+import ScrollableList from 'mastodon/components/scrollable_list';
+import { TimelineHint } from 'mastodon/components/timeline_hint';
+import { AccountHeader } from 'mastodon/features/account_timeline/components/account_header';
+import { LimitedAccountHint } from 'mastodon/features/account_timeline/components/limited_account_hint';
+import BundleColumnError from 'mastodon/features/ui/components/bundle_column_error';
+import Column from 'mastodon/features/ui/components/column';
+import type { MediaAttachment } from 'mastodon/models/media_attachment';
+import { normalizeForLookup } from 'mastodon/reducers/accounts_map';
+import { getAccountHidden } from 'mastodon/selectors/accounts';
+import type { RootState } from 'mastodon/store';
+import { useAppSelector, useAppDispatch } from 'mastodon/store';
+
+import { MediaItem } from './components/media_item';
+
+const getAccountGallery = createSelector(
+  [
+    (state: RootState, accountId: string) =>
+      (state.timelines as ImmutableMap<string, unknown>).getIn(
+        [`account:${accountId}:media`, 'items'],
+        ImmutableList(),
+      ) as ImmutableList<string>,
+    (state: RootState) => state.statuses,
+  ],
+  (statusIds, statuses) => {
+    let items = ImmutableList<MediaAttachment>();
+
+    statusIds.forEach((statusId) => {
+      const status = statuses.get(statusId) as
+        | ImmutableMap<string, unknown>
+        | undefined;
+
+      if (status) {
+        items = items.concat(
+          (
+            status.get('media_attachments') as ImmutableList<MediaAttachment>
+          ).map((media) => media.set('status', status)),
+        );
+      }
+    });
+
+    return items;
+  },
+);
+
+interface Params {
+  acct?: string;
+  id?: string;
+}
+
+const RemoteHint: React.FC<{
+  accountId: string;
+}> = ({ accountId }) => {
+  const account = useAppSelector((state) => state.accounts.get(accountId));
+  const acct = account?.acct;
+  const url = account?.url;
+  const domain = acct ? acct.split('@')[1] : undefined;
+
+  if (!url) {
+    return null;
+  }
+
+  return (
+    <TimelineHint
+      url={url}
+      message={
+        <FormattedMessage
+          id='hints.profiles.posts_may_be_missing'
+          defaultMessage='Some posts from this profile may be missing.'
+        />
+      }
+      label={
+        <FormattedMessage
+          id='hints.profiles.see_more_posts'
+          defaultMessage='See more posts on {domain}'
+          values={{ domain: <strong>{domain}</strong> }}
+        />
+      }
+    />
+  );
+};
+
+export const AccountGallery: React.FC<{
+  multiColumn: boolean;
+}> = ({ multiColumn }) => {
+  const { acct, id } = useParams<Params>();
+  const dispatch = useAppDispatch();
+  const accountId = useAppSelector(
+    (state) =>
+      id ??
+      (state.accounts_map.get(normalizeForLookup(acct)) as string | undefined),
+  );
+  const attachments = useAppSelector((state) =>
+    accountId
+      ? getAccountGallery(state, accountId)
+      : ImmutableList<MediaAttachment>(),
+  );
+  const isLoading = useAppSelector((state) =>
+    (state.timelines as ImmutableMap<string, unknown>).getIn([
+      `account:${accountId}:media`,
+      'isLoading',
+    ]),
+  );
+  const hasMore = useAppSelector((state) =>
+    (state.timelines as ImmutableMap<string, unknown>).getIn([
+      `account:${accountId}:media`,
+      'hasMore',
+    ]),
+  );
+  const account = useAppSelector((state) =>
+    accountId ? state.accounts.get(accountId) : undefined,
+  );
+  const blockedBy = useAppSelector(
+    (state) =>
+      state.relationships.getIn([accountId, 'blocked_by'], false) as boolean,
+  );
+  const suspended = useAppSelector(
+    (state) => state.accounts.getIn([accountId, 'suspended'], false) as boolean,
+  );
+  const isAccount = !!account;
+  const remote = account?.acct !== account?.username;
+  const hidden = useAppSelector((state) =>
+    accountId ? getAccountHidden(state, accountId) : false,
+  );
+  const maxId = attachments.last()?.getIn(['status', 'id']) as
+    | string
+    | undefined;
+
+  useEffect(() => {
+    if (!accountId) {
+      dispatch(lookupAccount(acct));
+    }
+  }, [dispatch, accountId, acct]);
+
+  useEffect(() => {
+    if (accountId && !isAccount) {
+      dispatch(fetchAccount(accountId));
+    }
+
+    if (accountId && isAccount) {
+      void dispatch(expandAccountMediaTimeline(accountId));
+    }
+  }, [dispatch, accountId, isAccount]);
+
+  const handleLoadMore = useCallback(() => {
+    if (maxId) {
+      void dispatch(expandAccountMediaTimeline(accountId, { maxId }));
+    }
+  }, [dispatch, accountId, maxId]);
+
+  const handleOpenMedia = useCallback(
+    (attachment: MediaAttachment) => {
+      const statusId = attachment.getIn(['status', 'id']);
+      const lang = attachment.getIn(['status', 'language']);
+
+      if (attachment.get('type') === 'video') {
+        dispatch(
+          openModal({
+            modalType: 'VIDEO',
+            modalProps: {
+              media: attachment,
+              statusId,
+              lang,
+              options: { autoPlay: true },
+            },
+          }),
+        );
+      } else if (attachment.get('type') === 'audio') {
+        dispatch(
+          openModal({
+            modalType: 'AUDIO',
+            modalProps: {
+              media: attachment,
+              statusId,
+              lang,
+              options: { autoPlay: true },
+            },
+          }),
+        );
+      } else {
+        const media = attachment.getIn([
+          'status',
+          'media_attachments',
+        ]) as ImmutableList<MediaAttachment>;
+        const index = media.findIndex(
+          (x) => x.get('id') === attachment.get('id'),
+        );
+
+        dispatch(
+          openModal({
+            modalType: 'MEDIA',
+            modalProps: { media, index, statusId, lang },
+          }),
+        );
+      }
+    },
+    [dispatch],
+  );
+
+  if (accountId && !isAccount) {
+    return <BundleColumnError multiColumn={multiColumn} errorType='routing' />;
+  }
+
+  let emptyMessage;
+
+  if (accountId) {
+    if (suspended) {
+      emptyMessage = (
+        <FormattedMessage
+          id='empty_column.account_suspended'
+          defaultMessage='Account suspended'
+        />
+      );
+    } else if (hidden) {
+      emptyMessage = <LimitedAccountHint accountId={accountId} />;
+    } else if (blockedBy) {
+      emptyMessage = (
+        <FormattedMessage
+          id='empty_column.account_unavailable'
+          defaultMessage='Profile unavailable'
+        />
+      );
+    } else if (remote && attachments.isEmpty()) {
+      emptyMessage = <RemoteHint accountId={accountId} />;
+    } else {
+      emptyMessage = (
+        <FormattedMessage
+          id='empty_column.account_timeline'
+          defaultMessage='No posts found'
+        />
+      );
+    }
+  }
+
+  const forceEmptyState = suspended || blockedBy || hidden;
+
+  return (
+    <Column>
+      <ColumnBackButton />
+
+      <ScrollableList
+        className='account-gallery__container'
+        prepend={
+          accountId && (
+            <AccountHeader accountId={accountId} hideTabs={forceEmptyState} />
+          )
+        }
+        alwaysPrepend
+        append={remote && accountId && <RemoteHint accountId={accountId} />}
+        scrollKey='account_gallery'
+        isLoading={isLoading}
+        hasMore={!forceEmptyState && hasMore}
+        onLoadMore={handleLoadMore}
+        emptyMessage={emptyMessage}
+        bindToDocument={!multiColumn}
+      >
+        {attachments.map((attachment) => (
+          <MediaItem
+            key={attachment.get('id') as string}
+            attachment={attachment}
+            onOpenMedia={handleOpenMedia}
+          />
+        ))}
+      </ScrollableList>
+    </Column>
+  );
+};
+
+// eslint-disable-next-line import/no-default-export
+export default AccountGallery;
diff --git a/app/javascript/mastodon/selectors/index.js b/app/javascript/mastodon/selectors/index.js
index 6d787272ea..d1523abc44 100644
--- a/app/javascript/mastodon/selectors/index.js
+++ b/app/javascript/mastodon/selectors/index.js
@@ -91,25 +91,6 @@ export const makeGetReport = () => createSelector([
   (state, _, targetAccountId) => state.getIn(['accounts', targetAccountId]),
 ], (base, targetAccount) => base.set('target_account', targetAccount));
 
-export const getAccountGallery = createSelector([
-  (state, id) => state.getIn(['timelines', `account:${id}:media`, 'items'], ImmutableList()),
-  state  => state.get('statuses'),
-  (state, id) => state.getIn(['accounts', id]),
-], (statusIds, statuses, account) => {
-  let medias = ImmutableList();
-
-  statusIds.forEach(statusId => {
-    let status = statuses.get(statusId);
-
-    if (status) {
-      status = status.set('account', account);
-      medias = medias.concat(status.get('media_attachments').map(media => media.set('status', status)));
-    }
-  });
-
-  return medias;
-});
-
 export const getStatusList = createSelector([
   (state, type) => state.getIn(['status_lists', type, 'items']),
 ], (items) => items.toList());
diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss
index 75c38d91f2..5e44553da8 100644
--- a/app/javascript/styles/mastodon/components.scss
+++ b/app/javascript/styles/mastodon/components.scss
@@ -7398,7 +7398,8 @@ a.status-card {
     border-radius: 0;
   }
 
-  .load-more {
+  .load-more,
+  .timeline-hint {
     grid-column: span 3;
   }
 }