Add useTransaction hook

This commit is contained in:
Alex Gleason 2023-06-23 14:12:12 -05:00
parent 2657c8f946
commit 9f53a81fa1
No known key found for this signature in database
GPG key ID: 7211D1F99744FBB7
7 changed files with 130 additions and 35 deletions

View file

@ -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<Account>(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,
};
}

View file

@ -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<typeof importEntities>
@ -104,7 +112,8 @@ type EntityAction =
| ReturnType<typeof entitiesFetchRequest>
| ReturnType<typeof entitiesFetchSuccess>
| ReturnType<typeof entitiesFetchFail>
| ReturnType<typeof invalidateEntityList>;
| ReturnType<typeof invalidateEntityList>
| ReturnType<typeof entitiesTransaction>;
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 };
export type { DeleteEntitiesOpts, EntityAction };

View file

@ -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'
}
}
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 };

View file

@ -6,4 +6,5 @@ export { useCreateEntity } from './useCreateEntity';
export { useDeleteEntity } from './useDeleteEntity';
export { useDismissEntity } from './useDismissEntity';
export { useIncrementEntity } from './useIncrementEntity';
export { useChangeEntity } from './useChangeEntity';
export { useChangeEntity } from './useChangeEntity';
export { useTransaction } from './useTransaction';

View file

@ -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<TEntity extends Entity> = Record<string, (entity: TEntity) => TEntity>
type Changes = Partial<{
[K in keyof EntityTypes]: Updater<EntityTypes[K]>
}>
function useTransaction() {
const dispatch = useAppDispatch();
function transaction(changes: Changes): void {
dispatch(entitiesTransaction(changes as EntitiesTransaction));
}
return { transaction };
}
export { useTransaction };

View file

@ -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<State> = {}, action: EntityAction): State {
switch (action.type) {
@ -175,6 +190,8 @@ function reducer(state: Readonly<State> = {}, 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;
}

View file

@ -50,11 +50,19 @@ interface EntityCache<TEntity extends Entity = Entity> {
/** 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]: <TEntity extends Entity>(entity: TEntity) => TEntity
}
}
export type {
Entity,
EntityStore,
EntityList,
EntityListState,
EntityCache,
ImportPosition,
EntitiesTransaction,
};