From bbd88d356d772a5b8210fce06403ccd043a11f1a Mon Sep 17 00:00:00 2001 From: Echo Date: Wed, 11 Feb 2026 14:19:18 +0100 Subject: [PATCH] Profile redesign: Show yourself in follower list (#37813) --- .../features/followers/components/empty.tsx | 64 ++++++ .../features/followers/components/list.tsx | 104 ++++++++++ .../features/followers/components/remote.tsx | 32 +++ .../mastodon/features/followers/index.jsx | 187 ------------------ .../mastodon/features/followers/index.tsx | 90 +++++++++ .../features/following/components/remote.tsx | 32 +++ .../mastodon/features/following/index.jsx | 187 ------------------ .../mastodon/features/following/index.tsx | 94 +++++++++ app/javascript/mastodon/hooks/useLayout.ts | 1 + .../mastodon/hooks/useRelationship.ts | 19 ++ app/javascript/mastodon/locales/en.json | 2 + .../mastodon/selectors/timelines.ts | 2 +- .../mastodon/selectors/user_lists.ts | 35 ++++ 13 files changed, 474 insertions(+), 375 deletions(-) create mode 100644 app/javascript/mastodon/features/followers/components/empty.tsx create mode 100644 app/javascript/mastodon/features/followers/components/list.tsx create mode 100644 app/javascript/mastodon/features/followers/components/remote.tsx delete mode 100644 app/javascript/mastodon/features/followers/index.jsx create mode 100644 app/javascript/mastodon/features/followers/index.tsx create mode 100644 app/javascript/mastodon/features/following/components/remote.tsx delete mode 100644 app/javascript/mastodon/features/following/index.jsx create mode 100644 app/javascript/mastodon/features/following/index.tsx create mode 100644 app/javascript/mastodon/hooks/useRelationship.ts create mode 100644 app/javascript/mastodon/selectors/user_lists.ts diff --git a/app/javascript/mastodon/features/followers/components/empty.tsx b/app/javascript/mastodon/features/followers/components/empty.tsx new file mode 100644 index 0000000000..3fb92c1061 --- /dev/null +++ b/app/javascript/mastodon/features/followers/components/empty.tsx @@ -0,0 +1,64 @@ +import type { FC, ReactNode } from 'react'; + +import { FormattedMessage } from 'react-intl'; + +import { LimitedAccountHint } from '@/mastodon/features/account_timeline/components/limited_account_hint'; +import { useAccountVisibility } from '@/mastodon/hooks/useAccountVisibility'; +import type { Account } from '@/mastodon/models/account'; + +import { RemoteHint } from './remote'; + +interface BaseEmptyMessageProps { + account?: Account; + defaultMessage: ReactNode; +} +export type EmptyMessageProps = Omit; + +export const BaseEmptyMessage: FC = ({ + account, + defaultMessage, +}) => { + const { blockedBy, hidden, suspended } = useAccountVisibility(account?.id); + + if (!account) { + return null; + } + + if (suspended) { + return ( + + ); + } + + if (hidden) { + return ; + } + + if (blockedBy) { + return ( + + ); + } + + if (account.hide_collections) { + return ( + + ); + } + + const domain = account.acct.split('@')[1]; + if (domain) { + return ; + } + + return defaultMessage; +}; diff --git a/app/javascript/mastodon/features/followers/components/list.tsx b/app/javascript/mastodon/features/followers/components/list.tsx new file mode 100644 index 0000000000..24d442d229 --- /dev/null +++ b/app/javascript/mastodon/features/followers/components/list.tsx @@ -0,0 +1,104 @@ +import { useMemo } from 'react'; +import type { FC, ReactNode } from 'react'; + +import { Account } from '@/mastodon/components/account'; +import { Column } from '@/mastodon/components/column'; +import { ColumnBackButton } from '@/mastodon/components/column_back_button'; +import { LoadingIndicator } from '@/mastodon/components/loading_indicator'; +import ScrollableList from '@/mastodon/components/scrollable_list'; +import BundleColumnError from '@/mastodon/features/ui/components/bundle_column_error'; +import { useAccount } from '@/mastodon/hooks/useAccount'; +import { useAccountVisibility } from '@/mastodon/hooks/useAccountVisibility'; +import { useLayout } from '@/mastodon/hooks/useLayout'; + +import { AccountHeader } from '../../account_timeline/components/account_header'; + +import { RemoteHint } from './remote'; + +export interface AccountList { + hasMore: boolean; + isLoading: boolean; + items: string[]; +} + +interface AccountListProps { + accountId?: string | null; + append?: ReactNode; + emptyMessage: ReactNode; + footer?: ReactNode; + list?: AccountList | null; + loadMore: () => void; + prependAccountId?: string | null; + scrollKey: string; +} + +export const AccountList: FC = ({ + accountId, + append, + emptyMessage, + footer, + list, + loadMore, + prependAccountId, + scrollKey, +}) => { + const account = useAccount(accountId); + + const { blockedBy, hidden, suspended } = useAccountVisibility(accountId); + const forceEmptyState = blockedBy || hidden || suspended; + + const children = useMemo(() => { + if (forceEmptyState) { + return []; + } + const children = + list?.items.map((followerId) => ( + + )) ?? []; + + if (prependAccountId) { + children.unshift( + , + ); + } + return children; + }, [prependAccountId, list, forceEmptyState]); + + const { multiColumn } = useLayout(); + + // Null means accountId does not exist (e.g. invalid acct). Undefined means loading. + if (accountId === null) { + return ; + } + + if (!accountId || !account) { + return ( + + + + ); + } + + const domain = account.acct.split('@')[1]; + + return ( + + + + } + alwaysPrepend + append={append ?? } + emptyMessage={emptyMessage} + bindToDocument={!multiColumn} + footer={footer} + > + {children} + + + ); +}; diff --git a/app/javascript/mastodon/features/followers/components/remote.tsx b/app/javascript/mastodon/features/followers/components/remote.tsx new file mode 100644 index 0000000000..68341095ef --- /dev/null +++ b/app/javascript/mastodon/features/followers/components/remote.tsx @@ -0,0 +1,32 @@ +import type { FC } from 'react'; + +import { FormattedMessage } from 'react-intl'; + +import { TimelineHint } from '@/mastodon/components/timeline_hint'; + +export const RemoteHint: FC<{ domain?: string; url: string }> = ({ + domain, + url, +}) => { + if (!domain) { + return null; + } + return ( + + } + label={ + {domain} }} + /> + } + /> + ); +}; diff --git a/app/javascript/mastodon/features/followers/index.jsx b/app/javascript/mastodon/features/followers/index.jsx deleted file mode 100644 index e55d3d9cd6..0000000000 --- a/app/javascript/mastodon/features/followers/index.jsx +++ /dev/null @@ -1,187 +0,0 @@ -import PropTypes from 'prop-types'; - -import { FormattedMessage } from 'react-intl'; - -import ImmutablePropTypes from 'react-immutable-proptypes'; -import ImmutablePureComponent from 'react-immutable-pure-component'; -import { connect } from 'react-redux'; - -import { debounce } from 'lodash'; - -import { Account } from 'mastodon/components/account'; -import { TimelineHint } from 'mastodon/components/timeline_hint'; -import { AccountHeader } from 'mastodon/features/account_timeline/components/account_header'; -import BundleColumnError from 'mastodon/features/ui/components/bundle_column_error'; -import { normalizeForLookup } from 'mastodon/reducers/accounts_map'; -import { getAccountHidden } from 'mastodon/selectors/accounts'; -import { useAppSelector } from 'mastodon/store'; - -import { - lookupAccount, - fetchAccount, - fetchFollowers, - expandFollowers, -} from '../../actions/accounts'; -import { ColumnBackButton } from '../../components/column_back_button'; -import { LoadingIndicator } from '../../components/loading_indicator'; -import ScrollableList from '../../components/scrollable_list'; -import { LimitedAccountHint } from '../account_timeline/components/limited_account_hint'; -import Column from '../ui/components/column'; - -const mapStateToProps = (state, { params: { acct, id } }) => { - const accountId = id || state.accounts_map[normalizeForLookup(acct)]; - - if (!accountId) { - return { - isLoading: true, - }; - } - - return { - accountId, - remote: !!(state.getIn(['accounts', accountId, 'acct']) !== state.getIn(['accounts', accountId, 'username'])), - remoteUrl: state.getIn(['accounts', accountId, 'url']), - isAccount: !!state.getIn(['accounts', accountId]), - accountIds: state.getIn(['user_lists', 'followers', accountId, 'items']), - hasMore: !!state.getIn(['user_lists', 'followers', accountId, 'next']), - isLoading: state.getIn(['user_lists', 'followers', accountId, 'isLoading'], true), - suspended: state.getIn(['accounts', accountId, 'suspended'], false), - hideCollections: state.getIn(['accounts', accountId, 'hide_collections'], false), - hidden: getAccountHidden(state, accountId), - blockedBy: state.getIn(['relationships', accountId, 'blocked_by'], false), - }; -}; - -const RemoteHint = ({ accountId, url }) => { - const acct = useAppSelector(state => state.accounts.get(accountId)?.acct); - const domain = acct ? acct.split('@')[1] : undefined; - - return ( - } - label={{domain} }} />} - /> - ); -}; - -RemoteHint.propTypes = { - url: PropTypes.string.isRequired, - accountId: PropTypes.string.isRequired, -}; - -class Followers extends ImmutablePureComponent { - - static propTypes = { - params: PropTypes.shape({ - acct: PropTypes.string, - id: PropTypes.string, - }).isRequired, - accountId: PropTypes.string, - dispatch: PropTypes.func.isRequired, - accountIds: ImmutablePropTypes.list, - hasMore: PropTypes.bool, - isLoading: PropTypes.bool, - blockedBy: PropTypes.bool, - isAccount: PropTypes.bool, - suspended: PropTypes.bool, - hidden: PropTypes.bool, - remote: PropTypes.bool, - remoteUrl: PropTypes.string, - multiColumn: PropTypes.bool, - }; - - _load () { - const { accountId, isAccount, dispatch } = this.props; - - if (!isAccount) dispatch(fetchAccount(accountId)); - dispatch(fetchFollowers(accountId)); - } - - componentDidMount () { - const { params: { acct }, accountId, dispatch } = this.props; - - if (accountId) { - this._load(); - } else { - dispatch(lookupAccount(acct)); - } - } - - componentDidUpdate (prevProps) { - const { params: { acct }, accountId, dispatch } = this.props; - - if (prevProps.accountId !== accountId && accountId) { - this._load(); - } else if (prevProps.params.acct !== acct) { - dispatch(lookupAccount(acct)); - } - } - - handleLoadMore = debounce(() => { - this.props.dispatch(expandFollowers(this.props.accountId)); - }, 300, { leading: true }); - - render () { - const { accountId, accountIds, hasMore, blockedBy, isAccount, multiColumn, isLoading, suspended, hidden, remote, remoteUrl, hideCollections } = this.props; - - if (!isAccount) { - return ( - - ); - } - - if (!accountIds) { - return ( - - - - ); - } - - let emptyMessage; - - const forceEmptyState = blockedBy || suspended || hidden; - - if (suspended) { - emptyMessage = ; - } else if (hidden) { - emptyMessage = ; - } else if (blockedBy) { - emptyMessage = ; - } else if (hideCollections && accountIds.isEmpty()) { - emptyMessage = ; - } else if (remote && accountIds.isEmpty()) { - emptyMessage = ; - } else { - emptyMessage = ; - } - - const remoteMessage = remote ? : null; - - return ( - - - - } - alwaysPrepend - append={remoteMessage} - emptyMessage={emptyMessage} - bindToDocument={!multiColumn} - > - {forceEmptyState ? [] : accountIds.map(id => - , - )} - - - ); - } - -} - -export default connect(mapStateToProps)(Followers); diff --git a/app/javascript/mastodon/features/followers/index.tsx b/app/javascript/mastodon/features/followers/index.tsx new file mode 100644 index 0000000000..15dcbb5a69 --- /dev/null +++ b/app/javascript/mastodon/features/followers/index.tsx @@ -0,0 +1,90 @@ +import { useEffect } from 'react'; +import type { FC } from 'react'; + +import { FormattedMessage } from 'react-intl'; + +import { useDebouncedCallback } from 'use-debounce'; + +import { expandFollowers, fetchFollowers } from '@/mastodon/actions/accounts'; +import { useAccount } from '@/mastodon/hooks/useAccount'; +import { useAccountId } from '@/mastodon/hooks/useAccountId'; +import { useRelationship } from '@/mastodon/hooks/useRelationship'; +import { selectUserListWithoutMe } from '@/mastodon/selectors/user_lists'; +import { useAppDispatch, useAppSelector } from '@/mastodon/store'; + +import type { EmptyMessageProps } from './components/empty'; +import { BaseEmptyMessage } from './components/empty'; +import { AccountList } from './components/list'; + +const Followers: FC = () => { + const accountId = useAccountId(); + const account = useAccount(accountId); + const currentAccountId = useAppSelector( + (state) => (state.meta.get('me') as string | null) ?? null, + ); + const followerList = useAppSelector((state) => + selectUserListWithoutMe(state, 'followers', accountId), + ); + + const dispatch = useAppDispatch(); + useEffect(() => { + if (!followerList && accountId) { + dispatch(fetchFollowers(accountId)); + } + }, [accountId, dispatch, followerList]); + + const loadMore = useDebouncedCallback( + () => { + if (accountId) { + dispatch(expandFollowers(accountId)); + } + }, + 300, + { leading: true }, + ); + + const relationship = useRelationship(accountId); + + const followerId = relationship?.following ? currentAccountId : null; + const followersExceptMeHidden = !!( + account?.hide_collections && + followerList?.items.length === 0 && + followerId + ); + + const footer = followersExceptMeHidden && ( +
+ +
+ ); + + return ( + } + list={followerList} + loadMore={loadMore} + prependAccountId={followerId} + scrollKey='followers' + /> + ); +}; + +const EmptyMessage: FC = (props) => ( + + } + /> +); + +// eslint-disable-next-line import/no-default-export -- Used by async components. +export default Followers; diff --git a/app/javascript/mastodon/features/following/components/remote.tsx b/app/javascript/mastodon/features/following/components/remote.tsx new file mode 100644 index 0000000000..7d1c6c580d --- /dev/null +++ b/app/javascript/mastodon/features/following/components/remote.tsx @@ -0,0 +1,32 @@ +import type { FC } from 'react'; + +import { FormattedMessage } from 'react-intl'; + +import { TimelineHint } from '@/mastodon/components/timeline_hint'; + +export const RemoteHint: FC<{ domain?: string; url: string }> = ({ + domain, + url, +}) => { + if (!domain) { + return null; + } + return ( + + } + label={ + {domain} }} + /> + } + /> + ); +}; diff --git a/app/javascript/mastodon/features/following/index.jsx b/app/javascript/mastodon/features/following/index.jsx deleted file mode 100644 index 1dc39df0ee..0000000000 --- a/app/javascript/mastodon/features/following/index.jsx +++ /dev/null @@ -1,187 +0,0 @@ -import PropTypes from 'prop-types'; - -import { FormattedMessage } from 'react-intl'; - -import ImmutablePropTypes from 'react-immutable-proptypes'; -import ImmutablePureComponent from 'react-immutable-pure-component'; -import { connect } from 'react-redux'; - -import { debounce } from 'lodash'; - -import { Account } from 'mastodon/components/account'; -import { TimelineHint } from 'mastodon/components/timeline_hint'; -import { AccountHeader } from 'mastodon/features/account_timeline/components/account_header'; -import BundleColumnError from 'mastodon/features/ui/components/bundle_column_error'; -import { normalizeForLookup } from 'mastodon/reducers/accounts_map'; -import { getAccountHidden } from 'mastodon/selectors/accounts'; -import { useAppSelector } from 'mastodon/store'; - -import { - lookupAccount, - fetchAccount, - fetchFollowing, - expandFollowing, -} from '../../actions/accounts'; -import { ColumnBackButton } from '../../components/column_back_button'; -import { LoadingIndicator } from '../../components/loading_indicator'; -import ScrollableList from '../../components/scrollable_list'; -import { LimitedAccountHint } from '../account_timeline/components/limited_account_hint'; -import Column from '../ui/components/column'; - -const mapStateToProps = (state, { params: { acct, id } }) => { - const accountId = id || state.accounts_map[normalizeForLookup(acct)]; - - if (!accountId) { - return { - isLoading: true, - }; - } - - return { - accountId, - remote: !!(state.getIn(['accounts', accountId, 'acct']) !== state.getIn(['accounts', accountId, 'username'])), - remoteUrl: state.getIn(['accounts', accountId, 'url']), - isAccount: !!state.getIn(['accounts', accountId]), - accountIds: state.getIn(['user_lists', 'following', accountId, 'items']), - hasMore: !!state.getIn(['user_lists', 'following', accountId, 'next']), - isLoading: state.getIn(['user_lists', 'following', accountId, 'isLoading'], true), - suspended: state.getIn(['accounts', accountId, 'suspended'], false), - hideCollections: state.getIn(['accounts', accountId, 'hide_collections'], false), - hidden: getAccountHidden(state, accountId), - blockedBy: state.getIn(['relationships', accountId, 'blocked_by'], false), - }; -}; - -const RemoteHint = ({ accountId, url }) => { - const acct = useAppSelector(state => state.accounts.get(accountId)?.acct); - const domain = acct ? acct.split('@')[1] : undefined; - - return ( - } - label={{domain} }} />} - /> - ); -}; - -RemoteHint.propTypes = { - url: PropTypes.string.isRequired, - accountId: PropTypes.string.isRequired, -}; - -class Following extends ImmutablePureComponent { - - static propTypes = { - params: PropTypes.shape({ - acct: PropTypes.string, - id: PropTypes.string, - }).isRequired, - accountId: PropTypes.string, - dispatch: PropTypes.func.isRequired, - accountIds: ImmutablePropTypes.list, - hasMore: PropTypes.bool, - isLoading: PropTypes.bool, - blockedBy: PropTypes.bool, - isAccount: PropTypes.bool, - suspended: PropTypes.bool, - hidden: PropTypes.bool, - remote: PropTypes.bool, - remoteUrl: PropTypes.string, - multiColumn: PropTypes.bool, - }; - - _load () { - const { accountId, isAccount, dispatch } = this.props; - - if (!isAccount) dispatch(fetchAccount(accountId)); - dispatch(fetchFollowing(accountId)); - } - - componentDidMount () { - const { params: { acct }, accountId, dispatch } = this.props; - - if (accountId) { - this._load(); - } else { - dispatch(lookupAccount(acct)); - } - } - - componentDidUpdate (prevProps) { - const { params: { acct }, accountId, dispatch } = this.props; - - if (prevProps.accountId !== accountId && accountId) { - this._load(); - } else if (prevProps.params.acct !== acct) { - dispatch(lookupAccount(acct)); - } - } - - handleLoadMore = debounce(() => { - this.props.dispatch(expandFollowing(this.props.accountId)); - }, 300, { leading: true }); - - render () { - const { accountId, accountIds, hasMore, blockedBy, isAccount, multiColumn, isLoading, suspended, hidden, remote, remoteUrl, hideCollections } = this.props; - - if (!isAccount) { - return ( - - ); - } - - if (!accountIds) { - return ( - - - - ); - } - - let emptyMessage; - - const forceEmptyState = blockedBy || suspended || hidden; - - if (suspended) { - emptyMessage = ; - } else if (hidden) { - emptyMessage = ; - } else if (blockedBy) { - emptyMessage = ; - } else if (hideCollections && accountIds.isEmpty()) { - emptyMessage = ; - } else if (remote && accountIds.isEmpty()) { - emptyMessage = ; - } else { - emptyMessage = ; - } - - const remoteMessage = remote ? : null; - - return ( - - - - } - alwaysPrepend - append={remoteMessage} - emptyMessage={emptyMessage} - bindToDocument={!multiColumn} - > - {forceEmptyState ? [] : accountIds.map(id => - , - )} - - - ); - } - -} - -export default connect(mapStateToProps)(Following); diff --git a/app/javascript/mastodon/features/following/index.tsx b/app/javascript/mastodon/features/following/index.tsx new file mode 100644 index 0000000000..6e6218cc68 --- /dev/null +++ b/app/javascript/mastodon/features/following/index.tsx @@ -0,0 +1,94 @@ +import { useEffect } from 'react'; +import type { FC } from 'react'; + +import { FormattedMessage } from 'react-intl'; + +import { useDebouncedCallback } from 'use-debounce'; + +import { expandFollowing, fetchFollowing } from '@/mastodon/actions/accounts'; +import { useAccount } from '@/mastodon/hooks/useAccount'; +import { useAccountId } from '@/mastodon/hooks/useAccountId'; +import { useRelationship } from '@/mastodon/hooks/useRelationship'; +import { selectUserListWithoutMe } from '@/mastodon/selectors/user_lists'; +import { useAppDispatch, useAppSelector } from '@/mastodon/store'; + +import type { EmptyMessageProps } from '../followers/components/empty'; +import { BaseEmptyMessage } from '../followers/components/empty'; +import { AccountList } from '../followers/components/list'; + +import { RemoteHint } from './components/remote'; + +const Followers: FC = () => { + const accountId = useAccountId(); + const account = useAccount(accountId); + const currentAccountId = useAppSelector( + (state) => (state.meta.get('me') as string | null) ?? null, + ); + const followingList = useAppSelector((state) => + selectUserListWithoutMe(state, 'following', accountId), + ); + + const dispatch = useAppDispatch(); + useEffect(() => { + if (!followingList && accountId) { + dispatch(fetchFollowing(accountId)); + } + }, [accountId, dispatch, followingList]); + + const loadMore = useDebouncedCallback( + () => { + if (accountId) { + dispatch(expandFollowing(accountId)); + } + }, + 300, + { leading: true }, + ); + + const relationship = useRelationship(accountId); + + const followedId = relationship?.followed_by ? currentAccountId : null; + const followingExceptMeHidden = !!( + account?.hide_collections && + followingList?.items.length === 0 && + followedId + ); + + const footer = followingExceptMeHidden && ( +
+ +
+ ); + + const domain = account?.acct.split('@')[1]; + return ( + } + emptyMessage={} + footer={footer} + list={followingList} + loadMore={loadMore} + prependAccountId={currentAccountId} + scrollKey='following' + /> + ); +}; + +const EmptyMessage: FC = (props) => ( + + } + /> +); + +// eslint-disable-next-line import/no-default-export -- Used by async components. +export default Followers; diff --git a/app/javascript/mastodon/hooks/useLayout.ts b/app/javascript/mastodon/hooks/useLayout.ts index fc1cf136bf..51f3e0122a 100644 --- a/app/javascript/mastodon/hooks/useLayout.ts +++ b/app/javascript/mastodon/hooks/useLayout.ts @@ -8,6 +8,7 @@ export const useLayout = () => { return { singleColumn: layout === 'single-column' || layout === 'mobile', + multiColumn: layout === 'multi-column', layout, }; }; diff --git a/app/javascript/mastodon/hooks/useRelationship.ts b/app/javascript/mastodon/hooks/useRelationship.ts new file mode 100644 index 0000000000..b1e8f5174f --- /dev/null +++ b/app/javascript/mastodon/hooks/useRelationship.ts @@ -0,0 +1,19 @@ +import { useEffect } from 'react'; + +import { fetchRelationships } from '../actions/accounts'; +import { useAppDispatch, useAppSelector } from '../store'; + +export function useRelationship(accountId?: string | null) { + const relationship = useAppSelector((state) => + accountId ? state.relationships.get(accountId) : null, + ); + + const dispatch = useAppDispatch(); + useEffect(() => { + if (accountId && !relationship) { + dispatch(fetchRelationships([accountId])); + } + }, [accountId, dispatch, relationship]); + + return relationship; +} diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json index b383546cb2..0fd37b3102 100644 --- a/app/javascript/mastodon/locales/en.json +++ b/app/javascript/mastodon/locales/en.json @@ -535,6 +535,8 @@ "follow_suggestions.view_all": "View all", "follow_suggestions.who_to_follow": "Who to follow", "followed_tags": "Followed hashtags", + "followers.hide_other_followers": "This user has chosen to not make their other followers visible", + "following.hide_other_following": "This user has chosen to not make the rest of who they follow visible", "footer.about": "About", "footer.about_mastodon": "About Mastodon", "footer.about_server": "About {domain}", diff --git a/app/javascript/mastodon/selectors/timelines.ts b/app/javascript/mastodon/selectors/timelines.ts index 5db50ea894..af1a4ebb21 100644 --- a/app/javascript/mastodon/selectors/timelines.ts +++ b/app/javascript/mastodon/selectors/timelines.ts @@ -47,5 +47,5 @@ export function toTypedTimeline(timeline?: ImmutableMap) { emptyList, ) as ImmutableList, items: timeline.get('items', emptyList) as ImmutableList, - } as TimelineShape; + } satisfies TimelineShape; } diff --git a/app/javascript/mastodon/selectors/user_lists.ts b/app/javascript/mastodon/selectors/user_lists.ts new file mode 100644 index 0000000000..9d681aa255 --- /dev/null +++ b/app/javascript/mastodon/selectors/user_lists.ts @@ -0,0 +1,35 @@ +import type { Map as ImmutableMap } from 'immutable'; +import { List as ImmutableList } from 'immutable'; + +import { createAppSelector } from '../store'; + +export const selectUserListWithoutMe = createAppSelector( + [ + (state) => state.user_lists, + (state) => (state.meta.get('me') as string | null) ?? null, + (_state, listName: string) => listName, + (_state, _listName, accountId?: string | null) => accountId ?? null, + ], + (lists, currentAccountId, listName, accountId) => { + if (!accountId || !listName) { + return null; + } + const list = lists.getIn([listName, accountId]) as + | ImmutableMap + | undefined; + if (!list) { + return null; + } + + // Returns the list except the current account. + return { + items: ( + list.get('items', ImmutableList()) as ImmutableList + ) + .filter((id) => id !== currentAccountId) + .toArray(), + isLoading: !!list.get('isLoading', true), + hasMore: !!list.get('hasMore', false), + }; + }, +);