feat: Add "Followers you know" widget to user profiles (#34652)
This commit is contained in:
		@@ -0,0 +1,22 @@
 | 
			
		||||
import { createDataLoadingThunk } from 'mastodon/store/typed_functions';
 | 
			
		||||
 | 
			
		||||
import { apiGetFamiliarFollowers } from '../api/accounts';
 | 
			
		||||
 | 
			
		||||
import { importFetchedAccounts } from './importer';
 | 
			
		||||
 | 
			
		||||
export const fetchAccountsFamiliarFollowers = createDataLoadingThunk(
 | 
			
		||||
  'accounts_familiar_followers/fetch',
 | 
			
		||||
  ({ id }: { id: string }) => apiGetFamiliarFollowers(id),
 | 
			
		||||
  ([data], { dispatch }) => {
 | 
			
		||||
    if (!data) {
 | 
			
		||||
      return null;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    dispatch(importFetchedAccounts(data.accounts));
 | 
			
		||||
 | 
			
		||||
    return {
 | 
			
		||||
      id: data.id,
 | 
			
		||||
      accountIds: data.accounts.map((account) => account.id),
 | 
			
		||||
    };
 | 
			
		||||
  },
 | 
			
		||||
);
 | 
			
		||||
@@ -1,5 +1,8 @@
 | 
			
		||||
import { apiRequestPost, apiRequestGet } from 'mastodon/api';
 | 
			
		||||
import type { ApiAccountJSON } from 'mastodon/api_types/accounts';
 | 
			
		||||
import type {
 | 
			
		||||
  ApiAccountJSON,
 | 
			
		||||
  ApiFamiliarFollowersJSON,
 | 
			
		||||
} from 'mastodon/api_types/accounts';
 | 
			
		||||
import type { ApiRelationshipJSON } from 'mastodon/api_types/relationships';
 | 
			
		||||
import type { ApiHashtagJSON } from 'mastodon/api_types/tags';
 | 
			
		||||
 | 
			
		||||
@@ -31,3 +34,8 @@ export const apiGetFeaturedTags = (id: string) =>
 | 
			
		||||
 | 
			
		||||
export const apiGetEndorsedAccounts = (id: string) =>
 | 
			
		||||
  apiRequestGet<ApiAccountJSON>(`v1/accounts/${id}/endorsements`);
 | 
			
		||||
 | 
			
		||||
export const apiGetFamiliarFollowers = (id: string) =>
 | 
			
		||||
  apiRequestGet<ApiFamiliarFollowersJSON>('/v1/accounts/familiar_followers', {
 | 
			
		||||
    id,
 | 
			
		||||
  });
 | 
			
		||||
 
 | 
			
		||||
@@ -54,3 +54,9 @@ export interface ApiMutedAccountJSON extends BaseApiAccountJSON {
 | 
			
		||||
// For now, we have the same type representing both `Account` and `MutedAccount`
 | 
			
		||||
// objects, but we should refactor this in the future.
 | 
			
		||||
export type ApiAccountJSON = ApiMutedAccountJSON;
 | 
			
		||||
 | 
			
		||||
// See app/serializers/rest/familiar_followers_serializer.rb
 | 
			
		||||
export type ApiFamiliarFollowersJSON = {
 | 
			
		||||
  id: string;
 | 
			
		||||
  accounts: ApiAccountJSON[];
 | 
			
		||||
}[];
 | 
			
		||||
 
 | 
			
		||||
@@ -1,7 +1,7 @@
 | 
			
		||||
import classNames from 'classnames';
 | 
			
		||||
import { Link } from 'react-router-dom';
 | 
			
		||||
 | 
			
		||||
import { Avatar } from 'mastodon/components/avatar';
 | 
			
		||||
import { NOTIFICATIONS_GROUP_MAX_AVATARS } from 'mastodon/models/notification_group';
 | 
			
		||||
import { useAppSelector } from 'mastodon/store';
 | 
			
		||||
 | 
			
		||||
const AvatarWrapper: React.FC<{ accountId: string }> = ({ accountId }) => {
 | 
			
		||||
@@ -20,11 +20,14 @@ const AvatarWrapper: React.FC<{ accountId: string }> = ({ accountId }) => {
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const AvatarGroup: React.FC<{ accountIds: string[] }> = ({
 | 
			
		||||
  accountIds,
 | 
			
		||||
}) => (
 | 
			
		||||
  <div className='notification-group__avatar-group'>
 | 
			
		||||
    {accountIds.slice(0, NOTIFICATIONS_GROUP_MAX_AVATARS).map((accountId) => (
 | 
			
		||||
export const AvatarGroup: React.FC<{
 | 
			
		||||
  accountIds: string[];
 | 
			
		||||
  compact?: boolean;
 | 
			
		||||
}> = ({ accountIds, compact = false }) => (
 | 
			
		||||
  <div
 | 
			
		||||
    className={classNames('avatar-group', { 'avatar-group--compact': compact })}
 | 
			
		||||
  >
 | 
			
		||||
    {accountIds.map((accountId) => (
 | 
			
		||||
      <AvatarWrapper key={accountId} accountId={accountId} />
 | 
			
		||||
    ))}
 | 
			
		||||
  </div>
 | 
			
		||||
@@ -59,6 +59,7 @@ import {
 | 
			
		||||
import { getAccountHidden } from 'mastodon/selectors/accounts';
 | 
			
		||||
import { useAppSelector, useAppDispatch } from 'mastodon/store';
 | 
			
		||||
 | 
			
		||||
import { FamiliarFollowers } from './familiar_followers';
 | 
			
		||||
import { MemorialNote } from './memorial_note';
 | 
			
		||||
import { MovedNote } from './moved_note';
 | 
			
		||||
 | 
			
		||||
@@ -1022,6 +1023,7 @@ export const AccountHeader: React.FC<{
 | 
			
		||||
                  />
 | 
			
		||||
                </NavLink>
 | 
			
		||||
              </div>
 | 
			
		||||
              <FamiliarFollowers accountId={accountId} />
 | 
			
		||||
            </div>
 | 
			
		||||
          )}
 | 
			
		||||
        </div>
 | 
			
		||||
 
 | 
			
		||||
@@ -0,0 +1,84 @@
 | 
			
		||||
import { useEffect } from 'react';
 | 
			
		||||
 | 
			
		||||
import { FormattedMessage } from 'react-intl';
 | 
			
		||||
 | 
			
		||||
import { Link } from 'react-router-dom';
 | 
			
		||||
 | 
			
		||||
import { fetchAccountsFamiliarFollowers } from '@/mastodon/actions/accounts_familiar_followers';
 | 
			
		||||
import { AvatarGroup } from '@/mastodon/components/avatar_group';
 | 
			
		||||
import type { Account } from '@/mastodon/models/account';
 | 
			
		||||
import { getAccountFamiliarFollowers } from '@/mastodon/selectors/accounts';
 | 
			
		||||
import { useAppDispatch, useAppSelector } from '@/mastodon/store';
 | 
			
		||||
 | 
			
		||||
const AccountLink: React.FC<{ account?: Account }> = ({ account }) => (
 | 
			
		||||
  <Link to={`/@${account?.username}`} data-hover-card-account={account?.id}>
 | 
			
		||||
    {account?.display_name}
 | 
			
		||||
  </Link>
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
const FamiliarFollowersReadout: React.FC<{ familiarFollowers: Account[] }> = ({
 | 
			
		||||
  familiarFollowers,
 | 
			
		||||
}) => {
 | 
			
		||||
  const messageData = {
 | 
			
		||||
    name1: <AccountLink account={familiarFollowers.at(0)} />,
 | 
			
		||||
    name2: <AccountLink account={familiarFollowers.at(1)} />,
 | 
			
		||||
    othersCount: familiarFollowers.length - 2,
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  if (familiarFollowers.length === 1) {
 | 
			
		||||
    return (
 | 
			
		||||
      <FormattedMessage
 | 
			
		||||
        id='account.familiar_followers_one'
 | 
			
		||||
        defaultMessage='Followed by {name1}'
 | 
			
		||||
        values={messageData}
 | 
			
		||||
      />
 | 
			
		||||
    );
 | 
			
		||||
  } else if (familiarFollowers.length === 2) {
 | 
			
		||||
    return (
 | 
			
		||||
      <FormattedMessage
 | 
			
		||||
        id='account.familiar_followers_two'
 | 
			
		||||
        defaultMessage='Followed by {name1} and {name2}'
 | 
			
		||||
        values={messageData}
 | 
			
		||||
      />
 | 
			
		||||
    );
 | 
			
		||||
  } else {
 | 
			
		||||
    return (
 | 
			
		||||
      <FormattedMessage
 | 
			
		||||
        id='account.familiar_followers_many'
 | 
			
		||||
        defaultMessage='Followed by {name1}, {name2}, and {othersCount, plural, one {# other} other {# others}}'
 | 
			
		||||
        values={messageData}
 | 
			
		||||
      />
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const FamiliarFollowers: React.FC<{ accountId: string }> = ({
 | 
			
		||||
  accountId,
 | 
			
		||||
}) => {
 | 
			
		||||
  const dispatch = useAppDispatch();
 | 
			
		||||
  const familiarFollowers = useAppSelector((state) =>
 | 
			
		||||
    getAccountFamiliarFollowers(state, accountId),
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  const hasNoData = familiarFollowers === null;
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    if (hasNoData) {
 | 
			
		||||
      void dispatch(fetchAccountsFamiliarFollowers({ id: accountId }));
 | 
			
		||||
    }
 | 
			
		||||
  }, [dispatch, accountId, hasNoData]);
 | 
			
		||||
 | 
			
		||||
  if (hasNoData || familiarFollowers.length === 0) {
 | 
			
		||||
    return null;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <div className='account__header__familiar-followers'>
 | 
			
		||||
      <AvatarGroup
 | 
			
		||||
        compact
 | 
			
		||||
        accountIds={familiarFollowers.slice(0, 3).map((account) => account.id)}
 | 
			
		||||
      />
 | 
			
		||||
      <FamiliarFollowersReadout familiarFollowers={familiarFollowers} />
 | 
			
		||||
    </div>
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
@@ -7,12 +7,13 @@ import { HotKeys } from 'react-hotkeys';
 | 
			
		||||
 | 
			
		||||
import { replyComposeById } from 'mastodon/actions/compose';
 | 
			
		||||
import { navigateToStatus } from 'mastodon/actions/statuses';
 | 
			
		||||
import { AvatarGroup } from 'mastodon/components/avatar_group';
 | 
			
		||||
import type { IconProp } from 'mastodon/components/icon';
 | 
			
		||||
import { Icon } from 'mastodon/components/icon';
 | 
			
		||||
import { RelativeTimestamp } from 'mastodon/components/relative_timestamp';
 | 
			
		||||
import { NOTIFICATIONS_GROUP_MAX_AVATARS } from 'mastodon/models/notification_group';
 | 
			
		||||
import { useAppSelector, useAppDispatch } from 'mastodon/store';
 | 
			
		||||
 | 
			
		||||
import { AvatarGroup } from './avatar_group';
 | 
			
		||||
import { DisplayedName } from './displayed_name';
 | 
			
		||||
import { EmbeddedStatus } from './embedded_status';
 | 
			
		||||
 | 
			
		||||
@@ -98,7 +99,12 @@ export const NotificationGroupWithStatus: React.FC<{
 | 
			
		||||
        <div className='notification-group__main'>
 | 
			
		||||
          <div className='notification-group__main__header'>
 | 
			
		||||
            <div className='notification-group__main__header__wrapper'>
 | 
			
		||||
              <AvatarGroup accountIds={accountIds} />
 | 
			
		||||
              <AvatarGroup
 | 
			
		||||
                accountIds={accountIds.slice(
 | 
			
		||||
                  0,
 | 
			
		||||
                  NOTIFICATIONS_GROUP_MAX_AVATARS,
 | 
			
		||||
                )}
 | 
			
		||||
              />
 | 
			
		||||
 | 
			
		||||
              {actions && (
 | 
			
		||||
                <div className='notification-group__actions'>{actions}</div>
 | 
			
		||||
 
 | 
			
		||||
@@ -28,6 +28,9 @@
 | 
			
		||||
  "account.edit_profile": "Edit profile",
 | 
			
		||||
  "account.enable_notifications": "Notify me when @{name} posts",
 | 
			
		||||
  "account.endorse": "Feature on profile",
 | 
			
		||||
  "account.familiar_followers_many": "Followed by {name1}, {name2}, and {othersCount, plural, one {# other} other {# others}}",
 | 
			
		||||
  "account.familiar_followers_one": "Followed by {name1}",
 | 
			
		||||
  "account.familiar_followers_two": "Followed by {name1} and {name2}",
 | 
			
		||||
  "account.featured": "Featured",
 | 
			
		||||
  "account.featured.accounts": "Profiles",
 | 
			
		||||
  "account.featured.hashtags": "Hashtags",
 | 
			
		||||
 
 | 
			
		||||
@@ -0,0 +1,19 @@
 | 
			
		||||
import { createReducer } from '@reduxjs/toolkit';
 | 
			
		||||
 | 
			
		||||
import { fetchAccountsFamiliarFollowers } from '../actions/accounts_familiar_followers';
 | 
			
		||||
 | 
			
		||||
const initialState: Record<string, string[]> = {};
 | 
			
		||||
 | 
			
		||||
export const accountsFamiliarFollowersReducer = createReducer(
 | 
			
		||||
  initialState,
 | 
			
		||||
  (builder) => {
 | 
			
		||||
    builder.addCase(
 | 
			
		||||
      fetchAccountsFamiliarFollowers.fulfilled,
 | 
			
		||||
      (state, { payload }) => {
 | 
			
		||||
        if (payload) {
 | 
			
		||||
          state[payload.id] = payload.accountIds;
 | 
			
		||||
        }
 | 
			
		||||
      },
 | 
			
		||||
    );
 | 
			
		||||
  },
 | 
			
		||||
);
 | 
			
		||||
@@ -4,6 +4,7 @@ import { loadingBarReducer } from 'react-redux-loading-bar';
 | 
			
		||||
import { combineReducers } from 'redux-immutable';
 | 
			
		||||
 | 
			
		||||
import { accountsReducer } from './accounts';
 | 
			
		||||
import { accountsFamiliarFollowersReducer } from './accounts_familiar_followers';
 | 
			
		||||
import { accountsMapReducer } from './accounts_map';
 | 
			
		||||
import { alertsReducer } from './alerts';
 | 
			
		||||
import announcements from './announcements';
 | 
			
		||||
@@ -50,6 +51,7 @@ const reducers = {
 | 
			
		||||
  status_lists,
 | 
			
		||||
  accounts: accountsReducer,
 | 
			
		||||
  accounts_map: accountsMapReducer,
 | 
			
		||||
  accounts_familiar_followers: accountsFamiliarFollowersReducer,
 | 
			
		||||
  statuses,
 | 
			
		||||
  relationships: relationshipsReducer,
 | 
			
		||||
  settings,
 | 
			
		||||
 
 | 
			
		||||
@@ -59,3 +59,16 @@ export const getAccountHidden = createSelector(
 | 
			
		||||
    return hidden && !(isSelf || followingOrRequested);
 | 
			
		||||
  },
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
export const getAccountFamiliarFollowers = createSelector(
 | 
			
		||||
  [
 | 
			
		||||
    (state: RootState) => state.accounts,
 | 
			
		||||
    (state: RootState, id: string) => state.accounts_familiar_followers[id],
 | 
			
		||||
  ],
 | 
			
		||||
  (accounts, accounts_familiar_followers) => {
 | 
			
		||||
    if (!accounts_familiar_followers) return null;
 | 
			
		||||
    return accounts_familiar_followers
 | 
			
		||||
      .map((id) => accounts.get(id))
 | 
			
		||||
      .filter((f) => !!f);
 | 
			
		||||
  },
 | 
			
		||||
);
 | 
			
		||||
 
 | 
			
		||||
@@ -2167,6 +2167,25 @@ a .account__avatar {
 | 
			
		||||
  cursor: pointer;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.avatar-group {
 | 
			
		||||
  display: flex;
 | 
			
		||||
  gap: 8px;
 | 
			
		||||
  flex-wrap: wrap;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.avatar-group--compact {
 | 
			
		||||
  gap: 0;
 | 
			
		||||
  flex-wrap: nowrap;
 | 
			
		||||
 | 
			
		||||
  & > :not(:first-child) {
 | 
			
		||||
    margin-inline-start: -8px;
 | 
			
		||||
 | 
			
		||||
    .account__avatar {
 | 
			
		||||
      box-shadow: 0 0 0 2px var(--background-color);
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.account__avatar-overlay {
 | 
			
		||||
  position: relative;
 | 
			
		||||
 | 
			
		||||
@@ -8119,6 +8138,23 @@ noscript {
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  &__familiar-followers {
 | 
			
		||||
    display: flex;
 | 
			
		||||
    align-items: center;
 | 
			
		||||
    gap: 10px;
 | 
			
		||||
    margin-block-end: 16px;
 | 
			
		||||
    color: $darker-text-color;
 | 
			
		||||
 | 
			
		||||
    a:any-link {
 | 
			
		||||
      color: inherit;
 | 
			
		||||
      text-decoration: underline;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    a:hover {
 | 
			
		||||
      text-decoration: none;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.account__contents {
 | 
			
		||||
@@ -10439,14 +10475,6 @@ noscript {
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  &__avatar-group {
 | 
			
		||||
    display: flex;
 | 
			
		||||
    gap: 8px;
 | 
			
		||||
    height: 28px;
 | 
			
		||||
    overflow-y: hidden;
 | 
			
		||||
    flex-wrap: wrap;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .status {
 | 
			
		||||
    padding: 0;
 | 
			
		||||
    border: 0;
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user