From f70d44f67cce1573fbebbc63da498b19d1432ad7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?marcin=20miko=C5=82ajczak?= Date: Fri, 13 May 2022 23:14:55 +0200 Subject: [PATCH] Display familiar followers on Mastodon MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: marcin mikołajczak --- app/soapbox/actions/familiar_followers.ts | 59 +++++++++++++ .../components/status_reply_mentions.js | 2 +- .../ui/components/birthdays_modal.tsx | 4 +- .../components/familiar_followers_modal.tsx | 57 +++++++++++++ .../features/ui/components/modal_root.js | 2 + .../components/profile_familiar_followers.tsx | 83 +++++++++++++++++++ .../ui/components/profile_info_panel.tsx | 3 + .../features/ui/util/async-components.ts | 4 + app/soapbox/locales/pl.json | 5 ++ .../reducers/__tests__/user_lists-test.js | 1 + app/soapbox/reducers/user_lists.js | 6 ++ app/soapbox/utils/features.ts | 6 ++ 12 files changed, 229 insertions(+), 3 deletions(-) create mode 100644 app/soapbox/actions/familiar_followers.ts create mode 100644 app/soapbox/features/ui/components/familiar_followers_modal.tsx create mode 100644 app/soapbox/features/ui/components/profile_familiar_followers.tsx diff --git a/app/soapbox/actions/familiar_followers.ts b/app/soapbox/actions/familiar_followers.ts new file mode 100644 index 000000000..ec6eca6d8 --- /dev/null +++ b/app/soapbox/actions/familiar_followers.ts @@ -0,0 +1,59 @@ +import { RootState } from 'soapbox/store'; + +import api from '../api'; + +import { ACCOUNTS_IMPORT, importFetchedAccounts } from './importer'; + +import type { APIEntity } from 'soapbox/types/entities'; + +export const FAMILIAR_FOLLOWERS_FETCH_REQUEST = 'FAMILIAR_FOLLOWERS_FETCH_REQUEST'; +export const FAMILIAR_FOLLOWERS_FETCH_SUCCESS = 'FAMILIAR_FOLLOWERS_FETCH_SUCCESS'; +export const FAMILIAR_FOLLOWERS_FETCH_FAIL = 'FAMILIAR_FOLLOWERS_FETCH_FAIL'; + +type FamiliarFollowersFetchRequestAction = { + type: typeof FAMILIAR_FOLLOWERS_FETCH_REQUEST, + id: string, +} + +type FamiliarFollowersFetchRequestSuccessAction = { + type: typeof FAMILIAR_FOLLOWERS_FETCH_SUCCESS, + id: string, + accounts: Array, +} + +type FamiliarFollowersFetchRequestFailAction = { + type: typeof FAMILIAR_FOLLOWERS_FETCH_FAIL, + id: string, + error: any, +} + +type AccountsImportAction = { + type: typeof ACCOUNTS_IMPORT, + accounts: Array, +} + +export type FamiliarFollowersActions = FamiliarFollowersFetchRequestAction | FamiliarFollowersFetchRequestSuccessAction | FamiliarFollowersFetchRequestFailAction | AccountsImportAction + +export const fetchAccountFamiliarFollowers = (accountId: string) => (dispatch: React.Dispatch, getState: () => RootState) => { + dispatch({ + type: FAMILIAR_FOLLOWERS_FETCH_REQUEST, + id: accountId, + }); + + api(getState).get(`/api/v1/accounts/familiar_followers?id=${accountId}`) + .then(({ data }) => { + const accounts = data.find(({ id }: { id: string }) => id === accountId).accounts; + + dispatch(importFetchedAccounts(accounts) as AccountsImportAction); + dispatch({ + type: FAMILIAR_FOLLOWERS_FETCH_SUCCESS, + id: accountId, + accounts, + }); + }) + .catch(error => dispatch({ + type: FAMILIAR_FOLLOWERS_FETCH_FAIL, + id: accountId, + error, + })); +}; diff --git a/app/soapbox/components/status_reply_mentions.js b/app/soapbox/components/status_reply_mentions.js index 76a48d5df..21b68ff21 100644 --- a/app/soapbox/components/status_reply_mentions.js +++ b/app/soapbox/components/status_reply_mentions.js @@ -64,7 +64,7 @@ class StatusReplyMentions extends ImmutablePureComponent { id='reply_mentions.reply' defaultMessage='Replying to {accounts}{more}' values={{ - accounts: to.slice(0, 2).map(account => (<> + accounts: to.slice(0, 2).map((account) => (<> @{account.get('username')} diff --git a/app/soapbox/features/ui/components/birthdays_modal.tsx b/app/soapbox/features/ui/components/birthdays_modal.tsx index 6c43bd7b4..f0b25f5f4 100644 --- a/app/soapbox/features/ui/components/birthdays_modal.tsx +++ b/app/soapbox/features/ui/components/birthdays_modal.tsx @@ -22,11 +22,11 @@ const BirthdaysModal = ({ onClose }: IBirthdaysModal) => { if (!accountIds) { body = ; } else { - const emptyMessage = ; + const emptyMessage = ; body = ( diff --git a/app/soapbox/features/ui/components/familiar_followers_modal.tsx b/app/soapbox/features/ui/components/familiar_followers_modal.tsx new file mode 100644 index 000000000..0ec081d58 --- /dev/null +++ b/app/soapbox/features/ui/components/familiar_followers_modal.tsx @@ -0,0 +1,57 @@ +import { OrderedSet as ImmutableOrderedSet } from 'immutable'; +import React from 'react'; +import { FormattedMessage } from 'react-intl'; + +import ScrollableList from 'soapbox/components/scrollable_list'; +import { Modal, Spinner } from 'soapbox/components/ui'; +import AccountContainer from 'soapbox/containers/account_container'; +import { useAppSelector } from 'soapbox/hooks'; +import { makeGetAccount } from 'soapbox/selectors'; + +const getAccount = makeGetAccount(); + +interface IFamiliarFollowersModal { + accountId: string, + onClose: (string: string) => void, +} + +const FamiliarFollowersModal = ({ accountId, onClose }: IFamiliarFollowersModal) => { + const account = useAppSelector(state => getAccount(state, accountId)); + const familiarFollowerIds: ImmutableOrderedSet = useAppSelector(state => state.user_lists.getIn(['familiar_followers', accountId])); + + const onClickClose = () => { + onClose('FAMILIAR_FOLLOWERS'); + }; + + let body; + + if (!account || !familiarFollowerIds) { + body = ; + } else { + const emptyMessage = }} />; + + body = ( + + {familiarFollowerIds.map(id => + , + )} + + ); + } + + + return ( + }} />} + onClose={onClickClose} + > + {body} + + ); +}; + +export default FamiliarFollowersModal; diff --git a/app/soapbox/features/ui/components/modal_root.js b/app/soapbox/features/ui/components/modal_root.js index 78f9ee2a4..98daaa78d 100644 --- a/app/soapbox/features/ui/components/modal_root.js +++ b/app/soapbox/features/ui/components/modal_root.js @@ -30,6 +30,7 @@ import { BirthdaysModal, AccountNoteModal, CompareHistoryModal, + FamiliarFollowersModal, } from 'soapbox/features/ui/util/async-components'; import BundleContainer from '../containers/bundle_container'; @@ -66,6 +67,7 @@ const MODAL_COMPONENTS = { 'BIRTHDAYS': BirthdaysModal, 'ACCOUNT_NOTE': AccountNoteModal, 'COMPARE_HISTORY': CompareHistoryModal, + 'FAMILIAR_FOLLOWERS': FamiliarFollowersModal, }; export default class ModalRoot extends React.PureComponent { diff --git a/app/soapbox/features/ui/components/profile_familiar_followers.tsx b/app/soapbox/features/ui/components/profile_familiar_followers.tsx new file mode 100644 index 000000000..a0dcef2c9 --- /dev/null +++ b/app/soapbox/features/ui/components/profile_familiar_followers.tsx @@ -0,0 +1,83 @@ +import { OrderedSet as ImmutableOrderedSet } from 'immutable'; +import React from 'react'; +import { useEffect } from 'react'; +import { FormattedList, FormattedMessage } from 'react-intl'; +import { useDispatch } from 'react-redux'; +import { Link } from 'react-router-dom'; + +import { fetchAccountFamiliarFollowers } from 'soapbox/actions/familiar_followers'; +import { openModal } from 'soapbox/actions/modals'; +import HoverRefWrapper from 'soapbox/components/hover_ref_wrapper'; +import { Text } from 'soapbox/components/ui'; +import VerificationBadge from 'soapbox/components/verification_badge'; +import { useAppSelector } from 'soapbox/hooks'; +import { makeGetAccount } from 'soapbox/selectors'; +import { getFeatures } from 'soapbox/utils/features'; + +import type { Account } from 'soapbox/types/entities'; + +const getAccount = makeGetAccount(); + +interface IProfileFamiliarFollowers { + account: Account, +} + +const ProfileFamiliarFollowers: React.FC = ({ account }) => { + const dispatch = useDispatch(); + const me = useAppSelector((state) => state.me); + const features = useAppSelector((state) => getFeatures(state.instance)); + const familiarFollowerIds: ImmutableOrderedSet = useAppSelector(state => state.user_lists.getIn(['familiar_followers', account.id], ImmutableOrderedSet())); + const familiarFollowers: ImmutableOrderedSet = useAppSelector(state => familiarFollowerIds.slice(0, 2).map(accountId => getAccount(state, accountId))); + + useEffect(() => { + if (me && features.familiarFollowers) { + dispatch(fetchAccountFamiliarFollowers(account.id)); + } + }, []); + + const openFamiliarFollowersModal = () => { + dispatch(openModal('FAMILIAR_FOLLOWERS', { + accountId: account.id, + })); + }; + + if (familiarFollowerIds.size === 0) { + return null; + } + + const accounts: Array = familiarFollowers.map(account => !!account && ( + + + + + {account.verified && } + + + )).toArray(); + + if (familiarFollowerIds.size > 2) { + accounts.push( + + + , + ); + } + + return ( + + , + }} + /> + + ); +}; + +export default ProfileFamiliarFollowers; \ No newline at end of file diff --git a/app/soapbox/features/ui/components/profile_info_panel.tsx b/app/soapbox/features/ui/components/profile_info_panel.tsx index 7fe628ddb..e3d9ac8f4 100644 --- a/app/soapbox/features/ui/components/profile_info_panel.tsx +++ b/app/soapbox/features/ui/components/profile_info_panel.tsx @@ -9,6 +9,7 @@ import VerificationBadge from 'soapbox/components/verification_badge'; import { useSoapboxConfig } from 'soapbox/hooks'; import { isLocal } from 'soapbox/utils/accounts'; +import ProfileFamiliarFollowers from './profile_familiar_followers'; import ProfileStats from './profile_stats'; import type { Account } from 'soapbox/types/entities'; @@ -222,6 +223,8 @@ const ProfileInfoPanel: React.FC = ({ account, username }) => {renderBirthday()} + + ); diff --git a/app/soapbox/features/ui/util/async-components.ts b/app/soapbox/features/ui/util/async-components.ts index aab5bf76d..ed1abd55e 100644 --- a/app/soapbox/features/ui/util/async-components.ts +++ b/app/soapbox/features/ui/util/async-components.ts @@ -501,3 +501,7 @@ export function CompareHistoryModal() { export function AuthTokenList() { return import(/* webpackChunkName: "features/auth_token_list" */'../../auth_token_list'); } + +export function FamiliarFollowersModal() { + return import(/*webpackChunkName: "modals/familiar_followers_modal" */'../components/familiar_followers_modal'); +} diff --git a/app/soapbox/locales/pl.json b/app/soapbox/locales/pl.json index e9a38c69c..7a19661ae 100644 --- a/app/soapbox/locales/pl.json +++ b/app/soapbox/locales/pl.json @@ -16,6 +16,9 @@ "account.direct": "Wyślij wiadomość bezpośrednią do @{name}", "account.edit_profile": "Edytuj profil", "account.endorse": "Polecaj na profilu", + "account.familiar_followers": "Obserwowany(-a) przez {accounts}", + "account.familiar_followers.empty": "Nie znasz nikogo obserwującego {name}.", + "account.familiar_followers.more": "{count} {count, plural, one {innego użytkownika, którego obserwujesz} other {innych użytkowników, których obserwujesz}}", "account.follow": "Śledź", "account.followers": "Śledzący", "account.followers.empty": "Nikt jeszcze nie śledzi tego użytkownika.", @@ -155,6 +158,7 @@ "backups.empty_message.action": "Chcesz utworzyć?", "backups.pending": "Oczekująca", "beta.also_available": "Dostępne w językach:", + "birthdays_modal.empty": "Nikt kogo znasz nie ma dziś urodzin.", "birthday_panel.title": "Birthdays", "boost_modal.combo": "Naciśnij {combo}, aby pominąć to następnym razem", "bundle_column_error.body": "Coś poszło nie tak podczas ładowania tego składnika.", @@ -203,6 +207,7 @@ "column.domain_blocks": "Ukryte domeny", "column.edit_profile": "Edytuj profil", "column.export_data": "Eksportuj dane", + "column.familiar_followers": "Obserwujący {name} których znasz", "column.favourited_statuses": "Polubione wpisy", "column.favourites": "Polubienia", "column.federation_restrictions": "Ograniczenia federacji", diff --git a/app/soapbox/reducers/__tests__/user_lists-test.js b/app/soapbox/reducers/__tests__/user_lists-test.js index a168f3cdc..7c5ec7e20 100644 --- a/app/soapbox/reducers/__tests__/user_lists-test.js +++ b/app/soapbox/reducers/__tests__/user_lists-test.js @@ -17,6 +17,7 @@ describe('user_lists reducer', () => { groups_removed_accounts: ImmutableMap(), pinned: ImmutableMap(), birthday_reminders: ImmutableMap(), + familiar_followers: ImmutableMap(), })); }); }); diff --git a/app/soapbox/reducers/user_lists.js b/app/soapbox/reducers/user_lists.js index 992f930cc..69e8c181c 100644 --- a/app/soapbox/reducers/user_lists.js +++ b/app/soapbox/reducers/user_lists.js @@ -27,6 +27,9 @@ import { DIRECTORY_EXPAND_SUCCESS, DIRECTORY_EXPAND_FAIL, } from '../actions/directory'; +import { + FAMILIAR_FOLLOWERS_FETCH_SUCCESS, +} from '../actions/familiar_followers'; import { GROUP_MEMBERS_FETCH_SUCCESS, GROUP_MEMBERS_EXPAND_SUCCESS, @@ -60,6 +63,7 @@ const initialState = ImmutableMap({ groups_removed_accounts: ImmutableMap(), pinned: ImmutableMap(), birthday_reminders: ImmutableMap(), + familiar_followers: ImmutableMap(), }); const normalizeList = (state, type, id, accounts, next) => { @@ -138,6 +142,8 @@ export default function userLists(state = initialState, action) { return normalizeList(state, 'pinned', action.id, action.accounts, action.next); case BIRTHDAY_REMINDERS_FETCH_SUCCESS: return state.setIn(['birthday_reminders', action.id], ImmutableOrderedSet(action.accounts.map(item => item.id))); + case FAMILIAR_FOLLOWERS_FETCH_SUCCESS: + return state.setIn(['familiar_followers', action.id], ImmutableOrderedSet(action.accounts.map(item => item.id))); default: return state; } diff --git a/app/soapbox/utils/features.ts b/app/soapbox/utils/features.ts index 9f60b5a05..45eed8936 100644 --- a/app/soapbox/utils/features.ts +++ b/app/soapbox/utils/features.ts @@ -256,6 +256,12 @@ const getInstanceFeatures = (instance: Instance) => { features.includes('exposable_reactions'), ]), + /** + * Can see accounts' followers you know + * @see GET /api/v1/accounts/familiar_followers + */ + familiarFollowers: v.software === MASTODON && gte(v.version, '3.5.0'), + /** Whether the instance federates. */ federating: federation.get('enabled', true) === true, // Assume true unless explicitly false