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/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 bff0463f6..eb7ba90a2 100644 --- a/app/soapbox/features/ui/components/modal_root.js +++ b/app/soapbox/features/ui/components/modal_root.js @@ -31,6 +31,7 @@ import { AccountNoteModal, CompareHistoryModal, VerifySmsModal, + FamiliarFollowersModal, } from 'soapbox/features/ui/util/async-components'; import BundleContainer from '../containers/bundle_container'; @@ -68,6 +69,7 @@ const MODAL_COMPONENTS = { 'ACCOUNT_NOTE': AccountNoteModal, 'COMPARE_HISTORY': CompareHistoryModal, 'VERIFY_SMS': VerifySmsModal, + '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..f2e0c9b61 --- /dev/null +++ b/app/soapbox/features/ui/components/profile_familiar_followers.tsx @@ -0,0 +1,82 @@ +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, useFeatures } from 'soapbox/hooks'; +import { makeGetAccount } from 'soapbox/selectors'; + +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 = useFeatures(); + 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 34d2f36b9..7515bc2ba 100644 --- a/app/soapbox/features/ui/util/async-components.ts +++ b/app/soapbox/features/ui/util/async-components.ts @@ -505,3 +505,7 @@ export function AuthTokenList() { export function VerifySmsModal() { return import(/* webpackChunkName: "features/ui" */'../components/modals/verify-sms-modal'); } + +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 f06071357..ac557b58d 100644 --- a/app/soapbox/locales/pl.json +++ b/app/soapbox/locales/pl.json @@ -10,13 +10,16 @@ "account.block_domain": "Blokuj wszystko z {domain}", "account.blocked": "Zablokowany(-a)", "account.chat": "Napisz do @{name}", - "account.column_settings.description": "These settings apply to all account timelines.", - "account.column_settings.title": "Account timeline settings", + "account.column_settings.description": "Te ustawienia dotyczą wszystkich osi czasu konta.", + "account.column_settings.title": "Ustawienia osi czasu", "account.deactivated": "Dezaktywowany(-a)", "account.direct": "Wyślij wiadomość bezpośrednią do @{name}", "account.domain_blocked": "Wyciszono domenę", "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.", @@ -209,6 +212,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", @@ -765,7 +769,7 @@ "notifications.column_settings.filter_bar.category": "Szybkie filtrowanie", "notifications.column_settings.filter_bar.show": "Pokaż", "notifications.column_settings.follow": "Nowi śledzący:", - "notifications.column_settings.follow_request": "Nowe prośby o możliwość śledzenia:", + "notifications.column_settings.follow_request": "Nowe prośby o możliwość śledzenia:", "notifications.column_settings.mention": "Wspomnienia:", "notifications.column_settings.move": "Przenoszone konta:", "notifications.column_settings.poll": "Wyniki głosowania:", 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 7819b2554..8db79a8b6 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