diff --git a/app/soapbox/features/followers/index.js b/app/soapbox/features/followers/index.js deleted file mode 100644 index a25fb0d7d..000000000 --- a/app/soapbox/features/followers/index.js +++ /dev/null @@ -1,138 +0,0 @@ -import debounce from 'lodash/debounce'; -import PropTypes from 'prop-types'; -import React from 'react'; -import ImmutablePropTypes from 'react-immutable-proptypes'; -import ImmutablePureComponent from 'react-immutable-pure-component'; -import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; -import { connect } from 'react-redux'; - -import { - fetchAccount, - fetchFollowers, - expandFollowers, - fetchAccountByUsername, -} from 'soapbox/actions/accounts'; -import MissingIndicator from 'soapbox/components/missing_indicator'; -import ScrollableList from 'soapbox/components/scrollable_list'; -import { Spinner } from 'soapbox/components/ui'; -import AccountContainer from 'soapbox/containers/account_container'; -import { findAccountByUsername } from 'soapbox/selectors'; -import { getFollowDifference } from 'soapbox/utils/accounts'; -import { getFeatures } from 'soapbox/utils/features'; - -import Column from '../ui/components/column'; - -const messages = defineMessages({ - heading: { id: 'column.followers', defaultMessage: 'Followers' }, -}); - -const mapStateToProps = (state, { params, withReplies = false }) => { - const username = params.username || ''; - const me = state.get('me'); - const accountFetchError = ((state.getIn(['accounts', -1, 'username']) || '').toLowerCase() === username.toLowerCase()); - const features = getFeatures(state.get('instance')); - - let accountId = -1; - if (accountFetchError) { - accountId = null; - } else { - const account = findAccountByUsername(state, username); - accountId = account ? account.getIn(['id'], null) : -1; - } - - const diffCount = getFollowDifference(state, accountId, 'followers'); - const isBlocked = state.getIn(['relationships', accountId, 'blocked_by'], false); - const unavailable = (me === accountId) ? false : (isBlocked && !features.blockersVisible); - - return { - accountId, - unavailable, - isAccount: !!state.getIn(['accounts', accountId]), - accountIds: state.user_lists.followers.get(accountId)?.items, - hasMore: !!state.user_lists.followers.get(accountId)?.next, - diffCount, - }; -}; - -export default @connect(mapStateToProps) -@injectIntl -class Followers extends ImmutablePureComponent { - - static propTypes = { - intl: PropTypes.object.isRequired, - params: PropTypes.object.isRequired, - dispatch: PropTypes.func.isRequired, - accountIds: ImmutablePropTypes.orderedSet, - hasMore: PropTypes.bool, - diffCount: PropTypes.number, - isAccount: PropTypes.bool, - unavailable: PropTypes.bool, - }; - - componentDidMount() { - const { params: { username }, accountId } = this.props; - - if (accountId && accountId !== -1) { - this.props.dispatch(fetchAccount(accountId)); - this.props.dispatch(fetchFollowers(accountId)); - } else { - this.props.dispatch(fetchAccountByUsername(username)); - } - } - - componentDidUpdate(prevProps) { - const { accountId, dispatch } = this.props; - if (accountId && accountId !== -1 && (accountId !== prevProps.accountId && accountId)) { - dispatch(fetchAccount(accountId)); - dispatch(fetchFollowers(accountId)); - } - } - - handleLoadMore = debounce(() => { - if (this.props.accountId && this.props.accountId !== -1) { - this.props.dispatch(expandFollowers(this.props.accountId)); - } - }, 300, { leading: true }); - - render() { - const { intl, accountIds, hasMore, diffCount, isAccount, accountId, unavailable } = this.props; - - if (!isAccount && accountId !== -1) { - return ( - - ); - } - - if (accountId === -1 || (!accountIds)) { - return ( - - ); - } - - if (unavailable) { - return ( -
- -
- ); - } - - return ( - - } - itemClassName='pb-4' - > - {accountIds.map(id => - , - )} - - - ); - } - -} diff --git a/app/soapbox/features/followers/index.tsx b/app/soapbox/features/followers/index.tsx new file mode 100644 index 000000000..8625f2142 --- /dev/null +++ b/app/soapbox/features/followers/index.tsx @@ -0,0 +1,115 @@ +import { OrderedSet as ImmutableOrderedSet } from 'immutable'; +import debounce from 'lodash/debounce'; +import React, { useCallback, useEffect, useState } from 'react'; +import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; + +import { + fetchAccount, + fetchFollowers, + expandFollowers, + fetchAccountByUsername, +} from 'soapbox/actions/accounts'; +import MissingIndicator from 'soapbox/components/missing_indicator'; +import ScrollableList from 'soapbox/components/scrollable_list'; +import { Spinner } from 'soapbox/components/ui'; +import AccountContainer from 'soapbox/containers/account_container'; +import { useAppDispatch, useAppSelector, useFeatures, useOwnAccount } from 'soapbox/hooks'; +import { findAccountByUsername } from 'soapbox/selectors'; + +import Column from '../ui/components/column'; + +const messages = defineMessages({ + heading: { id: 'column.followers', defaultMessage: 'Followers' }, +}); + +interface IFollowers { + params?: { + username?: string, + } +} + +/** Displays a list of accounts who follow the given account. */ +const Followers: React.FC = (props) => { + const intl = useIntl(); + const dispatch = useAppDispatch(); + const features = useFeatures(); + const ownAccount = useOwnAccount(); + + const [loading, setLoading] = useState(true); + + const username = props.params?.username || ''; + const account = useAppSelector(state => findAccountByUsername(state, username)); + const isOwnAccount = username.toLowerCase() === ownAccount?.username?.toLowerCase(); + + const accountIds = useAppSelector(state => state.user_lists.followers.get(account!?.id)?.items || ImmutableOrderedSet()); + const hasMore = useAppSelector(state => !!state.user_lists.followers.get(account!?.id)?.next); + + const isUnavailable = useAppSelector(state => { + const blockedBy = state.relationships.getIn([account?.id, 'blocked_by']) === true; + return isOwnAccount ? false : (blockedBy && !features.blockersVisible); + }); + + const handleLoadMore = useCallback(debounce(() => { + if (account) { + dispatch(expandFollowers(account.id)); + } + }, 300, { leading: true }), [account?.id]); + + useEffect(() => { + let promises = []; + + if (account) { + promises = [ + dispatch(fetchAccount(account.id)), + dispatch(fetchFollowers(account.id)), + ]; + } else { + promises = [ + dispatch(fetchAccountByUsername(username)), + ]; + } + + Promise.all(promises) + .then(() => setLoading(false)) + .catch(() => setLoading(false)); + + }, [account?.id, username]); + + if (loading && accountIds.isEmpty()) { + return ( + + ); + } + + if (!account) { + return ( + + ); + } + + if (isUnavailable) { + return ( +
+ +
+ ); + } + + return ( + + } + itemClassName='pb-4' + > + {accountIds.map(id => + , + )} + + + ); +}; + +export default Followers; \ No newline at end of file diff --git a/app/soapbox/features/following/index.js b/app/soapbox/features/following/index.js deleted file mode 100644 index 682a26411..000000000 --- a/app/soapbox/features/following/index.js +++ /dev/null @@ -1,138 +0,0 @@ -import debounce from 'lodash/debounce'; -import PropTypes from 'prop-types'; -import React from 'react'; -import ImmutablePropTypes from 'react-immutable-proptypes'; -import ImmutablePureComponent from 'react-immutable-pure-component'; -import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; -import { connect } from 'react-redux'; - -import { - fetchAccount, - fetchFollowing, - expandFollowing, - fetchAccountByUsername, -} from 'soapbox/actions/accounts'; -import MissingIndicator from 'soapbox/components/missing_indicator'; -import ScrollableList from 'soapbox/components/scrollable_list'; -import { Spinner } from 'soapbox/components/ui'; -import AccountContainer from 'soapbox/containers/account_container'; -import { findAccountByUsername } from 'soapbox/selectors'; -import { getFollowDifference } from 'soapbox/utils/accounts'; -import { getFeatures } from 'soapbox/utils/features'; - -import Column from '../ui/components/column'; - -const messages = defineMessages({ - heading: { id: 'column.following', defaultMessage: 'Following' }, -}); - -const mapStateToProps = (state, { params, withReplies = false }) => { - const username = params.username || ''; - const me = state.get('me'); - const accountFetchError = ((state.getIn(['accounts', -1, 'username']) || '').toLowerCase() === username.toLowerCase()); - const features = getFeatures(state.get('instance')); - - let accountId = -1; - if (accountFetchError) { - accountId = null; - } else { - const account = findAccountByUsername(state, username); - accountId = account ? account.getIn(['id'], null) : -1; - } - - const diffCount = getFollowDifference(state, accountId, 'following'); - const isBlocked = state.getIn(['relationships', accountId, 'blocked_by'], false); - const unavailable = (me === accountId) ? false : (isBlocked && !features.blockersVisible); - - return { - accountId, - unavailable, - isAccount: !!state.getIn(['accounts', accountId]), - accountIds: state.user_lists.following.get(accountId)?.items, - hasMore: !!state.user_lists.following.get(accountId)?.next, - diffCount, - }; -}; - -export default @connect(mapStateToProps) -@injectIntl -class Following extends ImmutablePureComponent { - - static propTypes = { - intl: PropTypes.object.isRequired, - params: PropTypes.object.isRequired, - dispatch: PropTypes.func.isRequired, - accountIds: ImmutablePropTypes.orderedSet, - hasMore: PropTypes.bool, - isAccount: PropTypes.bool, - unavailable: PropTypes.bool, - diffCount: PropTypes.number, - }; - - componentDidMount() { - const { params: { username }, accountId } = this.props; - - if (accountId && accountId !== -1) { - this.props.dispatch(fetchAccount(accountId)); - this.props.dispatch(fetchFollowing(accountId)); - } else { - this.props.dispatch(fetchAccountByUsername(username)); - } - } - - componentDidUpdate(prevProps) { - const { accountId, dispatch } = this.props; - if (accountId && accountId !== -1 && (accountId !== prevProps.accountId && accountId)) { - dispatch(fetchAccount(accountId)); - dispatch(fetchFollowing(accountId)); - } - } - - handleLoadMore = debounce(() => { - if (this.props.accountId && this.props.accountId !== -1) { - this.props.dispatch(expandFollowing(this.props.accountId)); - } - }, 300, { leading: true }); - - render() { - const { intl, accountIds, hasMore, isAccount, diffCount, accountId, unavailable } = this.props; - - if (!isAccount && accountId !== -1) { - return ( - - ); - } - - if (accountId === -1 || (!accountIds)) { - return ( - - ); - } - - if (unavailable) { - return ( -
- -
- ); - } - - return ( - - } - itemClassName='pb-4' - > - {accountIds.map(id => - , - )} - - - ); - } - -} diff --git a/app/soapbox/features/following/index.tsx b/app/soapbox/features/following/index.tsx new file mode 100644 index 000000000..cd0a10351 --- /dev/null +++ b/app/soapbox/features/following/index.tsx @@ -0,0 +1,115 @@ +import { OrderedSet as ImmutableOrderedSet } from 'immutable'; +import debounce from 'lodash/debounce'; +import React, { useCallback, useEffect, useState } from 'react'; +import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; + +import { + fetchAccount, + fetchFollowing, + expandFollowing, + fetchAccountByUsername, +} from 'soapbox/actions/accounts'; +import MissingIndicator from 'soapbox/components/missing_indicator'; +import ScrollableList from 'soapbox/components/scrollable_list'; +import { Spinner } from 'soapbox/components/ui'; +import AccountContainer from 'soapbox/containers/account_container'; +import { useAppDispatch, useAppSelector, useFeatures, useOwnAccount } from 'soapbox/hooks'; +import { findAccountByUsername } from 'soapbox/selectors'; + +import Column from '../ui/components/column'; + +const messages = defineMessages({ + heading: { id: 'column.following', defaultMessage: 'Following' }, +}); + +interface IFollowing { + params?: { + username?: string, + } +} + +/** Displays a list of accounts the given user is following. */ +const Following: React.FC = (props) => { + const intl = useIntl(); + const dispatch = useAppDispatch(); + const features = useFeatures(); + const ownAccount = useOwnAccount(); + + const [loading, setLoading] = useState(true); + + const username = props.params?.username || ''; + const account = useAppSelector(state => findAccountByUsername(state, username)); + const isOwnAccount = username.toLowerCase() === ownAccount?.username?.toLowerCase(); + + const accountIds = useAppSelector(state => state.user_lists.following.get(account!?.id)?.items || ImmutableOrderedSet()); + const hasMore = useAppSelector(state => !!state.user_lists.following.get(account!?.id)?.next); + + const isUnavailable = useAppSelector(state => { + const blockedBy = state.relationships.getIn([account?.id, 'blocked_by']) === true; + return isOwnAccount ? false : (blockedBy && !features.blockersVisible); + }); + + const handleLoadMore = useCallback(debounce(() => { + if (account) { + dispatch(expandFollowing(account.id)); + } + }, 300, { leading: true }), [account?.id]); + + useEffect(() => { + let promises = []; + + if (account) { + promises = [ + dispatch(fetchAccount(account.id)), + dispatch(fetchFollowing(account.id)), + ]; + } else { + promises = [ + dispatch(fetchAccountByUsername(username)), + ]; + } + + Promise.all(promises) + .then(() => setLoading(false)) + .catch(() => setLoading(false)); + + }, [account?.id, username]); + + if (loading && accountIds.isEmpty()) { + return ( + + ); + } + + if (!account) { + return ( + + ); + } + + if (isUnavailable) { + return ( +
+ +
+ ); + } + + return ( + + } + itemClassName='pb-4' + > + {accountIds.map(id => + , + )} + + + ); +}; + +export default Following; \ No newline at end of file diff --git a/app/soapbox/reducers/user_lists.ts b/app/soapbox/reducers/user_lists.ts index fc86cceb2..38017f0bb 100644 --- a/app/soapbox/reducers/user_lists.ts +++ b/app/soapbox/reducers/user_lists.ts @@ -98,7 +98,6 @@ type NestedListPath = ['followers' | 'following' | 'reblogged_by' | 'favourited_ type ListPath = ['follow_requests' | 'blocks' | 'mutes' | 'directory']; const normalizeList = (state: State, path: NestedListPath | ListPath, accounts: APIEntity[], next?: string | null) => { - return state.setIn(path, ListRecord({ next, items: ImmutableOrderedSet(accounts.map(item => item.id)), diff --git a/app/soapbox/utils/accounts.ts b/app/soapbox/utils/accounts.ts index b2ed45d39..3a1cadbe9 100644 --- a/app/soapbox/utils/accounts.ts +++ b/app/soapbox/utils/accounts.ts @@ -1,5 +1,3 @@ -import { Map as ImmutableMap, OrderedSet as ImmutableOrderedSet } from 'immutable'; - import type { Account } from 'soapbox/types/entities'; const getDomainFromURL = (account: Account): string => { @@ -28,12 +26,6 @@ export const getAcct = (account: Account, displayFqn: boolean): string => ( displayFqn === true ? account.fqn : account.acct ); -export const getFollowDifference = (state: ImmutableMap, accountId: string, type: string): number => { - const items: any = state.getIn(['user_lists', type, accountId, 'items'], ImmutableOrderedSet()); - const counter: number = Number(state.getIn(['accounts_counters', accountId, `${type}_count`], 0)); - return Math.max(counter - items.size, 0); -}; - export const isLocal = (account: Account): boolean => { const domain: string = account.acct.split('@')[1]; return domain === undefined ? true : false;