From 3b067c6fabd1ad12bd205759176dbffca6a1b4fe Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Sun, 4 Dec 2022 16:58:13 -0600 Subject: [PATCH 01/15] Scaffold entity store library --- app/soapbox/entity-store/actions.ts | 22 ++++++ app/soapbox/entity-store/hooks/index.ts | 2 + app/soapbox/entity-store/hooks/useEntities.ts | 72 +++++++++++++++++++ app/soapbox/entity-store/hooks/useEntity.ts | 41 +++++++++++ app/soapbox/entity-store/reducer.ts | 43 +++++++++++ app/soapbox/entity-store/types.ts | 44 ++++++++++++ app/soapbox/entity-store/utils.ts | 41 +++++++++++ app/soapbox/reducers/index.ts | 2 + package.json | 1 + yarn.lock | 5 ++ 10 files changed, 273 insertions(+) create mode 100644 app/soapbox/entity-store/actions.ts create mode 100644 app/soapbox/entity-store/hooks/index.ts create mode 100644 app/soapbox/entity-store/hooks/useEntities.ts create mode 100644 app/soapbox/entity-store/hooks/useEntity.ts create mode 100644 app/soapbox/entity-store/reducer.ts create mode 100644 app/soapbox/entity-store/types.ts create mode 100644 app/soapbox/entity-store/utils.ts diff --git a/app/soapbox/entity-store/actions.ts b/app/soapbox/entity-store/actions.ts new file mode 100644 index 0000000000..59a1afee84 --- /dev/null +++ b/app/soapbox/entity-store/actions.ts @@ -0,0 +1,22 @@ +import type { Entity } from './types'; + +const ENTITIES_IMPORT = 'ENTITIES_IMPORT'; + +/** Action to import entities into the cache. */ +function importEntities(entities: Entity[], entityType: string, listKey?: string) { + return { + type: ENTITIES_IMPORT, + entityType, + entities, + listKey, + }; +} + +/** Any action pertaining to entities. */ +type EntityAction = ReturnType; + +export { + ENTITIES_IMPORT, + importEntities, + 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..89afc23e3c --- /dev/null +++ b/app/soapbox/entity-store/hooks/useEntities.ts @@ -0,0 +1,72 @@ +import { useApi, useAppDispatch, useAppSelector } from 'soapbox/hooks'; + +import { importEntities } from '../actions'; + +import type { Entity } from '../types'; + +type EntityPath = [entityType: string, listKey: string] + +function useEntities(path: EntityPath, url: string) { + const api = useApi(); + const dispatch = useAppDispatch(); + + const [entityType, listKey] = path; + + const cache = useAppSelector(state => state.entities.get(entityType)); + const list = cache?.lists.get(listKey); + + const entityIds = list?.ids; + + const entities: readonly TEntity[] = entityIds ? ( + Array.from(entityIds).reduce((result, id) => { + const entity = cache?.store.get(id) as TEntity | undefined; + 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 fetchEntities = async() => { + const { data } = await api.get(url); + dispatch(importEntities(data, entityType, listKey)); + }; + + const fetchNextPage = async() => { + const next = list?.state.next; + + if (next) { + const { data } = await api.get(next); + dispatch(importEntities(data, entityType, listKey)); + } + }; + + const fetchPreviousPage = async() => { + const prev = list?.state.prev; + + if (prev) { + const { data } = await api.get(prev); + dispatch(importEntities(data, entityType, listKey)); + } + }; + + 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..2f37df2f38 --- /dev/null +++ b/app/soapbox/entity-store/hooks/useEntity.ts @@ -0,0 +1,41 @@ +import { 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] + +function useEntity(path: EntityPath, endpoint: string) { + const api = useApi(); + const dispatch = useAppDispatch(); + + const [entityType, entityId] = path; + const entity = useAppSelector(state => state.entities.get(entityType)?.store.get(entityId)) as TEntity | undefined; + + 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); + }); + }; + + 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..a9b01b84be --- /dev/null +++ b/app/soapbox/entity-store/reducer.ts @@ -0,0 +1,43 @@ +import produce, { enableMapSet } from 'immer'; + +import { EntityAction, ENTITIES_IMPORT } from './actions'; +import { createCache, createList, updateStore, updateList } from './utils'; + +import type { Entity, EntityCache } from './types'; + +enableMapSet(); + +/** Entity reducer state. */ +type State = Map; + +/** Import entities into the cache. */ +const importEntities = ( + state: Readonly, + entityType: string, + entities: Entity[], + listKey?: string, +): State => { + return produce(state, draft => { + const cache = draft.get(entityType) ?? createCache(); + cache.store = updateStore(cache.store, entities); + + if (listKey) { + const list = cache.lists.get(listKey) ?? createList(); + cache.lists.set(listKey, updateList(list, entities)); + } + + return draft.set(entityType, cache); + }); +}; + +/** Stores various entity data and lists in a one reducer. */ +function reducer(state: Readonly = new Map(), action: EntityAction): State { + switch (action.type) { + case ENTITIES_IMPORT: + return importEntities(state, action.entityType, action.entities, action.listKey); + 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..d114960b74 --- /dev/null +++ b/app/soapbox/entity-store/types.ts @@ -0,0 +1,44 @@ +/** 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. */ +type EntityStore = Map + +/** 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 +} + +/** 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: Map +} + +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..445cb44e9c --- /dev/null +++ b/app/soapbox/entity-store/utils.ts @@ -0,0 +1,41 @@ +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) => { + return store.set(entity.id, entity); + }, new Map(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: new Map(), + lists: new Map(), +}); + +/** Create an empty entity list. */ +const createList = (): EntityList => ({ + ids: new Set(), + state: { + next: undefined, + prev: undefined, + fetching: false, + error: null, + }, +}); + +export { + updateStore, + updateList, + createCache, + createList, +}; \ No newline at end of file diff --git a/app/soapbox/reducers/index.ts b/app/soapbox/reducers/index.ts index c900be16c2..3cffed6743 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'; @@ -116,6 +117,7 @@ const reducers = { rules, history, announcements, + entities, }; // Build a default state from all reducers: it has the key and `undefined` diff --git a/package.json b/package.json index d43fc9a6a6..e308ff374b 100644 --- a/package.json +++ b/package.json @@ -129,6 +129,7 @@ "html-webpack-harddisk-plugin": "^2.0.0", "html-webpack-plugin": "^5.5.0", "http-link-header": "^1.0.2", + "immer": "^9.0.16", "immutable": "^4.0.0", "imports-loader": "^4.0.0", "intersection-observer": "^0.12.0", diff --git a/yarn.lock b/yarn.lock index 433de75438..146f783aa5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6707,6 +6707,11 @@ immediate@~3.0.5: resolved "https://registry.yarnpkg.com/immediate/-/immediate-3.0.6.tgz#9db1dbd0faf8de6fbe0f5dd5e56bb606280de69b" integrity sha1-nbHb0Pr43m++D13V5Wu2BigN5ps= +immer@^9.0.16: + version "9.0.16" + resolved "https://registry.yarnpkg.com/immer/-/immer-9.0.16.tgz#8e7caab80118c2b54b37ad43e05758cdefad0198" + integrity sha512-qenGE7CstVm1NrHQbMh8YaSzTZTFNP3zPqr3YU0S0UY441j4bJTg4A2Hh5KAhwgaiU6ZZ1Ar6y/2f4TblnMReQ== + immer@^9.0.7: version "9.0.12" resolved "https://registry.yarnpkg.com/immer/-/immer-9.0.12.tgz#2d33ddf3ee1d247deab9d707ca472c8c942a0f20" From 52059f6f3732c9e98d43e4725e14d5dad365e58f Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Sun, 4 Dec 2022 17:26:28 -0600 Subject: [PATCH 02/15] EntityStore: add request/success/fail actions --- app/soapbox/entity-store/actions.ts | 42 +++++++++++++++++++++++++++-- app/soapbox/entity-store/reducer.ts | 32 +++++++++++++++++++++- 2 files changed, 71 insertions(+), 3 deletions(-) diff --git a/app/soapbox/entity-store/actions.ts b/app/soapbox/entity-store/actions.ts index 59a1afee84..e09191f87d 100644 --- a/app/soapbox/entity-store/actions.ts +++ b/app/soapbox/entity-store/actions.ts @@ -1,6 +1,9 @@ import type { Entity } from './types'; -const ENTITIES_IMPORT = 'ENTITIES_IMPORT'; +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) { @@ -12,11 +15,46 @@ function importEntities(entities: Entity[], entityType: string, listKey?: string }; } +function entitiesFetchRequest(entityType: string, listKey?: string) { + return { + type: ENTITIES_FETCH_REQUEST, + entityType, + listKey, + }; +} + +function entitiesFetchSuccess(entities: Entity[], entityType: string, listKey?: string) { + return { + type: ENTITIES_FETCH_SUCCESS, + entityType, + entities, + listKey, + }; +} + +function entitiesFetchFail(entityType: string, listKey?: string) { + return { + type: ENTITIES_FETCH_FAIL, + entityType, + listKey, + }; +} + /** Any action pertaining to entities. */ -type EntityAction = ReturnType; +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/reducer.ts b/app/soapbox/entity-store/reducer.ts index a9b01b84be..c588fd3ea1 100644 --- a/app/soapbox/entity-store/reducer.ts +++ b/app/soapbox/entity-store/reducer.ts @@ -1,6 +1,12 @@ import produce, { enableMapSet } from 'immer'; -import { EntityAction, ENTITIES_IMPORT } from './actions'; +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 } from './types'; @@ -30,11 +36,35 @@ const importEntities = ( }); }; +const setFetching = ( + state: State, + entityType: string, + listKey: string | undefined, + isFetching: boolean, +) => { + return produce(state, draft => { + const cache = draft.get(entityType) ?? createCache(); + + if (listKey) { + const list = cache.lists.get(listKey) ?? createList(); + list.state.fetching = isFetching; + cache.lists.set(listKey, list); + } + + return draft.set(entityType, cache); + }); +}; + /** Stores various entity data and lists in a one reducer. */ function reducer(state: Readonly = new Map(), action: EntityAction): State { switch (action.type) { case ENTITIES_IMPORT: + case ENTITIES_FETCH_SUCCESS: return importEntities(state, action.entityType, action.entities, action.listKey); + 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; } From f7bfc40b70ddba7f5b1ed76920fd7f91db3cd20d Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Sun, 4 Dec 2022 17:53:56 -0600 Subject: [PATCH 03/15] EntityStore: proper pagination support --- app/soapbox/api/index.ts | 4 ++ app/soapbox/entity-store/actions.ts | 8 ++-- app/soapbox/entity-store/hooks/useEntities.ts | 45 ++++++++++++------- app/soapbox/entity-store/reducer.ts | 14 ++++-- 4 files changed, 48 insertions(+), 23 deletions(-) diff --git a/app/soapbox/api/index.ts b/app/soapbox/api/index.ts index 97d7d25d71..46319cc963 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 index e09191f87d..d5e78c1f91 100644 --- a/app/soapbox/entity-store/actions.ts +++ b/app/soapbox/entity-store/actions.ts @@ -1,4 +1,4 @@ -import type { Entity } from './types'; +import type { Entity, EntityListState } from './types'; const ENTITIES_IMPORT = 'ENTITIES_IMPORT' as const; const ENTITIES_FETCH_REQUEST = 'ENTITIES_FETCH_REQUEST' as const; @@ -23,20 +23,22 @@ function entitiesFetchRequest(entityType: string, listKey?: string) { }; } -function entitiesFetchSuccess(entities: Entity[], entityType: string, listKey?: string) { +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) { +function entitiesFetchFail(entityType: string, listKey: string | undefined, error: any) { return { type: ENTITIES_FETCH_FAIL, entityType, listKey, + error, }; } diff --git a/app/soapbox/entity-store/hooks/useEntities.ts b/app/soapbox/entity-store/hooks/useEntities.ts index 89afc23e3c..10abc0d4da 100644 --- a/app/soapbox/entity-store/hooks/useEntities.ts +++ b/app/soapbox/entity-store/hooks/useEntities.ts @@ -1,12 +1,13 @@ +import { getNextLink, getPrevLink } from 'soapbox/api'; import { useApi, useAppDispatch, useAppSelector } from 'soapbox/hooks'; -import { importEntities } from '../actions'; +import { entitiesFetchFail, entitiesFetchRequest, entitiesFetchSuccess } from '../actions'; import type { Entity } from '../types'; type EntityPath = [entityType: string, listKey: string] -function useEntities(path: EntityPath, url: string) { +function useEntities(path: EntityPath, endpoint: string) { const api = useApi(); const dispatch = useAppDispatch(); @@ -32,26 +33,38 @@ function useEntities(path: EntityPath, url: string) { const hasNextPage = Boolean(list?.state.next); const hasPreviousPage = Boolean(list?.state.prev); - const fetchEntities = async() => { - const { data } = await api.get(url); - dispatch(importEntities(data, entityType, listKey)); - }; - - const fetchNextPage = async() => { - const next = list?.state.next; - - if (next) { - const { data } = await api.get(next); - dispatch(importEntities(data, entityType, listKey)); + const fetchPage = async(url: string): Promise => { + 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, + })); + } catch (error) { + dispatch(entitiesFetchFail(entityType, listKey, error)); } }; - const fetchPreviousPage = async() => { + const fetchEntities = async(): Promise => { + 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) { - const { data } = await api.get(prev); - dispatch(importEntities(data, entityType, listKey)); + await fetchPage(prev); } }; diff --git a/app/soapbox/entity-store/reducer.ts b/app/soapbox/entity-store/reducer.ts index c588fd3ea1..90170d9449 100644 --- a/app/soapbox/entity-store/reducer.ts +++ b/app/soapbox/entity-store/reducer.ts @@ -9,7 +9,7 @@ import { } from './actions'; import { createCache, createList, updateStore, updateList } from './utils'; -import type { Entity, EntityCache } from './types'; +import type { Entity, EntityCache, EntityListState } from './types'; enableMapSet(); @@ -22,14 +22,19 @@ const importEntities = ( entityType: string, entities: Entity[], listKey?: string, + newState?: EntityListState, ): State => { return produce(state, draft => { const cache = draft.get(entityType) ?? createCache(); cache.store = updateStore(cache.store, entities); if (listKey) { - const list = cache.lists.get(listKey) ?? createList(); - cache.lists.set(listKey, updateList(list, entities)); + let list = cache.lists.get(listKey) ?? createList(); + list = updateList(list, entities); + if (newState) { + list.state = newState; + } + cache.lists.set(listKey, list); } return draft.set(entityType, cache); @@ -59,8 +64,9 @@ const setFetching = ( function reducer(state: Readonly = new Map(), action: EntityAction): State { switch (action.type) { case ENTITIES_IMPORT: - case ENTITIES_FETCH_SUCCESS: 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: From 27500193d8f4b18e6f145982076c341080c57e4b Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Sun, 4 Dec 2022 18:54:54 -0600 Subject: [PATCH 04/15] EntityStore: incorporate Notifications, go back to using POJOs instead of Maps --- app/soapbox/entity-store/hooks/useEntities.ts | 6 +-- app/soapbox/entity-store/hooks/useEntity.ts | 2 +- app/soapbox/entity-store/reducer.ts | 28 +++++++------- app/soapbox/entity-store/types.ts | 8 +++- app/soapbox/entity-store/utils.ts | 9 +++-- app/soapbox/features/notifications/index.tsx | 37 +++++++++++-------- app/soapbox/hooks/useNotifications.ts | 18 +++++++++ 7 files changed, 70 insertions(+), 38 deletions(-) create mode 100644 app/soapbox/hooks/useNotifications.ts diff --git a/app/soapbox/entity-store/hooks/useEntities.ts b/app/soapbox/entity-store/hooks/useEntities.ts index 10abc0d4da..e525a86a77 100644 --- a/app/soapbox/entity-store/hooks/useEntities.ts +++ b/app/soapbox/entity-store/hooks/useEntities.ts @@ -13,14 +13,14 @@ function useEntities(path: EntityPath, endpoint: string) const [entityType, listKey] = path; - const cache = useAppSelector(state => state.entities.get(entityType)); - const list = cache?.lists.get(listKey); + 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 = cache?.store.get(id) as TEntity | undefined; + const entity = cache?.store[id] as TEntity | undefined; if (entity) { result.push(entity); } diff --git a/app/soapbox/entity-store/hooks/useEntity.ts b/app/soapbox/entity-store/hooks/useEntity.ts index 2f37df2f38..2861a25331 100644 --- a/app/soapbox/entity-store/hooks/useEntity.ts +++ b/app/soapbox/entity-store/hooks/useEntity.ts @@ -13,7 +13,7 @@ function useEntity(path: EntityPath, endpoint: string) { const dispatch = useAppDispatch(); const [entityType, entityId] = path; - const entity = useAppSelector(state => state.entities.get(entityType)?.store.get(entityId)) as TEntity | undefined; + const entity = useAppSelector(state => state.entities[entityType]?.store[entityId]) as TEntity | undefined; const [isFetching, setIsFetching] = useState(false); const isLoading = isFetching && !entity; diff --git a/app/soapbox/entity-store/reducer.ts b/app/soapbox/entity-store/reducer.ts index 90170d9449..f2af680a18 100644 --- a/app/soapbox/entity-store/reducer.ts +++ b/app/soapbox/entity-store/reducer.ts @@ -14,30 +14,32 @@ import type { Entity, EntityCache, EntityListState } from './types'; enableMapSet(); /** Entity reducer state. */ -type State = Map; +interface State { + [entityType: string]: EntityCache | undefined +} /** Import entities into the cache. */ const importEntities = ( - state: Readonly, + state: State, entityType: string, entities: Entity[], listKey?: string, newState?: EntityListState, ): State => { return produce(state, draft => { - const cache = draft.get(entityType) ?? createCache(); + const cache = draft[entityType] ?? createCache(); cache.store = updateStore(cache.store, entities); - if (listKey) { - let list = cache.lists.get(listKey) ?? createList(); + if (typeof listKey === 'string') { + let list = { ...(cache.lists[listKey] ?? createList()) }; list = updateList(list, entities); if (newState) { list.state = newState; } - cache.lists.set(listKey, list); + cache.lists[listKey] = list; } - return draft.set(entityType, cache); + draft[entityType] = cache; }); }; @@ -48,20 +50,20 @@ const setFetching = ( isFetching: boolean, ) => { return produce(state, draft => { - const cache = draft.get(entityType) ?? createCache(); + const cache = draft[entityType] ?? createCache(); - if (listKey) { - const list = cache.lists.get(listKey) ?? createList(); + if (typeof listKey === 'string') { + const list = cache.lists[listKey] ?? createList(); list.state.fetching = isFetching; - cache.lists.set(listKey, list); + cache.lists[listKey] = list; } - return draft.set(entityType, cache); + draft[entityType] = cache; }); }; /** Stores various entity data and lists in a one reducer. */ -function reducer(state: Readonly = new Map(), action: EntityAction): State { +function reducer(state: Readonly = {}, action: EntityAction): State { switch (action.type) { case ENTITIES_IMPORT: return importEntities(state, action.entityType, action.entities, action.listKey); diff --git a/app/soapbox/entity-store/types.ts b/app/soapbox/entity-store/types.ts index d114960b74..4e71201200 100644 --- a/app/soapbox/entity-store/types.ts +++ b/app/soapbox/entity-store/types.ts @@ -5,7 +5,9 @@ interface Entity { } /** Store of entities by ID. */ -type EntityStore = Map +interface EntityStore { + [id: string]: Entity | undefined +} /** List of entity IDs and fetch state. */ interface EntityList { @@ -32,7 +34,9 @@ interface EntityCache { /** Map of entities of this type. */ store: EntityStore /** Lists of entity IDs for a particular purpose. */ - lists: Map + lists: { + [listKey: string]: EntityList | undefined + } } export { diff --git a/app/soapbox/entity-store/utils.ts b/app/soapbox/entity-store/utils.ts index 445cb44e9c..fcff11a3ec 100644 --- a/app/soapbox/entity-store/utils.ts +++ b/app/soapbox/entity-store/utils.ts @@ -3,8 +3,9 @@ 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) => { - return store.set(entity.id, entity); - }, new Map(store)); + store[entity.id] = entity; + return store; + }, { ...store }); }; /** Update the list with new entity IDs. */ @@ -18,8 +19,8 @@ const updateList = (list: EntityList, entities: Entity[]): EntityList => { /** Create an empty entity cache. */ const createCache = (): EntityCache => ({ - store: new Map(), - lists: new Map(), + store: {}, + lists: {}, }); /** Create an empty entity list. */ diff --git a/app/soapbox/features/notifications/index.tsx b/app/soapbox/features/notifications/index.tsx index 820bb64c33..a9648e7eb6 100644 --- a/app/soapbox/features/notifications/index.tsx +++ b/app/soapbox/features/notifications/index.tsx @@ -17,6 +17,7 @@ import ScrollableList from 'soapbox/components/scrollable-list'; import { Column } from 'soapbox/components/ui'; import PlaceholderNotification from 'soapbox/features/placeholder/components/placeholder-notification'; import { useAppDispatch, useAppSelector, useSettings } from 'soapbox/hooks'; +import { useNotifications } from 'soapbox/hooks/useNotifications'; import FilterBar from './components/filter-bar'; import Notification from './components/notification'; @@ -50,24 +51,24 @@ const Notifications = () => { const intl = useIntl(); const settings = useSettings(); + const { + entities: notifications, + isLoading, + isFetching, + hasNextPage: hasMore, + fetchEntities, + } = useNotifications(); + const showFilterBar = settings.getIn(['notifications', 'quickFilter', 'show']); const activeFilter = settings.getIn(['notifications', 'quickFilter', 'active']); - const notifications = useAppSelector(state => getNotifications(state)); - const isLoading = useAppSelector(state => state.notifications.isLoading); - // const isUnread = useAppSelector(state => state.notifications.unread > 0); - const hasMore = useAppSelector(state => state.notifications.hasMore); const totalQueuedNotificationsCount = useAppSelector(state => state.notifications.totalQueuedNotificationsCount || 0); const node = useRef(null); const column = useRef(null); - const scrollableContentRef = useRef | null>(null); - - // const handleLoadGap = (maxId) => { - // dispatch(expandNotifications({ maxId })); - // }; + const scrollableContentRef = useRef | null>(null); const handleLoadOlder = useCallback(debounce(() => { - const last = notifications.last(); + const last = notifications[notifications.length]; dispatch(expandNotifications({ maxId: last && last.get('id') })); }, 300, { leading: true }), [notifications]); @@ -112,6 +113,12 @@ const Notifications = () => { return dispatch(expandNotifications()); }; + useEffect(() => { + if (!isFetching) { + fetchEntities(); + } + }, []); + useEffect(() => { handleDequeueNotifications(); dispatch(scrollTopNotifications(true)); @@ -128,7 +135,7 @@ const Notifications = () => { ? : ; - let scrollableContent: ImmutableList | null = null; + let scrollableContent: Iterable | null = null; const filterBarContainer = showFilterBar ? () @@ -136,7 +143,7 @@ const Notifications = () => { if (isLoading && scrollableContentRef.current) { scrollableContent = scrollableContentRef.current; - } else if (notifications.size > 0 || hasMore) { + } else if (notifications.length > 0 || hasMore) { scrollableContent = notifications.map((item) => ( { ref={node} scrollKey='notifications' isLoading={isLoading} - showLoading={isLoading && notifications.size === 0} + showLoading={isLoading && notifications.length === 0} hasMore={hasMore} emptyMessage={emptyMessage} placeholderComponent={PlaceholderNotification} @@ -165,8 +172,8 @@ const Notifications = () => { onScrollToTop={handleScrollToTop} onScroll={handleScroll} className={classNames({ - 'divide-y divide-gray-200 dark:divide-primary-800 divide-solid': notifications.size > 0, - 'space-y-2': notifications.size === 0, + 'divide-y divide-gray-200 dark:divide-primary-800 divide-solid': notifications.length > 0, + 'space-y-2': notifications.length === 0, })} > {scrollableContent as ImmutableList} diff --git a/app/soapbox/hooks/useNotifications.ts b/app/soapbox/hooks/useNotifications.ts new file mode 100644 index 0000000000..96181c4573 --- /dev/null +++ b/app/soapbox/hooks/useNotifications.ts @@ -0,0 +1,18 @@ +import { useEntities } from 'soapbox/entity-store/hooks'; +import { normalizeNotification } from 'soapbox/normalizers'; + +import type { Notification } from 'soapbox/types/entities'; + +function useNotifications() { + const result = useEntities(['Notification', ''], '/api/v1/notifications'); + + return { + ...result, + // TODO: handle this in the reducer by passing config. + entities: result.entities.map(normalizeNotification), + }; +} + +export { + useNotifications, +}; \ No newline at end of file From 4c6d13e4efdeaa92911be97066a8daa030aa0a0a Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Thu, 9 Mar 2023 11:21:27 -0600 Subject: [PATCH 05/15] Use EntityStore for Groups --- app/soapbox/features/groups/index.tsx | 13 +------------ app/soapbox/hooks/index.ts | 2 ++ app/soapbox/hooks/useGroup.ts | 24 ++++++++++++++++++++++++ app/soapbox/hooks/useGroups.ts | 24 ++++++++++++++++++++++++ app/soapbox/pages/group-page.tsx | 3 +-- 5 files changed, 52 insertions(+), 14 deletions(-) create mode 100644 app/soapbox/hooks/useGroup.ts create mode 100644 app/soapbox/hooks/useGroups.ts 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/index.ts b/app/soapbox/hooks/index.ts index afefefd72e..dceae2cdfc 100644 --- a/app/soapbox/hooks/index.ts +++ b/app/soapbox/hooks/index.ts @@ -5,6 +5,8 @@ export { useAppSelector } from './useAppSelector'; export { useClickOutside } from './useClickOutside'; export { useCompose } from './useCompose'; export { useDebounce } from './useDebounce'; +export { useGroup } from './useGroup'; +export { useGroups } from './useGroups'; export { useGroupsPath } from './useGroupsPath'; export { useDimensions } from './useDimensions'; export { useFeatures } from './useFeatures'; diff --git a/app/soapbox/hooks/useGroup.ts b/app/soapbox/hooks/useGroup.ts new file mode 100644 index 0000000000..7a2be86960 --- /dev/null +++ b/app/soapbox/hooks/useGroup.ts @@ -0,0 +1,24 @@ +import { useEffect } from 'react'; + +import { useEntity } from 'soapbox/entity-store/hooks'; +import { normalizeGroup } from 'soapbox/normalizers'; + +import type { Group } from 'soapbox/types/entities'; + +function useGroup(groupId: string) { + const result = useEntity(['Group', groupId], `/api/v1/groups/${groupId}`); + const { entity, isLoading, fetchEntity } = result; + + useEffect(() => { + if (!isLoading) { + fetchEntity(); + } + }, []); + + return { + ...result, + group: entity ? normalizeGroup(entity) : undefined, + }; +} + +export { useGroup }; \ No newline at end of file diff --git a/app/soapbox/hooks/useGroups.ts b/app/soapbox/hooks/useGroups.ts new file mode 100644 index 0000000000..eca2b663ba --- /dev/null +++ b/app/soapbox/hooks/useGroups.ts @@ -0,0 +1,24 @@ +import { useEffect } from 'react'; + +import { useEntities } from 'soapbox/entity-store/hooks'; +import { normalizeGroup } from 'soapbox/normalizers'; + +import type { Group } from 'soapbox/types/entities'; + +function useGroups() { + const result = useEntities(['Group', ''], '/api/v1/groups'); + const { entities, isLoading, fetchEntities } = result; + + useEffect(() => { + if (!isLoading) { + fetchEntities(); + } + }, []); + + return { + ...result, + groups: entities.map(normalizeGroup), + }; +} + +export { useGroups }; \ No newline at end of file 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'; From 8923e7b5d093e425def0063242d2931ef0896930 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Thu, 9 Mar 2023 11:23:14 -0600 Subject: [PATCH 06/15] Combine group hooks into one useGroups file --- app/soapbox/hooks/index.ts | 3 +-- app/soapbox/hooks/useGroup.ts | 24 ------------------------ app/soapbox/hooks/useGroups.ts | 22 ++++++++++++++++++++-- 3 files changed, 21 insertions(+), 28 deletions(-) delete mode 100644 app/soapbox/hooks/useGroup.ts diff --git a/app/soapbox/hooks/index.ts b/app/soapbox/hooks/index.ts index dceae2cdfc..1b0545e836 100644 --- a/app/soapbox/hooks/index.ts +++ b/app/soapbox/hooks/index.ts @@ -5,8 +5,7 @@ export { useAppSelector } from './useAppSelector'; export { useClickOutside } from './useClickOutside'; export { useCompose } from './useCompose'; export { useDebounce } from './useDebounce'; -export { useGroup } from './useGroup'; -export { useGroups } from './useGroups'; +export { useGroup, useGroups } from './useGroups'; export { useGroupsPath } from './useGroupsPath'; export { useDimensions } from './useDimensions'; export { useFeatures } from './useFeatures'; diff --git a/app/soapbox/hooks/useGroup.ts b/app/soapbox/hooks/useGroup.ts deleted file mode 100644 index 7a2be86960..0000000000 --- a/app/soapbox/hooks/useGroup.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { useEffect } from 'react'; - -import { useEntity } from 'soapbox/entity-store/hooks'; -import { normalizeGroup } from 'soapbox/normalizers'; - -import type { Group } from 'soapbox/types/entities'; - -function useGroup(groupId: string) { - const result = useEntity(['Group', groupId], `/api/v1/groups/${groupId}`); - const { entity, isLoading, fetchEntity } = result; - - useEffect(() => { - if (!isLoading) { - fetchEntity(); - } - }, []); - - return { - ...result, - group: entity ? normalizeGroup(entity) : undefined, - }; -} - -export { useGroup }; \ No newline at end of file diff --git a/app/soapbox/hooks/useGroups.ts b/app/soapbox/hooks/useGroups.ts index eca2b663ba..3d12e1be82 100644 --- a/app/soapbox/hooks/useGroups.ts +++ b/app/soapbox/hooks/useGroups.ts @@ -1,6 +1,6 @@ import { useEffect } from 'react'; -import { useEntities } from 'soapbox/entity-store/hooks'; +import { useEntities, useEntity } from 'soapbox/entity-store/hooks'; import { normalizeGroup } from 'soapbox/normalizers'; import type { Group } from 'soapbox/types/entities'; @@ -9,6 +9,8 @@ function useGroups() { const result = useEntities(['Group', ''], '/api/v1/groups'); const { entities, isLoading, fetchEntities } = result; + // Note: we have to fetch them in the hook right now because I haven't implemented + // max-age or cache expiry in the entity store yet. It's planned. useEffect(() => { if (!isLoading) { fetchEntities(); @@ -21,4 +23,20 @@ function useGroups() { }; } -export { useGroups }; \ No newline at end of file +function useGroup(groupId: string) { + const result = useEntity(['Group', groupId], `/api/v1/groups/${groupId}`); + const { entity, isLoading, fetchEntity } = result; + + useEffect(() => { + if (!isLoading) { + fetchEntity(); + } + }, []); + + return { + ...result, + group: entity ? normalizeGroup(entity) : undefined, + }; +} + +export { useGroup, useGroups }; \ No newline at end of file From 9964491da54536b2b108c8c0560c798b0dc3318d Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Thu, 9 Mar 2023 11:47:24 -0600 Subject: [PATCH 07/15] First draft of GroupRelationship entity hooks --- app/soapbox/hooks/useGroups.ts | 34 +++++++++++++++++++++++++++++++--- 1 file changed, 31 insertions(+), 3 deletions(-) diff --git a/app/soapbox/hooks/useGroups.ts b/app/soapbox/hooks/useGroups.ts index 3d12e1be82..1a69da1204 100644 --- a/app/soapbox/hooks/useGroups.ts +++ b/app/soapbox/hooks/useGroups.ts @@ -1,13 +1,14 @@ import { useEffect } from 'react'; import { useEntities, useEntity } from 'soapbox/entity-store/hooks'; -import { normalizeGroup } from 'soapbox/normalizers'; +import { normalizeGroup, normalizeGroupRelationship } from 'soapbox/normalizers'; -import type { Group } from 'soapbox/types/entities'; +import type { Group, GroupRelationship } from 'soapbox/types/entities'; function useGroups() { const result = useEntities(['Group', ''], '/api/v1/groups'); const { entities, isLoading, fetchEntities } = result; + const { entities: relationships } = useGroupRelationships(entities.map(entity => entity.id)); // Note: we have to fetch them in the hook right now because I haven't implemented // max-age or cache expiry in the entity store yet. It's planned. @@ -17,9 +18,19 @@ function useGroups() { } }, []); + const groups = entities.map((entity) => { + const group = normalizeGroup(entity); + // TODO: a generalistic useRelationships() hook that returns a map of values (would be faster). + const relationship = relationships.find(r => r.id === group.id); + if (relationship) { + return group.set('relationship', relationship); + } + return group; + }); + return { ...result, - groups: entities.map(normalizeGroup), + groups, }; } @@ -39,4 +50,21 @@ function useGroup(groupId: string) { }; } +function useGroupRelationships(groupIds: string[]) { + const q = groupIds.map(id => `id[]=${id}`).join('&'); + const result = useEntities(['GroupRelationship', ''], `/api/v1/groups/relationships?${q}`); + const { entities, isLoading, fetchEntities } = result; + + useEffect(() => { + if (!isLoading) { + fetchEntities(); + } + }, groupIds); + + return { + ...result, + relationships: entities.map(normalizeGroupRelationship), + }; +} + export { useGroup, useGroups }; \ No newline at end of file From 250b00963576978b30739cfe45580c3e953cef07 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Thu, 9 Mar 2023 12:32:50 -0600 Subject: [PATCH 08/15] EntityStore: allow passing a parser function to parse the entities --- app/soapbox/entity-store/hooks/useEntities.ts | 29 +++++++++++++++++-- app/soapbox/entity-store/hooks/useEntity.ts | 18 ++++++++++-- 2 files changed, 42 insertions(+), 5 deletions(-) diff --git a/app/soapbox/entity-store/hooks/useEntities.ts b/app/soapbox/entity-store/hooks/useEntities.ts index e525a86a77..c16abf69be 100644 --- a/app/soapbox/entity-store/hooks/useEntities.ts +++ b/app/soapbox/entity-store/hooks/useEntities.ts @@ -5,14 +5,37 @@ import { entitiesFetchFail, entitiesFetchRequest, entitiesFetchSuccess } from '. import type { Entity } from '../types'; -type EntityPath = [entityType: string, listKey: string] +/** 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, +] -function useEntities(path: EntityPath, endpoint: 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 +} + +/** 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'` */ + endpoint: string, + /** 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]; @@ -20,7 +43,7 @@ function useEntities(path: EntityPath, endpoint: string) const entities: readonly TEntity[] = entityIds ? ( Array.from(entityIds).reduce((result, id) => { - const entity = cache?.store[id] as TEntity | undefined; + const entity = parseEntity(cache?.store[id] as unknown); if (entity) { result.push(entity); } diff --git a/app/soapbox/entity-store/hooks/useEntity.ts b/app/soapbox/entity-store/hooks/useEntity.ts index 2861a25331..e74e0d5e85 100644 --- a/app/soapbox/entity-store/hooks/useEntity.ts +++ b/app/soapbox/entity-store/hooks/useEntity.ts @@ -8,12 +8,26 @@ import type { Entity } from '../types'; type EntityPath = [entityType: string, entityId: string] -function useEntity(path: EntityPath, endpoint: 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 +} + +function useEntity( + path: EntityPath, + endpoint: string, + opts: UseEntityOpts = {}, +) { const api = useApi(); const dispatch = useAppDispatch(); const [entityType, entityId] = path; - const entity = useAppSelector(state => state.entities[entityType]?.store[entityId]) as TEntity | undefined; + + 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; From d883f2f5bdcbdee12920361eb39fe67f65091bf5 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Thu, 9 Mar 2023 12:36:20 -0600 Subject: [PATCH 09/15] Group hooks: use new parser opt --- app/soapbox/hooks/useGroups.ts | 27 ++++++++++++++++++++------- 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/app/soapbox/hooks/useGroups.ts b/app/soapbox/hooks/useGroups.ts index 1a69da1204..7af1fa0247 100644 --- a/app/soapbox/hooks/useGroups.ts +++ b/app/soapbox/hooks/useGroups.ts @@ -5,8 +5,13 @@ import { normalizeGroup, normalizeGroupRelationship } from 'soapbox/normalizers' import type { Group, GroupRelationship } from 'soapbox/types/entities'; +// HACK: normalizers currently don't have the desired API. +// TODO: rewrite normalizers as Zod parsers. +const parseGroup = (entity: unknown) => normalizeGroup(entity as Record); +const parseGroupRelationship = (entity: unknown) => normalizeGroupRelationship(entity as Record); + function useGroups() { - const result = useEntities(['Group', ''], '/api/v1/groups'); + const result = useEntities(['Group', ''], '/api/v1/groups', { parser: parseGroup }); const { entities, isLoading, fetchEntities } = result; const { entities: relationships } = useGroupRelationships(entities.map(entity => entity.id)); @@ -18,8 +23,7 @@ function useGroups() { } }, []); - const groups = entities.map((entity) => { - const group = normalizeGroup(entity); + const groups = entities.map((group) => { // TODO: a generalistic useRelationships() hook that returns a map of values (would be faster). const relationship = relationships.find(r => r.id === group.id); if (relationship) { @@ -35,8 +39,9 @@ function useGroups() { } function useGroup(groupId: string) { - const result = useEntity(['Group', groupId], `/api/v1/groups/${groupId}`); + const result = useEntity(['Group', groupId], `/api/v1/groups/${groupId}`, { parser: parseGroup }); const { entity, isLoading, fetchEntity } = result; + const { relationship } = useGroupRelationship(groupId); useEffect(() => { if (!isLoading) { @@ -46,13 +51,21 @@ function useGroup(groupId: string) { return { ...result, - group: entity ? normalizeGroup(entity) : undefined, + group: entity?.set('relationship', relationship), + }; +} + +function useGroupRelationship(groupId: string) { + const { relationships, ...rest } = useGroupRelationships([groupId]); + return { + ...rest, + relationship: relationships[0], }; } function useGroupRelationships(groupIds: string[]) { const q = groupIds.map(id => `id[]=${id}`).join('&'); - const result = useEntities(['GroupRelationship', ''], `/api/v1/groups/relationships?${q}`); + const result = useEntities(['GroupRelationship', ''], `/api/v1/groups/relationships?${q}`, { parser: parseGroupRelationship }); const { entities, isLoading, fetchEntities } = result; useEffect(() => { @@ -63,7 +76,7 @@ function useGroupRelationships(groupIds: string[]) { return { ...result, - relationships: entities.map(normalizeGroupRelationship), + relationships: entities, }; } From a3b1f541bcf8dcb900b846a493025cd31fa2d17d Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Thu, 9 Mar 2023 14:20:04 -0600 Subject: [PATCH 10/15] EntityStore: support staleTime option (automatically fetch) --- app/soapbox/entity-store/hooks/useEntities.ts | 17 +++++++++++++++++ app/soapbox/entity-store/types.ts | 2 ++ app/soapbox/entity-store/utils.ts | 1 + 3 files changed, 20 insertions(+) diff --git a/app/soapbox/entity-store/hooks/useEntities.ts b/app/soapbox/entity-store/hooks/useEntities.ts index c16abf69be..ee64d64379 100644 --- a/app/soapbox/entity-store/hooks/useEntities.ts +++ b/app/soapbox/entity-store/hooks/useEntities.ts @@ -1,3 +1,5 @@ +import { useEffect } from 'react'; + import { getNextLink, getPrevLink } from 'soapbox/api'; import { useApi, useAppDispatch, useAppSelector } from 'soapbox/hooks'; @@ -17,6 +19,11 @@ type EntityPath = [ 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. */ @@ -65,6 +72,7 @@ function useEntities( prev: getPrevLink(response), fetching: false, error: null, + lastFetchedAt: new Date(), })); } catch (error) { dispatch(entitiesFetchFail(entityType, listKey, error)); @@ -91,6 +99,15 @@ function useEntities( } }; + const staleTime = opts.staleTime ?? 60000; + const lastFetchedAt = list?.state.lastFetchedAt; + + useEffect(() => { + if (!isFetching && (!lastFetchedAt || lastFetchedAt.getTime() + staleTime <= Date.now())) { + fetchEntities(); + } + }, [endpoint]); + return { entities, fetchEntities, diff --git a/app/soapbox/entity-store/types.ts b/app/soapbox/entity-store/types.ts index 4e71201200..efec97df13 100644 --- a/app/soapbox/entity-store/types.ts +++ b/app/soapbox/entity-store/types.ts @@ -27,6 +27,8 @@ interface EntityListState { 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.. */ diff --git a/app/soapbox/entity-store/utils.ts b/app/soapbox/entity-store/utils.ts index fcff11a3ec..22e0f0c5b2 100644 --- a/app/soapbox/entity-store/utils.ts +++ b/app/soapbox/entity-store/utils.ts @@ -31,6 +31,7 @@ const createList = (): EntityList => ({ prev: undefined, fetching: false, error: null, + lastFetchedAt: undefined, }, }); From ad583c89f85bbe2f74da6b7cd171dd65cea97fc5 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Thu, 9 Mar 2023 14:43:09 -0600 Subject: [PATCH 11/15] EntityStore: allow passing an undefined endpoint (to skip fetch), prevent race conditions in isFetching --- app/soapbox/entity-store/hooks/useEntities.ts | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/app/soapbox/entity-store/hooks/useEntities.ts b/app/soapbox/entity-store/hooks/useEntities.ts index ee64d64379..909273ea33 100644 --- a/app/soapbox/entity-store/hooks/useEntities.ts +++ b/app/soapbox/entity-store/hooks/useEntities.ts @@ -6,6 +6,7 @@ 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 = [ @@ -30,8 +31,8 @@ interface UseEntitiesOpts { function useEntities( /** Tells us where to find/store the entity in the cache. */ path: EntityPath, - /** API route to GET, eg `'/api/v1/notifications'` */ - endpoint: string, + /** API route to GET, eg `'/api/v1/notifications'`. If undefined, nothing will be fetched. */ + endpoint: string | undefined, /** Additional options for the hook. */ opts: UseEntitiesOpts = {}, ) { @@ -64,6 +65,10 @@ function useEntities( 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); @@ -80,7 +85,9 @@ function useEntities( }; const fetchEntities = async(): Promise => { - await fetchPage(endpoint); + if (endpoint) { + await fetchPage(endpoint); + } }; const fetchNextPage = async(): Promise => { From fa2884c11bce4c4098bc68182a721cce9eb307c2 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Thu, 9 Mar 2023 15:05:27 -0600 Subject: [PATCH 12/15] EntityStore: fetch with useEntity automatically, accept refetch opt --- app/soapbox/entity-store/hooks/useEntity.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/app/soapbox/entity-store/hooks/useEntity.ts b/app/soapbox/entity-store/hooks/useEntity.ts index e74e0d5e85..d0bae8630e 100644 --- a/app/soapbox/entity-store/hooks/useEntity.ts +++ b/app/soapbox/entity-store/hooks/useEntity.ts @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import { useApi, useAppDispatch, useAppSelector } from 'soapbox/hooks'; @@ -12,6 +12,8 @@ type EntityPath = [entityType: string, entityId: string] 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( @@ -42,6 +44,12 @@ function useEntity( }); }; + useEffect(() => { + if (!entity || opts.refetch) { + fetchEntity(); + } + }, []); + return { entity, fetchEntity, From 14a84e557c16eb293d56a15ffbe66a1053722641 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Thu, 9 Mar 2023 15:05:54 -0600 Subject: [PATCH 13/15] Update useGroups queries with EntityStore improvements --- app/soapbox/hooks/useGroups.ts | 70 +++++++++--------------------- app/soapbox/hooks/useGroupsPath.ts | 2 +- 2 files changed, 21 insertions(+), 51 deletions(-) diff --git a/app/soapbox/hooks/useGroups.ts b/app/soapbox/hooks/useGroups.ts index 7af1fa0247..a8e61b9af7 100644 --- a/app/soapbox/hooks/useGroups.ts +++ b/app/soapbox/hooks/useGroups.ts @@ -1,36 +1,13 @@ -import { useEffect } from 'react'; - import { useEntities, useEntity } from 'soapbox/entity-store/hooks'; import { normalizeGroup, normalizeGroupRelationship } from 'soapbox/normalizers'; import type { Group, GroupRelationship } from 'soapbox/types/entities'; -// HACK: normalizers currently don't have the desired API. -// TODO: rewrite normalizers as Zod parsers. -const parseGroup = (entity: unknown) => normalizeGroup(entity as Record); -const parseGroupRelationship = (entity: unknown) => normalizeGroupRelationship(entity as Record); - function useGroups() { - const result = useEntities(['Group', ''], '/api/v1/groups', { parser: parseGroup }); - const { entities, isLoading, fetchEntities } = result; - const { entities: relationships } = useGroupRelationships(entities.map(entity => entity.id)); + const { entities, ...result } = useEntities(['Group', ''], '/api/v1/groups', { parser: parseGroup }); + const { relationships } = useGroupRelationships(entities.map(entity => entity.id)); - // Note: we have to fetch them in the hook right now because I haven't implemented - // max-age or cache expiry in the entity store yet. It's planned. - useEffect(() => { - if (!isLoading) { - fetchEntities(); - } - }, []); - - const groups = entities.map((group) => { - // TODO: a generalistic useRelationships() hook that returns a map of values (would be faster). - const relationship = relationships.find(r => r.id === group.id); - if (relationship) { - return group.set('relationship', relationship); - } - return group; - }); + const groups = entities.map((group) => group.set('relationship', relationships[group.id] || null)); return { ...result, @@ -38,46 +15,39 @@ function useGroups() { }; } -function useGroup(groupId: string) { - const result = useEntity(['Group', groupId], `/api/v1/groups/${groupId}`, { parser: parseGroup }); - const { entity, isLoading, fetchEntity } = result; - const { relationship } = useGroupRelationship(groupId); - - useEffect(() => { - if (!isLoading) { - fetchEntity(); - } - }, []); +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: entity?.set('relationship', relationship), + group: group?.set('relationship', relationship || null), }; } function useGroupRelationship(groupId: string) { - const { relationships, ...rest } = useGroupRelationships([groupId]); - return { - ...rest, - relationship: relationships[0], - }; + 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 result = useEntities(['GroupRelationship', ''], `/api/v1/groups/relationships?${q}`, { parser: parseGroupRelationship }); - const { entities, isLoading, fetchEntities } = result; + const endpoint = groupIds.length ? `/api/v1/groups/relationships?${q}` : undefined; + const { entities, ...result } = useEntities(['GroupRelationship', ''], endpoint, { parser: parseGroupRelationship }); - useEffect(() => { - if (!isLoading) { - fetchEntities(); - } - }, groupIds); + const relationships = entities.reduce>((map, relationship) => { + map[relationship.id] = relationship; + return map; + }, {}); return { ...result, - relationships: entities, + relationships, }; } +// HACK: normalizers currently don't have the desired API. +// TODO: rewrite normalizers as Zod parsers. +const parseGroup = (entity: unknown) => normalizeGroup(entity as Record); +const parseGroupRelationship = (entity: unknown) => normalizeGroupRelationship(entity as Record); + 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'; From be9d922047908a5481c2c766de0f7d1a0b004389 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Thu, 9 Mar 2023 15:35:50 -0600 Subject: [PATCH 14/15] Actually, do put relationships in their own list. And fix parsers not doing the right thing. --- app/soapbox/hooks/useGroups.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/soapbox/hooks/useGroups.ts b/app/soapbox/hooks/useGroups.ts index a8e61b9af7..1c48e1e388 100644 --- a/app/soapbox/hooks/useGroups.ts +++ b/app/soapbox/hooks/useGroups.ts @@ -32,7 +32,7 @@ function useGroupRelationship(groupId: string) { 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', ''], endpoint, { parser: parseGroupRelationship }); + const { entities, ...result } = useEntities(['GroupRelationship', q], endpoint, { parser: parseGroupRelationship }); const relationships = entities.reduce>((map, relationship) => { map[relationship.id] = relationship; @@ -47,7 +47,7 @@ function useGroupRelationships(groupIds: string[]) { // HACK: normalizers currently don't have the desired API. // TODO: rewrite normalizers as Zod parsers. -const parseGroup = (entity: unknown) => normalizeGroup(entity as Record); -const parseGroupRelationship = (entity: unknown) => normalizeGroupRelationship(entity as Record); +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 From cf541e83b3308ecd41df53652d966467e5dd6e81 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Fri, 10 Mar 2023 10:56:00 -0600 Subject: [PATCH 15/15] Fix useGroupsPath test --- app/soapbox/hooks/__tests__/useGroupsPath.test.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) 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(() => {