feat: Add "Followers you know" widget to user profiles (#34652)
This commit is contained in:
parent
c9a554bdca
commit
b135a831ea
12 changed files with 213 additions and 17 deletions
|
@ -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 { 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 { ApiRelationshipJSON } from 'mastodon/api_types/relationships';
|
||||||
import type { ApiHashtagJSON } from 'mastodon/api_types/tags';
|
import type { ApiHashtagJSON } from 'mastodon/api_types/tags';
|
||||||
|
|
||||||
|
@ -31,3 +34,8 @@ export const apiGetFeaturedTags = (id: string) =>
|
||||||
|
|
||||||
export const apiGetEndorsedAccounts = (id: string) =>
|
export const apiGetEndorsedAccounts = (id: string) =>
|
||||||
apiRequestGet<ApiAccountJSON>(`v1/accounts/${id}/endorsements`);
|
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`
|
// For now, we have the same type representing both `Account` and `MutedAccount`
|
||||||
// objects, but we should refactor this in the future.
|
// objects, but we should refactor this in the future.
|
||||||
export type ApiAccountJSON = ApiMutedAccountJSON;
|
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 { Link } from 'react-router-dom';
|
||||||
|
|
||||||
import { Avatar } from 'mastodon/components/avatar';
|
import { Avatar } from 'mastodon/components/avatar';
|
||||||
import { NOTIFICATIONS_GROUP_MAX_AVATARS } from 'mastodon/models/notification_group';
|
|
||||||
import { useAppSelector } from 'mastodon/store';
|
import { useAppSelector } from 'mastodon/store';
|
||||||
|
|
||||||
const AvatarWrapper: React.FC<{ accountId: string }> = ({ accountId }) => {
|
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[] }> = ({
|
export const AvatarGroup: React.FC<{
|
||||||
accountIds,
|
accountIds: string[];
|
||||||
}) => (
|
compact?: boolean;
|
||||||
<div className='notification-group__avatar-group'>
|
}> = ({ accountIds, compact = false }) => (
|
||||||
{accountIds.slice(0, NOTIFICATIONS_GROUP_MAX_AVATARS).map((accountId) => (
|
<div
|
||||||
|
className={classNames('avatar-group', { 'avatar-group--compact': compact })}
|
||||||
|
>
|
||||||
|
{accountIds.map((accountId) => (
|
||||||
<AvatarWrapper key={accountId} accountId={accountId} />
|
<AvatarWrapper key={accountId} accountId={accountId} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
|
@ -59,6 +59,7 @@ import {
|
||||||
import { getAccountHidden } from 'mastodon/selectors/accounts';
|
import { getAccountHidden } from 'mastodon/selectors/accounts';
|
||||||
import { useAppSelector, useAppDispatch } from 'mastodon/store';
|
import { useAppSelector, useAppDispatch } from 'mastodon/store';
|
||||||
|
|
||||||
|
import { FamiliarFollowers } from './familiar_followers';
|
||||||
import { MemorialNote } from './memorial_note';
|
import { MemorialNote } from './memorial_note';
|
||||||
import { MovedNote } from './moved_note';
|
import { MovedNote } from './moved_note';
|
||||||
|
|
||||||
|
@ -1022,6 +1023,7 @@ export const AccountHeader: React.FC<{
|
||||||
/>
|
/>
|
||||||
</NavLink>
|
</NavLink>
|
||||||
</div>
|
</div>
|
||||||
|
<FamiliarFollowers accountId={accountId} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</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 { replyComposeById } from 'mastodon/actions/compose';
|
||||||
import { navigateToStatus } from 'mastodon/actions/statuses';
|
import { navigateToStatus } from 'mastodon/actions/statuses';
|
||||||
|
import { AvatarGroup } from 'mastodon/components/avatar_group';
|
||||||
import type { IconProp } from 'mastodon/components/icon';
|
import type { IconProp } from 'mastodon/components/icon';
|
||||||
import { Icon } from 'mastodon/components/icon';
|
import { Icon } from 'mastodon/components/icon';
|
||||||
import { RelativeTimestamp } from 'mastodon/components/relative_timestamp';
|
import { RelativeTimestamp } from 'mastodon/components/relative_timestamp';
|
||||||
|
import { NOTIFICATIONS_GROUP_MAX_AVATARS } from 'mastodon/models/notification_group';
|
||||||
import { useAppSelector, useAppDispatch } from 'mastodon/store';
|
import { useAppSelector, useAppDispatch } from 'mastodon/store';
|
||||||
|
|
||||||
import { AvatarGroup } from './avatar_group';
|
|
||||||
import { DisplayedName } from './displayed_name';
|
import { DisplayedName } from './displayed_name';
|
||||||
import { EmbeddedStatus } from './embedded_status';
|
import { EmbeddedStatus } from './embedded_status';
|
||||||
|
|
||||||
|
@ -98,7 +99,12 @@ export const NotificationGroupWithStatus: React.FC<{
|
||||||
<div className='notification-group__main'>
|
<div className='notification-group__main'>
|
||||||
<div className='notification-group__main__header'>
|
<div className='notification-group__main__header'>
|
||||||
<div className='notification-group__main__header__wrapper'>
|
<div className='notification-group__main__header__wrapper'>
|
||||||
<AvatarGroup accountIds={accountIds} />
|
<AvatarGroup
|
||||||
|
accountIds={accountIds.slice(
|
||||||
|
0,
|
||||||
|
NOTIFICATIONS_GROUP_MAX_AVATARS,
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
{actions && (
|
{actions && (
|
||||||
<div className='notification-group__actions'>{actions}</div>
|
<div className='notification-group__actions'>{actions}</div>
|
||||||
|
|
|
@ -28,6 +28,9 @@
|
||||||
"account.edit_profile": "Edit profile",
|
"account.edit_profile": "Edit profile",
|
||||||
"account.enable_notifications": "Notify me when @{name} posts",
|
"account.enable_notifications": "Notify me when @{name} posts",
|
||||||
"account.endorse": "Feature on profile",
|
"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": "Featured",
|
||||||
"account.featured.accounts": "Profiles",
|
"account.featured.accounts": "Profiles",
|
||||||
"account.featured.hashtags": "Hashtags",
|
"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 { combineReducers } from 'redux-immutable';
|
||||||
|
|
||||||
import { accountsReducer } from './accounts';
|
import { accountsReducer } from './accounts';
|
||||||
|
import { accountsFamiliarFollowersReducer } from './accounts_familiar_followers';
|
||||||
import { accountsMapReducer } from './accounts_map';
|
import { accountsMapReducer } from './accounts_map';
|
||||||
import { alertsReducer } from './alerts';
|
import { alertsReducer } from './alerts';
|
||||||
import announcements from './announcements';
|
import announcements from './announcements';
|
||||||
|
@ -50,6 +51,7 @@ const reducers = {
|
||||||
status_lists,
|
status_lists,
|
||||||
accounts: accountsReducer,
|
accounts: accountsReducer,
|
||||||
accounts_map: accountsMapReducer,
|
accounts_map: accountsMapReducer,
|
||||||
|
accounts_familiar_followers: accountsFamiliarFollowersReducer,
|
||||||
statuses,
|
statuses,
|
||||||
relationships: relationshipsReducer,
|
relationships: relationshipsReducer,
|
||||||
settings,
|
settings,
|
||||||
|
|
|
@ -59,3 +59,16 @@ export const getAccountHidden = createSelector(
|
||||||
return hidden && !(isSelf || followingOrRequested);
|
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;
|
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 {
|
.account__avatar-overlay {
|
||||||
position: relative;
|
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 {
|
.account__contents {
|
||||||
|
@ -10439,14 +10475,6 @@ noscript {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&__avatar-group {
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
height: 28px;
|
|
||||||
overflow-y: hidden;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status {
|
.status {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
border: 0;
|
border: 0;
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue