diff --git a/app/soapbox/api/hooks/accounts/useAccount.ts b/app/soapbox/api/hooks/accounts/useAccount.ts index f50190134..ac756f6ca 100644 --- a/app/soapbox/api/hooks/accounts/useAccount.ts +++ b/app/soapbox/api/hooks/accounts/useAccount.ts @@ -1,5 +1,6 @@ import { Entities } from 'soapbox/entity-store/entities'; import { useEntity } from 'soapbox/entity-store/hooks'; +import { useFeatures, useLoggedIn } from 'soapbox/hooks'; import { useApi } from 'soapbox/hooks/useApi'; import { type Account, accountSchema } from 'soapbox/schemas'; @@ -11,6 +12,8 @@ interface UseAccountOpts { function useAccount(accountId?: string, opts: UseAccountOpts = {}) { const api = useApi(); + const features = useFeatures(); + const { me } = useLoggedIn(); const { withRelationship } = opts; const { entity: account, ...result } = useEntity( @@ -24,10 +27,14 @@ function useAccount(accountId?: string, opts: UseAccountOpts = {}) { isLoading: isRelationshipLoading, } = useRelationship(accountId, { enabled: withRelationship }); + const isBlocked = account?.relationship?.blocked_by === true; + const isUnavailable = (me === account?.id) ? false : (isBlocked && !features.blockersVisible); + return { ...result, isLoading: result.isLoading, isRelationshipLoading, + isUnavailable, account: account ? { ...account, relationship } : undefined, }; } diff --git a/app/soapbox/api/hooks/accounts/useAccountLookup.ts b/app/soapbox/api/hooks/accounts/useAccountLookup.ts index d26e17b64..dc7f2fb29 100644 --- a/app/soapbox/api/hooks/accounts/useAccountLookup.ts +++ b/app/soapbox/api/hooks/accounts/useAccountLookup.ts @@ -1,5 +1,6 @@ import { Entities } from 'soapbox/entity-store/entities'; import { useEntityLookup } from 'soapbox/entity-store/hooks'; +import { useFeatures, useLoggedIn } from 'soapbox/hooks'; import { useApi } from 'soapbox/hooks/useApi'; import { type Account, accountSchema } from 'soapbox/schemas'; @@ -11,6 +12,8 @@ interface UseAccountLookupOpts { function useAccountLookup(acct: string | undefined, opts: UseAccountLookupOpts = {}) { const api = useApi(); + const features = useFeatures(); + const { me } = useLoggedIn(); const { withRelationship } = opts; const { entity: account, ...result } = useEntityLookup( @@ -25,10 +28,14 @@ function useAccountLookup(acct: string | undefined, opts: UseAccountLookupOpts = isLoading: isRelationshipLoading, } = useRelationship(account?.id, { enabled: withRelationship }); + const isBlocked = account?.relationship?.blocked_by === true; + const isUnavailable = (me === account?.id) ? false : (isBlocked && !features.blockersVisible); + return { ...result, isLoading: result.isLoading, isRelationshipLoading, + isUnavailable, account: account ? { ...account, relationship } : undefined, }; } diff --git a/app/soapbox/api/hooks/accounts/useFollowing.ts b/app/soapbox/api/hooks/accounts/useFollowing.ts new file mode 100644 index 000000000..41f9afeed --- /dev/null +++ b/app/soapbox/api/hooks/accounts/useFollowing.ts @@ -0,0 +1,30 @@ +import { Entities } from 'soapbox/entity-store/entities'; +import { useEntities } from 'soapbox/entity-store/hooks'; +import { useApi } from 'soapbox/hooks'; +import { Account, accountSchema } from 'soapbox/schemas'; + +import { useRelationships } from './useRelationships'; + +function useFollowing(accountId: string | undefined) { + const api = useApi(); + + const { entities, ...rest } = useEntities( + [Entities.ACCOUNTS, accountId!, 'following'], + () => api.get(`/api/v1/accounts/${accountId}/following`), + { schema: accountSchema, enabled: !!accountId }, + ); + + const { relationships } = useRelationships( + [accountId!, 'following'], + entities.map(({ id }) => id), + ); + + const accounts: Account[] = entities.map((account) => ({ + ...account, + relationship: relationships[account.id], + })); + + return { accounts, ...rest }; +} + +export { useFollowing }; \ No newline at end of file diff --git a/app/soapbox/api/hooks/accounts/useRelationships.ts b/app/soapbox/api/hooks/accounts/useRelationships.ts index 835a67664..f145a8a5c 100644 --- a/app/soapbox/api/hooks/accounts/useRelationships.ts +++ b/app/soapbox/api/hooks/accounts/useRelationships.ts @@ -1,24 +1,22 @@ import { Entities } from 'soapbox/entity-store/entities'; -import { useEntities } from 'soapbox/entity-store/hooks'; +import { useBatchedEntities } from 'soapbox/entity-store/hooks/useBatchedEntities'; import { useLoggedIn } from 'soapbox/hooks'; import { useApi } from 'soapbox/hooks/useApi'; import { type Relationship, relationshipSchema } from 'soapbox/schemas'; -function useRelationships(ids: string[]) { +function useRelationships(listKey: string[], ids: string[]) { const api = useApi(); const { isLoggedIn } = useLoggedIn(); const q = ids.map(id => `id[]=${id}`).join('&'); - const { entities: relationships, ...result } = useEntities( - [Entities.RELATIONSHIPS, q], + const { entityMap: relationships, ...result } = useBatchedEntities( + [Entities.RELATIONSHIPS, ...listKey], + ids, () => api.get(`/api/v1/accounts/relationships?${q}`), - { schema: relationshipSchema, enabled: isLoggedIn && ids.filter(Boolean).length > 0 }, + { schema: relationshipSchema, enabled: isLoggedIn }, ); - return { - ...result, - relationships, - }; + return { relationships, ...result }; } export { useRelationships }; \ No newline at end of file diff --git a/app/soapbox/entity-store/hooks/useBatchedEntities.ts b/app/soapbox/entity-store/hooks/useBatchedEntities.ts new file mode 100644 index 000000000..9ea6b3f8c --- /dev/null +++ b/app/soapbox/entity-store/hooks/useBatchedEntities.ts @@ -0,0 +1,103 @@ +import { useEffect } from 'react'; +import { z } from 'zod'; + +import { useAppDispatch, useAppSelector, useGetState } from 'soapbox/hooks'; +import { filteredArray } from 'soapbox/schemas/utils'; + +import { entitiesFetchFail, entitiesFetchRequest, entitiesFetchSuccess } from '../actions'; +import { selectCache, selectListState, useListState } from '../selectors'; + +import { parseEntitiesPath } from './utils'; + +import type { Entity } from '../types'; +import type { EntitiesPath, EntityFn, EntitySchema, ExpandedEntitiesPath } from './types'; +import type { RootState } from 'soapbox/store'; + +interface UseBatchedEntitiesOpts { + schema?: EntitySchema + enabled?: boolean +} + +function useBatchedEntities( + expandedPath: ExpandedEntitiesPath, + ids: string[], + entityFn: EntityFn, + opts: UseBatchedEntitiesOpts = {}, +) { + const getState = useGetState(); + const dispatch = useAppDispatch(); + const { entityType, listKey, path } = parseEntitiesPath(expandedPath); + const schema = opts.schema || z.custom(); + + const isEnabled = opts.enabled ?? true; + const isFetching = useListState(path, 'fetching'); + const lastFetchedAt = useListState(path, 'lastFetchedAt'); + const isFetched = useListState(path, 'fetched'); + const isInvalid = useListState(path, 'invalid'); + const error = useListState(path, 'error'); + + /** Get IDs of entities not yet in the store. */ + const filteredIds = useAppSelector((state) => { + const cache = selectCache(state, path); + if (!cache) return ids; + return ids.filter((id) => !cache.store[id]); + }); + + const entityMap = useAppSelector((state) => selectEntityMap(state, path, ids)); + + async function fetchEntities() { + const isFetching = selectListState(getState(), path, 'fetching'); + if (isFetching) return; + + dispatch(entitiesFetchRequest(entityType, listKey)); + try { + const response = await entityFn(filteredIds); + const entities = filteredArray(schema).parse(response.data); + dispatch(entitiesFetchSuccess(entities, entityType, listKey, 'end', { + next: undefined, + prev: undefined, + totalCount: undefined, + fetching: false, + fetched: true, + error: null, + lastFetchedAt: new Date(), + invalid: false, + })); + } catch (e) { + dispatch(entitiesFetchFail(entityType, listKey, e)); + } + } + + useEffect(() => { + if (filteredIds.length && isEnabled) { + fetchEntities(); + } + }, [filteredIds.length]); + + return { + entityMap, + isFetching, + lastFetchedAt, + isFetched, + isError: !!error, + isInvalid, + }; +} + +function selectEntityMap( + state: RootState, + path: EntitiesPath, + entityIds: string[], +): Record { + const cache = selectCache(state, path); + + return entityIds.reduce>((result, id) => { + const entity = cache?.store[id]; + if (entity) { + result[id] = entity as TEntity; + } + return result; + }, {}); +} + +export { useBatchedEntities }; \ No newline at end of file diff --git a/app/soapbox/entity-store/hooks/useEntities.ts b/app/soapbox/entity-store/hooks/useEntities.ts index cd413f487..1ec868c03 100644 --- a/app/soapbox/entity-store/hooks/useEntities.ts +++ b/app/soapbox/entity-store/hooks/useEntities.ts @@ -7,12 +7,12 @@ import { filteredArray } from 'soapbox/schemas/utils'; import { realNumberSchema } from 'soapbox/utils/numbers'; import { entitiesFetchFail, entitiesFetchRequest, entitiesFetchSuccess, invalidateEntityList } from '../actions'; +import { selectEntities, selectListState, useListState } from '../selectors'; import { parseEntitiesPath } from './utils'; -import type { Entity, EntityListState } from '../types'; -import type { EntitiesPath, EntityFn, EntitySchema, ExpandedEntitiesPath } from './types'; -import type { RootState } from 'soapbox/store'; +import type { Entity } from '../types'; +import type { EntityFn, EntitySchema, ExpandedEntitiesPath } from './types'; /** Additional options for the hook. */ interface UseEntitiesOpts { @@ -42,6 +42,7 @@ function useEntities( const { entityType, listKey, path } = parseEntitiesPath(expandedPath); const entities = useAppSelector(state => selectEntities(state, path)); + const schema = opts.schema || z.custom(); const isEnabled = opts.enabled ?? true; const isFetching = useListState(path, 'fetching'); @@ -62,7 +63,6 @@ function useEntities( dispatch(entitiesFetchRequest(entityType, listKey)); try { const response = await req(); - const schema = opts.schema || z.custom(); const entities = filteredArray(schema).parse(response.data); const parsedCount = realNumberSchema.safeParse(response.headers['x-total-count']); const totalCount = parsedCount.success ? parsedCount.data : undefined; @@ -133,46 +133,6 @@ function useEntities( }; } -/** Get cache at path from Redux. */ -const selectCache = (state: RootState, path: EntitiesPath) => state.entities[path[0]]; - -/** Get list at path from Redux. */ -const selectList = (state: RootState, path: EntitiesPath) => { - const [, ...listKeys] = path; - const listKey = listKeys.join(':'); - - return selectCache(state, path)?.lists[listKey]; -}; - -/** Select a particular item from a list state. */ -function selectListState(state: RootState, path: EntitiesPath, key: K) { - const listState = selectList(state, path)?.state; - return listState ? listState[key] : undefined; -} - -/** Hook to get a particular item from a list state. */ -function useListState(path: EntitiesPath, key: K) { - return useAppSelector(state => selectListState(state, path, key)); -} - -/** Get list of entities from Redux. */ -function selectEntities(state: RootState, path: EntitiesPath): readonly TEntity[] { - const cache = selectCache(state, path); - const list = selectList(state, path); - - const entityIds = list?.ids; - - return entityIds ? ( - Array.from(entityIds).reduce((result, id) => { - const entity = cache?.store[id]; - if (entity) { - result.push(entity as TEntity); - } - return result; - }, []) - ) : []; -} - export { useEntities, }; \ No newline at end of file diff --git a/app/soapbox/entity-store/selectors.ts b/app/soapbox/entity-store/selectors.ts new file mode 100644 index 000000000..ac5f3feff --- /dev/null +++ b/app/soapbox/entity-store/selectors.ts @@ -0,0 +1,53 @@ +import { useAppSelector } from 'soapbox/hooks'; + +import type { EntitiesPath } from './hooks/types'; +import type { Entity, EntityListState } from './types'; +import type { RootState } from 'soapbox/store'; + +/** Get cache at path from Redux. */ +const selectCache = (state: RootState, path: EntitiesPath) => state.entities[path[0]]; + +/** Get list at path from Redux. */ +const selectList = (state: RootState, path: EntitiesPath) => { + const [, ...listKeys] = path; + const listKey = listKeys.join(':'); + + return selectCache(state, path)?.lists[listKey]; +}; + +/** Select a particular item from a list state. */ +function selectListState(state: RootState, path: EntitiesPath, key: K) { + const listState = selectList(state, path)?.state; + return listState ? listState[key] : undefined; +} + +/** Hook to get a particular item from a list state. */ +function useListState(path: EntitiesPath, key: K) { + return useAppSelector(state => selectListState(state, path, key)); +} + +/** Get list of entities from Redux. */ +function selectEntities(state: RootState, path: EntitiesPath): readonly TEntity[] { + const cache = selectCache(state, path); + const list = selectList(state, path); + + const entityIds = list?.ids; + + return entityIds ? ( + Array.from(entityIds).reduce((result, id) => { + const entity = cache?.store[id]; + if (entity) { + result.push(entity as TEntity); + } + return result; + }, []) + ) : []; +} + +export { + selectCache, + selectList, + selectListState, + useListState, + selectEntities, +}; \ No newline at end of file diff --git a/app/soapbox/features/account-gallery/index.tsx b/app/soapbox/features/account-gallery/index.tsx index c98fdb3a3..ad164f7ff 100644 --- a/app/soapbox/features/account-gallery/index.tsx +++ b/app/soapbox/features/account-gallery/index.tsx @@ -8,7 +8,7 @@ import { useAccountLookup } from 'soapbox/api/hooks'; import LoadMore from 'soapbox/components/load-more'; import MissingIndicator from 'soapbox/components/missing-indicator'; import { Column, Spinner } from 'soapbox/components/ui'; -import { useAppDispatch, useAppSelector, useFeatures, useLoggedIn } from 'soapbox/hooks'; +import { useAppDispatch, useAppSelector } from 'soapbox/hooks'; import { getAccountGallery } from 'soapbox/selectors'; import MediaItem from './components/media-item'; @@ -34,17 +34,13 @@ const LoadMoreMedia: React.FC = ({ maxId, onLoadMore }) => { const AccountGallery = () => { const dispatch = useAppDispatch(); const { username } = useParams<{ username: string }>(); - const features = useFeatures(); - const { me } = useLoggedIn(); const { account, isLoading: accountLoading, + isUnavailable, } = useAccountLookup(username, { withRelationship: true }); - const isBlocked = account?.relationship?.blocked_by === true; - const unavailable = (me === account?.id) ? false : (isBlocked && !features.blockersVisible); - const attachments: ImmutableList = useAppSelector((state) => getAccountGallery(state, account!.id)); const isLoading = useAppSelector((state) => state.timelines.get(`account:${account?.id}:media`)?.isLoading); const hasMore = useAppSelector((state) => state.timelines.get(`account:${account?.id}:media`)?.hasMore); @@ -106,7 +102,7 @@ const AccountGallery = () => { loadOlder = ; } - if (unavailable) { + if (isUnavailable) { return (
diff --git a/app/soapbox/features/following/index.tsx b/app/soapbox/features/following/index.tsx index 166fcc3ee..ea6a36c9b 100644 --- a/app/soapbox/features/following/index.tsx +++ b/app/soapbox/features/following/index.tsx @@ -1,20 +1,12 @@ -import { OrderedSet as ImmutableOrderedSet } from 'immutable'; -import debounce from 'lodash/debounce'; -import React, { useCallback, useEffect, useState } from 'react'; +import React from 'react'; import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; -import { - fetchAccount, - fetchFollowing, - expandFollowing, - fetchAccountByUsername, -} from 'soapbox/actions/accounts'; import { useAccountLookup } from 'soapbox/api/hooks'; +import { useFollowing } from 'soapbox/api/hooks/accounts/useFollowing'; +import Account from 'soapbox/components/account'; import MissingIndicator from 'soapbox/components/missing-indicator'; import ScrollableList from 'soapbox/components/scrollable-list'; import { Column, Spinner } from 'soapbox/components/ui'; -import AccountContainer from 'soapbox/containers/account-container'; -import { useAppDispatch, useAppSelector, useFeatures, useOwnAccount } from 'soapbox/hooks'; const messages = defineMessages({ heading: { id: 'column.following', defaultMessage: 'Following' }, @@ -27,53 +19,19 @@ interface IFollowing { } /** Displays a list of accounts the given user is following. */ -const Following: React.FC = (props) => { +const Following: React.FC = ({ params }) => { const intl = useIntl(); - const dispatch = useAppDispatch(); - const features = useFeatures(); - const { account: ownAccount } = useOwnAccount(); - const [loading, setLoading] = useState(true); + const { account, isUnavailable } = useAccountLookup(params?.username); - const username = props.params?.username || ''; - const { account } = useAccountLookup(username); - const isOwnAccount = username.toLowerCase() === ownAccount?.username?.toLowerCase(); + const { + accounts, + hasNextPage, + fetchNextPage, + isLoading, + } = useFollowing(account?.id); - 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()) { + if (isLoading) { return ( ); @@ -97,14 +55,14 @@ const Following: React.FC = (props) => { } itemClassName='pb-4' > - {accountIds.map(id => - , - )} + {accounts.map((account) => ( + + ))} );