diff --git a/app/soapbox/api/index.ts b/app/soapbox/api/index.ts index c7fcb6230d..fc19e7c419 100644 --- a/app/soapbox/api/index.ts +++ b/app/soapbox/api/index.ts @@ -29,6 +29,10 @@ export const getNextLink = (response: AxiosResponse): string | undefined => { return getLinks(response).refs.find(link => link.rel === 'next')?.uri; }; +export const getPrevLink = (response: AxiosResponse): string | undefined => { + return getLinks(response).refs.find(link => link.rel === 'prev')?.uri; +}; + const getToken = (state: RootState, authType: string) => { return authType === 'app' ? getAppToken(state) : getAccessToken(state); }; diff --git a/app/soapbox/entity-store/actions.ts b/app/soapbox/entity-store/actions.ts new file mode 100644 index 0000000000..d5e78c1f91 --- /dev/null +++ b/app/soapbox/entity-store/actions.ts @@ -0,0 +1,62 @@ +import type { Entity, EntityListState } from './types'; + +const ENTITIES_IMPORT = 'ENTITIES_IMPORT' as const; +const ENTITIES_FETCH_REQUEST = 'ENTITIES_FETCH_REQUEST' as const; +const ENTITIES_FETCH_SUCCESS = 'ENTITIES_FETCH_SUCCESS' as const; +const ENTITIES_FETCH_FAIL = 'ENTITIES_FETCH_FAIL' as const; + +/** Action to import entities into the cache. */ +function importEntities(entities: Entity[], entityType: string, listKey?: string) { + return { + type: ENTITIES_IMPORT, + entityType, + entities, + listKey, + }; +} + +function entitiesFetchRequest(entityType: string, listKey?: string) { + return { + type: ENTITIES_FETCH_REQUEST, + entityType, + listKey, + }; +} + +function entitiesFetchSuccess(entities: Entity[], entityType: string, listKey?: string, newState?: EntityListState) { + return { + type: ENTITIES_FETCH_SUCCESS, + entityType, + entities, + listKey, + newState, + }; +} + +function entitiesFetchFail(entityType: string, listKey: string | undefined, error: any) { + return { + type: ENTITIES_FETCH_FAIL, + entityType, + listKey, + error, + }; +} + +/** Any action pertaining to entities. */ +type EntityAction = + ReturnType + | ReturnType + | ReturnType + | ReturnType; + +export { + ENTITIES_IMPORT, + ENTITIES_FETCH_REQUEST, + ENTITIES_FETCH_SUCCESS, + ENTITIES_FETCH_FAIL, + importEntities, + entitiesFetchRequest, + entitiesFetchSuccess, + entitiesFetchFail, + EntityAction, +}; \ No newline at end of file diff --git a/app/soapbox/entity-store/hooks/index.ts b/app/soapbox/entity-store/hooks/index.ts new file mode 100644 index 0000000000..07d597912f --- /dev/null +++ b/app/soapbox/entity-store/hooks/index.ts @@ -0,0 +1,2 @@ +export { useEntities } from './useEntities'; +export { useEntity } from './useEntity'; \ No newline at end of file diff --git a/app/soapbox/entity-store/hooks/useEntities.ts b/app/soapbox/entity-store/hooks/useEntities.ts new file mode 100644 index 0000000000..909273ea33 --- /dev/null +++ b/app/soapbox/entity-store/hooks/useEntities.ts @@ -0,0 +1,132 @@ +import { useEffect } from 'react'; + +import { getNextLink, getPrevLink } from 'soapbox/api'; +import { useApi, useAppDispatch, useAppSelector } from 'soapbox/hooks'; + +import { entitiesFetchFail, entitiesFetchRequest, entitiesFetchSuccess } from '../actions'; + +import type { Entity } from '../types'; +import type { RootState } from 'soapbox/store'; + +/** Tells us where to find/store the entity in the cache. */ +type EntityPath = [ + /** Name of the entity type for use in the global cache, eg `'Notification'`. */ + entityType: string, + /** Name of a particular index of this entity type. You can use empty-string (`''`) if you don't need separate lists. */ + listKey: string, +] + +/** Additional options for the hook. */ +interface UseEntitiesOpts { + /** A parser function that returns the desired type, or undefined if validation fails. */ + parser?: (entity: unknown) => TEntity | undefined + /** + * Time (milliseconds) until this query becomes stale and should be refetched. + * It is 1 minute by default, and can be set to `Infinity` to opt-out of automatic fetching. + */ + staleTime?: number +} + +/** A hook for fetching and displaying API entities. */ +function useEntities( + /** Tells us where to find/store the entity in the cache. */ + path: EntityPath, + /** API route to GET, eg `'/api/v1/notifications'`. If undefined, nothing will be fetched. */ + endpoint: string | undefined, + /** Additional options for the hook. */ + opts: UseEntitiesOpts = {}, +) { + const api = useApi(); + const dispatch = useAppDispatch(); + + const [entityType, listKey] = path; + + const defaultParser = (entity: unknown) => entity as TEntity; + const parseEntity = opts.parser || defaultParser; + + const cache = useAppSelector(state => state.entities[entityType]); + const list = cache?.lists[listKey]; + + const entityIds = list?.ids; + + const entities: readonly TEntity[] = entityIds ? ( + Array.from(entityIds).reduce((result, id) => { + const entity = parseEntity(cache?.store[id] as unknown); + if (entity) { + result.push(entity); + } + return result; + }, []) + ) : []; + + const isFetching = Boolean(list?.state.fetching); + const isLoading = isFetching && entities.length === 0; + const hasNextPage = Boolean(list?.state.next); + const hasPreviousPage = Boolean(list?.state.prev); + + const fetchPage = async(url: string): Promise => { + // Get `isFetching` state from the store again to prevent race conditions. + const isFetching = dispatch((_, getState: () => RootState) => Boolean(getState().entities[entityType]?.lists[listKey]?.state.fetching)); + if (isFetching) return; + + dispatch(entitiesFetchRequest(entityType, listKey)); + try { + const response = await api.get(url); + dispatch(entitiesFetchSuccess(response.data, entityType, listKey, { + next: getNextLink(response), + prev: getPrevLink(response), + fetching: false, + error: null, + lastFetchedAt: new Date(), + })); + } catch (error) { + dispatch(entitiesFetchFail(entityType, listKey, error)); + } + }; + + const fetchEntities = async(): Promise => { + if (endpoint) { + await fetchPage(endpoint); + } + }; + + const fetchNextPage = async(): Promise => { + const next = list?.state.next; + + if (next) { + await fetchPage(next); + } + }; + + const fetchPreviousPage = async(): Promise => { + const prev = list?.state.prev; + + if (prev) { + await fetchPage(prev); + } + }; + + const staleTime = opts.staleTime ?? 60000; + const lastFetchedAt = list?.state.lastFetchedAt; + + useEffect(() => { + if (!isFetching && (!lastFetchedAt || lastFetchedAt.getTime() + staleTime <= Date.now())) { + fetchEntities(); + } + }, [endpoint]); + + return { + entities, + fetchEntities, + isFetching, + isLoading, + hasNextPage, + hasPreviousPage, + fetchNextPage, + fetchPreviousPage, + }; +} + +export { + useEntities, +}; \ No newline at end of file diff --git a/app/soapbox/entity-store/hooks/useEntity.ts b/app/soapbox/entity-store/hooks/useEntity.ts new file mode 100644 index 0000000000..d0bae8630e --- /dev/null +++ b/app/soapbox/entity-store/hooks/useEntity.ts @@ -0,0 +1,63 @@ +import { useEffect, useState } from 'react'; + +import { useApi, useAppDispatch, useAppSelector } from 'soapbox/hooks'; + +import { importEntities } from '../actions'; + +import type { Entity } from '../types'; + +type EntityPath = [entityType: string, entityId: string] + +/** Additional options for the hook. */ +interface UseEntityOpts { + /** A parser function that returns the desired type, or undefined if validation fails. */ + parser?: (entity: unknown) => TEntity | undefined + /** Whether to refetch this entity every time the hook mounts, even if it's already in the store. */ + refetch?: boolean +} + +function useEntity( + path: EntityPath, + endpoint: string, + opts: UseEntityOpts = {}, +) { + const api = useApi(); + const dispatch = useAppDispatch(); + + const [entityType, entityId] = path; + + const defaultParser = (entity: unknown) => entity as TEntity; + const parseEntity = opts.parser || defaultParser; + + const entity = useAppSelector(state => parseEntity(state.entities[entityType]?.store[entityId])); + + const [isFetching, setIsFetching] = useState(false); + const isLoading = isFetching && !entity; + + const fetchEntity = () => { + setIsFetching(true); + api.get(endpoint).then(({ data }) => { + dispatch(importEntities([data], entityType)); + setIsFetching(false); + }).catch(() => { + setIsFetching(false); + }); + }; + + useEffect(() => { + if (!entity || opts.refetch) { + fetchEntity(); + } + }, []); + + return { + entity, + fetchEntity, + isFetching, + isLoading, + }; +} + +export { + useEntity, +}; \ No newline at end of file diff --git a/app/soapbox/entity-store/reducer.ts b/app/soapbox/entity-store/reducer.ts new file mode 100644 index 0000000000..f2af680a18 --- /dev/null +++ b/app/soapbox/entity-store/reducer.ts @@ -0,0 +1,81 @@ +import produce, { enableMapSet } from 'immer'; + +import { + ENTITIES_IMPORT, + ENTITIES_FETCH_REQUEST, + ENTITIES_FETCH_SUCCESS, + ENTITIES_FETCH_FAIL, + EntityAction, +} from './actions'; +import { createCache, createList, updateStore, updateList } from './utils'; + +import type { Entity, EntityCache, EntityListState } from './types'; + +enableMapSet(); + +/** Entity reducer state. */ +interface State { + [entityType: string]: EntityCache | undefined +} + +/** Import entities into the cache. */ +const importEntities = ( + state: State, + entityType: string, + entities: Entity[], + listKey?: string, + newState?: EntityListState, +): State => { + return produce(state, draft => { + const cache = draft[entityType] ?? createCache(); + cache.store = updateStore(cache.store, entities); + + if (typeof listKey === 'string') { + let list = { ...(cache.lists[listKey] ?? createList()) }; + list = updateList(list, entities); + if (newState) { + list.state = newState; + } + cache.lists[listKey] = list; + } + + draft[entityType] = cache; + }); +}; + +const setFetching = ( + state: State, + entityType: string, + listKey: string | undefined, + isFetching: boolean, +) => { + return produce(state, draft => { + const cache = draft[entityType] ?? createCache(); + + if (typeof listKey === 'string') { + const list = cache.lists[listKey] ?? createList(); + list.state.fetching = isFetching; + cache.lists[listKey] = list; + } + + draft[entityType] = cache; + }); +}; + +/** Stores various entity data and lists in a one reducer. */ +function reducer(state: Readonly = {}, action: EntityAction): State { + switch (action.type) { + case ENTITIES_IMPORT: + return importEntities(state, action.entityType, action.entities, action.listKey); + case ENTITIES_FETCH_SUCCESS: + return importEntities(state, action.entityType, action.entities, action.listKey, action.newState); + case ENTITIES_FETCH_REQUEST: + return setFetching(state, action.entityType, action.listKey, true); + case ENTITIES_FETCH_FAIL: + return setFetching(state, action.entityType, action.listKey, false); + default: + return state; + } +} + +export default reducer; \ No newline at end of file diff --git a/app/soapbox/entity-store/types.ts b/app/soapbox/entity-store/types.ts new file mode 100644 index 0000000000..efec97df13 --- /dev/null +++ b/app/soapbox/entity-store/types.ts @@ -0,0 +1,50 @@ +/** A Mastodon API entity. */ +interface Entity { + /** Unique ID for the entity (usually the primary key in the database). */ + id: string +} + +/** Store of entities by ID. */ +interface EntityStore { + [id: string]: Entity | undefined +} + +/** List of entity IDs and fetch state. */ +interface EntityList { + /** Set of entity IDs in this list. */ + ids: Set + /** Server state for this entity list. */ + state: EntityListState +} + +/** Fetch state for an entity list. */ +interface EntityListState { + /** Next URL for pagination, if any. */ + next: string | undefined + /** Previous URL for pagination, if any. */ + prev: string | undefined + /** Error returned from the API, if any. */ + error: any + /** Whether data for this list is currently being fetched. */ + fetching: boolean + /** Date of the last API fetch for this list. */ + lastFetchedAt: Date | undefined +} + +/** Cache data pertaining to a paritcular entity type.. */ +interface EntityCache { + /** Map of entities of this type. */ + store: EntityStore + /** Lists of entity IDs for a particular purpose. */ + lists: { + [listKey: string]: EntityList | undefined + } +} + +export { + Entity, + EntityStore, + EntityList, + EntityListState, + EntityCache, +}; \ No newline at end of file diff --git a/app/soapbox/entity-store/utils.ts b/app/soapbox/entity-store/utils.ts new file mode 100644 index 0000000000..22e0f0c5b2 --- /dev/null +++ b/app/soapbox/entity-store/utils.ts @@ -0,0 +1,43 @@ +import type { Entity, EntityStore, EntityList, EntityCache } from './types'; + +/** Insert the entities into the store. */ +const updateStore = (store: EntityStore, entities: Entity[]): EntityStore => { + return entities.reduce((store, entity) => { + store[entity.id] = entity; + return store; + }, { ...store }); +}; + +/** Update the list with new entity IDs. */ +const updateList = (list: EntityList, entities: Entity[]): EntityList => { + const newIds = entities.map(entity => entity.id); + return { + ...list, + ids: new Set([...Array.from(list.ids), ...newIds]), + }; +}; + +/** Create an empty entity cache. */ +const createCache = (): EntityCache => ({ + store: {}, + lists: {}, +}); + +/** Create an empty entity list. */ +const createList = (): EntityList => ({ + ids: new Set(), + state: { + next: undefined, + prev: undefined, + fetching: false, + error: null, + lastFetchedAt: undefined, + }, +}); + +export { + updateStore, + updateList, + createCache, + createList, +}; \ No newline at end of file diff --git a/app/soapbox/features/groups/index.tsx b/app/soapbox/features/groups/index.tsx index f48bd1520d..cdebabf6cd 100644 --- a/app/soapbox/features/groups/index.tsx +++ b/app/soapbox/features/groups/index.tsx @@ -6,8 +6,7 @@ import { openModal } from 'soapbox/actions/modals'; import GroupCard from 'soapbox/components/group-card'; import ScrollableList from 'soapbox/components/scrollable-list'; import { Button, Stack, Text } from 'soapbox/components/ui'; -import { useAppDispatch, useAppSelector, useFeatures } from 'soapbox/hooks'; -import { useGroups } from 'soapbox/queries/groups'; +import { useAppDispatch, useAppSelector, useGroups, useFeatures } from 'soapbox/hooks'; import { PERMISSION_CREATE_GROUPS, hasPermission } from 'soapbox/utils/permissions'; import PlaceholderGroupCard from '../placeholder/components/placeholder-group-card'; @@ -16,16 +15,6 @@ import TabBar, { TabItems } from './components/tab-bar'; import type { Group as GroupEntity } from 'soapbox/types/entities'; -// const getOrderedGroups = createSelector([ -// (state: RootState) => state.groups.items, -// (state: RootState) => state.group_relationships, -// ], (groups, group_relationships) => ({ -// groups: (groups.toList().filter((item: GroupEntity | false) => !!item) as ImmutableList) -// .map((item) => item.set('relationship', group_relationships.get(item.id) || null)) -// .filter((item) => item.relationship?.member) -// .sort((a, b) => a.display_name.localeCompare(b.display_name)), -// })); - const EmptyMessage = () => ( diff --git a/app/soapbox/hooks/__tests__/useGroupsPath.test.ts b/app/soapbox/hooks/__tests__/useGroupsPath.test.ts index d2ffb2452e..de123d2111 100644 --- a/app/soapbox/hooks/__tests__/useGroupsPath.test.ts +++ b/app/soapbox/hooks/__tests__/useGroupsPath.test.ts @@ -62,6 +62,19 @@ describe('useGroupsPath()', () => { }); test('should default to the discovery page', async () => { + const store = { + entities: { + Groups: { + store: { + '1': normalizeGroup({}), + }, + lists: { + '': new Set(['1']), + }, + }, + }, + }; + const { result } = renderHook(useGroupsPath, undefined, store); await waitFor(() => { diff --git a/app/soapbox/hooks/index.ts b/app/soapbox/hooks/index.ts index afefefd72e..1b0545e836 100644 --- a/app/soapbox/hooks/index.ts +++ b/app/soapbox/hooks/index.ts @@ -5,6 +5,7 @@ export { useAppSelector } from './useAppSelector'; export { useClickOutside } from './useClickOutside'; export { useCompose } from './useCompose'; export { useDebounce } from './useDebounce'; +export { useGroup, useGroups } from './useGroups'; export { useGroupsPath } from './useGroupsPath'; export { useDimensions } from './useDimensions'; export { useFeatures } from './useFeatures'; diff --git a/app/soapbox/hooks/useGroups.ts b/app/soapbox/hooks/useGroups.ts new file mode 100644 index 0000000000..1c48e1e388 --- /dev/null +++ b/app/soapbox/hooks/useGroups.ts @@ -0,0 +1,53 @@ +import { useEntities, useEntity } from 'soapbox/entity-store/hooks'; +import { normalizeGroup, normalizeGroupRelationship } from 'soapbox/normalizers'; + +import type { Group, GroupRelationship } from 'soapbox/types/entities'; + +function useGroups() { + const { entities, ...result } = useEntities(['Group', ''], '/api/v1/groups', { parser: parseGroup }); + const { relationships } = useGroupRelationships(entities.map(entity => entity.id)); + + const groups = entities.map((group) => group.set('relationship', relationships[group.id] || null)); + + return { + ...result, + groups, + }; +} + +function useGroup(groupId: string, refetch = true) { + const { entity: group, ...result } = useEntity(['Group', groupId], `/api/v1/groups/${groupId}`, { parser: parseGroup, refetch }); + const { entity: relationship } = useGroupRelationship(groupId); + + return { + ...result, + group: group?.set('relationship', relationship || null), + }; +} + +function useGroupRelationship(groupId: string) { + return useEntity(['GroupRelationship', groupId], `/api/v1/groups/relationships?id[]=${groupId}`, { parser: parseGroupRelationship }); +} + +function useGroupRelationships(groupIds: string[]) { + const q = groupIds.map(id => `id[]=${id}`).join('&'); + const endpoint = groupIds.length ? `/api/v1/groups/relationships?${q}` : undefined; + const { entities, ...result } = useEntities(['GroupRelationship', q], endpoint, { parser: parseGroupRelationship }); + + const relationships = entities.reduce>((map, relationship) => { + map[relationship.id] = relationship; + return map; + }, {}); + + return { + ...result, + relationships, + }; +} + +// HACK: normalizers currently don't have the desired API. +// TODO: rewrite normalizers as Zod parsers. +const parseGroup = (entity: unknown) => entity ? normalizeGroup(entity as Record) : undefined; +const parseGroupRelationship = (entity: unknown) => entity ? normalizeGroupRelationship(entity as Record) : undefined; + +export { useGroup, useGroups }; \ No newline at end of file diff --git a/app/soapbox/hooks/useGroupsPath.ts b/app/soapbox/hooks/useGroupsPath.ts index 8a4759f324..b855ec0a6f 100644 --- a/app/soapbox/hooks/useGroupsPath.ts +++ b/app/soapbox/hooks/useGroupsPath.ts @@ -1,4 +1,4 @@ -import { useGroups } from 'soapbox/queries/groups'; +import { useGroups } from 'soapbox/hooks'; import { useFeatures } from './useFeatures'; diff --git a/app/soapbox/pages/group-page.tsx b/app/soapbox/pages/group-page.tsx index 92518a55dd..f617b8bd7b 100644 --- a/app/soapbox/pages/group-page.tsx +++ b/app/soapbox/pages/group-page.tsx @@ -11,8 +11,7 @@ import { GroupMediaPanel, SignUpPanel, } from 'soapbox/features/ui/util/async-components'; -import { useOwnAccount } from 'soapbox/hooks'; -import { useGroup } from 'soapbox/queries/groups'; +import { useGroup, useOwnAccount } from 'soapbox/hooks'; import { Tabs } from '../components/ui'; diff --git a/app/soapbox/reducers/index.ts b/app/soapbox/reducers/index.ts index c3a12618b4..d6194509d8 100644 --- a/app/soapbox/reducers/index.ts +++ b/app/soapbox/reducers/index.ts @@ -3,6 +3,7 @@ import { combineReducers } from 'redux-immutable'; import { AUTH_LOGGED_OUT } from 'soapbox/actions/auth'; import * as BuildConfig from 'soapbox/build-config'; +import entities from 'soapbox/entity-store/reducer'; import account_notes from './account-notes'; import accounts from './accounts'; @@ -90,6 +91,7 @@ const reducers = { custom_emojis, domain_lists, dropdown_menu, + entities, filters, group_editor, group_memberships, diff --git a/package.json b/package.json index d84c45d7f5..1fcbf2a93b 100644 --- a/package.json +++ b/package.json @@ -124,6 +124,7 @@ "html-webpack-harddisk-plugin": "^2.0.0", "html-webpack-plugin": "^5.5.0", "http-link-header": "^1.0.2", + "immer": "^9.0.19", "immutable": "^4.2.1", "imports-loader": "^4.0.0", "intersection-observer": "^0.12.2", diff --git a/yarn.lock b/yarn.lock index 854b6b51ce..d81f71d21b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -10206,6 +10206,11 @@ immediate@~3.0.5: resolved "https://registry.yarnpkg.com/immediate/-/immediate-3.0.6.tgz#9db1dbd0faf8de6fbe0f5dd5e56bb606280de69b" integrity sha1-nbHb0Pr43m++D13V5Wu2BigN5ps= +immer@^9.0.19: + version "9.0.19" + resolved "https://registry.yarnpkg.com/immer/-/immer-9.0.19.tgz#67fb97310555690b5f9cd8380d38fc0aabb6b38b" + integrity sha512-eY+Y0qcsB4TZKwgQzLaE/lqYMlKhv5J9dyd2RhhtGhNo2njPXDqU9XPfcNfa3MIDsdtZt5KlkIsirlo4dHsWdQ== + immer@^9.0.7: version "9.0.12" resolved "https://registry.yarnpkg.com/immer/-/immer-9.0.12.tgz#2d33ddf3ee1d247deab9d707ca472c8c942a0f20"