diff --git a/FEDERATION.md b/FEDERATION.md
index cd1957cbd1..e3721d7241 100644
--- a/FEDERATION.md
+++ b/FEDERATION.md
@@ -27,4 +27,5 @@ More information on HTTP Signatures, as well as examples, can be found here: htt
 
 - Linked-Data Signatures: https://docs.joinmastodon.org/spec/security/#ld
 - Bearcaps: https://docs.joinmastodon.org/spec/bearcaps/
-- Followers collection synchronization: https://git.activitypub.dev/ActivityPubDev/Fediverse-Enhancement-Proposals/src/branch/main/feps/fep-8fcf.md
+- Followers collection synchronization: https://codeberg.org/fediverse/fep/src/branch/main/fep/8fcf/fep-8fcf.md
+- Search indexing consent for actors: https://codeberg.org/fediverse/fep/src/branch/main/fep/5feb/fep-5feb.md
diff --git a/Gemfile.lock b/Gemfile.lock
index 642be3f9ac..4ba0a09946 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -520,7 +520,7 @@ GEM
     pastel (0.8.0)
       tty-color (~> 0.5)
     pg (1.5.4)
-    pghero (3.3.3)
+    pghero (3.3.4)
       activerecord (>= 6)
     posix-spawn (0.3.15)
     premailer (1.21.0)
diff --git a/SECURITY.md b/SECURITY.md
index 7a79d9f91d..9a08c4e251 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -13,9 +13,9 @@ A "vulnerability in Mastodon" is a vulnerability in the code distributed through
 
 ## Supported Versions
 
-| Version | Supported |
-| ------- | --------- |
-| 4.1.x   | Yes       |
-| 4.0.x   | Yes       |
-| 3.5.x   | Yes       |
-| < 3.5   | No        |
+| Version | Supported        |
+| ------- | ---------------- |
+| 4.1.x   | Yes              |
+| 4.0.x   | Until 2023-10-31 |
+| 3.5.x   | Until 2023-12-31 |
+| < 3.5   | No               |
diff --git a/app/controllers/concerns/signature_verification.rb b/app/controllers/concerns/signature_verification.rb
index b0c4fff8bc..f0a344f1c9 100644
--- a/app/controllers/concerns/signature_verification.rb
+++ b/app/controllers/concerns/signature_verification.rb
@@ -119,7 +119,7 @@ module SignatureVerification
   private
 
   def fail_with!(message, **options)
-    Rails.logger.warn { "Signature verification failed: #{message}" }
+    Rails.logger.debug { "Signature verification failed: #{message}" }
 
     @signature_verification_failure_reason = { error: message }.merge(options)
     @signed_request_actor = nil
