diff --git a/app/soapbox/api/hooks/accounts/useFollow.ts b/app/soapbox/api/hooks/accounts/useFollow.ts index 9c1e4bb94..3d81182be 100644 --- a/app/soapbox/api/hooks/accounts/useFollow.ts +++ b/app/soapbox/api/hooks/accounts/useFollow.ts @@ -1,13 +1,9 @@ +import { importEntities } from 'soapbox/entity-store/actions'; import { Entities } from 'soapbox/entity-store/entities'; -import { useChangeEntity } from 'soapbox/entity-store/hooks'; -import { useLoggedIn } from 'soapbox/hooks'; +import { useTransaction } from 'soapbox/entity-store/hooks'; +import { useAppDispatch, useLoggedIn } from 'soapbox/hooks'; import { useApi } from 'soapbox/hooks/useApi'; -import { type Account } from 'soapbox/schemas'; - -function useChangeAccount() { - const { changeEntity: changeAccount } = useChangeEntity(Entities.ACCOUNTS); - return { changeAccount }; -} +import { relationshipSchema } from 'soapbox/schemas'; interface FollowOpts { reblogs?: boolean @@ -17,50 +13,75 @@ interface FollowOpts { function useFollow() { const api = useApi(); + const dispatch = useAppDispatch(); const { isLoggedIn } = useLoggedIn(); - const { changeAccount } = useChangeAccount(); + const { transaction } = useTransaction(); - function incrementFollowers(accountId: string) { - changeAccount(accountId, (account) => ({ - ...account, - followers_count: account.followers_count + 1, - })); + function followEffect(accountId: string) { + transaction({ + Accounts: { + [accountId]: (account) => ({ + ...account, + followers_count: account.followers_count + 1, + }), + }, + Relationships: { + [accountId]: (relationship) => ({ + ...relationship, + following: true, + }), + }, + }); } - function decrementFollowers(accountId: string) { - changeAccount(accountId, (account) => ({ - ...account, - followers_count: Math.max(0, account.followers_count - 1), - })); + function unfollowEffect(accountId: string) { + transaction({ + Accounts: { + [accountId]: (account) => ({ + ...account, + followers_count: Math.max(0, account.followers_count - 1), + }), + }, + Relationships: { + [accountId]: (relationship) => ({ + ...relationship, + following: false, + }), + }, + }); } async function follow(accountId: string, options: FollowOpts = {}) { if (!isLoggedIn) return; - incrementFollowers(accountId); + followEffect(accountId); try { - await api.post(`/api/v1/accounts/${accountId}/follow`, options); + const response = await api.post(`/api/v1/accounts/${accountId}/follow`, options); + const result = relationshipSchema.safeParse(response.data); + if (result.success) { + dispatch(importEntities([result.data], Entities.RELATIONSHIPS)); + } } catch (e) { - decrementFollowers(accountId); + unfollowEffect(accountId); } } async function unfollow(accountId: string) { if (!isLoggedIn) return; - decrementFollowers(accountId); + unfollowEffect(accountId); try { await api.post(`/api/v1/accounts/${accountId}/unfollow`); } catch (e) { - incrementFollowers(accountId); + followEffect(accountId); } } return { follow, unfollow, - incrementFollowers, - decrementFollowers, + followEffect, + unfollowEffect, }; } diff --git a/app/soapbox/entity-store/actions.ts b/app/soapbox/entity-store/actions.ts index bb96255c6..9678fe4d1 100644 --- a/app/soapbox/entity-store/actions.ts +++ b/app/soapbox/entity-store/actions.ts @@ -1,4 +1,4 @@ -import type { Entity, EntityListState, ImportPosition } from './types'; +import type { EntitiesTransaction, Entity, EntityListState, ImportPosition } from './types'; const ENTITIES_IMPORT = 'ENTITIES_IMPORT' as const; const ENTITIES_DELETE = 'ENTITIES_DELETE' as const; @@ -8,6 +8,7 @@ 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; const ENTITIES_INVALIDATE_LIST = 'ENTITIES_INVALIDATE_LIST' as const; +const ENTITIES_TRANSACTION = 'ENTITIES_TRANSACTION' as const; /** Action to import entities into the cache. */ function importEntities(entities: Entity[], entityType: string, listKey?: string, pos?: ImportPosition) { @@ -95,6 +96,13 @@ function invalidateEntityList(entityType: string, listKey: string) { }; } +function entitiesTransaction(transaction: EntitiesTransaction) { + return { + type: ENTITIES_TRANSACTION, + transaction, + }; +} + /** Any action pertaining to entities. */ type EntityAction = ReturnType @@ -104,7 +112,8 @@ type EntityAction = | ReturnType | ReturnType | ReturnType - | ReturnType; + | ReturnType + | ReturnType; export { ENTITIES_IMPORT, @@ -115,6 +124,7 @@ export { ENTITIES_FETCH_SUCCESS, ENTITIES_FETCH_FAIL, ENTITIES_INVALIDATE_LIST, + ENTITIES_TRANSACTION, importEntities, deleteEntities, dismissEntities, @@ -123,7 +133,7 @@ export { entitiesFetchSuccess, entitiesFetchFail, invalidateEntityList, - EntityAction, + entitiesTransaction, }; -export type { DeleteEntitiesOpts }; \ No newline at end of file +export type { DeleteEntitiesOpts, EntityAction }; \ No newline at end of file diff --git a/app/soapbox/entity-store/entities.ts b/app/soapbox/entity-store/entities.ts index b8129940e..3f40f7e16 100644 --- a/app/soapbox/entity-store/entities.ts +++ b/app/soapbox/entity-store/entities.ts @@ -1,4 +1,6 @@ -export enum Entities { +import type * as Schemas from 'soapbox/schemas'; + +enum Entities { ACCOUNTS = 'Accounts', GROUPS = 'Groups', GROUP_MEMBERSHIPS = 'GroupMemberships', @@ -7,4 +9,17 @@ export enum Entities { PATRON_USERS = 'PatronUsers', RELATIONSHIPS = 'Relationships', STATUSES = 'Statuses' -} \ No newline at end of file +} + +interface EntityTypes { + [Entities.ACCOUNTS]: Schemas.Account + [Entities.GROUPS]: Schemas.Group + [Entities.GROUP_MEMBERSHIPS]: Schemas.GroupMember + [Entities.GROUP_RELATIONSHIPS]: Schemas.GroupRelationship + [Entities.GROUP_TAGS]: Schemas.GroupTag + [Entities.PATRON_USERS]: Schemas.PatronUser + [Entities.RELATIONSHIPS]: Schemas.Relationship + [Entities.STATUSES]: Schemas.Status +} + +export { Entities, type EntityTypes }; \ No newline at end of file diff --git a/app/soapbox/entity-store/hooks/index.ts b/app/soapbox/entity-store/hooks/index.ts index de3ba0f1d..52a5fc499 100644 --- a/app/soapbox/entity-store/hooks/index.ts +++ b/app/soapbox/entity-store/hooks/index.ts @@ -6,4 +6,5 @@ export { useCreateEntity } from './useCreateEntity'; export { useDeleteEntity } from './useDeleteEntity'; export { useDismissEntity } from './useDismissEntity'; export { useIncrementEntity } from './useIncrementEntity'; -export { useChangeEntity } from './useChangeEntity'; \ No newline at end of file +export { useChangeEntity } from './useChangeEntity'; +export { useTransaction } from './useTransaction'; \ No newline at end of file diff --git a/app/soapbox/entity-store/hooks/useTransaction.ts b/app/soapbox/entity-store/hooks/useTransaction.ts new file mode 100644 index 000000000..eaedd1843 --- /dev/null +++ b/app/soapbox/entity-store/hooks/useTransaction.ts @@ -0,0 +1,23 @@ +import { entitiesTransaction } from 'soapbox/entity-store/actions'; +import { useAppDispatch } from 'soapbox/hooks'; + +import type { EntityTypes } from 'soapbox/entity-store/entities'; +import type { EntitiesTransaction, Entity } from 'soapbox/entity-store/types'; + +type Updater = Record TEntity> + +type Changes = Partial<{ + [K in keyof EntityTypes]: Updater +}> + +function useTransaction() { + const dispatch = useAppDispatch(); + + function transaction(changes: Changes): void { + dispatch(entitiesTransaction(changes as EntitiesTransaction)); + } + + return { transaction }; +} + +export { useTransaction }; \ No newline at end of file diff --git a/app/soapbox/entity-store/reducer.ts b/app/soapbox/entity-store/reducer.ts index ef7b604d9..72d4a1a4c 100644 --- a/app/soapbox/entity-store/reducer.ts +++ b/app/soapbox/entity-store/reducer.ts @@ -10,11 +10,12 @@ import { EntityAction, ENTITIES_INVALIDATE_LIST, ENTITIES_INCREMENT, + ENTITIES_TRANSACTION, } from './actions'; import { createCache, createList, updateStore, updateList } from './utils'; import type { DeleteEntitiesOpts } from './actions'; -import type { Entity, EntityCache, EntityListState, ImportPosition } from './types'; +import type { EntitiesTransaction, Entity, EntityCache, EntityListState, ImportPosition } from './types'; enableMapSet(); @@ -156,6 +157,20 @@ const invalidateEntityList = (state: State, entityType: string, listKey: string) }); }; +const doTransaction = (state: State, transaction: EntitiesTransaction) => { + return produce(state, draft => { + for (const [entityType, changes] of Object.entries(transaction)) { + const cache = draft[entityType] ?? createCache(); + for (const [id, change] of Object.entries(changes)) { + const entity = cache.store[id]; + if (entity) { + cache.store[id] = change(entity); + } + } + } + }); +}; + /** Stores various entity data and lists in a one reducer. */ function reducer(state: Readonly = {}, action: EntityAction): State { switch (action.type) { @@ -175,6 +190,8 @@ function reducer(state: Readonly = {}, action: EntityAction): State { return setFetching(state, action.entityType, action.listKey, false, action.error); case ENTITIES_INVALIDATE_LIST: return invalidateEntityList(state, action.entityType, action.listKey); + case ENTITIES_TRANSACTION: + return doTransaction(state, action.transaction); default: return state; } diff --git a/app/soapbox/entity-store/types.ts b/app/soapbox/entity-store/types.ts index ee60f317b..0f6e0ae5d 100644 --- a/app/soapbox/entity-store/types.ts +++ b/app/soapbox/entity-store/types.ts @@ -50,11 +50,19 @@ interface EntityCache { /** Whether to import items at the start or end of the list. */ type ImportPosition = 'start' | 'end' -export { +/** Map of entity mutation functions to perform at once on the store. */ +interface EntitiesTransaction { + [entityType: string]: { + [entityId: string]: (entity: TEntity) => TEntity + } +} + +export type { Entity, EntityStore, EntityList, EntityListState, EntityCache, ImportPosition, + EntitiesTransaction, }; \ No newline at end of file