Merge remote-tracking branch 'soapbox/develop' into lexical
This commit is contained in:
commit
c953da7c78
39 changed files with 883 additions and 317 deletions
|
@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||||
### Changed
|
### Changed
|
||||||
- Posts: truncate Nostr pubkeys in reply mentions.
|
- Posts: truncate Nostr pubkeys in reply mentions.
|
||||||
- Posts: upgraded emoji picker component.
|
- Posts: upgraded emoji picker component.
|
||||||
|
- UI: unified design of "approve" and "reject" buttons in follow requests and waitlist.
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
- Posts: fixed emojis being cut off in reactions modal.
|
- Posts: fixed emojis being cut off in reactions modal.
|
||||||
|
|
|
@ -789,9 +789,11 @@ const submitGroupEditor = (shouldReset?: boolean) => (dispatch: AppDispatch, get
|
||||||
const note = getState().group_editor.note;
|
const note = getState().group_editor.note;
|
||||||
const avatar = getState().group_editor.avatar;
|
const avatar = getState().group_editor.avatar;
|
||||||
const header = getState().group_editor.header;
|
const header = getState().group_editor.header;
|
||||||
|
const visibility = getState().group_editor.locked ? 'members_only' : 'everyone'; // Truth Social
|
||||||
|
|
||||||
const params: Record<string, any> = {
|
const params: Record<string, any> = {
|
||||||
display_name: displayName,
|
display_name: displayName,
|
||||||
|
group_visibility: visibility,
|
||||||
note,
|
note,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
72
app/soapbox/components/authorize-reject-buttons.tsx
Normal file
72
app/soapbox/components/authorize-reject-buttons.tsx
Normal file
|
@ -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> | unknown
|
||||||
|
onReject(): Promise<unknown> | unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Buttons to approve or reject a pending item, usually an account. */
|
||||||
|
const AuthorizeRejectButtons: React.FC<IAuthorizeRejectButtons> = ({ 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 (
|
||||||
|
<HStack space={3} alignItems='center'>
|
||||||
|
<IconButton
|
||||||
|
src={require('@tabler/icons/x.svg')}
|
||||||
|
onClick={handleReject}
|
||||||
|
theme='seamless'
|
||||||
|
className='h-10 w-10 items-center justify-center border-2 border-danger-600/10 hover:border-danger-600'
|
||||||
|
iconClassName='h-6 w-6 text-danger-600'
|
||||||
|
/>
|
||||||
|
<IconButton
|
||||||
|
src={require('@tabler/icons/check.svg')}
|
||||||
|
onClick={handleAuthorize}
|
||||||
|
theme='seamless'
|
||||||
|
className='h-10 w-10 items-center justify-center border-2 border-primary-500/10 hover:border-primary-500'
|
||||||
|
iconClassName='h-6 w-6 text-primary-500'
|
||||||
|
/>
|
||||||
|
</HStack>
|
||||||
|
);
|
||||||
|
case 'authorized':
|
||||||
|
return (
|
||||||
|
<div className='rounded-full bg-gray-100 px-4 py-2 dark:bg-gray-800'>
|
||||||
|
<Text theme='muted' size='sm'>
|
||||||
|
<FormattedMessage id='authorize.success' defaultMessage='Approved' />
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
case 'rejected':
|
||||||
|
return (
|
||||||
|
<div className='rounded-full bg-gray-100 px-4 py-2 dark:bg-gray-800'>
|
||||||
|
<Text theme='muted' size='sm'>
|
||||||
|
<FormattedMessage id='reject.success' defaultMessage='Rejected' />
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export { AuthorizeRejectButtons };
|
54
app/soapbox/components/pending-items-row.tsx
Normal file
54
app/soapbox/components/pending-items-row.tsx
Normal file
|
@ -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<IPendingItemsRow> = ({ to, count, size = 'md' }) => {
|
||||||
|
return (
|
||||||
|
<Link to={to} className='group' data-testid='pending-items-row'>
|
||||||
|
<HStack alignItems='center' justifyContent='between'>
|
||||||
|
<HStack alignItems='center' space={2}>
|
||||||
|
<div className={clsx('rounded-full bg-primary-200 text-primary-500 dark:bg-primary-800 dark:text-primary-200', {
|
||||||
|
'p-3': size === 'lg',
|
||||||
|
'p-2.5': size === 'md',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
src={require('@tabler/icons/exclamation-circle.svg')}
|
||||||
|
className={clsx({
|
||||||
|
'h-5 w-5': size === 'md',
|
||||||
|
'h-7 w-7': size === 'lg',
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Text weight='bold' size='md'>
|
||||||
|
<FormattedMessage
|
||||||
|
id='groups.pending.count'
|
||||||
|
defaultMessage='{number, plural, one {# pending request} other {# pending requests}}'
|
||||||
|
values={{ number: count }}
|
||||||
|
/>
|
||||||
|
</Text>
|
||||||
|
</HStack>
|
||||||
|
|
||||||
|
<Icon
|
||||||
|
src={require('@tabler/icons/chevron-right.svg')}
|
||||||
|
className='h-5 w-5 text-gray-600 transition-colors group-hover:text-gray-700 dark:text-gray-600 dark:group-hover:text-gray-500'
|
||||||
|
/>
|
||||||
|
</HStack>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export { PendingItemsRow };
|
|
@ -64,7 +64,7 @@ const CardHeader: React.FC<ICardHeader> = ({ className, children, backHref, onBa
|
||||||
const backAttributes = backHref ? { to: backHref } : { onClick: onBackClick };
|
const backAttributes = backHref ? { to: backHref } : { onClick: onBackClick };
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Comp {...backAttributes} className='text-gray-900 focus:ring-2 focus:ring-primary-500 dark:text-gray-100' aria-label={intl.formatMessage(messages.back)}>
|
<Comp {...backAttributes} className='rounded-full text-gray-900 focus:ring-2 focus:ring-primary-500 dark:text-gray-100' aria-label={intl.formatMessage(messages.back)}>
|
||||||
<SvgIcon src={require('@tabler/icons/arrow-left.svg')} className='h-6 w-6 rtl:rotate-180' />
|
<SvgIcon src={require('@tabler/icons/arrow-left.svg')} className='h-6 w-6 rtl:rotate-180' />
|
||||||
<span className='sr-only' data-testid='back-button'>{intl.formatMessage(messages.back)}</span>
|
<span className='sr-only' data-testid='back-button'>{intl.formatMessage(messages.back)}</span>
|
||||||
</Comp>
|
</Comp>
|
||||||
|
|
|
@ -156,7 +156,7 @@ const Tabs = ({ items, activeItem }: ITabs) => {
|
||||||
>
|
>
|
||||||
<div className='relative'>
|
<div className='relative'>
|
||||||
{count ? (
|
{count ? (
|
||||||
<span className='absolute -top-2 left-full ml-1'>
|
<span className='absolute left-full ml-2'>
|
||||||
<Counter count={count} />
|
<Counter count={count} />
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
|
@ -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 reducer, { State } from '../reducer';
|
||||||
import { createListState } from '../utils';
|
import { createListState } from '../utils';
|
||||||
|
|
||||||
|
@ -36,7 +44,8 @@ test('import entities into a list', () => {
|
||||||
const cache = result.TestEntity as EntityCache<TestEntity>;
|
const cache = result.TestEntity as EntityCache<TestEntity>;
|
||||||
|
|
||||||
expect(cache.store['2']!.msg).toBe('benis');
|
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.
|
// Now try adding an additional item.
|
||||||
const entities2: TestEntity[] = [
|
const entities2: TestEntity[] = [
|
||||||
|
@ -48,7 +57,8 @@ test('import entities into a list', () => {
|
||||||
const cache2 = result2.TestEntity as EntityCache<TestEntity>;
|
const cache2 = result2.TestEntity as EntityCache<TestEntity>;
|
||||||
|
|
||||||
expect(cache2.store['4']!.msg).toBe('hehe');
|
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.
|
// Finally, update an item.
|
||||||
const entities3: TestEntity[] = [
|
const entities3: TestEntity[] = [
|
||||||
|
@ -60,7 +70,8 @@ test('import entities into a list', () => {
|
||||||
const cache3 = result3.TestEntity as EntityCache<TestEntity>;
|
const cache3 = result3.TestEntity as EntityCache<TestEntity>;
|
||||||
|
|
||||||
expect(cache3.store['2']!.msg).toBe('yolofam');
|
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', () => {
|
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);
|
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<TestEntity>;
|
||||||
|
|
||||||
|
expect([...cache.lists.thingies!.ids]).toEqual(['4', '5']);
|
||||||
|
expect(cache.lists.thingies!.state.lastFetchedAt).toBe(now); // Also check that newState worked
|
||||||
|
});
|
||||||
|
|
||||||
test('deleting items', () => {
|
test('deleting items', () => {
|
||||||
const state: State = {
|
const state: State = {
|
||||||
TestEntity: {
|
TestEntity: {
|
||||||
|
@ -86,7 +135,7 @@ test('deleting items', () => {
|
||||||
lists: {
|
lists: {
|
||||||
'': {
|
'': {
|
||||||
ids: new Set(['1', '2', '3']),
|
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!.store).toMatchObject({ '2': { id: '2' } });
|
||||||
expect([...result.TestEntity!.lists['']!.ids]).toEqual(['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);
|
||||||
});
|
});
|
|
@ -2,9 +2,12 @@ import type { Entity, EntityListState } from './types';
|
||||||
|
|
||||||
const ENTITIES_IMPORT = 'ENTITIES_IMPORT' as const;
|
const ENTITIES_IMPORT = 'ENTITIES_IMPORT' as const;
|
||||||
const ENTITIES_DELETE = 'ENTITIES_DELETE' 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_REQUEST = 'ENTITIES_FETCH_REQUEST' as const;
|
||||||
const ENTITIES_FETCH_SUCCESS = 'ENTITIES_FETCH_SUCCESS' as const;
|
const ENTITIES_FETCH_SUCCESS = 'ENTITIES_FETCH_SUCCESS' as const;
|
||||||
const ENTITIES_FETCH_FAIL = 'ENTITIES_FETCH_FAIL' 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. */
|
/** Action to import entities into the cache. */
|
||||||
function importEntities(entities: Entity[], entityType: string, listKey?: string) {
|
function importEntities(entities: Entity[], entityType: string, listKey?: string) {
|
||||||
|
@ -29,6 +32,24 @@ function deleteEntities(ids: Iterable<string>, entityType: string, opts: DeleteE
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function dismissEntities(ids: Iterable<string>, 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) {
|
function entitiesFetchRequest(entityType: string, listKey?: string) {
|
||||||
return {
|
return {
|
||||||
type: ENTITIES_FETCH_REQUEST,
|
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 {
|
return {
|
||||||
type: ENTITIES_FETCH_SUCCESS,
|
type: ENTITIES_FETCH_SUCCESS,
|
||||||
entityType,
|
entityType,
|
||||||
entities,
|
entities,
|
||||||
listKey,
|
listKey,
|
||||||
newState,
|
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. */
|
/** Any action pertaining to entities. */
|
||||||
type EntityAction =
|
type EntityAction =
|
||||||
ReturnType<typeof importEntities>
|
ReturnType<typeof importEntities>
|
||||||
| ReturnType<typeof deleteEntities>
|
| ReturnType<typeof deleteEntities>
|
||||||
|
| ReturnType<typeof dismissEntities>
|
||||||
|
| ReturnType<typeof incrementEntities>
|
||||||
| ReturnType<typeof entitiesFetchRequest>
|
| ReturnType<typeof entitiesFetchRequest>
|
||||||
| ReturnType<typeof entitiesFetchSuccess>
|
| ReturnType<typeof entitiesFetchSuccess>
|
||||||
| ReturnType<typeof entitiesFetchFail>;
|
| ReturnType<typeof entitiesFetchFail>
|
||||||
|
| ReturnType<typeof invalidateEntityList>;
|
||||||
|
|
||||||
export {
|
export {
|
||||||
ENTITIES_IMPORT,
|
ENTITIES_IMPORT,
|
||||||
ENTITIES_DELETE,
|
ENTITIES_DELETE,
|
||||||
|
ENTITIES_DISMISS,
|
||||||
|
ENTITIES_INCREMENT,
|
||||||
ENTITIES_FETCH_REQUEST,
|
ENTITIES_FETCH_REQUEST,
|
||||||
ENTITIES_FETCH_SUCCESS,
|
ENTITIES_FETCH_SUCCESS,
|
||||||
ENTITIES_FETCH_FAIL,
|
ENTITIES_FETCH_FAIL,
|
||||||
|
ENTITIES_INVALIDATE_LIST,
|
||||||
importEntities,
|
importEntities,
|
||||||
deleteEntities,
|
deleteEntities,
|
||||||
|
dismissEntities,
|
||||||
|
incrementEntities,
|
||||||
entitiesFetchRequest,
|
entitiesFetchRequest,
|
||||||
entitiesFetchSuccess,
|
entitiesFetchSuccess,
|
||||||
entitiesFetchFail,
|
entitiesFetchFail,
|
||||||
|
invalidateEntityList,
|
||||||
EntityAction,
|
EntityAction,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
export enum Entities {
|
export enum Entities {
|
||||||
|
ACCOUNTS = 'Accounts',
|
||||||
GROUPS = 'Groups',
|
GROUPS = 'Groups',
|
||||||
GROUP_RELATIONSHIPS = 'GroupRelationships',
|
GROUP_RELATIONSHIPS = 'GroupRelationships',
|
||||||
GROUP_MEMBERSHIPS = 'GroupMemberships',
|
GROUP_MEMBERSHIPS = 'GroupMemberships',
|
||||||
|
|
|
@ -1,3 +1,7 @@
|
||||||
export { useEntities } from './useEntities';
|
export { useEntities } from './useEntities';
|
||||||
export { useEntity } from './useEntity';
|
export { useEntity } from './useEntity';
|
||||||
export { useEntityActions } from './useEntityActions';
|
export { useEntityActions } from './useEntityActions';
|
||||||
|
export { useCreateEntity } from './useCreateEntity';
|
||||||
|
export { useDeleteEntity } from './useDeleteEntity';
|
||||||
|
export { useDismissEntity } from './useDismissEntity';
|
||||||
|
export { useIncrementEntity } from './useIncrementEntity';
|
|
@ -1,6 +1,47 @@
|
||||||
import type { Entity } from '../types';
|
import type { Entity } from '../types';
|
||||||
|
import type { AxiosResponse } from 'axios';
|
||||||
import type z from 'zod';
|
import type z from 'zod';
|
||||||
|
|
||||||
type EntitySchema<TEntity extends Entity = Entity> = z.ZodType<TEntity, z.ZodTypeDef, any>;
|
type EntitySchema<TEntity extends Entity = Entity> = z.ZodType<TEntity, z.ZodTypeDef, any>;
|
||||||
|
|
||||||
export type { EntitySchema };
|
/**
|
||||||
|
* 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<Value, Error = unknown> {
|
||||||
|
onSuccess?(value: Value): void
|
||||||
|
onError?(error: Error): void
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Passed into hooks to make requests.
|
||||||
|
* Must return an Axios response.
|
||||||
|
*/
|
||||||
|
type EntityFn<T> = (value: T) => Promise<AxiosResponse>
|
||||||
|
|
||||||
|
export type {
|
||||||
|
EntitySchema,
|
||||||
|
ExpandedEntitiesPath,
|
||||||
|
EntitiesPath,
|
||||||
|
EntityPath,
|
||||||
|
EntityCallbacks,
|
||||||
|
EntityFn,
|
||||||
|
};
|
51
app/soapbox/entity-store/hooks/useCreateEntity.ts
Normal file
51
app/soapbox/entity-store/hooks/useCreateEntity.ts
Normal file
|
@ -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<TEntity extends Entity = Entity> {
|
||||||
|
schema?: EntitySchema<TEntity>
|
||||||
|
}
|
||||||
|
|
||||||
|
function useCreateEntity<TEntity extends Entity = Entity, Data = unknown>(
|
||||||
|
expandedPath: ExpandedEntitiesPath,
|
||||||
|
entityFn: EntityFn<Data>,
|
||||||
|
opts: UseCreateEntityOpts<TEntity> = {},
|
||||||
|
) {
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
|
const [isLoading, setPromise] = useLoading();
|
||||||
|
const { entityType, listKey } = parseEntitiesPath(expandedPath);
|
||||||
|
|
||||||
|
async function createEntity(data: Data, callbacks: EntityCallbacks<TEntity> = {}): Promise<void> {
|
||||||
|
try {
|
||||||
|
const result = await setPromise(entityFn(data));
|
||||||
|
const schema = opts.schema || z.custom<TEntity>();
|
||||||
|
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 };
|
54
app/soapbox/entity-store/hooks/useDeleteEntity.ts
Normal file
54
app/soapbox/entity-store/hooks/useDeleteEntity.ts
Normal file
|
@ -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<string>,
|
||||||
|
) {
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
const getState = useGetState();
|
||||||
|
const [isLoading, setPromise] = useLoading();
|
||||||
|
|
||||||
|
async function deleteEntity(entityId: string, callbacks: EntityCallbacks<string> = {}): Promise<void> {
|
||||||
|
// 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 };
|
32
app/soapbox/entity-store/hooks/useDismissEntity.ts
Normal file
32
app/soapbox/entity-store/hooks/useDismissEntity.ts
Normal file
|
@ -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<string>) {
|
||||||
|
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 };
|
|
@ -4,25 +4,16 @@ import z from 'zod';
|
||||||
import { getNextLink, getPrevLink } from 'soapbox/api';
|
import { getNextLink, getPrevLink } from 'soapbox/api';
|
||||||
import { useApi, useAppDispatch, useAppSelector, useGetState } from 'soapbox/hooks';
|
import { useApi, useAppDispatch, useAppSelector, useGetState } from 'soapbox/hooks';
|
||||||
import { filteredArray } from 'soapbox/schemas/utils';
|
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 { Entity, EntityListState } from '../types';
|
||||||
import type { EntitySchema } from './types';
|
import type { EntitiesPath, EntityFn, EntitySchema, ExpandedEntitiesPath } from './types';
|
||||||
import type { RootState } from 'soapbox/store';
|
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. */
|
/** Additional options for the hook. */
|
||||||
interface UseEntitiesOpts<TEntity extends Entity> {
|
interface UseEntitiesOpts<TEntity extends Entity> {
|
||||||
/** A zod schema to parse the API entities. */
|
/** A zod schema to parse the API entities. */
|
||||||
|
@ -39,9 +30,9 @@ interface UseEntitiesOpts<TEntity extends Entity> {
|
||||||
/** A hook for fetching and displaying API entities. */
|
/** A hook for fetching and displaying API entities. */
|
||||||
function useEntities<TEntity extends Entity>(
|
function useEntities<TEntity extends Entity>(
|
||||||
/** Tells us where to find/store the entity in the cache. */
|
/** 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. */
|
/** API route to GET, eg `'/api/v1/notifications'`. If undefined, nothing will be fetched. */
|
||||||
endpoint: string | undefined,
|
entityFn: EntityFn<void>,
|
||||||
/** Additional options for the hook. */
|
/** Additional options for the hook. */
|
||||||
opts: UseEntitiesOpts<TEntity> = {},
|
opts: UseEntitiesOpts<TEntity> = {},
|
||||||
) {
|
) {
|
||||||
|
@ -49,9 +40,7 @@ function useEntities<TEntity extends Entity>(
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const getState = useGetState();
|
const getState = useGetState();
|
||||||
|
|
||||||
const [entityType, ...listKeys] = path;
|
const { entityType, listKey, path } = parseEntitiesPath(expandedPath);
|
||||||
const listKey = listKeys.join(':');
|
|
||||||
|
|
||||||
const entities = useAppSelector(state => selectEntities<TEntity>(state, path));
|
const entities = useAppSelector(state => selectEntities<TEntity>(state, path));
|
||||||
|
|
||||||
const isEnabled = opts.enabled ?? true;
|
const isEnabled = opts.enabled ?? true;
|
||||||
|
@ -59,59 +48,71 @@ function useEntities<TEntity extends Entity>(
|
||||||
const lastFetchedAt = useListState(path, 'lastFetchedAt');
|
const lastFetchedAt = useListState(path, 'lastFetchedAt');
|
||||||
const isFetched = useListState(path, 'fetched');
|
const isFetched = useListState(path, 'fetched');
|
||||||
const isError = !!useListState(path, 'error');
|
const isError = !!useListState(path, 'error');
|
||||||
|
const totalCount = useListState(path, 'totalCount');
|
||||||
|
const isInvalid = useListState(path, 'invalid');
|
||||||
|
|
||||||
const next = useListState(path, 'next');
|
const next = useListState(path, 'next');
|
||||||
const prev = useListState(path, 'prev');
|
const prev = useListState(path, 'prev');
|
||||||
|
|
||||||
const fetchPage = async(url: string): Promise<void> => {
|
const fetchPage = async(req: EntityFn<void>, overwrite = false): Promise<void> => {
|
||||||
// Get `isFetching` state from the store again to prevent race conditions.
|
// Get `isFetching` state from the store again to prevent race conditions.
|
||||||
const isFetching = selectListState(getState(), path, 'fetching');
|
const isFetching = selectListState(getState(), path, 'fetching');
|
||||||
if (isFetching) return;
|
if (isFetching) return;
|
||||||
|
|
||||||
dispatch(entitiesFetchRequest(entityType, listKey));
|
dispatch(entitiesFetchRequest(entityType, listKey));
|
||||||
try {
|
try {
|
||||||
const response = await api.get(url);
|
const response = await req();
|
||||||
const schema = opts.schema || z.custom<TEntity>();
|
const schema = opts.schema || z.custom<TEntity>();
|
||||||
const entities = filteredArray(schema).parse(response.data);
|
const entities = filteredArray(schema).parse(response.data);
|
||||||
|
const parsedCount = realNumberSchema.safeParse(response.headers['x-total-count']);
|
||||||
|
|
||||||
dispatch(entitiesFetchSuccess(entities, entityType, listKey, {
|
dispatch(entitiesFetchSuccess(entities, entityType, listKey, {
|
||||||
next: getNextLink(response),
|
next: getNextLink(response),
|
||||||
prev: getPrevLink(response),
|
prev: getPrevLink(response),
|
||||||
|
totalCount: parsedCount.success ? parsedCount.data : undefined,
|
||||||
fetching: false,
|
fetching: false,
|
||||||
fetched: true,
|
fetched: true,
|
||||||
error: null,
|
error: null,
|
||||||
lastFetchedAt: new Date(),
|
lastFetchedAt: new Date(),
|
||||||
}));
|
invalid: false,
|
||||||
|
}, overwrite));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
dispatch(entitiesFetchFail(entityType, listKey, error));
|
dispatch(entitiesFetchFail(entityType, listKey, error));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const fetchEntities = async(): Promise<void> => {
|
const fetchEntities = async(): Promise<void> => {
|
||||||
if (endpoint) {
|
await fetchPage(entityFn, true);
|
||||||
await fetchPage(endpoint);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const fetchNextPage = async(): Promise<void> => {
|
const fetchNextPage = async(): Promise<void> => {
|
||||||
if (next) {
|
if (next) {
|
||||||
await fetchPage(next);
|
await fetchPage(() => api.get(next));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const fetchPreviousPage = async(): Promise<void> => {
|
const fetchPreviousPage = async(): Promise<void> => {
|
||||||
if (prev) {
|
if (prev) {
|
||||||
await fetchPage(prev);
|
await fetchPage(() => api.get(prev));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const invalidate = () => {
|
||||||
|
dispatch(invalidateEntityList(entityType, listKey));
|
||||||
|
};
|
||||||
|
|
||||||
const staleTime = opts.staleTime ?? 60000;
|
const staleTime = opts.staleTime ?? 60000;
|
||||||
|
|
||||||
useEffect(() => {
|
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();
|
fetchEntities();
|
||||||
}
|
}
|
||||||
}, [endpoint, isEnabled]);
|
}, [isEnabled]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
entities,
|
entities,
|
||||||
|
@ -120,18 +121,22 @@ function useEntities<TEntity extends Entity>(
|
||||||
fetchPreviousPage,
|
fetchPreviousPage,
|
||||||
hasNextPage: !!next,
|
hasNextPage: !!next,
|
||||||
hasPreviousPage: !!prev,
|
hasPreviousPage: !!prev,
|
||||||
|
totalCount,
|
||||||
isError,
|
isError,
|
||||||
isFetched,
|
isFetched,
|
||||||
isFetching,
|
isFetching,
|
||||||
isLoading: isFetching && entities.length === 0,
|
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. */
|
/** 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. */
|
/** Get list at path from Redux. */
|
||||||
const selectList = (state: RootState, path: EntityPath) => {
|
const selectList = (state: RootState, path: EntitiesPath) => {
|
||||||
const [, ...listKeys] = path;
|
const [, ...listKeys] = path;
|
||||||
const listKey = listKeys.join(':');
|
const listKey = listKeys.join(':');
|
||||||
|
|
||||||
|
@ -139,18 +144,18 @@ const selectList = (state: RootState, path: EntityPath) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Select a particular item from a list state. */
|
/** Select a particular item from a list state. */
|
||||||
function selectListState<K extends keyof EntityListState>(state: RootState, path: EntityPath, key: K) {
|
function selectListState<K extends keyof EntityListState>(state: RootState, path: EntitiesPath, key: K) {
|
||||||
const listState = selectList(state, path)?.state;
|
const listState = selectList(state, path)?.state;
|
||||||
return listState ? listState[key] : undefined;
|
return listState ? listState[key] : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Hook to get a particular item from a list state. */
|
/** Hook to get a particular item from a list state. */
|
||||||
function useListState<K extends keyof EntityListState>(path: EntityPath, key: K) {
|
function useListState<K extends keyof EntityListState>(path: EntitiesPath, key: K) {
|
||||||
return useAppSelector(state => selectListState(state, path, key));
|
return useAppSelector(state => selectListState(state, path, key));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Get list of entities from Redux. */
|
/** Get list of entities from Redux. */
|
||||||
function selectEntities<TEntity extends Entity>(state: RootState, path: EntityPath): readonly TEntity[] {
|
function selectEntities<TEntity extends Entity>(state: RootState, path: EntitiesPath): readonly TEntity[] {
|
||||||
const cache = selectCache(state, path);
|
const cache = selectCache(state, path);
|
||||||
const list = selectList(state, path);
|
const list = selectList(state, path);
|
||||||
|
|
||||||
|
|
|
@ -1,14 +1,12 @@
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect } from 'react';
|
||||||
import z from 'zod';
|
import z from 'zod';
|
||||||
|
|
||||||
import { useApi, useAppDispatch, useAppSelector } from 'soapbox/hooks';
|
import { useAppDispatch, useAppSelector, useLoading } from 'soapbox/hooks';
|
||||||
|
|
||||||
import { importEntities } from '../actions';
|
import { importEntities } from '../actions';
|
||||||
|
|
||||||
import type { Entity } from '../types';
|
import type { Entity } from '../types';
|
||||||
import type { EntitySchema } from './types';
|
import type { EntitySchema, EntityPath, EntityFn } from './types';
|
||||||
|
|
||||||
type EntityPath = [entityType: string, entityId: string]
|
|
||||||
|
|
||||||
/** Additional options for the hook. */
|
/** Additional options for the hook. */
|
||||||
interface UseEntityOpts<TEntity extends Entity> {
|
interface UseEntityOpts<TEntity extends Entity> {
|
||||||
|
@ -20,10 +18,10 @@ interface UseEntityOpts<TEntity extends Entity> {
|
||||||
|
|
||||||
function useEntity<TEntity extends Entity>(
|
function useEntity<TEntity extends Entity>(
|
||||||
path: EntityPath,
|
path: EntityPath,
|
||||||
endpoint: string,
|
entityFn: EntityFn<void>,
|
||||||
opts: UseEntityOpts<TEntity> = {},
|
opts: UseEntityOpts<TEntity> = {},
|
||||||
) {
|
) {
|
||||||
const api = useApi();
|
const [isFetching, setPromise] = useLoading();
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
const [entityType, entityId] = path;
|
const [entityType, entityId] = path;
|
||||||
|
@ -33,18 +31,16 @@ function useEntity<TEntity extends Entity>(
|
||||||
|
|
||||||
const entity = useAppSelector(state => state.entities[entityType]?.store[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;
|
const isLoading = isFetching && !entity;
|
||||||
|
|
||||||
const fetchEntity = () => {
|
const fetchEntity = async () => {
|
||||||
setIsFetching(true);
|
try {
|
||||||
api.get(endpoint).then(({ data }) => {
|
const response = await setPromise(entityFn());
|
||||||
const entity = schema.parse(data);
|
const entity = schema.parse(response.data);
|
||||||
dispatch(importEntities([entity], entityType));
|
dispatch(importEntities([entity], entityType));
|
||||||
setIsFetching(false);
|
} catch (e) {
|
||||||
}).catch(() => {
|
// do nothing
|
||||||
setIsFetching(false);
|
}
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
|
@ -1,110 +1,39 @@
|
||||||
import { useState } from 'react';
|
import { useApi } from 'soapbox/hooks';
|
||||||
import { z } from 'zod';
|
|
||||||
|
|
||||||
import { useApi, useAppDispatch, useGetState } from 'soapbox/hooks';
|
import { useCreateEntity } from './useCreateEntity';
|
||||||
|
import { useDeleteEntity } from './useDeleteEntity';
|
||||||
import { deleteEntities, importEntities } from '../actions';
|
import { parseEntitiesPath } from './utils';
|
||||||
|
|
||||||
import type { Entity } from '../types';
|
import type { Entity } from '../types';
|
||||||
import type { EntitySchema } from './types';
|
import type { EntitySchema, ExpandedEntitiesPath } from './types';
|
||||||
import type { AxiosResponse } from 'axios';
|
|
||||||
|
|
||||||
type EntityPath = [entityType: string, listKey?: string]
|
|
||||||
|
|
||||||
interface UseEntityActionsOpts<TEntity extends Entity = Entity> {
|
interface UseEntityActionsOpts<TEntity extends Entity = Entity> {
|
||||||
schema?: EntitySchema<TEntity>
|
schema?: EntitySchema<TEntity>
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CreateEntityResult<TEntity extends Entity = Entity> {
|
|
||||||
response: AxiosResponse
|
|
||||||
entity: TEntity
|
|
||||||
}
|
|
||||||
|
|
||||||
interface DeleteEntityResult {
|
|
||||||
response: AxiosResponse
|
|
||||||
}
|
|
||||||
|
|
||||||
interface EntityActionEndpoints {
|
interface EntityActionEndpoints {
|
||||||
post?: string
|
post?: string
|
||||||
delete?: string
|
delete?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
interface EntityCallbacks<TEntity extends Entity = Entity> {
|
function useEntityActions<TEntity extends Entity = Entity, Data = any>(
|
||||||
onSuccess?(entity?: TEntity): void
|
expandedPath: ExpandedEntitiesPath,
|
||||||
}
|
|
||||||
|
|
||||||
function useEntityActions<TEntity extends Entity = Entity, P = any>(
|
|
||||||
path: EntityPath,
|
|
||||||
endpoints: EntityActionEndpoints,
|
endpoints: EntityActionEndpoints,
|
||||||
opts: UseEntityActionsOpts<TEntity> = {},
|
opts: UseEntityActionsOpts<TEntity> = {},
|
||||||
) {
|
) {
|
||||||
const api = useApi();
|
const api = useApi();
|
||||||
const dispatch = useAppDispatch();
|
const { entityType, path } = parseEntitiesPath(expandedPath);
|
||||||
const getState = useGetState();
|
|
||||||
const [entityType, listKey] = path;
|
|
||||||
|
|
||||||
const [isLoading, setIsLoading] = useState<boolean>(false);
|
const { deleteEntity, isLoading: deleteLoading } =
|
||||||
|
useDeleteEntity(entityType, (entityId) => api.delete(endpoints.delete!.replaceAll(':id', entityId)));
|
||||||
|
|
||||||
function createEntity(params: P, callbacks: EntityCallbacks = {}): Promise<CreateEntityResult<TEntity>> {
|
const { createEntity, isLoading: createLoading } =
|
||||||
if (!endpoints.post) return Promise.reject(endpoints);
|
useCreateEntity<TEntity, Data>(path, (data) => api.post(endpoints.post!, data), opts);
|
||||||
|
|
||||||
setIsLoading(true);
|
|
||||||
|
|
||||||
return api.post(endpoints.post, params).then((response) => {
|
|
||||||
const schema = opts.schema || z.custom<TEntity>();
|
|
||||||
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<DeleteEntityResult> {
|
|
||||||
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);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
createEntity,
|
createEntity,
|
||||||
deleteEntity,
|
deleteEntity,
|
||||||
isLoading,
|
isLoading: createLoading || deleteLoading,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
37
app/soapbox/entity-store/hooks/useIncrementEntity.ts
Normal file
37
app/soapbox/entity-store/hooks/useIncrementEntity.ts
Normal file
|
@ -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<string>,
|
||||||
|
) {
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
const [isLoading, setPromise] = useLoading();
|
||||||
|
const { entityType, listKey } = parseEntitiesPath(expandedPath);
|
||||||
|
|
||||||
|
async function incrementEntity(entityId: string): Promise<void> {
|
||||||
|
dispatch(incrementEntities(entityType, listKey, diff));
|
||||||
|
try {
|
||||||
|
await setPromise(entityFn(entityId));
|
||||||
|
} catch (e) {
|
||||||
|
dispatch(incrementEntities(entityType, listKey, diff * -1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
incrementEntity,
|
||||||
|
isLoading,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export { useIncrementEntity };
|
16
app/soapbox/entity-store/hooks/utils.ts
Normal file
16
app/soapbox/entity-store/hooks/utils.ts
Normal file
|
@ -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 };
|
|
@ -3,10 +3,13 @@ import produce, { enableMapSet } from 'immer';
|
||||||
import {
|
import {
|
||||||
ENTITIES_IMPORT,
|
ENTITIES_IMPORT,
|
||||||
ENTITIES_DELETE,
|
ENTITIES_DELETE,
|
||||||
|
ENTITIES_DISMISS,
|
||||||
ENTITIES_FETCH_REQUEST,
|
ENTITIES_FETCH_REQUEST,
|
||||||
ENTITIES_FETCH_SUCCESS,
|
ENTITIES_FETCH_SUCCESS,
|
||||||
ENTITIES_FETCH_FAIL,
|
ENTITIES_FETCH_FAIL,
|
||||||
EntityAction,
|
EntityAction,
|
||||||
|
ENTITIES_INVALIDATE_LIST,
|
||||||
|
ENTITIES_INCREMENT,
|
||||||
} from './actions';
|
} from './actions';
|
||||||
import { createCache, createList, updateStore, updateList } from './utils';
|
import { createCache, createList, updateStore, updateList } from './utils';
|
||||||
|
|
||||||
|
@ -27,17 +30,25 @@ const importEntities = (
|
||||||
entities: Entity[],
|
entities: Entity[],
|
||||||
listKey?: string,
|
listKey?: string,
|
||||||
newState?: EntityListState,
|
newState?: EntityListState,
|
||||||
|
overwrite = false,
|
||||||
): State => {
|
): State => {
|
||||||
return produce(state, draft => {
|
return produce(state, draft => {
|
||||||
const cache = draft[entityType] ?? createCache();
|
const cache = draft[entityType] ?? createCache();
|
||||||
cache.store = updateStore(cache.store, entities);
|
cache.store = updateStore(cache.store, entities);
|
||||||
|
|
||||||
if (typeof listKey === 'string') {
|
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);
|
list = updateList(list, entities);
|
||||||
|
|
||||||
if (newState) {
|
if (newState) {
|
||||||
list.state = newState;
|
list.state = newState;
|
||||||
}
|
}
|
||||||
|
|
||||||
cache.lists[listKey] = list;
|
cache.lists[listKey] = list;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -59,7 +70,13 @@ const deleteEntities = (
|
||||||
|
|
||||||
if (!opts?.preserveLists) {
|
if (!opts?.preserveLists) {
|
||||||
for (const list of Object.values(cache.lists)) {
|
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<string>,
|
||||||
|
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 = (
|
const setFetching = (
|
||||||
state: State,
|
state: State,
|
||||||
entityType: string,
|
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. */
|
/** Stores various entity data and lists in a one reducer. */
|
||||||
function reducer(state: Readonly<State> = {}, action: EntityAction): State {
|
function reducer(state: Readonly<State> = {}, action: EntityAction): State {
|
||||||
switch (action.type) {
|
switch (action.type) {
|
||||||
|
@ -96,12 +162,18 @@ function reducer(state: Readonly<State> = {}, action: EntityAction): State {
|
||||||
return importEntities(state, action.entityType, action.entities, action.listKey);
|
return importEntities(state, action.entityType, action.entities, action.listKey);
|
||||||
case ENTITIES_DELETE:
|
case ENTITIES_DELETE:
|
||||||
return deleteEntities(state, action.entityType, action.ids, action.opts);
|
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:
|
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:
|
case ENTITIES_FETCH_REQUEST:
|
||||||
return setFetching(state, action.entityType, action.listKey, true);
|
return setFetching(state, action.entityType, action.listKey, true);
|
||||||
case ENTITIES_FETCH_FAIL:
|
case ENTITIES_FETCH_FAIL:
|
||||||
return setFetching(state, action.entityType, action.listKey, false, action.error);
|
return setFetching(state, action.entityType, action.listKey, false, action.error);
|
||||||
|
case ENTITIES_INVALIDATE_LIST:
|
||||||
|
return invalidateEntityList(state, action.entityType, action.listKey);
|
||||||
default:
|
default:
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
|
|
|
@ -23,6 +23,8 @@ interface EntityListState {
|
||||||
next: string | undefined
|
next: string | undefined
|
||||||
/** Previous URL for pagination, if any. */
|
/** Previous URL for pagination, if any. */
|
||||||
prev: string | undefined
|
prev: string | undefined
|
||||||
|
/** Total number of items according to the API. */
|
||||||
|
totalCount: number | undefined
|
||||||
/** Error returned from the API, if any. */
|
/** Error returned from the API, if any. */
|
||||||
error: any
|
error: any
|
||||||
/** Whether data has already been fetched */
|
/** Whether data has already been fetched */
|
||||||
|
@ -31,6 +33,8 @@ interface EntityListState {
|
||||||
fetching: boolean
|
fetching: boolean
|
||||||
/** Date of the last API fetch for this list. */
|
/** Date of the last API fetch for this list. */
|
||||||
lastFetchedAt: Date | undefined
|
lastFetchedAt: Date | undefined
|
||||||
|
/** Whether the entities should be refetched on the next component mount. */
|
||||||
|
invalid: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Cache data pertaining to a paritcular entity type.. */
|
/** Cache data pertaining to a paritcular entity type.. */
|
||||||
|
|
|
@ -11,9 +11,16 @@ const updateStore = (store: EntityStore, entities: Entity[]): EntityStore => {
|
||||||
/** Update the list with new entity IDs. */
|
/** Update the list with new entity IDs. */
|
||||||
const updateList = (list: EntityList, entities: Entity[]): EntityList => {
|
const updateList = (list: EntityList, entities: Entity[]): EntityList => {
|
||||||
const newIds = entities.map(entity => entity.id);
|
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 {
|
return {
|
||||||
...list,
|
...list,
|
||||||
ids: new Set([...Array.from(list.ids), ...newIds]),
|
ids,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -33,10 +40,12 @@ const createList = (): EntityList => ({
|
||||||
const createListState = (): EntityListState => ({
|
const createListState = (): EntityListState => ({
|
||||||
next: undefined,
|
next: undefined,
|
||||||
prev: undefined,
|
prev: undefined,
|
||||||
|
totalCount: 0,
|
||||||
error: null,
|
error: null,
|
||||||
fetched: false,
|
fetched: false,
|
||||||
fetching: false,
|
fetching: false,
|
||||||
lastFetchedAt: undefined,
|
lastFetchedAt: undefined,
|
||||||
|
invalid: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
export {
|
export {
|
||||||
|
|
|
@ -3,7 +3,8 @@ import { defineMessages, useIntl } from 'react-intl';
|
||||||
|
|
||||||
import { approveUsers } from 'soapbox/actions/admin';
|
import { approveUsers } from 'soapbox/actions/admin';
|
||||||
import { rejectUserModal } from 'soapbox/actions/moderation';
|
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 { useAppSelector, useAppDispatch } from 'soapbox/hooks';
|
||||||
import { makeGetAccount } from 'soapbox/selectors';
|
import { makeGetAccount } from 'soapbox/selectors';
|
||||||
import toast from 'soapbox/toast';
|
import toast from 'soapbox/toast';
|
||||||
|
@ -29,19 +30,21 @@ const UnapprovedAccount: React.FC<IUnapprovedAccount> = ({ accountId }) => {
|
||||||
if (!account) return null;
|
if (!account) return null;
|
||||||
|
|
||||||
const handleApprove = () => {
|
const handleApprove = () => {
|
||||||
dispatch(approveUsers([account.id]))
|
return dispatch(approveUsers([account.id]))
|
||||||
.then(() => {
|
.then(() => {
|
||||||
const message = intl.formatMessage(messages.approved, { acct: `@${account.acct}` });
|
const message = intl.formatMessage(messages.approved, { acct: `@${account.acct}` });
|
||||||
toast.success(message);
|
toast.success(message);
|
||||||
})
|
});
|
||||||
.catch(() => {});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleReject = () => {
|
const handleReject = () => {
|
||||||
|
return new Promise<void>((resolve) => {
|
||||||
dispatch(rejectUserModal(intl, account.id, () => {
|
dispatch(rejectUserModal(intl, account.id, () => {
|
||||||
const message = intl.formatMessage(messages.rejected, { acct: `@${account.acct}` });
|
const message = intl.formatMessage(messages.rejected, { acct: `@${account.acct}` });
|
||||||
toast.info(message);
|
toast.info(message);
|
||||||
|
resolve();
|
||||||
}));
|
}));
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -55,20 +58,12 @@ const UnapprovedAccount: React.FC<IUnapprovedAccount> = ({ accountId }) => {
|
||||||
</Text>
|
</Text>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
<HStack space={2} alignItems='center'>
|
<Stack justifyContent='center'>
|
||||||
<IconButton
|
<AuthorizeRejectButtons
|
||||||
src={require('@tabler/icons/check.svg')}
|
onAuthorize={handleApprove}
|
||||||
onClick={handleApprove}
|
onReject={handleReject}
|
||||||
theme='outlined'
|
|
||||||
iconClassName='p-1 text-gray-600 dark:text-gray-400'
|
|
||||||
/>
|
/>
|
||||||
<IconButton
|
</Stack>
|
||||||
src={require('@tabler/icons/x.svg')}
|
|
||||||
onClick={handleReject}
|
|
||||||
theme='outlined'
|
|
||||||
iconClassName='p-1 text-gray-600 dark:text-gray-400'
|
|
||||||
/>
|
|
||||||
</HStack>
|
|
||||||
</HStack>
|
</HStack>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,36 +1,23 @@
|
||||||
import React, { useCallback } from 'react';
|
import React, { useCallback } from 'react';
|
||||||
import { defineMessages, useIntl } from 'react-intl';
|
|
||||||
|
|
||||||
import { authorizeFollowRequest, rejectFollowRequest } from 'soapbox/actions/accounts';
|
import { authorizeFollowRequest, rejectFollowRequest } from 'soapbox/actions/accounts';
|
||||||
import Account from 'soapbox/components/account';
|
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 { useAppDispatch, useAppSelector } from 'soapbox/hooks';
|
||||||
import { makeGetAccount } from 'soapbox/selectors';
|
import { makeGetAccount } from 'soapbox/selectors';
|
||||||
|
|
||||||
const messages = defineMessages({
|
|
||||||
authorize: { id: 'follow_request.authorize', defaultMessage: 'Authorize' },
|
|
||||||
reject: { id: 'follow_request.reject', defaultMessage: 'Reject' },
|
|
||||||
});
|
|
||||||
|
|
||||||
interface IAccountAuthorize {
|
interface IAccountAuthorize {
|
||||||
id: string
|
id: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const AccountAuthorize: React.FC<IAccountAuthorize> = ({ id }) => {
|
const AccountAuthorize: React.FC<IAccountAuthorize> = ({ id }) => {
|
||||||
const intl = useIntl();
|
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
const getAccount = useCallback(makeGetAccount(), []);
|
const getAccount = useCallback(makeGetAccount(), []);
|
||||||
|
|
||||||
const account = useAppSelector((state) => getAccount(state, id));
|
const account = useAppSelector((state) => getAccount(state, id));
|
||||||
|
|
||||||
const onAuthorize = () => {
|
const onAuthorize = () => dispatch(authorizeFollowRequest(id));
|
||||||
dispatch(authorizeFollowRequest(id));
|
const onReject = () => dispatch(rejectFollowRequest(id));
|
||||||
};
|
|
||||||
|
|
||||||
const onReject = () => {
|
|
||||||
dispatch(rejectFollowRequest(id));
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!account) return null;
|
if (!account) return null;
|
||||||
|
|
||||||
|
@ -39,22 +26,10 @@ const AccountAuthorize: React.FC<IAccountAuthorize> = ({ id }) => {
|
||||||
<Account
|
<Account
|
||||||
account={account}
|
account={account}
|
||||||
action={
|
action={
|
||||||
<HStack className='ml-1' space={2}>
|
<AuthorizeRejectButtons
|
||||||
<Button
|
onAuthorize={onAuthorize}
|
||||||
theme='secondary'
|
onReject={onReject}
|
||||||
size='sm'
|
|
||||||
text={intl.formatMessage(messages.authorize)}
|
|
||||||
icon={require('@tabler/icons/check.svg')}
|
|
||||||
onClick={onAuthorize}
|
|
||||||
/>
|
/>
|
||||||
<Button
|
|
||||||
theme='danger'
|
|
||||||
size='sm'
|
|
||||||
text={intl.formatMessage(messages.reject)}
|
|
||||||
icon={require('@tabler/icons/x.svg')}
|
|
||||||
onClick={onReject}
|
|
||||||
/>
|
|
||||||
</HStack>
|
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1,8 +1,11 @@
|
||||||
|
import clsx from 'clsx';
|
||||||
import React, { useMemo } from 'react';
|
import React, { useMemo } from 'react';
|
||||||
|
|
||||||
|
import { PendingItemsRow } from 'soapbox/components/pending-items-row';
|
||||||
import ScrollableList from 'soapbox/components/scrollable-list';
|
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 { useGroupMembers } from 'soapbox/hooks/api/useGroupMembers';
|
||||||
import { useGroup } from 'soapbox/queries/groups';
|
|
||||||
import { GroupRoles } from 'soapbox/schemas/group-member';
|
import { GroupRoles } from 'soapbox/schemas/group-member';
|
||||||
|
|
||||||
import PlaceholderAccount from '../placeholder/components/placeholder-account';
|
import PlaceholderAccount from '../placeholder/components/placeholder-account';
|
||||||
|
@ -22,8 +25,9 @@ const GroupMembers: React.FC<IGroupMembers> = (props) => {
|
||||||
const { groupMembers: owners, isFetching: isFetchingOwners } = useGroupMembers(groupId, GroupRoles.OWNER);
|
const { groupMembers: owners, isFetching: isFetchingOwners } = useGroupMembers(groupId, GroupRoles.OWNER);
|
||||||
const { groupMembers: admins, isFetching: isFetchingAdmins } = useGroupMembers(groupId, GroupRoles.ADMIN);
|
const { groupMembers: admins, isFetching: isFetchingAdmins } = useGroupMembers(groupId, GroupRoles.ADMIN);
|
||||||
const { groupMembers: users, isFetching: isFetchingUsers, fetchNextPage, hasNextPage } = useGroupMembers(groupId, GroupRoles.USER);
|
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(() => [
|
const members = useMemo(() => [
|
||||||
...owners,
|
...owners,
|
||||||
|
@ -37,12 +41,17 @@ const GroupMembers: React.FC<IGroupMembers> = (props) => {
|
||||||
scrollKey='group-members'
|
scrollKey='group-members'
|
||||||
hasMore={hasNextPage}
|
hasMore={hasNextPage}
|
||||||
onLoadMore={fetchNextPage}
|
onLoadMore={fetchNextPage}
|
||||||
isLoading={isLoading || !group}
|
isLoading={!group || isLoading}
|
||||||
showLoading={!group || isLoading && members.length === 0}
|
showLoading={!group || isFetchingPending || isLoading && members.length === 0}
|
||||||
placeholderComponent={PlaceholderAccount}
|
placeholderComponent={PlaceholderAccount}
|
||||||
placeholderCount={3}
|
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'
|
itemClassName='py-3 last:pb-0'
|
||||||
|
prepend={(pendingCount > 0) && (
|
||||||
|
<div className={clsx('py-3', { 'border-b border-gray-200 dark:border-gray-800': members.length })}>
|
||||||
|
<PendingItemsRow to={`/groups/${groupId}/manage/requests`} count={pendingCount} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
{members.map((member) => (
|
{members.map((member) => (
|
||||||
<GroupMemberListItem
|
<GroupMemberListItem
|
||||||
|
|
|
@ -1,70 +1,48 @@
|
||||||
import React, { useCallback, useEffect } from 'react';
|
import React from 'react';
|
||||||
import { FormattedMessage, defineMessages, useIntl } from 'react-intl';
|
import { FormattedMessage, defineMessages, useIntl } from 'react-intl';
|
||||||
|
|
||||||
import { authorizeGroupMembershipRequest, fetchGroupMembershipRequests, rejectGroupMembershipRequest } from 'soapbox/actions/groups';
|
|
||||||
import Account from 'soapbox/components/account';
|
import Account from 'soapbox/components/account';
|
||||||
|
import { AuthorizeRejectButtons } from 'soapbox/components/authorize-reject-buttons';
|
||||||
import ScrollableList from 'soapbox/components/scrollable-list';
|
import ScrollableList from 'soapbox/components/scrollable-list';
|
||||||
import { Button, Column, HStack, Spinner } from 'soapbox/components/ui';
|
import { Column, HStack, Spinner } from 'soapbox/components/ui';
|
||||||
import { useAppDispatch, useAppSelector, useGroup } from 'soapbox/hooks';
|
import { useGroup } from 'soapbox/hooks';
|
||||||
import { makeGetAccount } from 'soapbox/selectors';
|
import { useGroupMembershipRequests } from 'soapbox/hooks/api/groups/useGroupMembershipRequests';
|
||||||
import toast from 'soapbox/toast';
|
import toast from 'soapbox/toast';
|
||||||
|
|
||||||
import ColumnForbidden from '../ui/components/column-forbidden';
|
import ColumnForbidden from '../ui/components/column-forbidden';
|
||||||
|
|
||||||
|
import type { Account as AccountEntity } from 'soapbox/schemas';
|
||||||
|
|
||||||
type RouteParams = { id: string };
|
type RouteParams = { id: string };
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
heading: { id: 'column.group_pending_requests', defaultMessage: 'Pending requests' },
|
heading: { id: 'column.group_pending_requests', defaultMessage: 'Pending requests' },
|
||||||
authorize: { id: 'group.group_mod_authorize', defaultMessage: 'Accept' },
|
authorizeFail: { id: 'group.group_mod_authorize.fail', defaultMessage: 'Failed to approve @{name}' },
|
||||||
authorized: { id: 'group.group_mod_authorize.success', defaultMessage: 'Accepted @{name} to group' },
|
rejectFail: { id: 'group.group_mod_reject.fail', defaultMessage: 'Failed to reject @{name}' },
|
||||||
reject: { id: 'group.group_mod_reject', defaultMessage: 'Reject' },
|
|
||||||
rejected: { id: 'group.group_mod_reject.success', defaultMessage: 'Rejected @{name} from group' },
|
|
||||||
});
|
});
|
||||||
|
|
||||||
interface IMembershipRequest {
|
interface IMembershipRequest {
|
||||||
accountId: string
|
account: AccountEntity
|
||||||
groupId: string
|
onAuthorize(account: AccountEntity): Promise<unknown>
|
||||||
|
onReject(account: AccountEntity): Promise<unknown>
|
||||||
}
|
}
|
||||||
|
|
||||||
const MembershipRequest: React.FC<IMembershipRequest> = ({ accountId, groupId }) => {
|
const MembershipRequest: React.FC<IMembershipRequest> = ({ account, onAuthorize, onReject }) => {
|
||||||
const intl = useIntl();
|
|
||||||
const dispatch = useAppDispatch();
|
|
||||||
|
|
||||||
const getAccount = useCallback(makeGetAccount(), []);
|
|
||||||
|
|
||||||
const account = useAppSelector((state) => getAccount(state, accountId));
|
|
||||||
|
|
||||||
if (!account) return null;
|
if (!account) return null;
|
||||||
|
|
||||||
const handleAuthorize = () =>
|
const handleAuthorize = () => onAuthorize(account);
|
||||||
dispatch(authorizeGroupMembershipRequest(groupId, accountId)).then(() => {
|
const handleReject = () => onReject(account);
|
||||||
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 }));
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<HStack space={1} alignItems='center' justifyContent='between' className='p-2.5'>
|
<HStack space={1} alignItems='center' justifyContent='between' className='p-2.5'>
|
||||||
<div className='w-full'>
|
<div className='w-full'>
|
||||||
<Account account={account} withRelationship={false} />
|
<Account account={account} withRelationship={false} />
|
||||||
</div>
|
</div>
|
||||||
<HStack space={2}>
|
|
||||||
<Button
|
<AuthorizeRejectButtons
|
||||||
theme='secondary'
|
onAuthorize={handleAuthorize}
|
||||||
size='sm'
|
onReject={handleReject}
|
||||||
text={intl.formatMessage(messages.authorize)}
|
|
||||||
onClick={handleAuthorize}
|
|
||||||
/>
|
/>
|
||||||
<Button
|
|
||||||
theme='danger'
|
|
||||||
size='sm'
|
|
||||||
text={intl.formatMessage(messages.reject)}
|
|
||||||
onClick={handleReject}
|
|
||||||
/>
|
|
||||||
</HStack>
|
|
||||||
</HStack>
|
</HStack>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -74,19 +52,14 @@ interface IGroupMembershipRequests {
|
||||||
}
|
}
|
||||||
|
|
||||||
const GroupMembershipRequests: React.FC<IGroupMembershipRequests> = ({ params }) => {
|
const GroupMembershipRequests: React.FC<IGroupMembershipRequests> = ({ params }) => {
|
||||||
const intl = useIntl();
|
|
||||||
const dispatch = useAppDispatch();
|
|
||||||
|
|
||||||
const id = params?.id;
|
const id = params?.id;
|
||||||
|
const intl = useIntl();
|
||||||
|
|
||||||
const { group } = useGroup(id);
|
const { group } = useGroup(id);
|
||||||
const accountIds = useAppSelector((state) => state.user_lists.membership_requests.get(id)?.items);
|
|
||||||
|
|
||||||
useEffect(() => {
|
const { accounts, authorize, reject, isLoading } = useGroupMembershipRequests(id);
|
||||||
dispatch(fetchGroupMembershipRequests(id));
|
|
||||||
}, [id]);
|
|
||||||
|
|
||||||
if (!group || !group.relationship || !accountIds) {
|
if (!group || !group.relationship || isLoading) {
|
||||||
return (
|
return (
|
||||||
<Column label={intl.formatMessage(messages.heading)}>
|
<Column label={intl.formatMessage(messages.heading)}>
|
||||||
<Spinner />
|
<Spinner />
|
||||||
|
@ -95,20 +68,39 @@ const GroupMembershipRequests: React.FC<IGroupMembershipRequests> = ({ params })
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!group.relationship.role || !['owner', 'admin', 'moderator'].includes(group.relationship.role)) {
|
if (!group.relationship.role || !['owner', 'admin', 'moderator'].includes(group.relationship.role)) {
|
||||||
return (<ColumnForbidden />);
|
return <ColumnForbidden />;
|
||||||
}
|
}
|
||||||
|
|
||||||
const emptyMessage = <FormattedMessage id='empty_column.group_membership_requests' defaultMessage='There are no pending membership requests for this group.' />;
|
async function handleAuthorize(account: AccountEntity) {
|
||||||
|
try {
|
||||||
|
await authorize(account.id);
|
||||||
|
} catch (_e) {
|
||||||
|
toast.error(intl.formatMessage(messages.authorizeFail, { name: account.username }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleReject(account: AccountEntity) {
|
||||||
|
try {
|
||||||
|
await reject(account.id);
|
||||||
|
} catch (_e) {
|
||||||
|
toast.error(intl.formatMessage(messages.rejectFail, { name: account.username }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Column label={intl.formatMessage(messages.heading)} backHref={`/groups/${id}/manage`}>
|
<Column label={intl.formatMessage(messages.heading)}>
|
||||||
<ScrollableList
|
<ScrollableList
|
||||||
scrollKey='group_membership_requests'
|
scrollKey='group_membership_requests'
|
||||||
emptyMessage={emptyMessage}
|
emptyMessage={<FormattedMessage id='empty_column.group_membership_requests' defaultMessage='There are no pending membership requests for this group.' />}
|
||||||
>
|
>
|
||||||
{accountIds.map((accountId) =>
|
{accounts.map((account) => (
|
||||||
<MembershipRequest key={accountId} accountId={accountId} groupId={id} />,
|
<MembershipRequest
|
||||||
)}
|
key={account.id}
|
||||||
|
account={account}
|
||||||
|
onAuthorize={handleAuthorize}
|
||||||
|
onReject={handleReject}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
</ScrollableList>
|
</ScrollableList>
|
||||||
</Column>
|
</Column>
|
||||||
);
|
);
|
||||||
|
|
|
@ -45,7 +45,7 @@ describe('<PendingGroupRows />', () => {
|
||||||
|
|
||||||
it('should not render', () => {
|
it('should not render', () => {
|
||||||
renderApp(store);
|
renderApp(store);
|
||||||
expect(screen.queryAllByTestId('pending-groups-row')).toHaveLength(0);
|
expect(screen.queryAllByTestId('pending-items-row')).toHaveLength(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -69,7 +69,7 @@ describe('<PendingGroupRows />', () => {
|
||||||
|
|
||||||
it('should not render', () => {
|
it('should not render', () => {
|
||||||
renderApp(store);
|
renderApp(store);
|
||||||
expect(screen.queryAllByTestId('pending-groups-row')).toHaveLength(0);
|
expect(screen.queryAllByTestId('pending-items-row')).toHaveLength(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -95,7 +95,7 @@ describe('<PendingGroupRows />', () => {
|
||||||
renderApp(store);
|
renderApp(store);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.queryAllByTestId('pending-groups-row')).toHaveLength(1);
|
expect(screen.queryAllByTestId('pending-items-row')).toHaveLength(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,8 +1,7 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { FormattedMessage } from 'react-intl';
|
|
||||||
import { Link } from 'react-router-dom';
|
|
||||||
|
|
||||||
import { Divider, HStack, Icon, Text } from 'soapbox/components/ui';
|
import { PendingItemsRow } from 'soapbox/components/pending-items-row';
|
||||||
|
import { Divider } from 'soapbox/components/ui';
|
||||||
import { useFeatures } from 'soapbox/hooks';
|
import { useFeatures } from 'soapbox/hooks';
|
||||||
import { usePendingGroups } from 'soapbox/queries/groups';
|
import { usePendingGroups } from 'soapbox/queries/groups';
|
||||||
|
|
||||||
|
@ -17,31 +16,11 @@ export default () => {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Link to='/groups/pending-requests' className='group' data-testid='pending-groups-row'>
|
<PendingItemsRow
|
||||||
<HStack alignItems='center' justifyContent='between'>
|
to='/groups/pending-requests'
|
||||||
<HStack alignItems='center' space={2}>
|
count={groups.length}
|
||||||
<div className='rounded-full bg-primary-200 p-3 text-primary-500 dark:bg-primary-800 dark:text-primary-200'>
|
size='lg'
|
||||||
<Icon
|
|
||||||
src={require('@tabler/icons/exclamation-circle.svg')}
|
|
||||||
className='h-7 w-7'
|
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
|
|
||||||
<Text weight='bold' size='md'>
|
|
||||||
<FormattedMessage
|
|
||||||
id='groups.pending.count'
|
|
||||||
defaultMessage='{number, plural, one {# pending request} other {# pending requests}}'
|
|
||||||
values={{ number: groups.length }}
|
|
||||||
/>
|
|
||||||
</Text>
|
|
||||||
</HStack>
|
|
||||||
|
|
||||||
<Icon
|
|
||||||
src={require('@tabler/icons/chevron-right.svg')}
|
|
||||||
className='h-5 w-5 text-gray-600 transition-colors group-hover:text-gray-700 dark:text-gray-600 dark:group-hover:text-gray-500'
|
|
||||||
/>
|
|
||||||
</HStack>
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
<Divider />
|
<Divider />
|
||||||
</>
|
</>
|
||||||
|
|
38
app/soapbox/hooks/api/groups/useGroupMembershipRequests.ts
Normal file
38
app/soapbox/hooks/api/groups/useGroupMembershipRequests.ts
Normal file
|
@ -0,0 +1,38 @@
|
||||||
|
import { Entities } from 'soapbox/entity-store/entities';
|
||||||
|
import { useEntities, useIncrementEntity } from 'soapbox/entity-store/hooks';
|
||||||
|
import { useApi } from 'soapbox/hooks/useApi';
|
||||||
|
import { accountSchema } from 'soapbox/schemas';
|
||||||
|
|
||||||
|
import type { ExpandedEntitiesPath } from 'soapbox/entity-store/hooks/types';
|
||||||
|
|
||||||
|
function useGroupMembershipRequests(groupId: string) {
|
||||||
|
const api = useApi();
|
||||||
|
const path: ExpandedEntitiesPath = [Entities.ACCOUNTS, 'membership_requests', groupId];
|
||||||
|
|
||||||
|
const { entities, invalidate, ...rest } = useEntities(
|
||||||
|
path,
|
||||||
|
() => api.get(`/api/v1/groups/${groupId}/membership_requests`),
|
||||||
|
{ schema: accountSchema },
|
||||||
|
);
|
||||||
|
|
||||||
|
const { incrementEntity: authorize } = useIncrementEntity(path, -1, async (accountId: string) => {
|
||||||
|
const response = await api.post(`/api/v1/groups/${groupId}/membership_requests/${accountId}/authorize`);
|
||||||
|
invalidate();
|
||||||
|
return response;
|
||||||
|
});
|
||||||
|
|
||||||
|
const { incrementEntity: reject } = useIncrementEntity(path, -1, async (accountId: string) => {
|
||||||
|
const response = await api.post(`/api/v1/groups/${groupId}/membership_requests/${accountId}/reject`);
|
||||||
|
invalidate();
|
||||||
|
return response;
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
accounts: entities,
|
||||||
|
authorize,
|
||||||
|
reject,
|
||||||
|
...rest,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export { useGroupMembershipRequests };
|
|
@ -2,10 +2,14 @@ import { Entities } from 'soapbox/entity-store/entities';
|
||||||
import { useEntities } from 'soapbox/entity-store/hooks';
|
import { useEntities } from 'soapbox/entity-store/hooks';
|
||||||
import { GroupMember, groupMemberSchema } from 'soapbox/schemas';
|
import { GroupMember, groupMemberSchema } from 'soapbox/schemas';
|
||||||
|
|
||||||
|
import { useApi } from '../useApi';
|
||||||
|
|
||||||
function useGroupMembers(groupId: string, role: string) {
|
function useGroupMembers(groupId: string, role: string) {
|
||||||
|
const api = useApi();
|
||||||
|
|
||||||
const { entities, ...result } = useEntities<GroupMember>(
|
const { entities, ...result } = useEntities<GroupMember>(
|
||||||
[Entities.GROUP_MEMBERSHIPS, groupId, role],
|
[Entities.GROUP_MEMBERSHIPS, groupId, role],
|
||||||
`/api/v1/groups/${groupId}/memberships?role=${role}`,
|
() => api.get(`/api/v1/groups/${groupId}/memberships?role=${role}`),
|
||||||
{ schema: groupMemberSchema },
|
{ schema: groupMemberSchema },
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
@ -2,15 +2,17 @@ import { Entities } from 'soapbox/entity-store/entities';
|
||||||
import { useEntities } from 'soapbox/entity-store/hooks';
|
import { useEntities } from 'soapbox/entity-store/hooks';
|
||||||
import { Group, groupSchema } from 'soapbox/schemas';
|
import { Group, groupSchema } from 'soapbox/schemas';
|
||||||
|
|
||||||
|
import { useApi } from '../useApi';
|
||||||
import { useFeatures } from '../useFeatures';
|
import { useFeatures } from '../useFeatures';
|
||||||
import { useGroupRelationships } from '../useGroups';
|
import { useGroupRelationships } from '../useGroups';
|
||||||
|
|
||||||
function usePopularGroups() {
|
function usePopularGroups() {
|
||||||
|
const api = useApi();
|
||||||
const features = useFeatures();
|
const features = useFeatures();
|
||||||
|
|
||||||
const { entities, ...result } = useEntities<Group>(
|
const { entities, ...result } = useEntities<Group>(
|
||||||
[Entities.GROUPS, 'popular'],
|
[Entities.GROUPS, 'popular'],
|
||||||
'/api/mock/groups', // '/api/v1/truth/trends/groups'
|
() => api.get('/api/mock/groups'), // '/api/v1/truth/trends/groups'
|
||||||
{
|
{
|
||||||
schema: groupSchema,
|
schema: groupSchema,
|
||||||
enabled: features.groupsDiscovery,
|
enabled: features.groupsDiscovery,
|
||||||
|
|
|
@ -2,15 +2,17 @@ import { Entities } from 'soapbox/entity-store/entities';
|
||||||
import { useEntities } from 'soapbox/entity-store/hooks';
|
import { useEntities } from 'soapbox/entity-store/hooks';
|
||||||
import { Group, groupSchema } from 'soapbox/schemas';
|
import { Group, groupSchema } from 'soapbox/schemas';
|
||||||
|
|
||||||
|
import { useApi } from '../useApi';
|
||||||
import { useFeatures } from '../useFeatures';
|
import { useFeatures } from '../useFeatures';
|
||||||
import { useGroupRelationships } from '../useGroups';
|
import { useGroupRelationships } from '../useGroups';
|
||||||
|
|
||||||
function useSuggestedGroups() {
|
function useSuggestedGroups() {
|
||||||
|
const api = useApi();
|
||||||
const features = useFeatures();
|
const features = useFeatures();
|
||||||
|
|
||||||
const { entities, ...result } = useEntities<Group>(
|
const { entities, ...result } = useEntities<Group>(
|
||||||
[Entities.GROUPS, 'suggested'],
|
[Entities.GROUPS, 'suggested'],
|
||||||
'/api/mock/groups', // '/api/v1/truth/suggestions/groups'
|
() => api.get('/api/mock/groups'), // '/api/v1/truth/suggestions/groups'
|
||||||
{
|
{
|
||||||
schema: groupSchema,
|
schema: groupSchema,
|
||||||
enabled: features.groupsDiscovery,
|
enabled: features.groupsDiscovery,
|
||||||
|
|
|
@ -11,6 +11,7 @@ export { useGroupsPath } from './useGroupsPath';
|
||||||
export { useDimensions } from './useDimensions';
|
export { useDimensions } from './useDimensions';
|
||||||
export { useFeatures } from './useFeatures';
|
export { useFeatures } from './useFeatures';
|
||||||
export { useInstance } from './useInstance';
|
export { useInstance } from './useInstance';
|
||||||
|
export { useLoading } from './useLoading';
|
||||||
export { useLocale } from './useLocale';
|
export { useLocale } from './useLocale';
|
||||||
export { useOnScreen } from './useOnScreen';
|
export { useOnScreen } from './useOnScreen';
|
||||||
export { useOwnAccount } from './useOwnAccount';
|
export { useOwnAccount } from './useOwnAccount';
|
||||||
|
|
|
@ -2,17 +2,19 @@ import { z } from 'zod';
|
||||||
|
|
||||||
import { Entities } from 'soapbox/entity-store/entities';
|
import { Entities } from 'soapbox/entity-store/entities';
|
||||||
import { useEntities, useEntity } from 'soapbox/entity-store/hooks';
|
import { useEntities, useEntity } from 'soapbox/entity-store/hooks';
|
||||||
|
import { useApi } from 'soapbox/hooks';
|
||||||
import { groupSchema, Group } from 'soapbox/schemas/group';
|
import { groupSchema, Group } from 'soapbox/schemas/group';
|
||||||
import { groupRelationshipSchema, GroupRelationship } from 'soapbox/schemas/group-relationship';
|
import { groupRelationshipSchema, GroupRelationship } from 'soapbox/schemas/group-relationship';
|
||||||
|
|
||||||
import { useFeatures } from './useFeatures';
|
import { useFeatures } from './useFeatures';
|
||||||
|
|
||||||
function useGroups() {
|
function useGroups() {
|
||||||
|
const api = useApi();
|
||||||
const features = useFeatures();
|
const features = useFeatures();
|
||||||
|
|
||||||
const { entities, ...result } = useEntities<Group>(
|
const { entities, ...result } = useEntities<Group>(
|
||||||
[Entities.GROUPS, ''],
|
[Entities.GROUPS],
|
||||||
'/api/v1/groups',
|
() => api.get('/api/v1/groups'),
|
||||||
{ enabled: features.groups, schema: groupSchema },
|
{ enabled: features.groups, schema: groupSchema },
|
||||||
);
|
);
|
||||||
const { relationships } = useGroupRelationships(entities.map(entity => entity.id));
|
const { relationships } = useGroupRelationships(entities.map(entity => entity.id));
|
||||||
|
@ -29,9 +31,11 @@ function useGroups() {
|
||||||
}
|
}
|
||||||
|
|
||||||
function useGroup(groupId: string, refetch = true) {
|
function useGroup(groupId: string, refetch = true) {
|
||||||
|
const api = useApi();
|
||||||
|
|
||||||
const { entity: group, ...result } = useEntity<Group>(
|
const { entity: group, ...result } = useEntity<Group>(
|
||||||
[Entities.GROUPS, groupId],
|
[Entities.GROUPS, groupId],
|
||||||
`/api/v1/groups/${groupId}`,
|
() => api.get(`/api/v1/groups/${groupId}`),
|
||||||
{ schema: groupSchema, refetch },
|
{ schema: groupSchema, refetch },
|
||||||
);
|
);
|
||||||
const { entity: relationship } = useGroupRelationship(groupId);
|
const { entity: relationship } = useGroupRelationship(groupId);
|
||||||
|
@ -43,20 +47,22 @@ function useGroup(groupId: string, refetch = true) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function useGroupRelationship(groupId: string) {
|
function useGroupRelationship(groupId: string) {
|
||||||
|
const api = useApi();
|
||||||
|
|
||||||
return useEntity<GroupRelationship>(
|
return useEntity<GroupRelationship>(
|
||||||
[Entities.GROUP_RELATIONSHIPS, groupId],
|
[Entities.GROUP_RELATIONSHIPS, groupId],
|
||||||
`/api/v1/groups/relationships?id[]=${groupId}`,
|
() => api.get(`/api/v1/groups/relationships?id[]=${groupId}`),
|
||||||
{ schema: z.array(groupRelationshipSchema).transform(arr => arr[0]) },
|
{ schema: z.array(groupRelationshipSchema).transform(arr => arr[0]) },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function useGroupRelationships(groupIds: string[]) {
|
function useGroupRelationships(groupIds: string[]) {
|
||||||
|
const api = useApi();
|
||||||
const q = groupIds.map(id => `id[]=${id}`).join('&');
|
const q = groupIds.map(id => `id[]=${id}`).join('&');
|
||||||
const endpoint = groupIds.length ? `/api/v1/groups/relationships?${q}` : undefined;
|
|
||||||
const { entities, ...result } = useEntities<GroupRelationship>(
|
const { entities, ...result } = useEntities<GroupRelationship>(
|
||||||
[Entities.GROUP_RELATIONSHIPS, ...groupIds],
|
[Entities.GROUP_RELATIONSHIPS, ...groupIds],
|
||||||
endpoint,
|
() => api.get(`/api/v1/groups/relationships?${q}`),
|
||||||
{ schema: groupRelationshipSchema },
|
{ schema: groupRelationshipSchema, enabled: groupIds.length > 0 },
|
||||||
);
|
);
|
||||||
|
|
||||||
const relationships = entities.reduce<Record<string, GroupRelationship>>((map, relationship) => {
|
const relationships = entities.reduce<Record<string, GroupRelationship>>((map, relationship) => {
|
||||||
|
|
19
app/soapbox/hooks/useLoading.ts
Normal file
19
app/soapbox/hooks/useLoading.ts
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
function useLoading() {
|
||||||
|
const [isLoading, setIsLoading] = useState<boolean>(false);
|
||||||
|
|
||||||
|
function setPromise<T>(promise: Promise<T>) {
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
promise
|
||||||
|
.then(() => setIsLoading(false))
|
||||||
|
.catch(() => setIsLoading(false));
|
||||||
|
|
||||||
|
return promise;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [isLoading, setPromise] as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
export { useLoading };
|
|
@ -192,6 +192,7 @@
|
||||||
"auth.invalid_credentials": "Wrong username or password",
|
"auth.invalid_credentials": "Wrong username or password",
|
||||||
"auth.logged_out": "Logged out.",
|
"auth.logged_out": "Logged out.",
|
||||||
"auth_layout.register": "Create an account",
|
"auth_layout.register": "Create an account",
|
||||||
|
"authorize.success": "Approved",
|
||||||
"backups.actions.create": "Create backup",
|
"backups.actions.create": "Create backup",
|
||||||
"backups.empty_message": "No backups found. {action}",
|
"backups.empty_message": "No backups found. {action}",
|
||||||
"backups.empty_message.action": "Create one now?",
|
"backups.empty_message.action": "Create one now?",
|
||||||
|
@ -766,16 +767,14 @@
|
||||||
"group.cancel_request": "Cancel Request",
|
"group.cancel_request": "Cancel Request",
|
||||||
"group.delete.success": "Group successfully deleted",
|
"group.delete.success": "Group successfully deleted",
|
||||||
"group.demote.user.success": "@{name} is now a member",
|
"group.demote.user.success": "@{name} is now a member",
|
||||||
"group.group_mod_authorize": "Accept",
|
"group.group_mod_authorize.fail": "Failed to approve @{name}",
|
||||||
"group.group_mod_authorize.success": "Accepted @{name} to group",
|
|
||||||
"group.group_mod_block": "Ban from group",
|
"group.group_mod_block": "Ban from group",
|
||||||
"group.group_mod_block.success": "@{name} is banned",
|
"group.group_mod_block.success": "@{name} is banned",
|
||||||
"group.group_mod_demote": "Remove {role} role",
|
"group.group_mod_demote": "Remove {role} role",
|
||||||
"group.group_mod_kick": "Kick @{name} from group",
|
"group.group_mod_kick": "Kick @{name} from group",
|
||||||
"group.group_mod_kick.success": "Kicked @{name} from group",
|
"group.group_mod_kick.success": "Kicked @{name} from group",
|
||||||
"group.group_mod_promote_mod": "Assign {role} role",
|
"group.group_mod_promote_mod": "Assign {role} role",
|
||||||
"group.group_mod_reject": "Reject",
|
"group.group_mod_reject.fail": "Failed to reject @{name}",
|
||||||
"group.group_mod_reject.success": "Rejected @{name} from group",
|
|
||||||
"group.group_mod_unblock": "Unblock",
|
"group.group_mod_unblock": "Unblock",
|
||||||
"group.group_mod_unblock.success": "Unblocked @{name} from group",
|
"group.group_mod_unblock.success": "Unblocked @{name} from group",
|
||||||
"group.header.alt": "Group header",
|
"group.header.alt": "Group header",
|
||||||
|
@ -1200,6 +1199,7 @@
|
||||||
"registrations.unprocessable_entity": "This username has already been taken.",
|
"registrations.unprocessable_entity": "This username has already been taken.",
|
||||||
"registrations.username.hint": "May only contain A-Z, 0-9, and underscores",
|
"registrations.username.hint": "May only contain A-Z, 0-9, and underscores",
|
||||||
"registrations.username.label": "Your username",
|
"registrations.username.label": "Your username",
|
||||||
|
"reject.success": "Rejected",
|
||||||
"relative_time.days": "{number}d",
|
"relative_time.days": "{number}d",
|
||||||
"relative_time.hours": "{number}h",
|
"relative_time.hours": "{number}h",
|
||||||
"relative_time.just_now": "now",
|
"relative_time.just_now": "now",
|
||||||
|
|
|
@ -13,6 +13,7 @@ import {
|
||||||
SuggestedGroupsPanel,
|
SuggestedGroupsPanel,
|
||||||
} from 'soapbox/features/ui/util/async-components';
|
} from 'soapbox/features/ui/util/async-components';
|
||||||
import { useGroup, useOwnAccount } from 'soapbox/hooks';
|
import { useGroup, useOwnAccount } from 'soapbox/hooks';
|
||||||
|
import { useGroupMembershipRequests } from 'soapbox/hooks/api/groups/useGroupMembershipRequests';
|
||||||
import { Group } from 'soapbox/schemas';
|
import { Group } from 'soapbox/schemas';
|
||||||
|
|
||||||
import { Tabs } from '../components/ui';
|
import { Tabs } from '../components/ui';
|
||||||
|
@ -64,17 +65,12 @@ const GroupPage: React.FC<IGroupPage> = ({ params, children }) => {
|
||||||
const id = params?.id || '';
|
const id = params?.id || '';
|
||||||
|
|
||||||
const { group } = useGroup(id);
|
const { group } = useGroup(id);
|
||||||
|
const { accounts: pending } = useGroupMembershipRequests(id);
|
||||||
|
|
||||||
const isMember = !!group?.relationship?.member;
|
const isMember = !!group?.relationship?.member;
|
||||||
const isBlocked = group?.relationship?.blocked_by;
|
const isBlocked = group?.relationship?.blocked_by;
|
||||||
const isPrivate = group?.locked;
|
const isPrivate = group?.locked;
|
||||||
|
|
||||||
// if ((group as any) === false) {
|
|
||||||
// return (
|
|
||||||
// <MissingIndicator />
|
|
||||||
// );
|
|
||||||
// }
|
|
||||||
|
|
||||||
const items = [
|
const items = [
|
||||||
{
|
{
|
||||||
text: intl.formatMessage(messages.all),
|
text: intl.formatMessage(messages.all),
|
||||||
|
@ -85,6 +81,7 @@ const GroupPage: React.FC<IGroupPage> = ({ params, children }) => {
|
||||||
text: intl.formatMessage(messages.members),
|
text: intl.formatMessage(messages.members),
|
||||||
to: `/groups/${group?.id}/members`,
|
to: `/groups/${group?.id}/members`,
|
||||||
name: '/groups/:id/members',
|
name: '/groups/:id/members',
|
||||||
|
count: pending.length,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
|
@ -359,6 +359,7 @@ const getInstanceFeatures = (instance: Instance) => {
|
||||||
]),
|
]),
|
||||||
|
|
||||||
editStatuses: any([
|
editStatuses: any([
|
||||||
|
v.software === FRIENDICA && gte(v.version, '2022.12.0'),
|
||||||
v.software === MASTODON && gte(v.version, '3.5.0'),
|
v.software === MASTODON && gte(v.version, '3.5.0'),
|
||||||
features.includes('editing'),
|
features.includes('editing'),
|
||||||
]),
|
]),
|
||||||
|
@ -432,6 +433,7 @@ const getInstanceFeatures = (instance: Instance) => {
|
||||||
|
|
||||||
/** Whether the accounts who favourited or emoji-reacted to a status can be viewed through the API. */
|
/** Whether the accounts who favourited or emoji-reacted to a status can be viewed through the API. */
|
||||||
exposableReactions: any([
|
exposableReactions: any([
|
||||||
|
v.software === FRIENDICA,
|
||||||
v.software === MASTODON,
|
v.software === MASTODON,
|
||||||
v.software === TAKAHE && gte(v.version, '0.6.1'),
|
v.software === TAKAHE && gte(v.version, '0.6.1'),
|
||||||
v.software === TRUTHSOCIAL,
|
v.software === TRUTHSOCIAL,
|
||||||
|
@ -775,6 +777,7 @@ const getInstanceFeatures = (instance: Instance) => {
|
||||||
* @see {@link https://docs.joinmastodon.org/methods/scheduled_statuses/}
|
* @see {@link https://docs.joinmastodon.org/methods/scheduled_statuses/}
|
||||||
*/
|
*/
|
||||||
scheduledStatuses: any([
|
scheduledStatuses: any([
|
||||||
|
v.software === FRIENDICA,
|
||||||
v.software === MASTODON && gte(v.version, '2.7.0'),
|
v.software === MASTODON && gte(v.version, '2.7.0'),
|
||||||
v.software === PLEROMA,
|
v.software === PLEROMA,
|
||||||
]),
|
]),
|
||||||
|
@ -853,7 +856,10 @@ const getInstanceFeatures = (instance: Instance) => {
|
||||||
* Trending statuses.
|
* Trending statuses.
|
||||||
* @see GET /api/v1/trends/statuses
|
* @see GET /api/v1/trends/statuses
|
||||||
*/
|
*/
|
||||||
trendingStatuses: v.software === MASTODON && gte(v.compatVersion, '3.5.0'),
|
trendingStatuses: any([
|
||||||
|
v.software === FRIENDICA && gte(v.version, '2022.12.0'),
|
||||||
|
v.software === MASTODON && gte(v.compatVersion, '3.5.0'),
|
||||||
|
]),
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Truth Social trending statuses API.
|
* Truth Social trending statuses API.
|
||||||
|
@ -866,6 +872,7 @@ const getInstanceFeatures = (instance: Instance) => {
|
||||||
* @see GET /api/v1/trends
|
* @see GET /api/v1/trends
|
||||||
*/
|
*/
|
||||||
trends: any([
|
trends: any([
|
||||||
|
v.software === FRIENDICA && gte(v.version, '2022.12.0'),
|
||||||
v.software === MASTODON && gte(v.compatVersion, '3.0.0'),
|
v.software === MASTODON && gte(v.compatVersion, '3.0.0'),
|
||||||
v.software === TRUTHSOCIAL,
|
v.software === TRUTHSOCIAL,
|
||||||
]),
|
]),
|
||||||
|
@ -919,7 +926,9 @@ export const parseVersion = (version: string): Backend => {
|
||||||
const match = regex.exec(version);
|
const match = regex.exec(version);
|
||||||
|
|
||||||
const semverString = match && (match[3] || match[1]);
|
const semverString = match && (match[3] || match[1]);
|
||||||
const semver = match ? semverParse(semverString) || semverCoerce(semverString) : null;
|
const semver = match ? semverParse(semverString) || semverCoerce(semverString, {
|
||||||
|
loose: true,
|
||||||
|
}) : null;
|
||||||
const compat = match ? semverParse(match[1]) || semverCoerce(match[1]) : null;
|
const compat = match ? semverParse(match[1]) || semverCoerce(match[1]) : null;
|
||||||
|
|
||||||
if (match && semver && compat) {
|
if (match && semver && compat) {
|
||||||
|
|
|
@ -1,9 +1,13 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { FormattedNumber } from 'react-intl';
|
import { FormattedNumber } from 'react-intl';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
/** Check if a value is REALLY a number. */
|
/** Check if a value is REALLY a number. */
|
||||||
export const isNumber = (value: unknown): value is number => typeof value === 'number' && !isNaN(value);
|
export const isNumber = (value: unknown): value is number => typeof value === 'number' && !isNaN(value);
|
||||||
|
|
||||||
|
/** The input is a number and is not NaN. */
|
||||||
|
export const realNumberSchema = z.coerce.number().refine(n => !isNaN(n));
|
||||||
|
|
||||||
export const secondsToDays = (seconds: number) => Math.floor(seconds / (3600 * 24));
|
export const secondsToDays = (seconds: number) => Math.floor(seconds / (3600 * 24));
|
||||||
|
|
||||||
const roundDown = (num: number) => {
|
const roundDown = (num: number) => {
|
||||||
|
|
Loading…
Reference in a new issue