diff --git a/app/javascript/mastodon/actions/search.js b/app/javascript/mastodon/actions/search.js
index 94e7f2ed75..21fd540768 100644
--- a/app/javascript/mastodon/actions/search.js
+++ b/app/javascript/mastodon/actions/search.js
@@ -37,17 +37,17 @@ export function submitSearch(type) {
     const signedIn = !!getState().getIn(['meta', 'me']);
 
     if (value.length === 0) {
-      dispatch(fetchSearchSuccess({ accounts: [], statuses: [], hashtags: [] }, ''));
+      dispatch(fetchSearchSuccess({ accounts: [], statuses: [], hashtags: [] }, '', type));
       return;
     }
 
-    dispatch(fetchSearchRequest());
+    dispatch(fetchSearchRequest(type));
 
     api(getState).get('/api/v2/search', {
       params: {
         q: value,
         resolve: signedIn,
-        limit: 5,
+        limit: 11,
         type,
       },
     }).then(response => {
@@ -59,7 +59,7 @@ export function submitSearch(type) {
         dispatch(importFetchedStatuses(response.data.statuses));
       }
 
-      dispatch(fetchSearchSuccess(response.data, value));
+      dispatch(fetchSearchSuccess(response.data, value, type));
       dispatch(fetchRelationships(response.data.accounts.map(item => item.id)));
     }).catch(error => {
       dispatch(fetchSearchFail(error));
@@ -67,16 +67,18 @@ export function submitSearch(type) {
   };
 }
 
-export function fetchSearchRequest() {
+export function fetchSearchRequest(searchType) {
   return {
     type: SEARCH_FETCH_REQUEST,
+    searchType,
   };
 }
 
-export function fetchSearchSuccess(results, searchTerm) {
+export function fetchSearchSuccess(results, searchTerm, searchType) {
   return {
     type: SEARCH_FETCH_SUCCESS,
     results,
+    searchType,
     searchTerm,
   };
 }
@@ -90,15 +92,16 @@ export function fetchSearchFail(error) {
 
 export const expandSearch = type => (dispatch, getState) => {
   const value  = getState().getIn(['search', 'value']);
-  const offset = getState().getIn(['search', 'results', type]).size;
+  const offset = getState().getIn(['search', 'results', type]).size - 1;
 
-  dispatch(expandSearchRequest());
+  dispatch(expandSearchRequest(type));
 
   api(getState).get('/api/v2/search', {
     params: {
       q: value,
       type,
       offset,
+      limit: 11,
     },
   }).then(({ data }) => {
     if (data.accounts) {
@@ -116,8 +119,9 @@ export const expandSearch = type => (dispatch, getState) => {
   });
 };
 
-export const expandSearchRequest = () => ({
+export const expandSearchRequest = (searchType) => ({
   type: SEARCH_EXPAND_REQUEST,
+  searchType,
 });
 
 export const expandSearchSuccess = (results, searchTerm, searchType) => ({
diff --git a/app/javascript/mastodon/components/animated_number.tsx b/app/javascript/mastodon/components/animated_number.tsx
index 05a7e01898..e98e30b242 100644
--- a/app/javascript/mastodon/components/animated_number.tsx
+++ b/app/javascript/mastodon/components/animated_number.tsx
@@ -6,21 +6,10 @@ import { reduceMotion } from '../initial_state';
 
 import { ShortNumber } from './short_number';
 
-const obfuscatedCount = (count: number) => {
-  if (count < 0) {
-    return 0;
-  } else if (count <= 1) {
-    return count;
-  } else {
-    return '1+';
-  }
-};
-
 interface Props {
   value: number;
-  obfuscate?: boolean;
 }
-export const AnimatedNumber: React.FC<Props> = ({ value, obfuscate }) => {
+export const AnimatedNumber: React.FC<Props> = ({ value }) => {
   const [previousValue, setPreviousValue] = useState(value);
   const [direction, setDirection] = useState<1 | -1>(1);
 
@@ -36,11 +25,7 @@ export const AnimatedNumber: React.FC<Props> = ({ value, obfuscate }) => {
   );
 
   if (reduceMotion) {
-    return obfuscate ? (
-      <>{obfuscatedCount(value)}</>
-    ) : (
-      <ShortNumber value={value} />
-    );
+    return <ShortNumber value={value} />;
   }
 
   const styles = [
@@ -67,11 +52,7 @@ export const AnimatedNumber: React.FC<Props> = ({ value, obfuscate }) => {
                 transform: `translateY(${style.y * 100}%)`,
               }}
             >
-              {obfuscate ? (
-                obfuscatedCount(data as number)
-              ) : (
-                <ShortNumber value={data as number} />
-              )}
+              <ShortNumber value={data as number} />
             </span>
           ))}
         </span>
diff --git a/app/javascript/mastodon/components/icon_button.tsx b/app/javascript/mastodon/components/icon_button.tsx
index 9dbee2cc24..da6f19e9ea 100644
--- a/app/javascript/mastodon/components/icon_button.tsx
+++ b/app/javascript/mastodon/components/icon_button.tsx
@@ -24,7 +24,6 @@ interface Props {
   overlay: boolean;
   tabIndex: number;
   counter?: number;
-  obfuscateCount?: boolean;
   href?: string;
   ariaHidden: boolean;
 }
@@ -105,7 +104,6 @@ export class IconButton extends PureComponent<Props, States> {
       tabIndex,
       title,
       counter,
-      obfuscateCount,
       href,
       ariaHidden,
     } = this.props;
@@ -131,7 +129,7 @@ export class IconButton extends PureComponent<Props, States> {
         <Icon id={icon} fixedWidth aria-hidden='true' />{' '}
         {typeof counter !== 'undefined' && (
           <span className='icon-button__counter'>
-            <AnimatedNumber value={counter} obfuscate={obfuscateCount} />
+            <AnimatedNumber value={counter} />
           </span>
         )}
       </>
diff --git a/app/javascript/mastodon/components/status_action_bar.jsx b/app/javascript/mastodon/components/status_action_bar.jsx
index 5d441c10c2..13e6dff74d 100644
--- a/app/javascript/mastodon/components/status_action_bar.jsx
+++ b/app/javascript/mastodon/components/status_action_bar.jsx
@@ -362,7 +362,7 @@ class StatusActionBar extends ImmutablePureComponent {
 
     return (
       <div className='status__action-bar'>
-        <IconButton className='status__action-bar__button' title={replyTitle} icon={status.get('in_reply_to_account_id') === status.getIn(['account', 'id']) ? 'reply' : replyIcon} onClick={this.handleReplyClick} counter={status.get('replies_count')} obfuscateCount />
+        <IconButton className='status__action-bar__button' title={replyTitle} icon={status.get('in_reply_to_account_id') === status.getIn(['account', 'id']) ? 'reply' : replyIcon} onClick={this.handleReplyClick} counter={status.get('replies_count')} />
         <IconButton className={classNames('status__action-bar__button', { reblogPrivate })} disabled={!publicStatus && !reblogPrivate} active={status.get('reblogged')} title={reblogTitle} icon='retweet' onClick={this.handleReblogClick} counter={withCounters ? status.get('reblogs_count') : undefined} />
         <IconButton className='status__action-bar__button star-icon' animate active={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} counter={withCounters ? status.get('favourites_count') : undefined} />
         <IconButton className='status__action-bar__button bookmark-icon' disabled={!signedIn} active={status.get('bookmarked')} title={intl.formatMessage(messages.bookmark)} icon='bookmark' onClick={this.handleBookmarkClick} />
diff --git a/app/javascript/mastodon/features/compose/components/search.jsx b/app/javascript/mastodon/features/compose/components/search.jsx
index 848b812632..b90e9fe060 100644
--- a/app/javascript/mastodon/features/compose/components/search.jsx
+++ b/app/javascript/mastodon/features/compose/components/search.jsx
@@ -53,6 +53,7 @@ class Search extends PureComponent {
     { label: <><mark>before:</mark> <FormattedMessage id='search_popout.specific_date' defaultMessage='specific date' /></>, action: e => { e.preventDefault(); this._insertText('before:') } },
     { label: <><mark>during:</mark> <FormattedMessage id='search_popout.specific_date' defaultMessage='specific date' /></>, action: e => { e.preventDefault(); this._insertText('during:') } },
     { label: <><mark>after:</mark> <FormattedMessage id='search_popout.specific_date' defaultMessage='specific date' /></>, action: e => { e.preventDefault(); this._insertText('after:') } },
+    { label: <><mark>in:</mark> <FormattedList type='disjunction' value={['all', 'library']} /></>, action: e => { e.preventDefault(); this._insertText('in:') } }
   ];
 
   setRef = c => {
diff --git a/app/javascript/mastodon/features/compose/components/search_results.jsx b/app/javascript/mastodon/features/compose/components/search_results.jsx
index b11ac478a4..346d9b18aa 100644
--- a/app/javascript/mastodon/features/compose/components/search_results.jsx
+++ b/app/javascript/mastodon/features/compose/components/search_results.jsx
@@ -1,46 +1,36 @@
 import PropTypes from 'prop-types';
 
-import { FormattedMessage, defineMessages, injectIntl } from 'react-intl';
+import { FormattedMessage } from 'react-intl';
 
 import ImmutablePropTypes from 'react-immutable-proptypes';
 import ImmutablePureComponent from 'react-immutable-pure-component';
 
 import { Icon }  from 'mastodon/components/icon';
 import { LoadMore } from 'mastodon/components/load_more';
+import { SearchSection } from 'mastodon/features/explore/components/search_section';
 
 import { ImmutableHashtag as Hashtag } from '../../../components/hashtag';
 import AccountContainer from '../../../containers/account_container';
 import StatusContainer from '../../../containers/status_container';
-import { searchEnabled } from '../../../initial_state';
 
-const messages = defineMessages({
-  dismissSuggestion: { id: 'suggestions.dismiss', defaultMessage: 'Dismiss suggestion' },
-});
+const INITIAL_PAGE_LIMIT = 10;
+
+const withoutLastResult = list => {
+  if (list.size > INITIAL_PAGE_LIMIT && list.size % INITIAL_PAGE_LIMIT === 1) {
+    return list.skipLast(1);
+  } else {
+    return list;
+  }
+};
 
 class SearchResults extends ImmutablePureComponent {
 
   static propTypes = {
     results: ImmutablePropTypes.map.isRequired,
-    suggestions: ImmutablePropTypes.list.isRequired,
-    fetchSuggestions: PropTypes.func.isRequired,
     expandSearch: PropTypes.func.isRequired,
-    dismissSuggestion: PropTypes.func.isRequired,
     searchTerm: PropTypes.string,
-    intl: PropTypes.object.isRequired,
   };
 
-  componentDidMount () {
-    if (this.props.searchTerm === '') {
-      this.props.fetchSuggestions();
-    }
-  }
-
-  componentDidUpdate () {
-    if (this.props.searchTerm === '') {
-      this.props.fetchSuggestions();
-    }
-  }
-
   handleLoadMoreAccounts = () => this.props.expandSearch('accounts');
 
   handleLoadMoreStatuses = () => this.props.expandSearch('statuses');
@@ -48,97 +38,52 @@ class SearchResults extends ImmutablePureComponent {
   handleLoadMoreHashtags = () => this.props.expandSearch('hashtags');
 
   render () {
-    const { intl, results, suggestions, dismissSuggestion, searchTerm } = this.props;
-
-    if (searchTerm === '' && !suggestions.isEmpty()) {
-      return (
-        <div className='search-results'>
-          <div className='trends'>
-            <div className='trends__header'>
-              <Icon id='user-plus' fixedWidth />
-              <FormattedMessage id='suggestions.header' defaultMessage='You might be interested in…' />
-            </div>
-
-            {suggestions && suggestions.map(suggestion => (
-              <AccountContainer
-                key={suggestion.get('account')}
-                id={suggestion.get('account')}
-                actionIcon={suggestion.get('source') === 'past_interactions' ? 'times' : null}
-                actionTitle={suggestion.get('source') === 'past_interactions' ? intl.formatMessage(messages.dismissSuggestion) : null}
-                onActionClick={dismissSuggestion}
-              />
-            ))}
-          </div>
-        </div>
-      );
-    }
+    const { results } = this.props;
 
     let accounts, statuses, hashtags;
-    let count = 0;
 
     if (results.get('accounts') && results.get('accounts').size > 0) {
-      count   += results.get('accounts').size;
       accounts = (
-        <div className='search-results__section'>
-          <h5><Icon id='users' fixedWidth /><FormattedMessage id='search_results.accounts' defaultMessage='Profiles' /></h5>
-
-          {results.get('accounts').map(accountId => <AccountContainer key={accountId} id={accountId} />)}
-
-          {results.get('accounts').size >= 5 && <LoadMore visible onClick={this.handleLoadMoreAccounts} />}
-        </div>
-      );
-    }
-
-    if (results.get('statuses') && results.get('statuses').size > 0) {
-      count   += results.get('statuses').size;
-      statuses = (
-        <div className='search-results__section'>
-          <h5><Icon id='quote-right' fixedWidth /><FormattedMessage id='search_results.statuses' defaultMessage='Posts' /></h5>
-
-          {results.get('statuses').map(statusId => <StatusContainer key={statusId} id={statusId} />)}
-
-          {results.get('statuses').size >= 5 && <LoadMore visible onClick={this.handleLoadMoreStatuses} />}
-        </div>
-      );
-    } else if(results.get('statuses') && results.get('statuses').size === 0 && !searchEnabled && !(searchTerm.startsWith('@') || searchTerm.startsWith('#') || searchTerm.includes(' '))) {
-      statuses = (
-        <div className='search-results__section'>
-          <h5><Icon id='quote-right' fixedWidth /><FormattedMessage id='search_results.statuses' defaultMessage='Posts' /></h5>
-
-          <div className='search-results__info'>
-            <FormattedMessage id='search_results.statuses_fts_disabled' defaultMessage='Searching posts by their content is not enabled on this Mastodon server.' />
-          </div>
-        </div>
+        <SearchSection title={<><Icon id='users' fixedWidth /><FormattedMessage id='search_results.accounts' defaultMessage='Profiles' /></>}>
+          {withoutLastResult(results.get('accounts')).map(accountId => <AccountContainer key={accountId} id={accountId} />)}
+          {(results.get('accounts').size > INITIAL_PAGE_LIMIT && results.get('accounts').size % INITIAL_PAGE_LIMIT === 1) && <LoadMore visible onClick={this.handleLoadMoreAccounts} />}
+        </SearchSection>
       );
     }
 
     if (results.get('hashtags') && results.get('hashtags').size > 0) {
-      count += results.get('hashtags').size;
       hashtags = (
-        <div className='search-results__section'>
-          <h5><Icon id='hashtag' fixedWidth /><FormattedMessage id='search_results.hashtags' defaultMessage='Hashtags' /></h5>
-
-          {results.get('hashtags').map(hashtag => <Hashtag key={hashtag.get('name')} hashtag={hashtag} />)}
-
-          {results.get('hashtags').size >= 5 && <LoadMore visible onClick={this.handleLoadMoreHashtags} />}
-        </div>
+        <SearchSection title={<><Icon id='hashtag' fixedWidth /><FormattedMessage id='search_results.hashtags' defaultMessage='Hashtags' /></>}>
+          {withoutLastResult(results.get('hashtags')).map(hashtag => <Hashtag key={hashtag.get('name')} hashtag={hashtag} />)}
+          {(results.get('hashtags').size > INITIAL_PAGE_LIMIT && results.get('hashtags').size % INITIAL_PAGE_LIMIT === 1) && <LoadMore visible onClick={this.handleLoadMoreHashtags} />}
+        </SearchSection>
       );
     }
 
+    if (results.get('statuses') && results.get('statuses').size > 0) {
+      statuses = (
+        <SearchSection title={<><Icon id='quote-right' fixedWidth /><FormattedMessage id='search_results.statuses' defaultMessage='Posts' /></>}>
+          {withoutLastResult(results.get('statuses')).map(statusId => <StatusContainer key={statusId} id={statusId} />)}
+          {(results.get('statuses').size > INITIAL_PAGE_LIMIT && results.get('statuses').size % INITIAL_PAGE_LIMIT === 1) && <LoadMore visible onClick={this.handleLoadMoreStatuses} />}
+        </SearchSection>
+      );
+    }
+
+
     return (
       <div className='search-results'>
         <div className='search-results__header'>
           <Icon id='search' fixedWidth />
-          <FormattedMessage id='search_results.total' defaultMessage='{count, plural, one {# result} other {# results}}' values={{ count }} />
+          <FormattedMessage id='explore.search_results' defaultMessage='Search results' />
         </div>
 
         {accounts}
-        {statuses}
         {hashtags}
+        {statuses}
       </div>
     );
   }
 
 }
 
-export default injectIntl(SearchResults);
+export default SearchResults;
diff --git a/app/javascript/mastodon/features/explore/components/search_section.jsx b/app/javascript/mastodon/features/explore/components/search_section.jsx
new file mode 100644
index 0000000000..c84e3f7cef
--- /dev/null
+++ b/app/javascript/mastodon/features/explore/components/search_section.jsx
@@ -0,0 +1,20 @@
+import PropTypes from 'prop-types';
+
+import { FormattedMessage } from 'react-intl';
+
+export const SearchSection = ({ title, onClickMore, children }) => (
+  <div className='search-results__section'>
+    <div className='search-results__section__header'>
+      <h3>{title}</h3>
+      {onClickMore && <button onClick={onClickMore}><FormattedMessage id='search_results.see_all' defaultMessage='See all' /></button>}
+    </div>
+
+    {children}
+  </div>
+);
+
+SearchSection.propTypes = {
+  title: PropTypes.node.isRequired,
+  onClickMore: PropTypes.func,
+  children: PropTypes.children,
+};
\ No newline at end of file
diff --git a/app/javascript/mastodon/features/explore/results.jsx b/app/javascript/mastodon/features/explore/results.jsx
index 7d1ce69ad0..1b9d2f30db 100644
--- a/app/javascript/mastodon/features/explore/results.jsx
+++ b/app/javascript/mastodon/features/explore/results.jsx
@@ -9,13 +9,15 @@ import { List as ImmutableList } from 'immutable';
 import ImmutablePropTypes from 'react-immutable-proptypes';
 import { connect } from 'react-redux';
 
-import { expandSearch } from 'mastodon/actions/search';
+import { submitSearch, expandSearch } from 'mastodon/actions/search';
 import { ImmutableHashtag as Hashtag } from 'mastodon/components/hashtag';
-import { LoadMore } from 'mastodon/components/load_more';
-import { LoadingIndicator } from 'mastodon/components/loading_indicator';
+import { Icon } from 'mastodon/components/icon';
+import ScrollableList from 'mastodon/components/scrollable_list';
 import Account from 'mastodon/containers/account_container';
 import Status from 'mastodon/containers/status_container';
 
+import { SearchSection } from './components/search_section';
+
 const messages = defineMessages({
   title: { id: 'search_results.title', defaultMessage: 'Search for {q}' },
 });
@@ -24,85 +26,175 @@ const mapStateToProps = state => ({
   isLoading: state.getIn(['search', 'isLoading']),
   results: state.getIn(['search', 'results']),
   q: state.getIn(['search', 'searchTerm']),
+  submittedType: state.getIn(['search', 'type']),
 });
 
-const appendLoadMore = (id, list, onLoadMore) => {
-  if (list.size >= 5) {
-    return list.push(<LoadMore key={`${id}-load-more`} visible onClick={onLoadMore} />);
+const INITIAL_PAGE_LIMIT = 10;
+const INITIAL_DISPLAY = 4;
+
+const hidePeek = list => {
+  if (list.size > INITIAL_PAGE_LIMIT && list.size % INITIAL_PAGE_LIMIT === 1) {
+    return list.skipLast(1);
   } else {
     return list;
   }
 };
 
-const renderAccounts = (results, onLoadMore) => appendLoadMore('accounts', results.get('accounts', ImmutableList()).map(item => (
-  <Account key={`account-${item}`} id={item} />
-)), onLoadMore);
+const renderAccounts = accounts => hidePeek(accounts).map(id => (
+  <Account key={id} id={id} />
+));
 
-const renderHashtags = (results, onLoadMore) => appendLoadMore('hashtags', results.get('hashtags', ImmutableList()).map(item => (
-  <Hashtag key={`tag-${item.get('name')}`} hashtag={item} />
-)), onLoadMore);
+const renderHashtags = hashtags => hidePeek(hashtags).map(hashtag => (
+  <Hashtag key={hashtag.get('name')} hashtag={hashtag} />
+));
 
-const renderStatuses = (results, onLoadMore) => appendLoadMore('statuses', results.get('statuses', ImmutableList()).map(item => (
-  <Status key={`status-${item}`} id={item} />
-)), onLoadMore);
+const renderStatuses = statuses => hidePeek(statuses).map(id => (
+  <Status key={id} id={id} />
+));
 
 class Results extends PureComponent {
 
   static propTypes = {
-    results: ImmutablePropTypes.map,
+    results: ImmutablePropTypes.contains({
+      accounts: ImmutablePropTypes.orderedSet,
+      statuses: ImmutablePropTypes.orderedSet,
+      hashtags: ImmutablePropTypes.orderedSet,
+    }),
     isLoading: PropTypes.bool,
     multiColumn: PropTypes.bool,
     dispatch: PropTypes.func.isRequired,
     q: PropTypes.string,
     intl: PropTypes.object,
+    submittedType: PropTypes.oneOf(['accounts', 'statuses', 'hashtags']),
   };
 
   state = {
-    type: 'all',
+    type: this.props.submittedType || 'all',
   };
 
-  handleSelectAll = () => this.setState({ type: 'all' });
-  handleSelectAccounts = () => this.setState({ type: 'accounts' });
-  handleSelectHashtags = () => this.setState({ type: 'hashtags' });
-  handleSelectStatuses = () => this.setState({ type: 'statuses' });
-  handleLoadMoreAccounts = () => this.loadMore('accounts');
-  handleLoadMoreStatuses = () => this.loadMore('statuses');
-  handleLoadMoreHashtags = () => this.loadMore('hashtags');
+  static getDerivedStateFromProps(props, state) {
+    if (props.submittedType !== state.type) {
+      return {
+        type: props.submittedType || 'all',
+      };
+    }
 
-  loadMore (type) {
+    return null;
+  };
+
+  handleSelectAll = () => {
+    const { submittedType, dispatch } = this.props;
+
+    // If we originally searched for a specific type, we need to resubmit
+    // the query to get all types of results
+    if (submittedType) {
+      dispatch(submitSearch());
+    }
+
+    this.setState({ type: 'all' });
+  };
+
+  handleSelectAccounts = () => {
+    const { submittedType, dispatch } = this.props;
+
+    // If we originally searched for something else (but not everything),
+    // we need to resubmit the query for this specific type
+    if (submittedType !== 'accounts') {
+      dispatch(submitSearch('accounts'));
+    }
+
+    this.setState({ type: 'accounts' });
+  };
+
+  handleSelectHashtags = () => {
+    const { submittedType, dispatch } = this.props;
+
+    // If we originally searched for something else (but not everything),
+    // we need to resubmit the query for this specific type
+    if (submittedType !== 'hashtags') {
+      dispatch(submitSearch('hashtags'));
+    }
+
+    this.setState({ type: 'hashtags' });
+  }
+
+  handleSelectStatuses = () => {
+    const { submittedType, dispatch } = this.props;
+
+    // If we originally searched for something else (but not everything),
+    // we need to resubmit the query for this specific type
+    if (submittedType !== 'statuses') {
+      dispatch(submitSearch('statuses'));
+    }
+
+    this.setState({ type: 'statuses' });
+  }
+
+  handleLoadMoreAccounts = () => this._loadMore('accounts');
+  handleLoadMoreStatuses = () => this._loadMore('statuses');
+  handleLoadMoreHashtags = () => this._loadMore('hashtags');
+
+  _loadMore (type) {
     const { dispatch } = this.props;
     dispatch(expandSearch(type));
   }
 
+  handleLoadMore = () => {
+    const { type } = this.state;
+
+    if (type !== 'all') {
+      this._loadMore(type);
+    }
+  };
+
   render () {
     const { intl, isLoading, q, results } = this.props;
     const { type } = this.state;
 
-    let filteredResults = ImmutableList();
+    // We request 1 more result than we display so we can tell if there'd be a next page
+    const hasMore = type !== 'all' ? results.get(type, ImmutableList()).size > INITIAL_PAGE_LIMIT && results.get(type).size % INITIAL_PAGE_LIMIT === 1 : false;
+
+    let filteredResults;
 
     if (!isLoading) {
+      const accounts = results.get('accounts', ImmutableList());
+      const hashtags = results.get('hashtags', ImmutableList());
+      const statuses = results.get('statuses', ImmutableList());
+
       switch(type) {
       case 'all':
-        filteredResults = filteredResults.concat(renderAccounts(results, this.handleLoadMoreAccounts), renderHashtags(results, this.handleLoadMoreHashtags), renderStatuses(results, this.handleLoadMoreStatuses));
+        filteredResults = (accounts.size + hashtags.size + statuses.size) > 0 ? (
+          <>
+            {accounts.size > 0 && (
+              <SearchSection key='accounts' title={<><Icon id='users' fixedWidth /><FormattedMessage id='search_results.accounts' defaultMessage='Profiles' /></>} onClickMore={this.handleLoadMoreAccounts}>
+                {accounts.take(INITIAL_DISPLAY).map(id => <Account key={id} id={id} />)}
+              </SearchSection>
+            )}
+
+            {hashtags.size > 0 && (
+              <SearchSection key='hashtags' title={<><Icon id='hashtag' fixedWidth /><FormattedMessage id='search_results.hashtags' defaultMessage='Hashtags' /></>} onClickMore={this.handleLoadMoreHashtags}>
+                {hashtags.take(INITIAL_DISPLAY).map(hashtag => <Hashtag key={hashtag.get('name')} hashtag={hashtag} />)}
+              </SearchSection>
+            )}
+
+            {statuses.size > 0 && (
+              <SearchSection key='statuses' title={<><Icon id='quote-right' fixedWidth /><FormattedMessage id='search_results.statuses' defaultMessage='Posts' /></>} onClickMore={this.handleLoadMoreStatuses}>
+                {statuses.take(INITIAL_DISPLAY).map(id => <Status key={id} id={id} />)}
+              </SearchSection>
+            )}
+          </>
+        ) : [];
         break;
       case 'accounts':
-        filteredResults = filteredResults.concat(renderAccounts(results, this.handleLoadMoreAccounts));
+        filteredResults = renderAccounts(accounts);
         break;
       case 'hashtags':
-        filteredResults = filteredResults.concat(renderHashtags(results, this.handleLoadMoreHashtags));
+        filteredResults = renderHashtags(hashtags);
         break;
       case 'statuses':
-        filteredResults = filteredResults.concat(renderStatuses(results, this.handleLoadMoreStatuses));
+        filteredResults = renderStatuses(statuses);
         break;
       }
-
-      if (filteredResults.size === 0) {
-        filteredResults = (
-          <div className='empty-column-indicator'>
-            <FormattedMessage id='search_results.nothing_found' defaultMessage='Could not find anything for these search terms' />
-          </div>
-        );
-      }
     }
 
     return (
@@ -115,7 +207,16 @@ class Results extends PureComponent {
         </div>
 
         <div className='explore__search-results'>
-          {isLoading ? <LoadingIndicator /> : filteredResults}
+          <ScrollableList
+            scrollKey='search-results'
+            isLoading={isLoading}
+            onLoadMore={this.handleLoadMore}
+            hasMore={hasMore}
+            emptyMessage={<FormattedMessage id='search_results.nothing_found' defaultMessage='Could not find anything for these search terms' />}
+            bindToDocument
+          >
+            {filteredResults}
+          </ScrollableList>
         </div>
 
         <Helmet>
diff --git a/app/javascript/mastodon/features/picture_in_picture/components/footer.jsx b/app/javascript/mastodon/features/picture_in_picture/components/footer.jsx
index 0f3244b9c0..587f1f7836 100644
--- a/app/javascript/mastodon/features/picture_in_picture/components/footer.jsx
+++ b/app/javascript/mastodon/features/picture_in_picture/components/footer.jsx
@@ -194,7 +194,7 @@ class Footer extends ImmutablePureComponent {
 
     return (
       <div className='picture-in-picture__footer'>
-        <IconButton className='status__action-bar-button' title={replyTitle} icon={status.get('in_reply_to_account_id') === status.getIn(['account', 'id']) ? 'reply' : replyIcon} onClick={this.handleReplyClick} counter={status.get('replies_count')} obfuscateCount />
+        <IconButton className='status__action-bar-button' title={replyTitle} icon={status.get('in_reply_to_account_id') === status.getIn(['account', 'id']) ? 'reply' : replyIcon} onClick={this.handleReplyClick} counter={status.get('replies_count')} />
         <IconButton className={classNames('status__action-bar-button', { reblogPrivate })} disabled={!publicStatus && !reblogPrivate}  active={status.get('reblogged')} title={reblogTitle} icon='retweet' onClick={this.handleReblogClick} counter={status.get('reblogs_count')} />
         <IconButton className='status__action-bar-button star-icon' animate active={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} counter={status.get('favourites_count')} />
         {withOpenButton && <IconButton className='status__action-bar-button' title={intl.formatMessage(messages.open)} icon='external-link' onClick={this.handleOpenClick} href={`/@${status.getIn(['account', 'acct'])}/${status.get('id')}`} />}
diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json
index 13cddba723..2a99e8ebfd 100644
--- a/app/javascript/mastodon/locales/en.json
+++ b/app/javascript/mastodon/locales/en.json
@@ -600,10 +600,9 @@
   "search_results.all": "All",
   "search_results.hashtags": "Hashtags",
   "search_results.nothing_found": "Could not find anything for these search terms",
+  "search_results.see_all": "See all",
   "search_results.statuses": "Posts",
-  "search_results.statuses_fts_disabled": "Searching posts by their content is not enabled on this Mastodon server.",
   "search_results.title": "Search for {q}",
-  "search_results.total": "{count, number} {count, plural, one {result} other {results}}",
   "server_banner.about_active_users": "People using this server during the last 30 days (Monthly Active Users)",
   "server_banner.active_users": "active users",
   "server_banner.administered_by": "Administered by:",
@@ -675,8 +674,6 @@
   "subscribed_languages.lead": "Only posts in selected languages will appear on your home and list timelines after the change. Select none to receive posts in all languages.",
   "subscribed_languages.save": "Save changes",
   "subscribed_languages.target": "Change subscribed languages for {target}",
-  "suggestions.dismiss": "Dismiss suggestion",
-  "suggestions.header": "You might be interested in…",
   "tabs_bar.home": "Home",
   "tabs_bar.notifications": "Notifications",
   "time_remaining.days": "{number, plural, one {# day} other {# days}} left",
diff --git a/app/javascript/mastodon/reducers/search.js b/app/javascript/mastodon/reducers/search.js
index ccef314031..c81d7ff3c8 100644
--- a/app/javascript/mastodon/reducers/search.js
+++ b/app/javascript/mastodon/reducers/search.js
@@ -1,4 +1,4 @@
-import { Map as ImmutableMap, List as ImmutableList, OrderedSet as ImmutableOrderedSet, fromJS } from 'immutable';
+import { Map as ImmutableMap, OrderedSet as ImmutableOrderedSet, fromJS } from 'immutable';
 
 import {
   COMPOSE_MENTION,
@@ -12,6 +12,7 @@ import {
   SEARCH_FETCH_FAIL,
   SEARCH_FETCH_SUCCESS,
   SEARCH_SHOW,
+  SEARCH_EXPAND_REQUEST,
   SEARCH_EXPAND_SUCCESS,
   SEARCH_RESULT_CLICK,
   SEARCH_RESULT_FORGET,
@@ -24,6 +25,7 @@ const initialState = ImmutableMap({
   results: ImmutableMap(),
   isLoading: false,
   searchTerm: '',
+  type: null,
   recent: ImmutableOrderedSet(),
 });
 
@@ -37,6 +39,8 @@ export default function search(state = initialState, action) {
       map.set('results', ImmutableMap());
       map.set('submitted', false);
       map.set('hidden', false);
+      map.set('searchTerm', '');
+      map.set('type', null);
     });
   case SEARCH_SHOW:
     return state.set('hidden', false);
@@ -48,23 +52,27 @@ export default function search(state = initialState, action) {
     return state.withMutations(map => {
       map.set('isLoading', true);
       map.set('submitted', true);
+      map.set('type', action.searchType);
     });
   case SEARCH_FETCH_FAIL:
     return state.set('isLoading', false);
   case SEARCH_FETCH_SUCCESS:
     return state.withMutations(map => {
       map.set('results', ImmutableMap({
-        accounts: ImmutableList(action.results.accounts.map(item => item.id)),
-        statuses: ImmutableList(action.results.statuses.map(item => item.id)),
-        hashtags: fromJS(action.results.hashtags),
+        accounts: ImmutableOrderedSet(action.results.accounts.map(item => item.id)),
+        statuses: ImmutableOrderedSet(action.results.statuses.map(item => item.id)),
+        hashtags: ImmutableOrderedSet(fromJS(action.results.hashtags)),
       }));
 
       map.set('searchTerm', action.searchTerm);
+      map.set('type', action.searchType);
       map.set('isLoading', false);
     });
+  case SEARCH_EXPAND_REQUEST:
+    return state.set('type', action.searchType);
   case SEARCH_EXPAND_SUCCESS:
-    const results = action.searchType === 'hashtags' ? fromJS(action.results.hashtags) : action.results[action.searchType].map(item => item.id);
-    return state.updateIn(['results', action.searchType], list => list.concat(results));
+    const results = action.searchType === 'hashtags' ? ImmutableOrderedSet(fromJS(action.results.hashtags)) : action.results[action.searchType].map(item => item.id);
+    return state.updateIn(['results', action.searchType], list => list.union(results));
   case SEARCH_RESULT_CLICK:
     return state.update('recent', set => set.add(fromJS(action.result)));
   case SEARCH_RESULT_FORGET:
diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss
index 60d54e42d2..c35bcbd741 100644
--- a/app/javascript/styles/mastodon/components.scss
+++ b/app/javascript/styles/mastodon/components.scss
@@ -5172,22 +5172,39 @@ a.status-card {
 }
 
 .search-results__section {
-  margin-bottom: 5px;
+  border-bottom: 1px solid lighten($ui-base-color, 8%);
 
-  h5 {
+  &:last-child {
+    border-bottom: 0;
+  }
+
+  &__header {
     background: darken($ui-base-color, 4%);
     border-bottom: 1px solid lighten($ui-base-color, 8%);
-    cursor: default;
-    display: flex;
     padding: 15px;
     font-weight: 500;
-    font-size: 16px;
-    color: $dark-text-color;
+    font-size: 14px;
+    color: $darker-text-color;
+    display: flex;
+    justify-content: space-between;
 
-    .fa {
-      display: inline-block;
+    h3 .fa {
       margin-inline-end: 5px;
     }
+
+    button {
+      color: $highlight-text-color;
+      padding: 0;
+      border: 0;
+      background: 0;
+      font: inherit;
+
+      &:hover,
+      &:active,
+      &:focus {
+        text-decoration: underline;
+      }
+    }
   }
 
   .account:last-child,
@@ -6815,14 +6832,14 @@ a.status-card {
 
 .notification__filter-bar,
 .account__section-headline {
-  background: darken($ui-base-color, 4%);
+  background: $ui-base-color;
   border-bottom: 1px solid lighten($ui-base-color, 8%);
   cursor: default;
   display: flex;
   flex-shrink: 0;
 
   button {
-    background: darken($ui-base-color, 4%);
+    background: transparent;
     border: 0;
     margin: 0;
   }
@@ -6842,26 +6859,18 @@ a.status-card {
     white-space: nowrap;
 
     &.active {
-      color: $secondary-text-color;
+      color: $primary-text-color;
 
-      &::before,
-      &::after {
+      &::before {
         display: block;
         content: '';
         position: absolute;
-        bottom: 0;
-        left: 50%;
-        width: 0;
-        height: 0;
-        transform: translateX(-50%);
-        border-style: solid;
-        border-width: 0 10px 10px;
-        border-color: transparent transparent lighten($ui-base-color, 8%);
-      }
-
-      &::after {
         bottom: -1px;
-        border-color: transparent transparent $ui-base-color;
+        left: 0;
+        width: 100%;
+        height: 3px;
+        border-radius: 4px;
+        background: $highlight-text-color;
       }
     }
   }
diff --git a/app/lib/tag_manager.rb b/app/lib/tag_manager.rb
index ea096855f0..f1b61ff4fd 100644
--- a/app/lib/tag_manager.rb
+++ b/app/lib/tag_manager.rb
@@ -29,7 +29,7 @@ class TagManager
     domain = uri.host + (uri.port ? ":#{uri.port}" : '')
 
     TagManager.instance.web_domain?(domain)
-  rescue Addressable::URI::InvalidURIError
+  rescue Addressable::URI::InvalidURIError, IDN::Idna::IdnaError
     false
   end
 end
diff --git a/lib/mastodon/sidekiq_middleware.rb b/lib/mastodon/sidekiq_middleware.rb
index 9832e1a27c..3a747afb63 100644
--- a/lib/mastodon/sidekiq_middleware.rb
+++ b/lib/mastodon/sidekiq_middleware.rb
@@ -16,7 +16,7 @@ class Mastodon::SidekiqMiddleware
   private
 
   def limit_backtrace_and_raise(exception)
-    exception.set_backtrace(exception.backtrace.first(BACKTRACE_LIMIT))
+    exception.set_backtrace(exception.backtrace.first(BACKTRACE_LIMIT)) unless ENV['BACKTRACE']
     raise exception
   end