Browse Source

Change onboarding prompt to follow suggestions carousel in web UI (#28878)

Eugen Rochko 3 months ago
parent
commit
9cdc60ecc6

+ 1 - 8
app/javascript/mastodon/actions/suggestions.js

@@ -54,12 +54,5 @@ export const dismissSuggestion = accountId => (dispatch, getState) => {
     id: accountId,
   });
 
-  api(getState).delete(`/api/v1/suggestions/${accountId}`).then(() => {
-    dispatch(fetchSuggestionsRequest());
-
-    api(getState).get('/api/v2/suggestions').then(response => {
-      dispatch(importFetchedAccounts(response.data.map(x => x.account)));
-      dispatch(fetchSuggestionsSuccess(response.data));
-    }).catch(error => dispatch(fetchSuggestionsFail(error)));
-  }).catch(() => {});
+  api(getState).delete(`/api/v1/suggestions/${accountId}`).catch(() => {});
 };

+ 21 - 0
app/javascript/mastodon/actions/timelines.js

@@ -21,6 +21,10 @@ export const TIMELINE_DISCONNECT   = 'TIMELINE_DISCONNECT';
 export const TIMELINE_CONNECT      = 'TIMELINE_CONNECT';
 
 export const TIMELINE_MARK_AS_PARTIAL = 'TIMELINE_MARK_AS_PARTIAL';
