diff --git a/CHANGELOG.md b/CHANGELOG.md index 0e04be8028..8a4937018c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - Posts: truncate Nostr pubkeys in reply mentions. - Posts: upgraded emoji picker component. +- UI: unified design of "approve" and "reject" buttons in follow requests and waitlist. ### Fixed - Posts: fixed emojis being cut off in reactions modal. diff --git a/app/soapbox/actions/groups.ts b/app/soapbox/actions/groups.ts index d78e7f5d8c..8a6ad065ed 100644 --- a/app/soapbox/actions/groups.ts +++ b/app/soapbox/actions/groups.ts @@ -789,9 +789,11 @@ const submitGroupEditor = (shouldReset?: boolean) => (dispatch: AppDispatch, get const note = getState().group_editor.note; const avatar = getState().group_editor.avatar; const header = getState().group_editor.header; + const visibility = getState().group_editor.locked ? 'members_only' : 'everyone'; // Truth Social const params: Record = { display_name: displayName, + group_visibility: visibility, note, }; diff --git a/app/soapbox/components/authorize-reject-buttons.tsx b/app/soapbox/components/authorize-reject-buttons.tsx new file mode 100644 index 0000000000..66d8d1d4ae --- /dev/null +++ b/app/soapbox/components/authorize-reject-buttons.tsx @@ -0,0 +1,72 @@ +import React, { useState } from 'react'; +import { FormattedMessage } from 'react-intl'; + +import { HStack, IconButton, Text } from 'soapbox/components/ui'; + +interface IAuthorizeRejectButtons { + onAuthorize(): Promise | unknown + onReject(): Promise | unknown +} + +/** Buttons to approve or reject a pending item, usually an account. */ +const AuthorizeRejectButtons: React.FC = ({ onAuthorize, onReject }) => { + const [state, setState] = useState<'authorized' | 'rejected' | 'pending'>('pending'); + + async function handleAuthorize() { + try { + await onAuthorize(); + setState('authorized'); + } catch (e) { + console.error(e); + } + } + + async function handleReject() { + try { + await onReject(); + setState('rejected'); + } catch (e) { + console.error(e); + } + } + + switch (state) { + case 'pending': + return ( + + + + + ); + case 'authorized': + return ( +
+ + + +
+ ); + case 'rejected': + return ( +
+ + + +
+ ); + } +}; + +export { AuthorizeRejectButtons }; \ No newline at end of file diff --git a/app/soapbox/components/pending-items-row.tsx b/app/soapbox/components/pending-items-row.tsx new file mode 100644 index 0000000000..4fbf236cd6 --- /dev/null +++ b/app/soapbox/components/pending-items-row.tsx @@ -0,0 +1,54 @@ +import clsx from 'clsx'; +import React from 'react'; +import { FormattedMessage } from 'react-intl'; +import { Link } from 'react-router-dom'; + +import { HStack, Icon, Text } from 'soapbox/components/ui'; + +interface IPendingItemsRow { + /** Path to navigate the user when clicked. */ + to: string + /** Number of pending items. */ + count: number + /** Size of the icon. */ + size?: 'md' | 'lg' +} + +const PendingItemsRow: React.FC = ({ to, count, size = 'md' }) => { + return ( + + + +
+ +
+ + + + +
+ + +
+ + ); +}; + +export { PendingItemsRow }; \ No newline at end of file diff --git a/app/soapbox/components/ui/card/card.tsx b/app/soapbox/components/ui/card/card.tsx index 927b6944a3..aedf3e132d 100644 --- a/app/soapbox/components/ui/card/card.tsx +++ b/app/soapbox/components/ui/card/card.tsx @@ -64,7 +64,7 @@ const CardHeader: React.FC = ({ className, children, backHref, onBa const backAttributes = backHref ? { to: backHref } : { onClick: onBackClick }; return ( - + {intl.formatMessage(messages.back)} diff --git a/app/soapbox/components/ui/tabs/tabs.tsx b/app/soapbox/components/ui/tabs/tabs.tsx index 75c80a3636..d94ecb7a85 100644 --- a/app/soapbox/components/ui/tabs/tabs.tsx +++ b/app/soapbox/components/ui/tabs/tabs.tsx @@ -156,7 +156,7 @@ const Tabs = ({ items, activeItem }: ITabs) => { >
{count ? ( - + ) : null} diff --git a/app/soapbox/entity-store/__tests__/reducer.test.ts b/app/soapbox/entity-store/__tests__/reducer.test.ts index df0ec8e57b..1cfc196970 100644 --- a/app/soapbox/entity-store/__tests__/reducer.test.ts +++ b/app/soapbox/entity-store/__tests__/reducer.test.ts @@ -1,4 +1,12 @@ -import { deleteEntities, entitiesFetchFail, entitiesFetchRequest, importEntities } from '../actions'; +import { + deleteEntities, + dismissEntities, + entitiesFetchFail, + entitiesFetchRequest, + entitiesFetchSuccess, + importEntities, + incrementEntities, +} from '../actions'; import reducer, { State } from '../reducer'; import { createListState } from '../utils'; @@ -36,7 +44,8 @@ test('import entities into a list', () => { const cache = result.TestEntity as EntityCache; expect(cache.store['2']!.msg).toBe('benis'); - expect(cache.lists.thingies?.ids.size).toBe(3); + expect(cache.lists.thingies!.ids.size).toBe(3); + expect(cache.lists.thingies!.state.totalCount).toBe(3); // Now try adding an additional item. const entities2: TestEntity[] = [ @@ -48,7 +57,8 @@ test('import entities into a list', () => { const cache2 = result2.TestEntity as EntityCache; expect(cache2.store['4']!.msg).toBe('hehe'); - expect(cache2.lists.thingies?.ids.size).toBe(4); + expect(cache2.lists.thingies!.ids.size).toBe(4); + expect(cache2.lists.thingies!.state.totalCount).toBe(4); // Finally, update an item. const entities3: TestEntity[] = [ @@ -60,7 +70,8 @@ test('import entities into a list', () => { const cache3 = result3.TestEntity as EntityCache; expect(cache3.store['2']!.msg).toBe('yolofam'); - expect(cache3.lists.thingies?.ids.size).toBe(4); // unchanged + expect(cache3.lists.thingies!.ids.size).toBe(4); // unchanged + expect(cache3.lists.thingies!.state.totalCount).toBe(4); }); test('fetching updates the list state', () => { @@ -79,6 +90,44 @@ test('failure adds the error to the state', () => { expect(result.TestEntity!.lists.thingies!.state.error).toBe(error); }); +test('import entities with override', () => { + const state: State = { + TestEntity: { + store: { '1': { id: '1' }, '2': { id: '2' }, '3': { id: '3' } }, + lists: { + thingies: { + ids: new Set(['1', '2', '3']), + state: { ...createListState(), totalCount: 3 }, + }, + }, + }, + }; + + const entities: TestEntity[] = [ + { id: '4', msg: 'yolo' }, + { id: '5', msg: 'benis' }, + ]; + + const now = new Date(); + + const action = entitiesFetchSuccess(entities, 'TestEntity', 'thingies', { + next: undefined, + prev: undefined, + totalCount: 2, + error: null, + fetched: true, + fetching: false, + lastFetchedAt: now, + invalid: false, + }, true); + + const result = reducer(state, action); + const cache = result.TestEntity as EntityCache; + + expect([...cache.lists.thingies!.ids]).toEqual(['4', '5']); + expect(cache.lists.thingies!.state.lastFetchedAt).toBe(now); // Also check that newState worked +}); + test('deleting items', () => { const state: State = { TestEntity: { @@ -86,7 +135,7 @@ test('deleting items', () => { lists: { '': { ids: new Set(['1', '2', '3']), - state: createListState(), + state: { ...createListState(), totalCount: 3 }, }, }, }, @@ -97,4 +146,64 @@ test('deleting items', () => { expect(result.TestEntity!.store).toMatchObject({ '2': { id: '2' } }); expect([...result.TestEntity!.lists['']!.ids]).toEqual(['2']); + expect(result.TestEntity!.lists['']!.state.totalCount).toBe(1); +}); + +test('dismiss items', () => { + const state: State = { + TestEntity: { + store: { '1': { id: '1' }, '2': { id: '2' }, '3': { id: '3' } }, + lists: { + yolo: { + ids: new Set(['1', '2', '3']), + state: { ...createListState(), totalCount: 3 }, + }, + }, + }, + }; + + const action = dismissEntities(['3', '1'], 'TestEntity', 'yolo'); + const result = reducer(state, action); + + expect(result.TestEntity!.store).toMatchObject(state.TestEntity!.store); + expect([...result.TestEntity!.lists.yolo!.ids]).toEqual(['2']); + expect(result.TestEntity!.lists.yolo!.state.totalCount).toBe(1); +}); + +test('increment items', () => { + const state: State = { + TestEntity: { + store: { '1': { id: '1' }, '2': { id: '2' }, '3': { id: '3' } }, + lists: { + thingies: { + ids: new Set(['1', '2', '3']), + state: { ...createListState(), totalCount: 3 }, + }, + }, + }, + }; + + const action = incrementEntities('TestEntity', 'thingies', 1); + const result = reducer(state, action); + + expect(result.TestEntity!.lists.thingies!.state.totalCount).toBe(4); +}); + +test('decrement items', () => { + const state: State = { + TestEntity: { + store: { '1': { id: '1' }, '2': { id: '2' }, '3': { id: '3' } }, + lists: { + thingies: { + ids: new Set(['1', '2', '3']), + state: { ...createListState(), totalCount: 3 }, + }, + }, + }, + }; + + const action = incrementEntities('TestEntity', 'thingies', -1); + const result = reducer(state, action); + + expect(result.TestEntity!.lists.thingies!.state.totalCount).toBe(2); }); \ No newline at end of file diff --git a/app/soapbox/entity-store/actions.ts b/app/soapbox/entity-store/actions.ts index 4d9355cd87..c3ba255596 100644 --- a/app/soapbox/entity-store/actions.ts +++ b/app/soapbox/entity-store/actions.ts @@ -2,9 +2,12 @@ import type { Entity, EntityListState } from './types'; const ENTITIES_IMPORT = 'ENTITIES_IMPORT' as const; const ENTITIES_DELETE = 'ENTITIES_DELETE' as const; +const ENTITIES_DISMISS = 'ENTITIES_DISMISS' as const; +const ENTITIES_INCREMENT = 'ENTITIES_INCREMENT' 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; +const ENTITIES_INVALIDATE_LIST = 'ENTITIES_INVALIDATE_LIST' as const; /** Action to import entities into the cache. */ function importEntities(entities: Entity[], entityType: string, listKey?: string) { @@ -29,6 +32,24 @@ function deleteEntities(ids: Iterable, entityType: string, opts: DeleteE }; } +function dismissEntities(ids: Iterable, entityType: string, listKey: string) { + return { + type: ENTITIES_DISMISS, + ids, + entityType, + listKey, + }; +} + +function incrementEntities(entityType: string, listKey: string, diff: number) { + return { + type: ENTITIES_INCREMENT, + entityType, + listKey, + diff, + }; +} + function entitiesFetchRequest(entityType: string, listKey?: string) { return { type: ENTITIES_FETCH_REQUEST, @@ -37,13 +58,20 @@ function entitiesFetchRequest(entityType: string, listKey?: string) { }; } -function entitiesFetchSuccess(entities: Entity[], entityType: string, listKey?: string, newState?: EntityListState) { +function entitiesFetchSuccess( + entities: Entity[], + entityType: string, + listKey?: string, + newState?: EntityListState, + overwrite = false, +) { return { type: ENTITIES_FETCH_SUCCESS, entityType, entities, listKey, newState, + overwrite, }; } @@ -56,25 +84,42 @@ function entitiesFetchFail(entityType: string, listKey: string | undefined, erro }; } +function invalidateEntityList(entityType: string, listKey: string) { + return { + type: ENTITIES_INVALIDATE_LIST, + entityType, + listKey, + }; +} + /** Any action pertaining to entities. */ type EntityAction = ReturnType | ReturnType + | ReturnType + | ReturnType | ReturnType | ReturnType - | ReturnType; + | ReturnType + | ReturnType; export { ENTITIES_IMPORT, ENTITIES_DELETE, + ENTITIES_DISMISS, + ENTITIES_INCREMENT, ENTITIES_FETCH_REQUEST, ENTITIES_FETCH_SUCCESS, ENTITIES_FETCH_FAIL, + ENTITIES_INVALIDATE_LIST, importEntities, deleteEntities, + dismissEntities, + incrementEntities, entitiesFetchRequest, entitiesFetchSuccess, entitiesFetchFail, + invalidateEntityList, EntityAction, }; diff --git a/app/soapbox/entity-store/entities.ts b/app/soapbox/entity-store/entities.ts index 30220eed63..44f2db3c96 100644 --- a/app/soapbox/entity-store/entities.ts +++ b/app/soapbox/entity-store/entities.ts @@ -1,4 +1,5 @@ export enum Entities { + ACCOUNTS = 'Accounts', GROUPS = 'Groups', GROUP_RELATIONSHIPS = 'GroupRelationships', GROUP_MEMBERSHIPS = 'GroupMemberships', diff --git a/app/soapbox/entity-store/hooks/index.ts b/app/soapbox/entity-store/hooks/index.ts index af27c8f3e8..d113c505a3 100644 --- a/app/soapbox/entity-store/hooks/index.ts +++ b/app/soapbox/entity-store/hooks/index.ts @@ -1,3 +1,7 @@ export { useEntities } from './useEntities'; export { useEntity } from './useEntity'; -export { useEntityActions } from './useEntityActions'; \ No newline at end of file +export { useEntityActions } from './useEntityActions'; +export { useCreateEntity } from './useCreateEntity'; +export { useDeleteEntity } from './useDeleteEntity'; +export { useDismissEntity } from './useDismissEntity'; +export { useIncrementEntity } from './useIncrementEntity'; \ No newline at end of file diff --git a/app/soapbox/entity-store/hooks/types.ts b/app/soapbox/entity-store/hooks/types.ts index 89992c12d5..95ba8b0162 100644 --- a/app/soapbox/entity-store/hooks/types.ts +++ b/app/soapbox/entity-store/hooks/types.ts @@ -1,6 +1,47 @@ import type { Entity } from '../types'; +import type { AxiosResponse } from 'axios'; import type z from 'zod'; type EntitySchema = z.ZodType; -export type { EntitySchema }; \ No newline at end of file +/** + * Tells us where to find/store the entity in the cache. + * This value is accepted in hooks, but needs to be parsed into an `EntitiesPath` + * before being passed to the store. + */ +type ExpandedEntitiesPath = [ + /** Name of the entity type for use in the global cache, eg `'Notification'`. */ + entityType: string, + /** + * Name of a particular index of this entity type. + * Multiple params get combined into one string with a `:` separator. + */ + ...listKeys: string[], +] + +/** Used to look up an entity in a list. */ +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 new file mode 100644 index 0000000000..ba9dd802b8 --- /dev/null +++ b/app/soapbox/entity-store/hooks/useCreateEntity.ts @@ -0,0 +1,51 @@ +import { z } from 'zod'; + +import { useAppDispatch, useLoading } from 'soapbox/hooks'; + +import { importEntities } from '../actions'; + +import { parseEntitiesPath } from './utils'; + +import type { Entity } from '../types'; +import type { EntityCallbacks, EntityFn, EntitySchema, ExpandedEntitiesPath } from './types'; + +interface UseCreateEntityOpts { + schema?: EntitySchema +} + +function useCreateEntity( + expandedPath: ExpandedEntitiesPath, + entityFn: EntityFn, + opts: UseCreateEntityOpts = {}, +) { + const dispatch = useAppDispatch(); + + const [isLoading, setPromise] = useLoading(); + const { entityType, listKey } = parseEntitiesPath(expandedPath); + + async function createEntity(data: Data, callbacks: EntityCallbacks = {}): Promise { + try { + const result = await setPromise(entityFn(data)); + const schema = opts.schema || z.custom(); + const entity = schema.parse(result.data); + + // TODO: optimistic updating + dispatch(importEntities([entity], entityType, listKey)); + + if (callbacks.onSuccess) { + callbacks.onSuccess(entity); + } + } catch (error) { + if (callbacks.onError) { + callbacks.onError(error); + } + } + } + + return { + createEntity, + isLoading, + }; +} + +export { useCreateEntity }; \ No newline at end of file diff --git a/app/soapbox/entity-store/hooks/useDeleteEntity.ts b/app/soapbox/entity-store/hooks/useDeleteEntity.ts new file mode 100644 index 0000000000..767224af60 --- /dev/null +++ b/app/soapbox/entity-store/hooks/useDeleteEntity.ts @@ -0,0 +1,54 @@ +import { useAppDispatch, useGetState, useLoading } from 'soapbox/hooks'; + +import { deleteEntities, importEntities } from '../actions'; + +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( + entityType: string, + entityFn: EntityFn, +) { + const dispatch = useAppDispatch(); + const getState = useGetState(); + const [isLoading, setPromise] = useLoading(); + + 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]; + + // Optimistically delete the entity from the _store_ but keep the lists in tact. + dispatch(deleteEntities([entityId], entityType, { preserveLists: true })); + + try { + await setPromise(entityFn(entityId)); + + // Success - finish deleting entity from the state. + dispatch(deleteEntities([entityId], entityType)); + + if (callbacks.onSuccess) { + callbacks.onSuccess(entityId); + } + } catch (e) { + if (entity) { + // If the API failed, reimport the entity. + dispatch(importEntities([entity], entityType)); + } + + if (callbacks.onError) { + callbacks.onError(e); + } + } + } + + return { + deleteEntity, + isLoading, + }; +} + +export { useDeleteEntity }; \ No newline at end of file diff --git a/app/soapbox/entity-store/hooks/useDismissEntity.ts b/app/soapbox/entity-store/hooks/useDismissEntity.ts new file mode 100644 index 0000000000..b09e35951d --- /dev/null +++ b/app/soapbox/entity-store/hooks/useDismissEntity.ts @@ -0,0 +1,32 @@ +import { useAppDispatch, useLoading } from 'soapbox/hooks'; + +import { dismissEntities } from '../actions'; + +import { parseEntitiesPath } from './utils'; + +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, entityFn: EntityFn) { + const dispatch = useAppDispatch(); + + const [isLoading, setPromise] = useLoading(); + const { entityType, listKey } = parseEntitiesPath(expandedPath); + + // TODO: optimistic dismissing + async function dismissEntity(entityId: string) { + const result = await setPromise(entityFn(entityId)); + dispatch(dismissEntities([entityId], entityType, listKey)); + return result; + } + + return { + dismissEntity, + isLoading, + }; +} + +export { useDismissEntity }; \ No newline at end of file diff --git a/app/soapbox/entity-store/hooks/useEntities.ts b/app/soapbox/entity-store/hooks/useEntities.ts index 6945ccd8db..f2e84c93e5 100644 --- a/app/soapbox/entity-store/hooks/useEntities.ts +++ b/app/soapbox/entity-store/hooks/useEntities.ts @@ -4,25 +4,16 @@ import z from 'zod'; import { getNextLink, getPrevLink } from 'soapbox/api'; import { useApi, useAppDispatch, useAppSelector, useGetState } from 'soapbox/hooks'; import { filteredArray } from 'soapbox/schemas/utils'; +import { realNumberSchema } from 'soapbox/utils/numbers'; -import { entitiesFetchFail, entitiesFetchRequest, entitiesFetchSuccess } from '../actions'; +import { entitiesFetchFail, entitiesFetchRequest, entitiesFetchSuccess, invalidateEntityList } from '../actions'; + +import { parseEntitiesPath } from './utils'; import type { Entity, EntityListState } from '../types'; -import type { EntitySchema } from './types'; +import type { EntitiesPath, EntityFn, EntitySchema, ExpandedEntitiesPath } from './types'; import type { RootState } from 'soapbox/store'; -/** Tells us where to find/store the entity in the cache. */ -type EntityPath = [ - /** Name of the entity type for use in the global cache, eg `'Notification'`. */ - entityType: string, - /** - * Name of a particular index of this entity type. - * Multiple params get combined into one string with a `:` separator. - * You can use empty-string (`''`) if you don't need separate lists. - */ - ...listKeys: string[], -] - /** Additional options for the hook. */ interface UseEntitiesOpts { /** A zod schema to parse the API entities. */ @@ -39,9 +30,9 @@ interface UseEntitiesOpts { /** A hook for fetching and displaying API entities. */ function useEntities( /** Tells us where to find/store the entity in the cache. */ - path: EntityPath, + 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 = {}, ) { @@ -49,9 +40,7 @@ function useEntities( const dispatch = useAppDispatch(); const getState = useGetState(); - const [entityType, ...listKeys] = path; - const listKey = listKeys.join(':'); - + const { entityType, listKey, path } = parseEntitiesPath(expandedPath); const entities = useAppSelector(state => selectEntities(state, path)); const isEnabled = opts.enabled ?? true; @@ -59,59 +48,71 @@ function useEntities( const lastFetchedAt = useListState(path, 'lastFetchedAt'); const isFetched = useListState(path, 'fetched'); const isError = !!useListState(path, 'error'); + const totalCount = useListState(path, 'totalCount'); + const isInvalid = useListState(path, 'invalid'); const next = useListState(path, 'next'); const prev = useListState(path, 'prev'); - const fetchPage = async(url: string): 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']); dispatch(entitiesFetchSuccess(entities, entityType, listKey, { next: getNextLink(response), prev: getPrevLink(response), + totalCount: parsedCount.success ? parsedCount.data : undefined, fetching: false, fetched: true, error: null, lastFetchedAt: new Date(), - })); + invalid: false, + }, overwrite)); } catch (error) { dispatch(entitiesFetchFail(entityType, listKey, error)); } }; const fetchEntities = async(): Promise => { - if (endpoint) { - await fetchPage(endpoint); - } + 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)); } }; + const invalidate = () => { + dispatch(invalidateEntityList(entityType, listKey)); + }; + const staleTime = opts.staleTime ?? 60000; useEffect(() => { - if (isEnabled && !isFetching && (!lastFetchedAt || lastFetchedAt.getTime() + staleTime <= Date.now())) { + if (!isEnabled) return; + if (isFetching) return; + const isUnset = !lastFetchedAt; + const isStale = lastFetchedAt ? Date.now() >= lastFetchedAt.getTime() + staleTime : false; + + if (isInvalid || isUnset || isStale) { fetchEntities(); } - }, [endpoint, isEnabled]); + }, [isEnabled]); return { entities, @@ -120,18 +121,22 @@ function useEntities( fetchPreviousPage, hasNextPage: !!next, hasPreviousPage: !!prev, + totalCount, isError, isFetched, isFetching, isLoading: isFetching && entities.length === 0, + invalidate, + /** The `X-Total-Count` from the API if available, or the length of items in the store. */ + count: typeof totalCount === 'number' ? totalCount : entities.length, }; } /** Get cache at path from Redux. */ -const selectCache = (state: RootState, path: EntityPath) => state.entities[path[0]]; +const selectCache = (state: RootState, path: EntitiesPath) => state.entities[path[0]]; /** Get list at path from Redux. */ -const selectList = (state: RootState, path: EntityPath) => { +const selectList = (state: RootState, path: EntitiesPath) => { const [, ...listKeys] = path; const listKey = listKeys.join(':'); @@ -139,18 +144,18 @@ const selectList = (state: RootState, path: EntityPath) => { }; /** Select a particular item from a list state. */ -function selectListState(state: RootState, path: EntityPath, key: K) { +function selectListState(state: RootState, path: EntitiesPath, key: K) { const listState = selectList(state, path)?.state; return listState ? listState[key] : undefined; } /** Hook to get a particular item from a list state. */ -function useListState(path: EntityPath, key: K) { +function useListState(path: EntitiesPath, key: K) { return useAppSelector(state => selectListState(state, path, key)); } /** Get list of entities from Redux. */ -function selectEntities(state: RootState, path: EntityPath): readonly TEntity[] { +function selectEntities(state: RootState, path: EntitiesPath): readonly TEntity[] { const cache = selectCache(state, path); const list = selectList(state, path); diff --git a/app/soapbox/entity-store/hooks/useEntity.ts b/app/soapbox/entity-store/hooks/useEntity.ts index 1dad1ff1e3..f30c9a18a6 100644 --- a/app/soapbox/entity-store/hooks/useEntity.ts +++ b/app/soapbox/entity-store/hooks/useEntity.ts @@ -1,14 +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 } from './types'; - -type EntityPath = [entityType: string, entityId: string] +import type { EntitySchema, EntityPath, EntityFn } from './types'; /** Additional options for the hook. */ interface UseEntityOpts { @@ -20,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; @@ -33,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 3a60f46276..dab6f7f77b 100644 --- a/app/soapbox/entity-store/hooks/useEntityActions.ts +++ b/app/soapbox/entity-store/hooks/useEntityActions.ts @@ -1,110 +1,39 @@ -import { useState } from 'react'; -import { z } from 'zod'; +import { useApi } from 'soapbox/hooks'; -import { useApi, useAppDispatch, useGetState } from 'soapbox/hooks'; - -import { deleteEntities, importEntities } from '../actions'; +import { useCreateEntity } from './useCreateEntity'; +import { useDeleteEntity } from './useDeleteEntity'; +import { parseEntitiesPath } from './utils'; import type { Entity } from '../types'; -import type { EntitySchema } from './types'; -import type { AxiosResponse } from 'axios'; - -type EntityPath = [entityType: string, listKey?: string] +import type { EntitySchema, ExpandedEntitiesPath } from './types'; interface UseEntityActionsOpts { schema?: EntitySchema } -interface CreateEntityResult { - response: AxiosResponse - entity: TEntity -} - -interface DeleteEntityResult { - response: AxiosResponse -} - interface EntityActionEndpoints { post?: string delete?: string } -interface EntityCallbacks { - onSuccess?(entity?: TEntity): void -} - -function useEntityActions( - path: EntityPath, +function useEntityActions( + expandedPath: ExpandedEntitiesPath, endpoints: EntityActionEndpoints, opts: UseEntityActionsOpts = {}, ) { const api = useApi(); - const dispatch = useAppDispatch(); - const getState = useGetState(); - const [entityType, listKey] = path; + const { entityType, path } = parseEntitiesPath(expandedPath); - const [isLoading, setIsLoading] = useState(false); + const { deleteEntity, isLoading: deleteLoading } = + useDeleteEntity(entityType, (entityId) => api.delete(endpoints.delete!.replaceAll(':id', entityId))); - function createEntity(params: P, callbacks: EntityCallbacks = {}): Promise> { - if (!endpoints.post) return Promise.reject(endpoints); - - setIsLoading(true); - - return api.post(endpoints.post, params).then((response) => { - const schema = opts.schema || z.custom(); - const entity = schema.parse(response.data); - - // TODO: optimistic updating - dispatch(importEntities([entity], entityType, listKey)); - - if (callbacks.onSuccess) { - callbacks.onSuccess(entity); - } - - setIsLoading(false); - - return { - response, - entity, - }; - }); - } - - function deleteEntity(entityId: string, callbacks: EntityCallbacks = {}): Promise { - if (!endpoints.delete) return Promise.reject(endpoints); - // Get the entity before deleting, so we can reverse the action if the API request fails. - const entity = getState().entities[entityType]?.store[entityId]; - // Optimistically delete the entity from the _store_ but keep the lists in tact. - dispatch(deleteEntities([entityId], entityType, { preserveLists: true })); - - setIsLoading(true); - - return api.delete(endpoints.delete.replaceAll(':id', entityId)).then((response) => { - if (callbacks.onSuccess) { - callbacks.onSuccess(); - } - - // Success - finish deleting entity from the state. - dispatch(deleteEntities([entityId], entityType)); - - return { - response, - }; - }).catch((e) => { - if (entity) { - // If the API failed, reimport the entity. - dispatch(importEntities([entity], entityType)); - } - throw e; - }).finally(() => { - setIsLoading(false); - }); - } + 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 new file mode 100644 index 0000000000..2b09cc445c --- /dev/null +++ b/app/soapbox/entity-store/hooks/useIncrementEntity.ts @@ -0,0 +1,37 @@ +import { useAppDispatch, useLoading } from 'soapbox/hooks'; + +import { incrementEntities } from '../actions'; + +import { parseEntitiesPath } from './utils'; + +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( + expandedPath: ExpandedEntitiesPath, + diff: number, + entityFn: EntityFn, +) { + const dispatch = useAppDispatch(); + const [isLoading, setPromise] = useLoading(); + const { entityType, listKey } = parseEntitiesPath(expandedPath); + + async function incrementEntity(entityId: string): Promise { + dispatch(incrementEntities(entityType, listKey, diff)); + try { + await setPromise(entityFn(entityId)); + } catch (e) { + dispatch(incrementEntities(entityType, listKey, diff * -1)); + } + } + + return { + incrementEntity, + isLoading, + }; +} + +export { useIncrementEntity }; \ No newline at end of file diff --git a/app/soapbox/entity-store/hooks/utils.ts b/app/soapbox/entity-store/hooks/utils.ts new file mode 100644 index 0000000000..8b9269a2e9 --- /dev/null +++ b/app/soapbox/entity-store/hooks/utils.ts @@ -0,0 +1,16 @@ +import type { EntitiesPath, ExpandedEntitiesPath } from './types'; + +function parseEntitiesPath(expandedPath: ExpandedEntitiesPath) { + const [entityType, ...listKeys] = expandedPath; + const listKey = (listKeys || []).join(':'); + const path: EntitiesPath = [entityType, listKey]; + + return { + entityType, + listKey, + path, + }; +} + + +export { parseEntitiesPath }; \ No newline at end of file diff --git a/app/soapbox/entity-store/reducer.ts b/app/soapbox/entity-store/reducer.ts index 891e42f4cb..b71fb812f4 100644 --- a/app/soapbox/entity-store/reducer.ts +++ b/app/soapbox/entity-store/reducer.ts @@ -3,10 +3,13 @@ import produce, { enableMapSet } from 'immer'; import { ENTITIES_IMPORT, ENTITIES_DELETE, + ENTITIES_DISMISS, ENTITIES_FETCH_REQUEST, ENTITIES_FETCH_SUCCESS, ENTITIES_FETCH_FAIL, EntityAction, + ENTITIES_INVALIDATE_LIST, + ENTITIES_INCREMENT, } from './actions'; import { createCache, createList, updateStore, updateList } from './utils'; @@ -27,17 +30,25 @@ const importEntities = ( entities: Entity[], listKey?: string, newState?: EntityListState, + overwrite = false, ): State => { return produce(state, draft => { const cache = draft[entityType] ?? createCache(); cache.store = updateStore(cache.store, entities); if (typeof listKey === 'string') { - let list = { ...(cache.lists[listKey] ?? createList()) }; + let list = cache.lists[listKey] ?? createList(); + + if (overwrite) { + list.ids = new Set(); + } + list = updateList(list, entities); + if (newState) { list.state = newState; } + cache.lists[listKey] = list; } @@ -59,7 +70,13 @@ const deleteEntities = ( if (!opts?.preserveLists) { for (const list of Object.values(cache.lists)) { - list?.ids.delete(id); + if (list) { + list.ids.delete(id); + + if (typeof list.state.totalCount === 'number') { + list.state.totalCount--; + } + } } } } @@ -68,6 +85,47 @@ const deleteEntities = ( }); }; +const dismissEntities = ( + state: State, + entityType: string, + ids: Iterable, + listKey: string, +) => { + return produce(state, draft => { + const cache = draft[entityType] ?? createCache(); + const list = cache.lists[listKey]; + + if (list) { + for (const id of ids) { + list.ids.delete(id); + + if (typeof list.state.totalCount === 'number') { + list.state.totalCount--; + } + } + + draft[entityType] = cache; + } + }); +}; + +const incrementEntities = ( + state: State, + entityType: string, + listKey: string, + diff: number, +) => { + return produce(state, draft => { + const cache = draft[entityType] ?? createCache(); + const list = cache.lists[listKey]; + + if (typeof list?.state?.totalCount === 'number') { + list.state.totalCount += diff; + draft[entityType] = cache; + } + }); +}; + const setFetching = ( state: State, entityType: string, @@ -89,6 +147,14 @@ const setFetching = ( }); }; +const invalidateEntityList = (state: State, entityType: string, listKey: string) => { + return produce(state, draft => { + const cache = draft[entityType] ?? createCache(); + const list = cache.lists[listKey] ?? createList(); + list.state.invalid = true; + }); +}; + /** Stores various entity data and lists in a one reducer. */ function reducer(state: Readonly = {}, action: EntityAction): State { switch (action.type) { @@ -96,12 +162,18 @@ function reducer(state: Readonly = {}, action: EntityAction): State { return importEntities(state, action.entityType, action.entities, action.listKey); case ENTITIES_DELETE: return deleteEntities(state, action.entityType, action.ids, action.opts); + case ENTITIES_DISMISS: + return dismissEntities(state, action.entityType, action.ids, action.listKey); + case ENTITIES_INCREMENT: + return incrementEntities(state, action.entityType, action.listKey, action.diff); case ENTITIES_FETCH_SUCCESS: - return importEntities(state, action.entityType, action.entities, action.listKey, action.newState); + return importEntities(state, action.entityType, action.entities, action.listKey, action.newState, action.overwrite); case ENTITIES_FETCH_REQUEST: return setFetching(state, action.entityType, action.listKey, true); case ENTITIES_FETCH_FAIL: return setFetching(state, action.entityType, action.listKey, false, action.error); + case ENTITIES_INVALIDATE_LIST: + return invalidateEntityList(state, action.entityType, action.listKey); default: return state; } diff --git a/app/soapbox/entity-store/types.ts b/app/soapbox/entity-store/types.ts index 0e34b62a54..006b13ba2c 100644 --- a/app/soapbox/entity-store/types.ts +++ b/app/soapbox/entity-store/types.ts @@ -23,6 +23,8 @@ interface EntityListState { next: string | undefined /** Previous URL for pagination, if any. */ prev: string | undefined + /** Total number of items according to the API. */ + totalCount: number | undefined /** Error returned from the API, if any. */ error: any /** Whether data has already been fetched */ @@ -31,6 +33,8 @@ interface EntityListState { fetching: boolean /** Date of the last API fetch for this list. */ lastFetchedAt: Date | undefined + /** Whether the entities should be refetched on the next component mount. */ + invalid: boolean } /** 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 9d56ceb42a..e108639c2d 100644 --- a/app/soapbox/entity-store/utils.ts +++ b/app/soapbox/entity-store/utils.ts @@ -11,9 +11,16 @@ const updateStore = (store: EntityStore, entities: Entity[]): EntityStore => { /** Update the list with new entity IDs. */ const updateList = (list: EntityList, entities: Entity[]): EntityList => { const newIds = entities.map(entity => entity.id); + const ids = new Set([...Array.from(list.ids), ...newIds]); + + if (typeof list.state.totalCount === 'number') { + const sizeDiff = ids.size - list.ids.size; + list.state.totalCount += sizeDiff; + } + return { ...list, - ids: new Set([...Array.from(list.ids), ...newIds]), + ids, }; }; @@ -33,10 +40,12 @@ const createList = (): EntityList => ({ const createListState = (): EntityListState => ({ next: undefined, prev: undefined, + totalCount: 0, error: null, fetched: false, fetching: false, lastFetchedAt: undefined, + invalid: false, }); export { diff --git a/app/soapbox/features/admin/components/unapproved-account.tsx b/app/soapbox/features/admin/components/unapproved-account.tsx index bbc5e84a9f..26f4b661ec 100644 --- a/app/soapbox/features/admin/components/unapproved-account.tsx +++ b/app/soapbox/features/admin/components/unapproved-account.tsx @@ -3,7 +3,8 @@ import { defineMessages, useIntl } from 'react-intl'; import { approveUsers } from 'soapbox/actions/admin'; import { rejectUserModal } from 'soapbox/actions/moderation'; -import { Stack, HStack, Text, IconButton } from 'soapbox/components/ui'; +import { AuthorizeRejectButtons } from 'soapbox/components/authorize-reject-buttons'; +import { Stack, HStack, Text } from 'soapbox/components/ui'; import { useAppSelector, useAppDispatch } from 'soapbox/hooks'; import { makeGetAccount } from 'soapbox/selectors'; import toast from 'soapbox/toast'; @@ -29,19 +30,21 @@ const UnapprovedAccount: React.FC = ({ accountId }) => { if (!account) return null; const handleApprove = () => { - dispatch(approveUsers([account.id])) + return dispatch(approveUsers([account.id])) .then(() => { const message = intl.formatMessage(messages.approved, { acct: `@${account.acct}` }); toast.success(message); - }) - .catch(() => {}); + }); }; const handleReject = () => { - dispatch(rejectUserModal(intl, account.id, () => { - const message = intl.formatMessage(messages.rejected, { acct: `@${account.acct}` }); - toast.info(message); - })); + return new Promise((resolve) => { + dispatch(rejectUserModal(intl, account.id, () => { + const message = intl.formatMessage(messages.rejected, { acct: `@${account.acct}` }); + toast.info(message); + resolve(); + })); + }); }; return ( @@ -55,20 +58,12 @@ const UnapprovedAccount: React.FC = ({ accountId }) => { - - + - - + ); }; diff --git a/app/soapbox/features/follow-requests/components/account-authorize.tsx b/app/soapbox/features/follow-requests/components/account-authorize.tsx index a2ab88450e..91f492523f 100644 --- a/app/soapbox/features/follow-requests/components/account-authorize.tsx +++ b/app/soapbox/features/follow-requests/components/account-authorize.tsx @@ -1,36 +1,23 @@ import React, { useCallback } from 'react'; -import { defineMessages, useIntl } from 'react-intl'; import { authorizeFollowRequest, rejectFollowRequest } from 'soapbox/actions/accounts'; import Account from 'soapbox/components/account'; -import { Button, HStack } from 'soapbox/components/ui'; +import { AuthorizeRejectButtons } from 'soapbox/components/authorize-reject-buttons'; import { useAppDispatch, useAppSelector } from 'soapbox/hooks'; import { makeGetAccount } from 'soapbox/selectors'; -const messages = defineMessages({ - authorize: { id: 'follow_request.authorize', defaultMessage: 'Authorize' }, - reject: { id: 'follow_request.reject', defaultMessage: 'Reject' }, -}); - interface IAccountAuthorize { id: string } const AccountAuthorize: React.FC = ({ id }) => { - const intl = useIntl(); const dispatch = useAppDispatch(); const getAccount = useCallback(makeGetAccount(), []); - const account = useAppSelector((state) => getAccount(state, id)); - const onAuthorize = () => { - dispatch(authorizeFollowRequest(id)); - }; - - const onReject = () => { - dispatch(rejectFollowRequest(id)); - }; + const onAuthorize = () => dispatch(authorizeFollowRequest(id)); + const onReject = () => dispatch(rejectFollowRequest(id)); if (!account) return null; @@ -39,22 +26,10 @@ const AccountAuthorize: React.FC = ({ id }) => { -
diff --git a/app/soapbox/features/group/group-members.tsx b/app/soapbox/features/group/group-members.tsx index 9fa1d135f7..39fbd940f1 100644 --- a/app/soapbox/features/group/group-members.tsx +++ b/app/soapbox/features/group/group-members.tsx @@ -1,8 +1,11 @@ +import clsx from 'clsx'; import React, { useMemo } from 'react'; +import { PendingItemsRow } from 'soapbox/components/pending-items-row'; import ScrollableList from 'soapbox/components/scrollable-list'; +import { useGroup } from 'soapbox/hooks'; +import { useGroupMembershipRequests } from 'soapbox/hooks/api/groups/useGroupMembershipRequests'; import { useGroupMembers } from 'soapbox/hooks/api/useGroupMembers'; -import { useGroup } from 'soapbox/queries/groups'; import { GroupRoles } from 'soapbox/schemas/group-member'; import PlaceholderAccount from '../placeholder/components/placeholder-account'; @@ -22,8 +25,9 @@ const GroupMembers: React.FC = (props) => { const { groupMembers: owners, isFetching: isFetchingOwners } = useGroupMembers(groupId, GroupRoles.OWNER); const { groupMembers: admins, isFetching: isFetchingAdmins } = useGroupMembers(groupId, GroupRoles.ADMIN); const { groupMembers: users, isFetching: isFetchingUsers, fetchNextPage, hasNextPage } = useGroupMembers(groupId, GroupRoles.USER); + const { isFetching: isFetchingPending, count: pendingCount } = useGroupMembershipRequests(groupId); - const isLoading = isFetchingGroup || isFetchingOwners || isFetchingAdmins || isFetchingUsers; + const isLoading = isFetchingGroup || isFetchingOwners || isFetchingAdmins || isFetchingUsers || isFetchingPending; const members = useMemo(() => [ ...owners, @@ -37,12 +41,17 @@ const GroupMembers: React.FC = (props) => { scrollKey='group-members' hasMore={hasNextPage} onLoadMore={fetchNextPage} - isLoading={isLoading || !group} - showLoading={!group || isLoading && members.length === 0} + isLoading={!group || isLoading} + showLoading={!group || isFetchingPending || isLoading && members.length === 0} placeholderComponent={PlaceholderAccount} placeholderCount={3} - className='divide-y divide-solid divide-gray-300' + className='divide-y divide-solid divide-gray-200 dark:divide-gray-800' itemClassName='py-3 last:pb-0' + prepend={(pendingCount > 0) && ( +
+ +
+ )} > {members.map((member) => ( + onReject(account: AccountEntity): Promise } -const MembershipRequest: React.FC = ({ accountId, groupId }) => { - const intl = useIntl(); - const dispatch = useAppDispatch(); - - const getAccount = useCallback(makeGetAccount(), []); - - const account = useAppSelector((state) => getAccount(state, accountId)); - +const MembershipRequest: React.FC = ({ account, onAuthorize, onReject }) => { if (!account) return null; - const handleAuthorize = () => - dispatch(authorizeGroupMembershipRequest(groupId, accountId)).then(() => { - toast.success(intl.formatMessage(messages.authorized, { name: account.acct })); - }); - - const handleReject = () => - dispatch(rejectGroupMembershipRequest(groupId, accountId)).then(() => { - toast.success(intl.formatMessage(messages.rejected, { name: account.acct })); - }); + const handleAuthorize = () => onAuthorize(account); + const handleReject = () => onReject(account); return (
- -