diff --git a/app/soapbox/entity-store/hooks/types.ts b/app/soapbox/entity-store/hooks/types.ts index 7ce99fd82..95ba8b016 100644 --- a/app/soapbox/entity-store/hooks/types.ts +++ b/app/soapbox/entity-store/hooks/types.ts @@ -1,4 +1,5 @@ import type { Entity } from '../types'; +import type { AxiosResponse } from 'axios'; import type z from 'zod'; type EntitySchema = z.ZodType; @@ -24,9 +25,23 @@ type EntitiesPath = [entityType: string, listKey: string] /** Used to look up a single entity by its ID. */ type EntityPath = [entityType: string, entityId: string] +/** Callback functions for entity actions. */ +interface EntityCallbacks { + onSuccess?(value: Value): void + onError?(error: Error): void +} + +/** + * Passed into hooks to make requests. + * Must return an Axios response. + */ +type EntityFn = (value: T) => Promise + export type { EntitySchema, ExpandedEntitiesPath, EntitiesPath, EntityPath, + EntityCallbacks, + EntityFn, }; \ No newline at end of file diff --git a/app/soapbox/entity-store/hooks/useCreateEntity.ts b/app/soapbox/entity-store/hooks/useCreateEntity.ts index 373434e73..ba9dd802b 100644 --- a/app/soapbox/entity-store/hooks/useCreateEntity.ts +++ b/app/soapbox/entity-store/hooks/useCreateEntity.ts @@ -1,52 +1,33 @@ import { z } from 'zod'; -import { useAppDispatch } from 'soapbox/hooks'; +import { useAppDispatch, useLoading } from 'soapbox/hooks'; import { importEntities } from '../actions'; import { parseEntitiesPath } from './utils'; import type { Entity } from '../types'; -import type { EntitySchema, ExpandedEntitiesPath } from './types'; - -type CreateFn = (params: Params) => Promise | Result; +import type { EntityCallbacks, EntityFn, EntitySchema, ExpandedEntitiesPath } from './types'; interface UseCreateEntityOpts { schema?: EntitySchema } -type CreateEntityResult = - { - success: true - result: Result - entity: TEntity - } | { - success: false - error: Error - } - -interface EntityCallbacks { - onSuccess?(entity: TEntity): void - onError?(error: Error): void -} - -function useCreateEntity( +function useCreateEntity( expandedPath: ExpandedEntitiesPath, - createFn: CreateFn, + entityFn: EntityFn, opts: UseCreateEntityOpts = {}, ) { - const { entityType, listKey } = parseEntitiesPath(expandedPath); - const dispatch = useAppDispatch(); - return async function createEntity( - params: Params, - callbacks: EntityCallbacks = {}, - ): Promise> { + const [isLoading, setPromise] = useLoading(); + const { entityType, listKey } = parseEntitiesPath(expandedPath); + + async function createEntity(data: Data, callbacks: EntityCallbacks = {}): Promise { try { - const result = await createFn(params); + const result = await setPromise(entityFn(data)); const schema = opts.schema || z.custom(); - const entity = schema.parse(result); + const entity = schema.parse(result.data); // TODO: optimistic updating dispatch(importEntities([entity], entityType, listKey)); @@ -54,22 +35,16 @@ function useCreateEntity = (entityId: string) => Promise | T; - -interface EntityCallbacks { - onSuccess?(): void -} +import type { EntityCallbacks, EntityFn } from './types'; /** * Optimistically deletes an entity from the store. * This hook should be used to globally delete an entity from all lists. * To remove an entity from a single list, see `useDismissEntity`. */ -function useDeleteEntity( +function useDeleteEntity( entityType: string, - deleteFn: DeleteFn, + entityFn: EntityFn, ) { const dispatch = useAppDispatch(); const getState = useGetState(); + const [isLoading, setPromise] = useLoading(); - return async function deleteEntity(entityId: string, callbacks: EntityCallbacks = {}): Promise { + async function deleteEntity(entityId: string, callbacks: EntityCallbacks = {}): Promise { // Get the entity before deleting, so we can reverse the action if the API request fails. const entity = getState().entities[entityType]?.store[entityId]; @@ -28,22 +25,29 @@ function useDeleteEntity( dispatch(deleteEntities([entityId], entityType, { preserveLists: true })); try { - const result = await deleteFn(entityId); + await setPromise(entityFn(entityId)); + // Success - finish deleting entity from the state. dispatch(deleteEntities([entityId], entityType)); if (callbacks.onSuccess) { - callbacks.onSuccess(); + callbacks.onSuccess(entityId); } - - return result; } catch (e) { if (entity) { // If the API failed, reimport the entity. dispatch(importEntities([entity], entityType)); } - throw e; + + if (callbacks.onError) { + callbacks.onError(e); + } } + } + + return { + deleteEntity, + isLoading, }; } diff --git a/app/soapbox/entity-store/hooks/useDismissEntity.ts b/app/soapbox/entity-store/hooks/useDismissEntity.ts index 1ba5f4a60..b09e35951 100644 --- a/app/soapbox/entity-store/hooks/useDismissEntity.ts +++ b/app/soapbox/entity-store/hooks/useDismissEntity.ts @@ -1,27 +1,31 @@ -import { useAppDispatch } from 'soapbox/hooks'; +import { useAppDispatch, useLoading } from 'soapbox/hooks'; import { dismissEntities } from '../actions'; import { parseEntitiesPath } from './utils'; -import type { ExpandedEntitiesPath } from './types'; - -type DismissFn = (entityId: string) => Promise | T; +import type { EntityFn, ExpandedEntitiesPath } from './types'; /** * Removes an entity from a specific list. * To remove an entity globally from all lists, see `useDeleteEntity`. */ -function useDismissEntity(expandedPath: ExpandedEntitiesPath, dismissFn: DismissFn) { - const { entityType, listKey } = parseEntitiesPath(expandedPath); - +function useDismissEntity(expandedPath: ExpandedEntitiesPath, entityFn: EntityFn) { const dispatch = useAppDispatch(); + const [isLoading, setPromise] = useLoading(); + const { entityType, listKey } = parseEntitiesPath(expandedPath); + // TODO: optimistic dismissing - return async function dismissEntity(entityId: string): Promise { - const result = await dismissFn(entityId); + async function dismissEntity(entityId: string) { + const result = await setPromise(entityFn(entityId)); dispatch(dismissEntities([entityId], entityType, listKey)); return result; + } + + return { + dismissEntity, + isLoading, }; } diff --git a/app/soapbox/entity-store/hooks/useEntities.ts b/app/soapbox/entity-store/hooks/useEntities.ts index 8e679709a..f2e84c93e 100644 --- a/app/soapbox/entity-store/hooks/useEntities.ts +++ b/app/soapbox/entity-store/hooks/useEntities.ts @@ -11,7 +11,7 @@ import { entitiesFetchFail, entitiesFetchRequest, entitiesFetchSuccess, invalida import { parseEntitiesPath } from './utils'; import type { Entity, EntityListState } from '../types'; -import type { EntitiesPath, EntitySchema, ExpandedEntitiesPath } from './types'; +import type { EntitiesPath, EntityFn, EntitySchema, ExpandedEntitiesPath } from './types'; import type { RootState } from 'soapbox/store'; /** Additional options for the hook. */ @@ -32,7 +32,7 @@ function useEntities( /** Tells us where to find/store the entity in the cache. */ expandedPath: ExpandedEntitiesPath, /** API route to GET, eg `'/api/v1/notifications'`. If undefined, nothing will be fetched. */ - endpoint: string | undefined, + entityFn: EntityFn, /** Additional options for the hook. */ opts: UseEntitiesOpts = {}, ) { @@ -54,14 +54,14 @@ function useEntities( const next = useListState(path, 'next'); const prev = useListState(path, 'prev'); - const fetchPage = async(url: string, overwrite = false): Promise => { + const fetchPage = async(req: EntityFn, overwrite = false): Promise => { // Get `isFetching` state from the store again to prevent race conditions. const isFetching = selectListState(getState(), path, 'fetching'); if (isFetching) return; dispatch(entitiesFetchRequest(entityType, listKey)); try { - const response = await api.get(url); + 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']); @@ -82,20 +82,18 @@ function useEntities( }; const fetchEntities = async(): Promise => { - if (endpoint) { - await fetchPage(endpoint, true); - } + await fetchPage(entityFn, true); }; const fetchNextPage = async(): Promise => { if (next) { - await fetchPage(next); + await fetchPage(() => api.get(next)); } }; const fetchPreviousPage = async(): Promise => { if (prev) { - await fetchPage(prev); + await fetchPage(() => api.get(prev)); } }; @@ -114,7 +112,7 @@ function useEntities( if (isInvalid || isUnset || isStale) { fetchEntities(); } - }, [endpoint, isEnabled]); + }, [isEnabled]); return { entities, diff --git a/app/soapbox/entity-store/hooks/useEntity.ts b/app/soapbox/entity-store/hooks/useEntity.ts index aa7b40b5d..f30c9a18a 100644 --- a/app/soapbox/entity-store/hooks/useEntity.ts +++ b/app/soapbox/entity-store/hooks/useEntity.ts @@ -1,12 +1,12 @@ -import { useEffect, useState } from 'react'; +import { useEffect } from 'react'; import z from 'zod'; -import { useApi, useAppDispatch, useAppSelector } from 'soapbox/hooks'; +import { useAppDispatch, useAppSelector, useLoading } from 'soapbox/hooks'; import { importEntities } from '../actions'; import type { Entity } from '../types'; -import type { EntitySchema, EntityPath } from './types'; +import type { EntitySchema, EntityPath, EntityFn } from './types'; /** Additional options for the hook. */ interface UseEntityOpts { @@ -18,10 +18,10 @@ interface UseEntityOpts { function useEntity( path: EntityPath, - endpoint: string, + entityFn: EntityFn, opts: UseEntityOpts = {}, ) { - const api = useApi(); + const [isFetching, setPromise] = useLoading(); const dispatch = useAppDispatch(); const [entityType, entityId] = path; @@ -31,18 +31,16 @@ function useEntity( const entity = useAppSelector(state => state.entities[entityType]?.store[entityId] as TEntity | undefined); - const [isFetching, setIsFetching] = useState(false); const isLoading = isFetching && !entity; - const fetchEntity = () => { - setIsFetching(true); - api.get(endpoint).then(({ data }) => { - const entity = schema.parse(data); + const fetchEntity = async () => { + try { + const response = await setPromise(entityFn()); + const entity = schema.parse(response.data); dispatch(importEntities([entity], entityType)); - setIsFetching(false); - }).catch(() => { - setIsFetching(false); - }); + } catch (e) { + // do nothing + } }; useEffect(() => { diff --git a/app/soapbox/entity-store/hooks/useEntityActions.ts b/app/soapbox/entity-store/hooks/useEntityActions.ts index 9312aef67..dab6f7f77 100644 --- a/app/soapbox/entity-store/hooks/useEntityActions.ts +++ b/app/soapbox/entity-store/hooks/useEntityActions.ts @@ -1,5 +1,3 @@ -import { useState } from 'react'; - import { useApi } from 'soapbox/hooks'; import { useCreateEntity } from './useCreateEntity'; @@ -18,7 +16,7 @@ interface EntityActionEndpoints { delete?: string } -function useEntityActions( +function useEntityActions( expandedPath: ExpandedEntitiesPath, endpoints: EntityActionEndpoints, opts: UseEntityActionsOpts = {}, @@ -26,24 +24,16 @@ function useEntityActions( const api = useApi(); const { entityType, path } = parseEntitiesPath(expandedPath); - const [isLoading, setIsLoading] = useState(false); + const { deleteEntity, isLoading: deleteLoading } = + useDeleteEntity(entityType, (entityId) => api.delete(endpoints.delete!.replaceAll(':id', entityId))); - const deleteEntity = useDeleteEntity(entityType, (entityId) => { - if (!endpoints.delete) return Promise.reject(endpoints); - return api.delete(endpoints.delete.replace(':id', entityId)) - .finally(() => setIsLoading(false)); - }); - - const createEntity = useCreateEntity(path, (params: Params) => { - if (!endpoints.post) return Promise.reject(endpoints); - return api.post(endpoints.post, params) - .finally(() => setIsLoading(false)); - }, opts); + const { createEntity, isLoading: createLoading } = + useCreateEntity(path, (data) => api.post(endpoints.post!, data), opts); return { createEntity, deleteEntity, - isLoading, + isLoading: createLoading || deleteLoading, }; } diff --git a/app/soapbox/entity-store/hooks/useIncrementEntity.ts b/app/soapbox/entity-store/hooks/useIncrementEntity.ts index c0cbd133d..2b09cc445 100644 --- a/app/soapbox/entity-store/hooks/useIncrementEntity.ts +++ b/app/soapbox/entity-store/hooks/useIncrementEntity.ts @@ -1,32 +1,36 @@ -import { useAppDispatch } from 'soapbox/hooks'; +import { useAppDispatch, useLoading } from 'soapbox/hooks'; import { incrementEntities } from '../actions'; import { parseEntitiesPath } from './utils'; -import type { ExpandedEntitiesPath } from './types'; - -type IncrementFn = (entityId: string) => Promise | T; +import type { EntityFn, ExpandedEntitiesPath } from './types'; /** * Increases (or decreases) the `totalCount` in the entity list by the specified amount. * This only works if the API returns an `X-Total-Count` header and your components read it. */ -function useIncrementEntity( +function useIncrementEntity( expandedPath: ExpandedEntitiesPath, diff: number, - incrementFn: IncrementFn, + entityFn: EntityFn, ) { - const { entityType, listKey } = parseEntitiesPath(expandedPath); const dispatch = useAppDispatch(); + const [isLoading, setPromise] = useLoading(); + const { entityType, listKey } = parseEntitiesPath(expandedPath); - return async function incrementEntity(entityId: string): Promise { + async function incrementEntity(entityId: string): Promise { dispatch(incrementEntities(entityType, listKey, diff)); try { - await incrementFn(entityId); + await setPromise(entityFn(entityId)); } catch (e) { dispatch(incrementEntities(entityType, listKey, diff * -1)); } + } + + return { + incrementEntity, + isLoading, }; } diff --git a/app/soapbox/entity-store/hooks/utils.ts b/app/soapbox/entity-store/hooks/utils.ts index d137ca1fb..8b9269a2e 100644 --- a/app/soapbox/entity-store/hooks/utils.ts +++ b/app/soapbox/entity-store/hooks/utils.ts @@ -12,4 +12,5 @@ function parseEntitiesPath(expandedPath: ExpandedEntitiesPath) { }; } + export { parseEntitiesPath }; \ No newline at end of file diff --git a/app/soapbox/hooks/api/groups/useGroupMembershipRequests.ts b/app/soapbox/hooks/api/groups/useGroupMembershipRequests.ts index 560aef329..6fab87209 100644 --- a/app/soapbox/hooks/api/groups/useGroupMembershipRequests.ts +++ b/app/soapbox/hooks/api/groups/useGroupMembershipRequests.ts @@ -11,20 +11,20 @@ function useGroupMembershipRequests(groupId: string) { const { entities, invalidate, ...rest } = useEntities( path, - `/api/v1/groups/${groupId}/membership_requests`, + () => api.get(`/api/v1/groups/${groupId}/membership_requests`), { schema: accountSchema }, ); - const authorize = useIncrementEntity(path, -1, (accountId: string) => { - return api - .post(`/api/v1/groups/${groupId}/membership_requests/${accountId}/authorize`) - .then(invalidate); + const { incrementEntity: authorize } = useIncrementEntity(path, -1, async (accountId: string) => { + const response = await api.post(`/api/v1/groups/${groupId}/membership_requests/${accountId}/authorize`); + invalidate(); + return response; }); - const reject = useIncrementEntity(path, -1, (accountId: string) => { - return api - .post(`/api/v1/groups/${groupId}/membership_requests/${accountId}/reject`) - .then(invalidate); + const { incrementEntity: reject } = useIncrementEntity(path, -1, async (accountId: string) => { + const response = await api.post(`/api/v1/groups/${groupId}/membership_requests/${accountId}/reject`); + invalidate(); + return response; }); return { diff --git a/app/soapbox/hooks/api/useGroupMembers.ts b/app/soapbox/hooks/api/useGroupMembers.ts index 8948660d6..669f1c082 100644 --- a/app/soapbox/hooks/api/useGroupMembers.ts +++ b/app/soapbox/hooks/api/useGroupMembers.ts @@ -2,10 +2,14 @@ import { Entities } from 'soapbox/entity-store/entities'; import { useEntities } from 'soapbox/entity-store/hooks'; import { GroupMember, groupMemberSchema } from 'soapbox/schemas'; +import { useApi } from '../useApi'; + function useGroupMembers(groupId: string, role: string) { + const api = useApi(); + const { entities, ...result } = useEntities( [Entities.GROUP_MEMBERSHIPS, groupId, role], - `/api/v1/groups/${groupId}/memberships?role=${role}`, + () => api.get(`/api/v1/groups/${groupId}/memberships?role=${role}`), { schema: groupMemberSchema }, ); diff --git a/app/soapbox/hooks/api/usePopularGroups.ts b/app/soapbox/hooks/api/usePopularGroups.ts index 88ae48c9d..97d375174 100644 --- a/app/soapbox/hooks/api/usePopularGroups.ts +++ b/app/soapbox/hooks/api/usePopularGroups.ts @@ -2,15 +2,17 @@ import { Entities } from 'soapbox/entity-store/entities'; import { useEntities } from 'soapbox/entity-store/hooks'; import { Group, groupSchema } from 'soapbox/schemas'; +import { useApi } from '../useApi'; import { useFeatures } from '../useFeatures'; import { useGroupRelationships } from '../useGroups'; function usePopularGroups() { + const api = useApi(); const features = useFeatures(); const { entities, ...result } = useEntities( [Entities.GROUPS, 'popular'], - '/api/mock/groups', // '/api/v1/truth/trends/groups' + () => api.get('/api/mock/groups'), // '/api/v1/truth/trends/groups' { schema: groupSchema, enabled: features.groupsDiscovery, diff --git a/app/soapbox/hooks/api/useSuggestedGroups.ts b/app/soapbox/hooks/api/useSuggestedGroups.ts index c1b85805c..9d5e20ace 100644 --- a/app/soapbox/hooks/api/useSuggestedGroups.ts +++ b/app/soapbox/hooks/api/useSuggestedGroups.ts @@ -2,15 +2,17 @@ import { Entities } from 'soapbox/entity-store/entities'; import { useEntities } from 'soapbox/entity-store/hooks'; import { Group, groupSchema } from 'soapbox/schemas'; +import { useApi } from '../useApi'; import { useFeatures } from '../useFeatures'; import { useGroupRelationships } from '../useGroups'; function useSuggestedGroups() { + const api = useApi(); const features = useFeatures(); const { entities, ...result } = useEntities( [Entities.GROUPS, 'suggested'], - '/api/mock/groups', // '/api/v1/truth/suggestions/groups' + () => api.get('/api/mock/groups'), // '/api/v1/truth/suggestions/groups' { schema: groupSchema, enabled: features.groupsDiscovery, diff --git a/app/soapbox/hooks/index.ts b/app/soapbox/hooks/index.ts index 9cfd0a5e1..3f66a8147 100644 --- a/app/soapbox/hooks/index.ts +++ b/app/soapbox/hooks/index.ts @@ -11,6 +11,7 @@ export { useGroupsPath } from './useGroupsPath'; export { useDimensions } from './useDimensions'; export { useFeatures } from './useFeatures'; export { useInstance } from './useInstance'; +export { useLoading } from './useLoading'; export { useLocale } from './useLocale'; export { useOnScreen } from './useOnScreen'; export { useOwnAccount } from './useOwnAccount'; diff --git a/app/soapbox/hooks/useGroups.ts b/app/soapbox/hooks/useGroups.ts index d143b5f99..9bd0e99ca 100644 --- a/app/soapbox/hooks/useGroups.ts +++ b/app/soapbox/hooks/useGroups.ts @@ -2,17 +2,19 @@ import { z } from 'zod'; import { Entities } from 'soapbox/entity-store/entities'; import { useEntities, useEntity } from 'soapbox/entity-store/hooks'; +import { useApi } from 'soapbox/hooks'; import { groupSchema, Group } from 'soapbox/schemas/group'; import { groupRelationshipSchema, GroupRelationship } from 'soapbox/schemas/group-relationship'; import { useFeatures } from './useFeatures'; function useGroups() { + const api = useApi(); const features = useFeatures(); const { entities, ...result } = useEntities( [Entities.GROUPS], - '/api/v1/groups', + () => api.get('/api/v1/groups'), { enabled: features.groups, schema: groupSchema }, ); const { relationships } = useGroupRelationships(entities.map(entity => entity.id)); @@ -29,9 +31,11 @@ function useGroups() { } function useGroup(groupId: string, refetch = true) { + const api = useApi(); + const { entity: group, ...result } = useEntity( [Entities.GROUPS, groupId], - `/api/v1/groups/${groupId}`, + () => api.get(`/api/v1/groups/${groupId}`), { schema: groupSchema, refetch }, ); const { entity: relationship } = useGroupRelationship(groupId); @@ -43,20 +47,22 @@ function useGroup(groupId: string, refetch = true) { } function useGroupRelationship(groupId: string) { + const api = useApi(); + return useEntity( [Entities.GROUP_RELATIONSHIPS, groupId], - `/api/v1/groups/relationships?id[]=${groupId}`, + () => api.get(`/api/v1/groups/relationships?id[]=${groupId}`), { schema: z.array(groupRelationshipSchema).transform(arr => arr[0]) }, ); } function useGroupRelationships(groupIds: string[]) { + const api = useApi(); const q = groupIds.map(id => `id[]=${id}`).join('&'); - const endpoint = groupIds.length ? `/api/v1/groups/relationships?${q}` : undefined; const { entities, ...result } = useEntities( [Entities.GROUP_RELATIONSHIPS, ...groupIds], - endpoint, - { schema: groupRelationshipSchema }, + () => api.get(`/api/v1/groups/relationships?${q}`), + { schema: groupRelationshipSchema, enabled: groupIds.length > 0 }, ); const relationships = entities.reduce>((map, relationship) => { diff --git a/app/soapbox/hooks/useLoading.ts b/app/soapbox/hooks/useLoading.ts new file mode 100644 index 000000000..51f15e521 --- /dev/null +++ b/app/soapbox/hooks/useLoading.ts @@ -0,0 +1,19 @@ +import { useState } from 'react'; + +function useLoading() { + const [isLoading, setIsLoading] = useState(false); + + function setPromise(promise: Promise) { + setIsLoading(true); + + promise + .then(() => setIsLoading(false)) + .catch(() => setIsLoading(false)); + + return promise; + } + + return [isLoading, setPromise] as const; +} + +export { useLoading }; \ No newline at end of file