diff --git a/app/javascript/flavours/glitch/features/account/components/profile_column_header.jsx b/app/javascript/flavours/glitch/features/account/components/profile_column_header.jsx deleted file mode 100644 index 0628773497..0000000000 --- a/app/javascript/flavours/glitch/features/account/components/profile_column_header.jsx +++ /dev/null @@ -1,39 +0,0 @@ -import PropTypes from 'prop-types'; -import { PureComponent } from 'react'; - -import { injectIntl, defineMessages } from 'react-intl'; - -import PersonIcon from '@/material-icons/400-24px/person.svg?react'; - -import ColumnHeader from '../../../components/column_header'; - -const messages = defineMessages({ - profile: { id: 'column_header.profile', defaultMessage: 'Profile' }, -}); - -class ProfileColumnHeader extends PureComponent { - - static propTypes = { - onClick: PropTypes.func, - multiColumn: PropTypes.bool, - intl: PropTypes.object.isRequired, - }; - - render() { - const { onClick, intl, multiColumn } = this.props; - - return ( - - ); - } - -} - -export default injectIntl(ProfileColumnHeader); diff --git a/app/javascript/flavours/glitch/features/account/components/profile_column_header.tsx b/app/javascript/flavours/glitch/features/account/components/profile_column_header.tsx new file mode 100644 index 0000000000..8ccc44e290 --- /dev/null +++ b/app/javascript/flavours/glitch/features/account/components/profile_column_header.tsx @@ -0,0 +1,34 @@ +import type { FC } from 'react'; + +import { defineMessages, useIntl } from 'react-intl'; + +import PersonIcon from '@/material-icons/400-24px/person.svg?react'; + +import { ColumnHeader } from '../../../components/column_header'; + +const messages = defineMessages({ + profile: { id: 'column_header.profile', defaultMessage: 'Profile' }, +}); + +interface ProfileColumnHeaderProps { + onClick: () => void; + multiColumn: boolean; +} + +export const ProfileColumnHeader: FC = ({ + onClick, + multiColumn, +}) => { + const intl = useIntl(); + + return ( + + ); +}; diff --git a/app/javascript/flavours/glitch/features/account_timeline/index.jsx b/app/javascript/flavours/glitch/features/account_timeline/index.jsx index 554e266075..65b9c38803 100644 --- a/app/javascript/flavours/glitch/features/account_timeline/index.jsx +++ b/app/javascript/flavours/glitch/features/account_timeline/index.jsx @@ -7,7 +7,7 @@ import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePureComponent from 'react-immutable-pure-component'; import { connect } from 'react-redux'; -import ProfileColumnHeader from 'flavours/glitch/features/account/components/profile_column_header'; +import { ProfileColumnHeader } from 'flavours/glitch/features/account/components/profile_column_header'; import BundleColumnError from 'flavours/glitch/features/ui/components/bundle_column_error'; import { normalizeForLookup } from 'flavours/glitch/reducers/accounts_map'; import { getAccountHidden } from 'flavours/glitch/selectors/accounts'; diff --git a/app/javascript/flavours/glitch/features/followers/components/empty.tsx b/app/javascript/flavours/glitch/features/followers/components/empty.tsx new file mode 100644 index 0000000000..8999ddb5ba --- /dev/null +++ b/app/javascript/flavours/glitch/features/followers/components/empty.tsx @@ -0,0 +1,64 @@ +import type { FC, ReactNode } from 'react'; + +import { FormattedMessage } from 'react-intl'; + +import { LimitedAccountHint } from '@/flavours/glitch/features/account_timeline/components/limited_account_hint'; +import { useAccountVisibility } from '@/flavours/glitch/hooks/useAccountVisibility'; +import type { Account } from '@/flavours/glitch/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/flavours/glitch/features/followers/components/list.tsx b/app/javascript/flavours/glitch/features/followers/components/list.tsx new file mode 100644 index 0000000000..359192393c --- /dev/null +++ b/app/javascript/flavours/glitch/features/followers/components/list.tsx @@ -0,0 +1,113 @@ +import { useCallback, useMemo, useRef } from 'react'; +import type { FC, ReactNode } from 'react'; + +import { Account } from '@/flavours/glitch/components/account'; +import type { ColumnRef } from '@/flavours/glitch/components/column'; +import { Column } from '@/flavours/glitch/components/column'; +import { LoadingIndicator } from '@/flavours/glitch/components/loading_indicator'; +import ScrollableList from '@/flavours/glitch/components/scrollable_list'; +import BundleColumnError from '@/flavours/glitch/features/ui/components/bundle_column_error'; +import { useAccount } from '@/flavours/glitch/hooks/useAccount'; +import { useAccountVisibility } from '@/flavours/glitch/hooks/useAccountVisibility'; +import { useLayout } from '@/flavours/glitch/hooks/useLayout'; + +import { ProfileColumnHeader } from '../../account/components/profile_column_header'; +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 columnRef = useRef(null); + const handleHeaderClick = useCallback(() => { + columnRef.current?.scrollTop(); + }, []); + + 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/flavours/glitch/features/followers/components/remote.tsx b/app/javascript/flavours/glitch/features/followers/components/remote.tsx new file mode 100644 index 0000000000..29e9d647f0 --- /dev/null +++ b/app/javascript/flavours/glitch/features/followers/components/remote.tsx @@ -0,0 +1,32 @@ +import type { FC } from 'react'; + +import { FormattedMessage } from 'react-intl'; + +import { TimelineHint } from '@/flavours/glitch/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/flavours/glitch/features/followers/index.jsx b/app/javascript/flavours/glitch/features/followers/index.jsx deleted file mode 100644 index b9f16c7715..0000000000 --- a/app/javascript/flavours/glitch/features/followers/index.jsx +++ /dev/null @@ -1,191 +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 'flavours/glitch/components/account'; -import { TimelineHint } from 'flavours/glitch/components/timeline_hint'; -import { AccountHeader } from 'flavours/glitch/features/account_timeline/components/account_header'; -import BundleColumnError from 'flavours/glitch/features/ui/components/bundle_column_error'; -import { normalizeForLookup } from 'flavours/glitch/reducers/accounts_map'; -import { getAccountHidden } from 'flavours/glitch/selectors/accounts'; -import { useAppSelector } from 'flavours/glitch/store'; - -import { - lookupAccount, - fetchAccount, - fetchFollowers, - expandFollowers, -} from '../../actions/accounts'; -import { LoadingIndicator } from '../../components/loading_indicator'; -import ScrollableList from '../../components/scrollable_list'; -import ProfileColumnHeader from '../account/components/profile_column_header'; -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), - }; -}; - -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, - 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 }); - - setRef = c => { - this.column = c; - }; - - handleHeaderClick = () => { - this.column.scrollTop(); - }; - - render () { - const { accountId, accountIds, hasMore, isAccount, multiColumn, isLoading, suspended, hidden, remote, remoteUrl, hideCollections } = this.props; - - if (!isAccount) { - return ( - - ); - } - - if (!accountIds) { - return ( - - - - ); - } - - let emptyMessage; - - const forceEmptyState = suspended || hidden; - - if (suspended) { - emptyMessage = ; - } else if (hidden) { - 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} - > - {accountIds.map(id => - , - )} - - - ); - } - -} - -export default connect(mapStateToProps)(Followers); diff --git a/app/javascript/flavours/glitch/features/followers/index.tsx b/app/javascript/flavours/glitch/features/followers/index.tsx new file mode 100644 index 0000000000..109f161f28 --- /dev/null +++ b/app/javascript/flavours/glitch/features/followers/index.tsx @@ -0,0 +1,93 @@ +import { useEffect } from 'react'; +import type { FC } from 'react'; + +import { FormattedMessage } from 'react-intl'; + +import { useDebouncedCallback } from 'use-debounce'; + +import { + expandFollowers, + fetchFollowers, +} from '@/flavours/glitch/actions/accounts'; +import { useAccount } from '@/flavours/glitch/hooks/useAccount'; +import { useAccountId } from '@/flavours/glitch/hooks/useAccountId'; +import { useRelationship } from '@/flavours/glitch/hooks/useRelationship'; +import { selectUserListWithoutMe } from '@/flavours/glitch/selectors/user_lists'; +import { useAppDispatch, useAppSelector } from '@/flavours/glitch/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/flavours/glitch/features/following/components/remote.tsx b/app/javascript/flavours/glitch/features/following/components/remote.tsx new file mode 100644 index 0000000000..1576c4b020 --- /dev/null +++ b/app/javascript/flavours/glitch/features/following/components/remote.tsx @@ -0,0 +1,32 @@ +import type { FC } from 'react'; + +import { FormattedMessage } from 'react-intl'; + +import { TimelineHint } from '@/flavours/glitch/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/flavours/glitch/features/following/index.jsx b/app/javascript/flavours/glitch/features/following/index.jsx deleted file mode 100644 index d673e3ffc4..0000000000 --- a/app/javascript/flavours/glitch/features/following/index.jsx +++ /dev/null @@ -1,191 +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 'flavours/glitch/components/account'; -import { TimelineHint } from 'flavours/glitch/components/timeline_hint'; -import { AccountHeader } from 'flavours/glitch/features/account_timeline/components/account_header'; -import BundleColumnError from 'flavours/glitch/features/ui/components/bundle_column_error'; -import { normalizeForLookup } from 'flavours/glitch/reducers/accounts_map'; -import { getAccountHidden } from 'flavours/glitch/selectors/accounts'; -import { useAppSelector } from 'flavours/glitch/store'; - -import { - lookupAccount, - fetchAccount, - fetchFollowing, - expandFollowing, -} from '../../actions/accounts'; -import { LoadingIndicator } from '../../components/loading_indicator'; -import ScrollableList from '../../components/scrollable_list'; -import ProfileColumnHeader from '../account/components/profile_column_header'; -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), - }; -}; - -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, - 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 }); - - setRef = c => { - this.column = c; - }; - - handleHeaderClick = () => { - this.column.scrollTop(); - }; - - render () { - const { accountId, accountIds, hasMore, isAccount, multiColumn, isLoading, suspended, hidden, remote, remoteUrl, hideCollections } = this.props; - - if (!isAccount) { - return ( - - ); - } - - if (!accountIds) { - return ( - - - - ); - } - - let emptyMessage; - - const forceEmptyState = suspended || hidden; - - if (suspended) { - emptyMessage = ; - } else if (hidden) { - 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} - > - {accountIds.map(id => - , - )} - - - ); - } - -} - -export default connect(mapStateToProps)(Following); diff --git a/app/javascript/flavours/glitch/features/following/index.tsx b/app/javascript/flavours/glitch/features/following/index.tsx new file mode 100644 index 0000000000..6198cca38e --- /dev/null +++ b/app/javascript/flavours/glitch/features/following/index.tsx @@ -0,0 +1,97 @@ +import { useEffect } from 'react'; +import type { FC } from 'react'; + +import { FormattedMessage } from 'react-intl'; + +import { useDebouncedCallback } from 'use-debounce'; + +import { + expandFollowing, + fetchFollowing, +} from '@/flavours/glitch/actions/accounts'; +import { useAccount } from '@/flavours/glitch/hooks/useAccount'; +import { useAccountId } from '@/flavours/glitch/hooks/useAccountId'; +import { useRelationship } from '@/flavours/glitch/hooks/useRelationship'; +import { selectUserListWithoutMe } from '@/flavours/glitch/selectors/user_lists'; +import { useAppDispatch, useAppSelector } from '@/flavours/glitch/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/flavours/glitch/hooks/useLayout.ts b/app/javascript/flavours/glitch/hooks/useLayout.ts index fc1cf136bf..51f3e0122a 100644 --- a/app/javascript/flavours/glitch/hooks/useLayout.ts +++ b/app/javascript/flavours/glitch/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/flavours/glitch/hooks/useRelationship.ts b/app/javascript/flavours/glitch/hooks/useRelationship.ts new file mode 100644 index 0000000000..b1e8f5174f --- /dev/null +++ b/app/javascript/flavours/glitch/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/flavours/glitch/selectors/timelines.ts b/app/javascript/flavours/glitch/selectors/timelines.ts index 5db50ea894..af1a4ebb21 100644 --- a/app/javascript/flavours/glitch/selectors/timelines.ts +++ b/app/javascript/flavours/glitch/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/flavours/glitch/selectors/user_lists.ts b/app/javascript/flavours/glitch/selectors/user_lists.ts new file mode 100644 index 0000000000..9d681aa255 --- /dev/null +++ b/app/javascript/flavours/glitch/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), + }; + }, +);