+export const TIMELINE_INSERT          = 'TIMELINE_INSERT';
+
+export const TIMELINE_SUGGESTIONS = 'inline-follow-suggestions';
+export const TIMELINE_GAP = null;
 
 export const loadPending = timeline => ({
   type: TIMELINE_LOAD_PENDING,
@@ -112,9 +116,19 @@ export function expandTimeline(timelineId, path, params = {}, done = noOp) {
 
     api(getState).get(path, { params }).then(response => {
       const next = getLinks(response).refs.find(link => link.rel === 'next');
+
       dispatch(importFetchedStatuses(response.data));
       dispatch(expandTimelineSuccess(timelineId, response.data, next ? next.uri : null, response.status === 206, isLoadingRecent, isLoadingMore, isLoadingRecent && preferPendingItems));
 
+      if (timelineId === 'home' && !isLoadingMore && !isLoadingRecent) {
+        const now = new Date();
+        const fittingIndex = response.data.findIndex(status => now - (new Date(status.created_at)) > 4 * 3600 * 1000);
+
+        if (fittingIndex !== -1) {
+          dispatch(insertIntoTimeline(timelineId, TIMELINE_SUGGESTIONS, Math.max(1, fittingIndex)));
+        }
+      }
+
       if (timelineId === 'home') {
         dispatch(submitMarkers());
       }
@@ -221,3 +235,10 @@ export const markAsPartial = timeline => ({
   type: TIMELINE_MARK_AS_PARTIAL,
   timeline,
 });
+
+export const insertIntoTimeline = (timeline, key, index) => ({
+  type: TIMELINE_INSERT,
+  timeline,
+  index,
+  key,
+});

+ 34 - 19
app/javascript/mastodon/components/status_list.jsx

@@ -5,7 +5,9 @@ import ImmutablePureComponent from 'react-immutable-pure-component';
 
 import { debounce } from 'lodash';
 
+import { TIMELINE_GAP, TIMELINE_SUGGESTIONS } from 'mastodon/actions/timelines';
 import RegenerationIndicator from 'mastodon/components/regeneration_indicator';
+import { InlineFollowSuggestions } from 'mastodon/features/home_timeline/components/inline_follow_suggestions';
 
 import StatusContainer from '../containers/status_container';
 
@@ -91,25 +93,38 @@ export default class StatusList extends ImmutablePureComponent {
     }
 
     let scrollableContent = (isLoading || statusIds.size > 0) ? (
-      statusIds.map((statusId, index) => statusId === null ? (
-        <LoadGap
-          key={'gap:' + statusIds.get(index + 1)}
-          disabled={isLoading}
-          maxId={index > 0 ? statusIds.get(index - 1) : null}
-          onClick={onLoadMore}
-        />
-      ) : (
-        <StatusContainer
-          key={statusId}
-          id={statusId}
-          onMoveUp={this.handleMoveUp}
-          onMoveDown={this.handleMoveDown}
-          contextType={timelineId}
-          scrollKey={this.props.scrollKey}
-          showThread
-          withCounters={this.props.withCounters}
-        />
-      ))
+      statusIds.map((statusId, index) => {
+        switch(statusId) {
+        case TIMELINE_SUGGESTIONS:
+          return (
+            <InlineFollowSuggestions
+              key='inline-follow-suggestions'
+            />
+          );
+        case TIMELINE_GAP:
+          return (
+            <LoadGap
+              key={'gap:' + statusIds.get(index + 1)}
+              disabled={isLoading}
+              maxId={index > 0 ? statusIds.get(index - 1) : null}
+              onClick={onLoadMore}
+            />
+          );
+        default:
+          return (
+            <StatusContainer
+              key={statusId}
+              id={statusId}
+              onMoveUp={this.handleMoveUp}
+              onMoveDown={this.handleMoveDown}
+              contextType={timelineId}
+              scrollKey={this.props.scrollKey}
+              showThread
+              withCounters={this.props.withCounters}
+            />
+          );
+        }
+      })
     ) : null;
 
     if (scrollableContent && featuredStatusIds) {

+ 0 - 46
app/javascript/mastodon/features/home_timeline/components/explore_prompt.tsx

@@ -1,46 +0,0 @@
-import { FormattedMessage } from 'react-intl';
-
-import { Link } from 'react-router-dom';
-
-import background from '@/images/friends-cropped.png';
-import { DismissableBanner } from 'mastodon/components/dismissable_banner';
-
-export const ExplorePrompt = () => (
-  <DismissableBanner id='home.explore_prompt'>
-    <img
-      src={background}
-      alt=''
-      className='dismissable-banner__background-image'
-    />
-
-    <h1>
-      <FormattedMessage
-        id='home.explore_prompt.title'
-        defaultMessage='This is your home base within Mastodon.'
-      />
-    </h1>
-    <p>
-      <FormattedMessage
-        id='home.explore_prompt.body'
-        defaultMessage="Your home feed will have a mix of posts from the hashtags you've chosen to follow, the people you've chosen to follow, and the posts they boost. If that feels too quiet, you may want to:"
-      />
-    </p>
-
-    <div className='dismissable-banner__message__wrapper'>
-      <div className='dismissable-banner__message__actions'>
-        <Link to='/explore' className='button'>
-          <FormattedMessage
-            id='home.actions.go_to_explore'
-            defaultMessage="See what's trending"
-          />
-        </Link>
-        <Link to='/explore/suggestions' className='button button-tertiary'>
-          <FormattedMessage
-            id='home.actions.go_to_suggestions'
-            defaultMessage='Find people to follow'
-          />
-        </Link>
-      </div>
-    </div>
-  </DismissableBanner>
-);

+ 201 - 0
app/javascript/mastodon/features/home_timeline/components/inline_follow_suggestions.jsx

@@ -0,0 +1,201 @@
+import PropTypes from 'prop-types';
+import { useEffect, useCallback, useRef, useState } from 'react';
+
+import { FormattedMessage, useIntl, defineMessages } from 'react-intl';
+
+import { Link } from 'react-router-dom';
+
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { useDispatch, useSelector } from 'react-redux';
+
+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 { Icon } from 'mastodon/components/icon';
+import { IconButton } from 'mastodon/components/icon_button';
+import { VerifiedBadge } from 'mastodon/components/verified_badge';
+
+const messages = defineMessages({
+  follow: { id: 'account.follow', defaultMessage: 'Follow' },
+  unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
+  previous: { id: 'lightbox.previous', defaultMessage: 'Previous' },
+  next: { id: 'lightbox.next', defaultMessage: 'Next' },
+  dismiss: { id: 'follow_suggestions.dismiss', defaultMessage: "Don't show again" },
+});
+
+const Source = ({ id }) => {
+  let label;
+
+  switch (id) {
+  case 'friends_of_friends':
+  case 'similar_to_recently_followed':
+    label = <FormattedMessage id='follow_suggestions.personalized_suggestion' defaultMessage='Personalized suggestion' />;
+    break;
+  case 'featured':
+    label = <FormattedMessage id='follow_suggestions.curated_suggestion' defaultMessage="Editors' Choice" />;
+    break;
+  case 'most_followed':
+  case 'most_interactions':
+    label = <FormattedMessage id='follow_suggestions.popular_suggestion' defaultMessage='Popular suggestion' />;
+    break;
+  }
+
+  return (
+    <div className='inline-follow-suggestions__body__scrollable__card__text-stack__source'>
+      <Icon icon={InfoIcon} />
+      {label}
+    </div>
+  );
+};
+
+Source.propTypes = {
+  id: PropTypes.oneOf(['friends_of_friends', 'similar_to_recently_followed', 'featured', 'most_followed', 'most_interactions']),
+};
+
+const Card = ({ id, source }) => {
+  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));
+  }, [id, dispatch]);
+
+  return (
+    <div className='inline-follow-suggestions__body__scrollable__card'>
+      <IconButton iconComponent={CloseIcon} onClick={handleDismiss} title={intl.formatMessage(messages.dismiss)} />
+
+      <div className='inline-follow-suggestions__body__scrollable__card__avatar'>
+        <Link to={`/@${account.get('acct')}`}><Avatar account={account} size={72} /></Link>
+      </div>
+
+      <div className='inline-follow-suggestions__body__scrollable__card__text-stack'>
+        <Link to={`/@${account.get('acct')}`}><DisplayName account={account} /></Link>
+        {firstVerifiedField ? <VerifiedBadge link={firstVerifiedField.get('value')} /> : <Source id={source.get(0)} />}
+      </div>
+
+      <Button text={intl.formatMessage(following ? messages.unfollow : messages.follow)} onClick={handleFollow} />
+    </div>
+  );
+};
+
+Card.propTypes = {
+  id: PropTypes.string.isRequired,
+  source: ImmutablePropTypes.list,
+};
+
+const DISMISSIBLE_ID = 'home/follow-suggestions';
+
+export const InlineFollowSuggestions = ({ hidden }) => {
+  const intl = useIntl();
+  const dispatch = useDispatch();
+  const suggestions = useSelector(state => state.getIn(['suggestions', 'items']));
+  const isLoading = useSelector(state => state.getIn(['suggestions', 'isLoading']));
+  const dismissed = useSelector(state => state.getIn(['settings', 'dismissed_banners', DISMISSIBLE_ID]));
+  const bodyRef = useRef();
+  const [canScrollLeft, setCanScrollLeft] = useState(false);
+  const [canScrollRight, setCanScrollRight] = useState(true);
+
+  useEffect(() => {
+    dispatch(fetchSuggestions());
+  }, [dispatch]);
+
+  useEffect(() => {
+    if (!bodyRef.current) {
+      return;
+    }
+
+    setCanScrollLeft(bodyRef.current.scrollLeft > 0);
+    setCanScrollRight((bodyRef.current.scrollLeft + bodyRef.current.clientWidth) < bodyRef.current.scrollWidth);
+  }, [setCanScrollRight, setCanScrollLeft, bodyRef, suggestions]);
+
+  const handleLeftNav = useCallback(() => {
+    bodyRef.current.scrollLeft -= 200;
+  }, [bodyRef]);
+
+  const handleRightNav = useCallback(() => {
+    bodyRef.current.scrollLeft += 200;
+  }, [bodyRef]);
+
+  const handleScroll = useCallback(() => {
+    if (!bodyRef.current) {
+      return;
+    }
+
+    setCanScrollLeft(bodyRef.current.scrollLeft > 0);
+    setCanScrollRight((bodyRef.current.scrollLeft + bodyRef.current.clientWidth) < bodyRef.current.scrollWidth);
+  }, [setCanScrollRight, setCanScrollLeft, bodyRef]);
+
+  const handleDismiss = useCallback(() => {
+    dispatch(changeSetting(['dismissed_banners', DISMISSIBLE_ID], true));
+  }, [dispatch]);
+
+  if (dismissed || (!isLoading && suggestions.isEmpty())) {
+    return null;
+  }
+
+  if (hidden) {
+    return (
+      <div className='inline-follow-suggestions' />
+    );
+  }
+
+  return (
+    <div className='inline-follow-suggestions'>
+      <div className='inline-follow-suggestions__header'>
+        <h3><FormattedMessage id='follow_suggestions.who_to_follow' defaultMessage='Who to follow' /></h3>
+
+        <div className='inline-follow-suggestions__header__actions'>
+          <button className='link-button' onClick={handleDismiss}><FormattedMessage id='follow_suggestions.dismiss' defaultMessage="Don't show again" /></button>
+          <Link to='/explore/suggestions' className='link-button'><FormattedMessage id='follow_suggestions.view_all' defaultMessage='View all' /></Link>
+        </div>
+      </div>
+
+      <div className='inline-follow-suggestions__body'>
+        <div className='inline-follow-suggestions__body__scrollable' ref={bodyRef} onScroll={handleScroll}>
+          {suggestions.map(suggestion => (
+            <Card
+              key={suggestion.get('account')}
+              id={suggestion.get('account')}
+              source={suggestion.get('source')}
+            />
+          ))}
+        </div>
+
+        {canScrollLeft && (
+          <button className='inline-follow-suggestions__body__scroll-button left' onClick={handleLeftNav} aria-label={intl.formatMessage(messages.previous)}>
+            <div className='inline-follow-suggestions__body__scroll-button__icon'><Icon icon={ChevronLeftIcon} /></div>
+          </button>
+        )}
+
+        {canScrollRight && (
+          <button className='inline-follow-suggestions__body__scroll-button right' onClick={handleRightNav} aria-label={intl.formatMessage(messages.next)}>
+            <div className='inline-follow-suggestions__body__scroll-button__icon'><Icon icon={ChevronRightIcon} /></div>
+          </button>
+        )}
+      </div>
+    </div>
+  );
+};
+
+InlineFollowSuggestions.propTypes = {
+  hidden: PropTypes.bool,
+};

+ 2 - 49
app/javascript/mastodon/features/home_timeline/index.jsx

@@ -6,8 +6,6 @@ import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
 import classNames from 'classnames';
 import { Helmet } from 'react-helmet';
 
-import { createSelector } from '@reduxjs/toolkit';
-import { List as ImmutableList } from 'immutable';
 import { connect } from 'react-redux';
 
 import CampaignIcon from '@/material-icons/400-24px/campaign.svg?react';
@@ -16,7 +14,7 @@ import { fetchAnnouncements, toggleShowAnnouncements } from 'mastodon/actions/an
 import { IconWithBadge } from 'mastodon/components/icon_with_badge';
 import { NotSignedInIndicator } from 'mastodon/components/not_signed_in_indicator';
 import AnnouncementsContainer from 'mastodon/features/getting_started/containers/announcements_container';
-import { me, criticalUpdatesPending } from 'mastodon/initial_state';
+import { criticalUpdatesPending } from 'mastodon/initial_state';
 
 import { addColumn, removeColumn, moveColumn } from '../../actions/columns';
 import { expandHomeTimeline } from '../../actions/timelines';
@@ -26,7 +24,6 @@ import StatusListContainer from '../ui/containers/status_list_container';
 
 import { ColumnSettings } from './components/column_settings';
 import { CriticalUpdateBanner } from './components/critical_update_banner';
-import { ExplorePrompt } from './components/explore_prompt';
 
 const messages = defineMessages({
   title: { id: 'column.home', defaultMessage: 'Home' },
@@ -34,51 +31,12 @@ const messages = defineMessages({
   hide_announcements: { id: 'home.hide_announcements', defaultMessage: 'Hide announcements' },
 });
 
-const getHomeFeedSpeed = createSelector([
-  state => state.getIn(['timelines', 'home', 'items'], ImmutableList()),
-  state => state.getIn(['timelines', 'home', 'pendingItems'], ImmutableList()),
-  state => state.get('statuses'),
-], (statusIds, pendingStatusIds, statusMap) => {
-  const recentStatusIds = pendingStatusIds.concat(statusIds);
-  const statuses = recentStatusIds.filter(id => id !== null).map(id => statusMap.get(id)).filter(status => status?.get('account') !== me).take(20);
-
-  if (statuses.isEmpty()) {
-    return {
-      gap: 0,
-      newest: new Date(0),
-    };
-  }
-
-  const datetimes = statuses.map(status => status.get('created_at', 0));
-  const oldest = new Date(datetimes.min());
-  const newest = new Date(datetimes.max());
-  const averageGap = (newest - oldest) / (1000 * (statuses.size + 1)); // Average gap between posts on first page in seconds
-
-  return {
-    gap: averageGap,
-    newest,
-  };
-});
-
-const homeTooSlow = createSelector([
-  state => state.getIn(['timelines', 'home', 'isLoading']),
-  state => state.getIn(['timelines', 'home', 'isPartial']),
-  getHomeFeedSpeed,
-], (isLoading, isPartial, speed) =>
-  !isLoading && !isPartial // Only if the home feed has finished loading
-  && (
-    (speed.gap > (30 * 60) // If the average gap between posts is more than 30 minutes
-    || (Date.now() - speed.newest) > (1000 * 3600)) // If the most recent post is from over an hour ago
-  )
-);
-
 const mapStateToProps = state => ({
   hasUnread: state.getIn(['timelines', 'home', 'unread']) > 0,
   isPartial: state.getIn(['timelines', 'home', 'isPartial']),
   hasAnnouncements: !state.getIn(['announcements', 'items']).isEmpty(),
   unreadAnnouncements: state.getIn(['announcements', 'items']).count(item => !item.get('read')),
   showAnnouncements: state.getIn(['announcements', 'show']),
-  tooSlow: homeTooSlow(state),
 });
 
 class HomeTimeline extends PureComponent {
@@ -97,7 +55,6 @@ class HomeTimeline extends PureComponent {
     hasAnnouncements: PropTypes.bool,
     unreadAnnouncements: PropTypes.number,
     showAnnouncements: PropTypes.bool,
-    tooSlow: PropTypes.bool,
   };
 
   handlePin = () => {
@@ -167,7 +124,7 @@ class HomeTimeline extends PureComponent {
   };
 
   render () {
-    const { intl, hasUnread, columnId, multiColumn, tooSlow, hasAnnouncements, unreadAnnouncements, showAnnouncements } = this.props;
+    const { intl, hasUnread, columnId, multiColumn, hasAnnouncements, unreadAnnouncements, showAnnouncements } = this.props;
     const pinned = !!columnId;
     const { signedIn } = this.context.identity;
     const banners = [];
@@ -192,10 +149,6 @@ class HomeTimeline extends PureComponent {
       banners.push(<CriticalUpdateBanner key='critical-update-banner' />);
     }
 
-    if (tooSlow) {
-      banners.push(<ExplorePrompt key='explore-prompt' />);
-    }
-
     return (
       <Column bindToDocument={!multiColumn} ref={this.setRef} label={intl.formatMessage(messages.title)}>
         <ColumnHeader

+ 1 - 1
app/javascript/mastodon/features/ui/containers/status_list_container.js

@@ -14,7 +14,7 @@ const makeGetStatusIds = (pending = false) => createSelector([
   (state)           => state.get('statuses'),
 ], (columnSettings, statusIds, statuses) => {
   return statusIds.filter(id => {
-    if (id === null) return true;
+    if (id === null || id === 'inline-follow-suggestions') return true;
 
     const statusForId = statuses.get(id);
     let showStatus    = true;

+ 6 - 4
app/javascript/mastodon/locales/en.json

@@ -277,6 +277,12 @@
   "follow_request.authorize": "Authorize",
   "follow_request.reject": "Reject",
   "follow_requests.unlocked_explanation": "Even though your account is not locked, the {domain} staff thought you might want to review follow requests from these accounts manually.",
+  "follow_suggestions.curated_suggestion": "Editors' Choice",
+  "follow_suggestions.dismiss": "Don't show again",
+  "follow_suggestions.personalized_suggestion": "Personalized suggestion",
+  "follow_suggestions.popular_suggestion": "Popular suggestion",
+  "follow_suggestions.view_all": "View all",
+  "follow_suggestions.who_to_follow": "Who to follow",
   "followed_tags": "Followed hashtags",
   "footer.about": "About",
   "footer.directory": "Profiles directory",
@@ -303,13 +309,9 @@
   "hashtag.follow": "Follow hashtag",
   "hashtag.unfollow": "Unfollow hashtag",
   "hashtags.and_other": "…and {count, plural, other {# more}}",
-  "home.actions.go_to_explore": "See what's trending",
-  "home.actions.go_to_suggestions": "Find people to follow",
   "home.column_settings.basic": "Basic",
   "home.column_settings.show_reblogs": "Show boosts",
   "home.column_settings.show_replies": "Show replies",
-  "home.explore_prompt.body": "Your home feed will have a mix of posts from the hashtags you've chosen to follow, the people you've chosen to follow, and the posts they boost. If that feels too quiet, you may want to:",
-  "home.explore_prompt.title": "This is your home base within Mastodon.",
   "home.hide_announcements": "Hide announcements",
   "home.pending_critical_update.body": "Please update your Mastodon server as soon as possible!",
   "home.pending_critical_update.link": "See updates",

+ 1 - 1
app/javascript/mastodon/reducers/settings.js

@@ -104,7 +104,7 @@ const initialState = ImmutableMap({
   dismissed_banners: ImmutableMap({
     'public_timeline': false,
     'community_timeline': false,
-    'home.explore_prompt': false,
+    'home/follow-suggestions': false,
     'explore/links': false,
     'explore/statuses': false,
     'explore/tags': false,

+ 3 - 3
app/javascript/mastodon/reducers/suggestions.js

@@ -28,12 +28,12 @@ export default function suggestionsReducer(state = initialState, action) {
   case SUGGESTIONS_FETCH_FAIL:
     return state.set('isLoading', false);
   case SUGGESTIONS_DISMISS:
-    return state.update('items', list => list.filterNot(x => x.account === action.id));
+    return state.update('items', list => list.filterNot(x => x.get('account') === action.id));
   case blockAccountSuccess.type:
   case muteAccountSuccess.type:
-    return state.update('items', list => list.filterNot(x => x.account === action.payload.relationship.id));
+    return state.update('items', list => list.filterNot(x => x.get('account') === action.payload.relationship.id));
   case blockDomainSuccess.type:
-    return state.update('items', list => list.filterNot(x => action.payload.accounts.includes(x.account)));
+    return state.update('items', list => list.filterNot(x => action.payload.accounts.includes(x.get('account'))));
   default:
     return state;
   }

+ 24 - 7
app/javascript/mastodon/reducers/timelines.js

@@ -17,6 +17,9 @@ import {
   TIMELINE_DISCONNECT,
   TIMELINE_LOAD_PENDING,
   TIMELINE_MARK_AS_PARTIAL,
+  TIMELINE_INSERT,
+  TIMELINE_GAP,
+  TIMELINE_SUGGESTIONS,
 } from '../actions/timelines';
 import { compareId } from '../compare_id';
 
@@ -32,6 +35,8 @@ const initialTimeline = ImmutableMap({
   items: ImmutableList(),
 });
 
+const isPlaceholder = value => value === TIMELINE_GAP || value === TIMELINE_SUGGESTIONS;
+
 const expandNormalizedTimeline = (state, timeline, statuses, next, isPartial, isLoadingRecent, usePendingItems) => {
   // This method is pretty tricky because:
   // - existing items in the timeline might be out of order
@@ -63,20 +68,20 @@ const expandNormalizedTimeline = (state, timeline, statuses, next, isPartial, is
         // First, find the furthest (if properly sorted, oldest) item in the timeline that is
         // newer than the oldest fetched one, as it's most likely that it delimits the gap.
         // Start the gap *after* that item.
-        const lastIndex = oldIds.findLastIndex(id => id !== null && compareId(id, newIds.last()) >= 0) + 1;
+        const lastIndex = oldIds.findLastIndex(id => !isPlaceholder(id) && compareId(id, newIds.last()) >= 0) + 1;
 
         // Then, try to find the furthest (if properly sorted, oldest) item in the timeline that
         // is newer than the most recent fetched one, as it delimits a section comprised of only
         // items older or within `newIds` (or that were deleted from the server, so should be removed
         // anyway).
         // Stop the gap *after* that item.
-        const firstIndex = oldIds.take(lastIndex).findLastIndex(id => id !== null && compareId(id, newIds.first()) > 0) + 1;
+        const firstIndex = oldIds.take(lastIndex).findLastIndex(id => !isPlaceholder(id) && compareId(id, newIds.first()) > 0) + 1;
 
         let insertedIds = ImmutableOrderedSet(newIds).withMutations(insertedIds => {
           // It is possible, though unlikely, that the slice we are replacing contains items older
           // than the elements we got from the API. Get them and add them back at the back of the
           // slice.
-          const olderIds = oldIds.slice(firstIndex, lastIndex).filter(id => id !== null && compareId(id, newIds.last()) < 0);
+          const olderIds = oldIds.slice(firstIndex, lastIndex).filter(id => !isPlaceholder(id) && compareId(id, newIds.last()) < 0);
           insertedIds.union(olderIds);
 
           // Make sure we aren't inserting duplicates
@@ -84,8 +89,8 @@ const expandNormalizedTimeline = (state, timeline, statuses, next, isPartial, is
         }).toList();
 
         // Finally, insert a gap marker if the data is marked as partial by the server
-        if (isPartial && (firstIndex === 0 || oldIds.get(firstIndex - 1) !== null)) {
-          insertedIds = insertedIds.unshift(null);
+        if (isPartial && (firstIndex === 0 || oldIds.get(firstIndex - 1) !== TIMELINE_GAP)) {
+          insertedIds = insertedIds.unshift(TIMELINE_GAP);
         }
 
         return oldIds.take(firstIndex).concat(
@@ -178,7 +183,7 @@ const reconnectTimeline = (state, usePendingItems) => {
   }
 
   return state.withMutations(mMap => {
-    mMap.update(usePendingItems ? 'pendingItems' : 'items', items => items.first() ? items.unshift(null) : items);
+    mMap.update(usePendingItems ? 'pendingItems' : 'items', items => items.first() ? items.unshift(TIMELINE_GAP) : items);
     mMap.set('online', true);
   });
 };
@@ -213,7 +218,7 @@ export default function timelines(state = initialState, action) {
     return state.update(
       action.timeline,
       initialTimeline,
-      map => map.set('online', false).update(action.usePendingItems ? 'pendingItems' : 'items', items => items.first() ? items.unshift(null) : items),
+      map => map.set('online', false).update(action.usePendingItems ? 'pendingItems' : 'items', items => items.first() ? items.unshift(TIMELINE_GAP) : items),
     );
   case TIMELINE_MARK_AS_PARTIAL:
     return state.update(
@@ -221,6 +226,18 @@ export default function timelines(state = initialState, action) {
       initialTimeline,
       map => map.set('isPartial', true).set('items', ImmutableList()).set('pendingItems', ImmutableList()).set('unread', 0),
     );
+  case TIMELINE_INSERT:
+    return state.update(
+      action.timeline,
+      initialTimeline,
+      map => map.update('items', ImmutableList(), list => {
+        if (!list.includes(action.key)) {
+          return list.insert(action.index, action.key);
+        }
+
+        return list;
+      })
+    );
   default:
     return state;
   }

+ 1 - 0
app/javascript/material-icons/400-24px/navigate_before-fill.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24"><path d="M560-240 320-480l240-240 56 56-184 184 184 184-56 56Z"/></svg>

+ 1 - 0
app/javascript/material-icons/400-24px/navigate_before.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24"><path d="M560-240 320-480l240-240 56 56-184 184 184 184-56 56Z"/></svg>

+ 1 - 0
app/javascript/material-icons/400-24px/navigate_next-fill.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24"><path d="M504-480 320-664l56-56 240 240-240 240-56-56 184-184Z"/></svg>

+ 1 - 0
app/javascript/material-icons/400-24px/navigate_next.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24"><path d="M504-480 320-664l56-56 240 240-240 240-56-56 184-184Z"/></svg>

+ 13 - 0
app/javascript/styles/mastodon-light/diff.scss

@@ -578,3 +578,16 @@ html {
 .poll__option input[type='text'] {
   background: darken($ui-base-color, 10%);
 }
+
+.inline-follow-suggestions {
+  background-color: rgba($ui-highlight-color, 0.1);
+  border-bottom-color: rgba($ui-highlight-color, 0.3);
+}
+
+.inline-follow-suggestions__body__scrollable__card {
+  background: $white;
+}
+
+.inline-follow-suggestions__body__scroll-button__icon {
+  color: $white;
+}

+ 196 - 0
app/javascript/styles/mastodon/components.scss

@@ -9459,3 +9459,199 @@ noscript {
     padding: 0;
   }
 }
+
+.inline-follow-suggestions {
+  display: flex;
+  flex-direction: column;
+  gap: 12px;
+  padding: 16px 0;
+  border-bottom: 1px solid mix($ui-base-color, $ui-highlight-color, 75%);
+  background: mix($ui-base-color, $ui-highlight-color, 95%);
+
+  &__header {
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    padding: 0 16px;
+
+    h3 {
+      font-size: 15px;
+      line-height: 22px;
+      font-weight: 500;
+    }
+
+    &__actions {
+      display: flex;
+      align-items: center;
+      gap: 24px;
+    }
+
+    .link-button {
+      font-size: 13px;
+      font-weight: 500;
+    }
+  }
+
+  &__body {
+    position: relative;
+
+    &__scroll-button {
+      position: absolute;
+      height: 100%;
+      background: transparent;
+      border: none;
+      cursor: pointer;
+      top: 0;
+      color: $primary-text-color;
+
+      &.left {
+        left: 0;
+      }
+
+      &.right {
+        right: 0;
+      }
+
+      &__icon {
+        border-radius: 50%;
+        background: $ui-highlight-color;
+        display: flex;
+        align-items: center;
+        justify-content: center;
+        aspect-ratio: 1;
+        padding: 8px;
+
+        .icon {
+          width: 24px;
+          height: 24px;
+        }
+      }
+
+      &:hover,
+      &:focus,
+      &:active {
+        .inline-follow-suggestions__body__scroll-button__icon {
+          background: lighten($ui-highlight-color, 4%);
+        }
+      }
+    }
+
+    &__scrollable {
+      display: flex;
+      flex-wrap: nowrap;
+      gap: 16px;
+      padding: 16px;
+      padding-bottom: 0;
+      scroll-snap-type: x mandatory;
+      scroll-padding: 16px;
+      scroll-behavior: smooth;
+      overflow-x: hidden;
+
+      &__card {
+        background: darken($ui-base-color, 4%);
+        border: 1px solid lighten($ui-base-color, 8%);
+        border-radius: 4px;
+        display: flex;
+        flex-direction: column;
+        gap: 12px;
+        align-items: center;
+        padding: 12px;
+        scroll-snap-align: start;
+        flex: 0 0 auto;
+        width: 200px;
+        box-sizing: border-box;
+        position: relative;
+
+        a {
+          text-decoration: none;
+        }
+
+        & > .icon-button {
+          position: absolute;
+          inset-inline-end: 8px;
+          top: 8px;
+        }
+
+        &__avatar {
+          height: 48px;
+          display: flex;
+
+          a {
+            display: flex;
+            text-decoration: none;
+          }
+        }
+
+        .account__avatar {
+          flex-shrink: 0;
+          align-self: flex-end;
+          border: 1px solid lighten($ui-base-color, 8%);
+          background-color: $ui-base-color;
+        }
+
+        &__text-stack {
+          display: flex;
+          flex-direction: column;
+          gap: 4px;
+          align-items: center;
+          max-width: 100%;
+
+          a {
+            max-width: 100%;
+          }
+
+          &__source {
+            display: inline-flex;
+            align-items: center;
+            color: $dark-text-color;
+            gap: 4px;
+            overflow: hidden;
+            white-space: nowrap;
+
+            > span {
+              overflow: hidden;
+              text-overflow: ellipsis;
+            }
+
+            .icon {
+              width: 16px;
+              height: 16px;
+            }
+          }
+        }
+
+        .display-name {
+          display: flex;
+          flex-direction: column;
+          gap: 4px;
+          align-items: center;
+
+          & > * {
+            max-width: 100%;
+          }
+
+          &__html {
+            font-size: 15px;
+            font-weight: 500;
+            color: $secondary-text-color;
+          }
+
+          &__account {
+            font-size: 14px;
+            color: $darker-text-color;
+          }
+        }
+
+        .verified-badge {
+          font-size: 14px;
+          max-width: 100%;
+        }
+
+        .button {
+          display: block;
+          width: 100%;
+        }
+      }
+    }
+  }
+}