From b135a831eaa8b17d86345ab21f28b1bfe40dc2b2 Mon Sep 17 00:00:00 2001 From: diondiondion Date: Tue, 13 May 2025 08:38:18 +0200 Subject: [PATCH] feat: Add "Followers you know" widget to user profiles (#34652) --- .../actions/accounts_familiar_followers.ts | 22 +++++ app/javascript/mastodon/api/accounts.ts | 10 ++- app/javascript/mastodon/api_types/accounts.ts | 6 ++ .../components/avatar_group.tsx | 15 ++-- .../components/account_header.tsx | 2 + .../components/familiar_followers.tsx | 84 +++++++++++++++++++ .../notification_group_with_status.tsx | 10 ++- app/javascript/mastodon/locales/en.json | 3 + .../reducers/accounts_familiar_followers.ts | 19 +++++ app/javascript/mastodon/reducers/index.ts | 2 + app/javascript/mastodon/selectors/accounts.ts | 13 +++ .../styles/mastodon/components.scss | 44 ++++++++-- 12 files changed, 213 insertions(+), 17 deletions(-) create mode 100644 app/javascript/mastodon/actions/accounts_familiar_followers.ts rename app/javascript/mastodon/{features/notifications_v2 => }/components/avatar_group.tsx (66%) create mode 100644 app/javascript/mastodon/features/account_timeline/components/familiar_followers.tsx create mode 100644 app/javascript/mastodon/reducers/accounts_familiar_followers.ts diff --git a/app/javascript/mastodon/actions/accounts_familiar_followers.ts b/app/javascript/mastodon/actions/accounts_familiar_followers.ts new file mode 100644 index 0000000000..48968793e4 --- /dev/null +++ b/app/javascript/mastodon/actions/accounts_familiar_followers.ts @@ -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), + }; + }, +); diff --git a/app/javascript/mastodon/api/accounts.ts b/app/javascript/mastodon/api/accounts.ts index 074bcffaa1..6ce7d7248c 100644 --- a/app/javascript/mastodon/api/accounts.ts +++ b/app/javascript/mastodon/api/accounts.ts @@ -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(`v1/accounts/${id}/endorsements`); + +export const apiGetFamiliarFollowers = (id: string) => + apiRequestGet('/v1/accounts/familiar_followers', { + id, + }); diff --git a/app/javascript/mastodon/api_types/accounts.ts b/app/javascript/mastodon/api_types/accounts.ts index 3f8b27497f..b93054a1f6 100644 --- a/app/javascript/mastodon/api_types/accounts.ts +++ b/app/javascript/mastodon/api_types/accounts.ts @@ -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[]; +}[]; diff --git a/app/javascript/mastodon/features/notifications_v2/components/avatar_group.tsx b/app/javascript/mastodon/components/avatar_group.tsx similarity index 66% rename from app/javascript/mastodon/features/notifications_v2/components/avatar_group.tsx rename to app/javascript/mastodon/components/avatar_group.tsx index b5da8914a1..4f583defcf 100644 --- a/app/javascript/mastodon/features/notifications_v2/components/avatar_group.tsx +++ b/app/javascript/mastodon/components/avatar_group.tsx @@ -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, -}) => ( -
- {accountIds.slice(0, NOTIFICATIONS_GROUP_MAX_AVATARS).map((accountId) => ( +export const AvatarGroup: React.FC<{ + accountIds: string[]; + compact?: boolean; +}> = ({ accountIds, compact = false }) => ( +
+ {accountIds.map((accountId) => ( ))}
diff --git a/app/javascript/mastodon/features/account_timeline/components/account_header.tsx b/app/javascript/mastodon/features/account_timeline/components/account_header.tsx index b7908cc8d3..35554d9735 100644 --- a/app/javascript/mastodon/features/account_timeline/components/account_header.tsx +++ b/app/javascript/mastodon/features/account_timeline/components/account_header.tsx @@ -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<{ />
+ )} diff --git a/app/javascript/mastodon/features/account_timeline/components/familiar_followers.tsx b/app/javascript/mastodon/features/account_timeline/components/familiar_followers.tsx new file mode 100644 index 0000000000..b3b97c317b --- /dev/null +++ b/app/javascript/mastodon/features/account_timeline/components/familiar_followers.tsx @@ -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 }) => ( + + {account?.display_name} + +); + +const FamiliarFollowersReadout: React.FC<{ familiarFollowers: Account[] }> = ({ + familiarFollowers, +}) => { + const messageData = { + name1: , + name2: , + othersCount: familiarFollowers.length - 2, + }; + + if (familiarFollowers.length === 1) { + return ( + + ); + } else if (familiarFollowers.length === 2) { + return ( + + ); + } else { + return ( + + ); + } +}; + +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 ( +
+ account.id)} + /> + +
+ ); +}; diff --git a/app/javascript/mastodon/features/notifications_v2/components/notification_group_with_status.tsx b/app/javascript/mastodon/features/notifications_v2/components/notification_group_with_status.tsx index 861556620f..cbb0b85f1d 100644 --- a/app/javascript/mastodon/features/notifications_v2/components/notification_group_with_status.tsx +++ b/app/javascript/mastodon/features/notifications_v2/components/notification_group_with_status.tsx @@ -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<{
- + {actions && (
{actions}
diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json index 3d6e166498..45bc6109b7 100644 --- a/app/javascript/mastodon/locales/en.json +++ b/app/javascript/mastodon/locales/en.json @@ -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", diff --git a/app/javascript/mastodon/reducers/accounts_familiar_followers.ts b/app/javascript/mastodon/reducers/accounts_familiar_followers.ts new file mode 100644 index 0000000000..8d1c994040 --- /dev/null +++ b/app/javascript/mastodon/reducers/accounts_familiar_followers.ts @@ -0,0 +1,19 @@ +import { createReducer } from '@reduxjs/toolkit'; + +import { fetchAccountsFamiliarFollowers } from '../actions/accounts_familiar_followers'; + +const initialState: Record = {}; + +export const accountsFamiliarFollowersReducer = createReducer( + initialState, + (builder) => { + builder.addCase( + fetchAccountsFamiliarFollowers.fulfilled, + (state, { payload }) => { + if (payload) { + state[payload.id] = payload.accountIds; + } + }, + ); + }, +); diff --git a/app/javascript/mastodon/reducers/index.ts b/app/javascript/mastodon/reducers/index.ts index 0b6e66a1b2..d35d166115 100644 --- a/app/javascript/mastodon/reducers/index.ts +++ b/app/javascript/mastodon/reducers/index.ts @@ -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, diff --git a/app/javascript/mastodon/selectors/accounts.ts b/app/javascript/mastodon/selectors/accounts.ts index a33daee867..f9ba1a76a6 100644 --- a/app/javascript/mastodon/selectors/accounts.ts +++ b/app/javascript/mastodon/selectors/accounts.ts @@ -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); + }, +); diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss index e22a9ed9c9..ea48d98ec6 100644 --- a/app/javascript/styles/mastodon/components.scss +++ b/app/javascript/styles/mastodon/components.scss @@ -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;