Add support for pending Group Requests
This commit is contained in:
parent
83532aedba
commit
f21f72461a
|
@ -1,7 +1,11 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { FormattedMessage, defineMessages, useIntl } from 'react-intl';
|
import { defineMessages, useIntl } from 'react-intl';
|
||||||
|
|
||||||
import { Avatar, HStack, Icon, Stack, Text } from './ui';
|
import GroupMemberCount from 'soapbox/features/group/components/group-member-count';
|
||||||
|
import GroupPrivacy from 'soapbox/features/group/components/group-privacy';
|
||||||
|
import GroupRelationship from 'soapbox/features/group/components/group-relationship';
|
||||||
|
|
||||||
|
import { Avatar, HStack, Stack, Text } from './ui';
|
||||||
|
|
||||||
import type { Group as GroupEntity } from 'soapbox/types/entities';
|
import type { Group as GroupEntity } from 'soapbox/types/entities';
|
||||||
|
|
||||||
|
@ -17,7 +21,10 @@ const GroupCard: React.FC<IGroupCard> = ({ group }) => {
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack className='relative h-[240px] rounded-lg border border-solid border-gray-300 bg-white dark:border-primary-800 dark:bg-primary-900'>
|
<Stack
|
||||||
|
className='relative h-[240px] rounded-lg border border-solid border-gray-300 bg-white dark:border-primary-800 dark:bg-primary-900'
|
||||||
|
data-testid='group-card'
|
||||||
|
>
|
||||||
{/* Group Cover Image */}
|
{/* Group Cover Image */}
|
||||||
<Stack grow className='relative basis-1/2 rounded-t-lg bg-primary-100 dark:bg-gray-800'>
|
<Stack grow className='relative basis-1/2 rounded-t-lg bg-primary-100 dark:bg-gray-800'>
|
||||||
{group.header && (
|
{group.header && (
|
||||||
|
@ -37,30 +44,10 @@ const GroupCard: React.FC<IGroupCard> = ({ group }) => {
|
||||||
<Stack alignItems='center' justifyContent='end' grow className='basis-1/2 py-4' space={0.5}>
|
<Stack alignItems='center' justifyContent='end' grow className='basis-1/2 py-4' space={0.5}>
|
||||||
<Text size='lg' weight='bold' dangerouslySetInnerHTML={{ __html: group.display_name_html }} />
|
<Text size='lg' weight='bold' dangerouslySetInnerHTML={{ __html: group.display_name_html }} />
|
||||||
|
|
||||||
<HStack className='text-gray-700 dark:text-gray-600' space={3} wrap>
|
<HStack className='text-gray-700 dark:text-gray-600' space={2} wrap>
|
||||||
{group.relationship?.role === 'admin' ? (
|
<GroupRelationship group={group} />
|
||||||
<HStack space={1} alignItems='center'>
|
<GroupPrivacy group={group} />
|
||||||
<Icon className='h-4 w-4' src={require('@tabler/icons/users.svg')} />
|
<GroupMemberCount group={group} />
|
||||||
<Text theme='inherit'><FormattedMessage id='group.role.admin' defaultMessage='Admin' /></Text>
|
|
||||||
</HStack>
|
|
||||||
) : group.relationship?.role === 'moderator' && (
|
|
||||||
<HStack space={1} alignItems='center'>
|
|
||||||
<Icon className='h-4 w-4' src={require('@tabler/icons/gavel.svg')} />
|
|
||||||
<Text theme='inherit'><FormattedMessage id='group.role.moderator' defaultMessage='Moderator' /></Text>
|
|
||||||
</HStack>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{group.locked ? (
|
|
||||||
<HStack space={1} alignItems='center'>
|
|
||||||
<Icon className='h-4 w-4' src={require('@tabler/icons/lock.svg')} />
|
|
||||||
<Text theme='inherit'><FormattedMessage id='group.privacy.locked' defaultMessage='Private' /></Text>
|
|
||||||
</HStack>
|
|
||||||
) : (
|
|
||||||
<HStack space={1} alignItems='center'>
|
|
||||||
<Icon className='h-4 w-4' src={require('@tabler/icons/world.svg')} />
|
|
||||||
<Text theme='inherit'><FormattedMessage id='group.privacy.public' defaultMessage='Public' /></Text>
|
|
||||||
</HStack>
|
|
||||||
)}
|
|
||||||
</HStack>
|
</HStack>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
|
@ -0,0 +1,84 @@
|
||||||
|
import { Map as ImmutableMap } from 'immutable';
|
||||||
|
import React from 'react';
|
||||||
|
import { VirtuosoMockContext } from 'react-virtuoso';
|
||||||
|
|
||||||
|
import { __stub } from 'soapbox/api';
|
||||||
|
import { render, screen, waitFor } from 'soapbox/jest/test-helpers';
|
||||||
|
import { normalizeAccount, normalizeGroup, normalizeGroupRelationship, normalizeInstance } from 'soapbox/normalizers';
|
||||||
|
|
||||||
|
import PendingRequests from '../pending-requests';
|
||||||
|
|
||||||
|
const userId = '1';
|
||||||
|
const store: any = {
|
||||||
|
me: userId,
|
||||||
|
accounts: ImmutableMap({
|
||||||
|
[userId]: normalizeAccount({
|
||||||
|
id: userId,
|
||||||
|
acct: 'justin-username',
|
||||||
|
display_name: 'Justin L',
|
||||||
|
avatar: 'test.jpg',
|
||||||
|
chats_onboarded: false,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
instance: normalizeInstance({
|
||||||
|
version: '3.4.1 (compatible; TruthSocial 1.0.0)',
|
||||||
|
software: 'TRUTHSOCIAL',
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderApp = () => (
|
||||||
|
render(
|
||||||
|
<VirtuosoMockContext.Provider value={{ viewportHeight: 300, itemHeight: 100 }}>
|
||||||
|
<PendingRequests />
|
||||||
|
</VirtuosoMockContext.Provider>,
|
||||||
|
undefined,
|
||||||
|
store,
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
describe('<PendingRequests />', () => {
|
||||||
|
describe('without pending group requests', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
__stub((mock) => {
|
||||||
|
mock.onGet('/api/v1/groups?pending=true').reply(200, []);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render the blankslate', async () => {
|
||||||
|
renderApp();
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('pending-requests-blankslate')).toBeInTheDocument();
|
||||||
|
expect(screen.queryAllByTestId('group-card')).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('with pending group requests', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
__stub((mock) => {
|
||||||
|
mock.onGet('/api/v1/groups').reply(200, [
|
||||||
|
normalizeGroup({
|
||||||
|
display_name: 'Group',
|
||||||
|
id: '1',
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
mock.onGet('/api/v1/groups/relationships?id[]=1').reply(200, [
|
||||||
|
normalizeGroupRelationship({
|
||||||
|
id: '1',
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render the groups', async () => {
|
||||||
|
renderApp();
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.queryAllByTestId('group-card')).toHaveLength(1);
|
||||||
|
expect(screen.queryAllByTestId('pending-requests-blankslate')).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -0,0 +1,103 @@
|
||||||
|
import { Map as ImmutableMap } from 'immutable';
|
||||||
|
import React from 'react';
|
||||||
|
import { VirtuosoMockContext } from 'react-virtuoso';
|
||||||
|
|
||||||
|
import { __stub } from 'soapbox/api';
|
||||||
|
import { render, screen, waitFor } from 'soapbox/jest/test-helpers';
|
||||||
|
import { normalizeAccount, normalizeGroup, normalizeGroupRelationship, normalizeInstance } from 'soapbox/normalizers';
|
||||||
|
|
||||||
|
import PendingGroupsRow from '../pending-groups-row';
|
||||||
|
|
||||||
|
const userId = '1';
|
||||||
|
let store: any = {
|
||||||
|
me: userId,
|
||||||
|
accounts: ImmutableMap({
|
||||||
|
[userId]: normalizeAccount({
|
||||||
|
id: userId,
|
||||||
|
acct: 'justin-username',
|
||||||
|
display_name: 'Justin L',
|
||||||
|
avatar: 'test.jpg',
|
||||||
|
chats_onboarded: false,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderApp = (store: any) => (
|
||||||
|
render(
|
||||||
|
<VirtuosoMockContext.Provider value={{ viewportHeight: 300, itemHeight: 100 }}>
|
||||||
|
<PendingGroupsRow />
|
||||||
|
</VirtuosoMockContext.Provider>,
|
||||||
|
undefined,
|
||||||
|
store,
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
describe('<PendingGroupRows />', () => {
|
||||||
|
describe('without the feature', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
store = {
|
||||||
|
...store,
|
||||||
|
instance: normalizeInstance({
|
||||||
|
version: '2.7.2 (compatible; Pleroma 2.3.0)',
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not render', () => {
|
||||||
|
renderApp(store);
|
||||||
|
expect(screen.queryAllByTestId('pending-groups-row')).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('with the feature', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
store = {
|
||||||
|
...store,
|
||||||
|
instance: normalizeInstance({
|
||||||
|
version: '3.4.1 (compatible; TruthSocial 1.0.0)',
|
||||||
|
software: 'TRUTHSOCIAL',
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('without pending group requests', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
__stub((mock) => {
|
||||||
|
mock.onGet('/api/v1/groups?pending=true').reply(200, []);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not render', () => {
|
||||||
|
renderApp(store);
|
||||||
|
expect(screen.queryAllByTestId('pending-groups-row')).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('with pending group requests', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
__stub((mock) => {
|
||||||
|
mock.onGet('/api/v1/groups').reply(200, [
|
||||||
|
normalizeGroup({
|
||||||
|
display_name: 'Group',
|
||||||
|
id: '1',
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
mock.onGet('/api/v1/groups/relationships?id[]=1').reply(200, [
|
||||||
|
normalizeGroupRelationship({
|
||||||
|
id: '1',
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render the row', async () => {
|
||||||
|
renderApp(store);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.queryAllByTestId('pending-groups-row')).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -12,7 +12,7 @@ interface IGroup {
|
||||||
width?: number
|
width?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
const Group = forwardRef((props: IGroup, ref: React.ForwardedRef<HTMLDivElement>) => {
|
const GroupGridItem = forwardRef((props: IGroup, ref: React.ForwardedRef<HTMLDivElement>) => {
|
||||||
const { group, width = 'auto' } = props;
|
const { group, width = 'auto' } = props;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -78,4 +78,4 @@ const Group = forwardRef((props: IGroup, ref: React.ForwardedRef<HTMLDivElement>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
export default Group;
|
export default GroupGridItem;
|
|
@ -0,0 +1,80 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { FormattedMessage } from 'react-intl';
|
||||||
|
|
||||||
|
import { Avatar, Button, HStack, Icon, Stack, Text } from 'soapbox/components/ui';
|
||||||
|
import { Group as GroupEntity } from 'soapbox/types/entities';
|
||||||
|
import { shortNumberFormat } from 'soapbox/utils/numbers';
|
||||||
|
|
||||||
|
interface IGroup {
|
||||||
|
group: GroupEntity
|
||||||
|
withJoinAction?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const GroupListItem = (props: IGroup) => {
|
||||||
|
const { group, withJoinAction = true } = props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<HStack
|
||||||
|
key={group.id}
|
||||||
|
alignItems='center'
|
||||||
|
justifyContent='between'
|
||||||
|
>
|
||||||
|
<HStack alignItems='center' space={2}>
|
||||||
|
<Avatar
|
||||||
|
className='ring-2 ring-white dark:ring-primary-900'
|
||||||
|
src={group.avatar}
|
||||||
|
size={44}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Stack>
|
||||||
|
<Text
|
||||||
|
weight='bold'
|
||||||
|
dangerouslySetInnerHTML={{ __html: group.display_name_html }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<HStack className='text-gray-700 dark:text-gray-600' space={1} alignItems='center'>
|
||||||
|
<Icon
|
||||||
|
className='h-4.5 w-4.5'
|
||||||
|
src={group.locked ? require('@tabler/icons/lock.svg') : require('@tabler/icons/world.svg')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Text theme='inherit' tag='span' size='sm' weight='medium'>
|
||||||
|
{group.locked ? (
|
||||||
|
<FormattedMessage id='group.privacy.locked' defaultMessage='Private' />
|
||||||
|
) : (
|
||||||
|
<FormattedMessage id='group.privacy.public' defaultMessage='Public' />
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{typeof group.members_count !== 'undefined' && (
|
||||||
|
<>
|
||||||
|
<span>•</span>
|
||||||
|
<Text theme='inherit' tag='span' size='sm' weight='medium'>
|
||||||
|
{shortNumberFormat(group.members_count)}
|
||||||
|
{' '}
|
||||||
|
<FormattedMessage
|
||||||
|
id='groups.discover.search.results.member_count'
|
||||||
|
defaultMessage='{members, plural, one {member} other {members}}'
|
||||||
|
values={{
|
||||||
|
members: group.members_count,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</HStack>
|
||||||
|
</Stack>
|
||||||
|
</HStack>
|
||||||
|
|
||||||
|
{withJoinAction && (
|
||||||
|
<Button theme='primary'>
|
||||||
|
{group.locked
|
||||||
|
? <FormattedMessage id='group.join.private' defaultMessage='Request Access' />
|
||||||
|
: <FormattedMessage id='group.join.public' defaultMessage='Join Group' />}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</HStack>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default GroupListItem;
|
|
@ -5,7 +5,7 @@ import { Carousel, Stack, Text } from 'soapbox/components/ui';
|
||||||
import PlaceholderGroupDiscover from 'soapbox/features/placeholder/components/placeholder-group-discover';
|
import PlaceholderGroupDiscover from 'soapbox/features/placeholder/components/placeholder-group-discover';
|
||||||
import { usePopularGroups } from 'soapbox/queries/groups';
|
import { usePopularGroups } from 'soapbox/queries/groups';
|
||||||
|
|
||||||
import Group from './group';
|
import GroupGridItem from './group-grid-item';
|
||||||
|
|
||||||
const PopularGroups = () => {
|
const PopularGroups = () => {
|
||||||
const { groups, isFetching, isFetched, isError } = usePopularGroups();
|
const { groups, isFetching, isFetched, isError } = usePopularGroups();
|
||||||
|
@ -49,7 +49,7 @@ const PopularGroups = () => {
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
groups.map((group) => (
|
groups.map((group) => (
|
||||||
<Group
|
<GroupGridItem
|
||||||
key={group.id}
|
key={group.id}
|
||||||
group={group}
|
group={group}
|
||||||
width={width}
|
width={width}
|
||||||
|
|
|
@ -3,12 +3,12 @@ import React, { useCallback, useState } from 'react';
|
||||||
import { FormattedMessage } from 'react-intl';
|
import { FormattedMessage } from 'react-intl';
|
||||||
import { Components, Virtuoso, VirtuosoGrid } from 'react-virtuoso';
|
import { Components, Virtuoso, VirtuosoGrid } from 'react-virtuoso';
|
||||||
|
|
||||||
import { Avatar, Button, HStack, Icon, Stack, Text } from 'soapbox/components/ui';
|
import { HStack, Icon, Stack, Text } from 'soapbox/components/ui';
|
||||||
import { useGroupSearch } from 'soapbox/queries/groups/search';
|
import { useGroupSearch } from 'soapbox/queries/groups/search';
|
||||||
import { Group } from 'soapbox/types/entities';
|
import { Group } from 'soapbox/types/entities';
|
||||||
import { shortNumberFormat } from 'soapbox/utils/numbers';
|
|
||||||
|
|
||||||
import GroupComp from '../group';
|
import GroupGridItem from '../group-grid-item';
|
||||||
|
import GroupListItem from '../group-list-item';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
groupSearchResult: ReturnType<typeof useGroupSearch>
|
groupSearchResult: ReturnType<typeof useGroupSearch>
|
||||||
|
@ -38,73 +38,20 @@ export default (props: Props) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderGroupList = useCallback((group: Group, index: number) => (
|
const renderGroupList = useCallback((group: Group, index: number) => (
|
||||||
<HStack
|
<div
|
||||||
alignItems='center'
|
|
||||||
justifyContent='between'
|
|
||||||
className={
|
className={
|
||||||
clsx({
|
clsx({
|
||||||
'pt-4': index !== 0,
|
'pt-4': index !== 0,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<HStack alignItems='center' space={2}>
|
<GroupListItem group={group} withJoinAction />
|
||||||
<Avatar
|
</div>
|
||||||
className='ring-2 ring-white dark:ring-primary-900'
|
|
||||||
src={group.avatar}
|
|
||||||
size={44}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Stack>
|
|
||||||
<Text
|
|
||||||
weight='bold'
|
|
||||||
dangerouslySetInnerHTML={{ __html: group.display_name_html }}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<HStack className='text-gray-700 dark:text-gray-600' space={1} alignItems='center'>
|
|
||||||
<Icon
|
|
||||||
className='h-4.5 w-4.5'
|
|
||||||
src={group.locked ? require('@tabler/icons/lock.svg') : require('@tabler/icons/world.svg')}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Text theme='inherit' tag='span' size='sm' weight='medium'>
|
|
||||||
{group.locked ? (
|
|
||||||
<FormattedMessage id='group.privacy.locked' defaultMessage='Private' />
|
|
||||||
) : (
|
|
||||||
<FormattedMessage id='group.privacy.public' defaultMessage='Public' />
|
|
||||||
)}
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
{typeof group.members_count !== 'undefined' && (
|
|
||||||
<>
|
|
||||||
<span>•</span>
|
|
||||||
<Text theme='inherit' tag='span' size='sm' weight='medium'>
|
|
||||||
{shortNumberFormat(group.members_count)}
|
|
||||||
{' '}
|
|
||||||
<FormattedMessage
|
|
||||||
id='groups.discover.search.results.member_count'
|
|
||||||
defaultMessage='{members, plural, one {member} other {members}}'
|
|
||||||
values={{
|
|
||||||
members: group.members_count,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Text>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</HStack>
|
|
||||||
</Stack>
|
|
||||||
</HStack>
|
|
||||||
|
|
||||||
<Button theme='primary'>
|
|
||||||
{group.locked
|
|
||||||
? <FormattedMessage id='group.join.private' defaultMessage='Request Access' />
|
|
||||||
: <FormattedMessage id='group.join.public' defaultMessage='Join Group' />}
|
|
||||||
</Button>
|
|
||||||
</HStack>
|
|
||||||
), []);
|
), []);
|
||||||
|
|
||||||
const renderGroupGrid = useCallback((group: Group, index: number) => (
|
const renderGroupGrid = useCallback((group: Group, index: number) => (
|
||||||
<div className='pb-4'>
|
<div className='pb-4'>
|
||||||
<GroupComp group={group} />
|
<GroupGridItem group={group} />
|
||||||
</div>
|
</div>
|
||||||
), []);
|
), []);
|
||||||
|
|
||||||
|
|
|
@ -5,7 +5,7 @@ import { Carousel, Stack, Text } from 'soapbox/components/ui';
|
||||||
import PlaceholderGroupDiscover from 'soapbox/features/placeholder/components/placeholder-group-discover';
|
import PlaceholderGroupDiscover from 'soapbox/features/placeholder/components/placeholder-group-discover';
|
||||||
import { useSuggestedGroups } from 'soapbox/queries/groups';
|
import { useSuggestedGroups } from 'soapbox/queries/groups';
|
||||||
|
|
||||||
import Group from './group';
|
import GroupGridItem from './group-grid-item';
|
||||||
|
|
||||||
const SuggestedGroups = () => {
|
const SuggestedGroups = () => {
|
||||||
const { groups, isFetching, isFetched, isError } = useSuggestedGroups();
|
const { groups, isFetching, isFetched, isError } = useSuggestedGroups();
|
||||||
|
@ -49,7 +49,7 @@ const SuggestedGroups = () => {
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
groups.map((group) => (
|
groups.map((group) => (
|
||||||
<Group
|
<GroupGridItem
|
||||||
key={group.id}
|
key={group.id}
|
||||||
group={group}
|
group={group}
|
||||||
width={width}
|
width={width}
|
||||||
|
|
|
@ -0,0 +1,49 @@
|
||||||
|
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 { useFeatures } from 'soapbox/hooks';
|
||||||
|
import { usePendingGroups } from 'soapbox/queries/groups';
|
||||||
|
|
||||||
|
export default () => {
|
||||||
|
const features = useFeatures();
|
||||||
|
|
||||||
|
const { groups, isFetching } = usePendingGroups();
|
||||||
|
|
||||||
|
if (!features.groupsPending || isFetching || groups.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Link to='/groups/pending-requests' className='group' data-testid='pending-groups-row'>
|
||||||
|
<HStack alignItems='center' justifyContent='between'>
|
||||||
|
<HStack alignItems='center' space={2}>
|
||||||
|
<div className='rounded-full bg-primary-200 p-3 text-primary-500 dark:bg-primary-800 dark:text-primary-200'>
|
||||||
|
<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 />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
|
@ -11,6 +11,7 @@ import { PERMISSION_CREATE_GROUPS, hasPermission } from 'soapbox/utils/permissio
|
||||||
|
|
||||||
import PlaceholderGroupCard from '../placeholder/components/placeholder-group-card';
|
import PlaceholderGroupCard from '../placeholder/components/placeholder-group-card';
|
||||||
|
|
||||||
|
import PendingGroupsRow from './components/pending-groups-row';
|
||||||
import TabBar, { TabItems } from './components/tab-bar';
|
import TabBar, { TabItems } from './components/tab-bar';
|
||||||
|
|
||||||
import type { Group as GroupEntity } from 'soapbox/types/entities';
|
import type { Group as GroupEntity } from 'soapbox/types/entities';
|
||||||
|
@ -75,11 +76,13 @@ const Groups: React.FC = () => {
|
||||||
<TabBar activeTab={TabItems.MY_GROUPS} />
|
<TabBar activeTab={TabItems.MY_GROUPS} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<PendingGroupsRow />
|
||||||
|
|
||||||
<ScrollableList
|
<ScrollableList
|
||||||
scrollKey='groups'
|
scrollKey='groups'
|
||||||
emptyMessage={renderBlankslate()}
|
emptyMessage={renderBlankslate()}
|
||||||
emptyMessageCard={false}
|
emptyMessageCard={false}
|
||||||
itemClassName='py-3 first:pt-0 last:pb-0'
|
itemClassName='pb-4 last:pb-0'
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
showLoading={isLoading && groups.length === 0}
|
showLoading={isLoading && groups.length === 0}
|
||||||
placeholderComponent={PlaceholderGroupCard}
|
placeholderComponent={PlaceholderGroupCard}
|
||||||
|
|
|
@ -0,0 +1,68 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
|
import GroupCard from 'soapbox/components/group-card';
|
||||||
|
import ScrollableList from 'soapbox/components/scrollable-list';
|
||||||
|
import { Column, Stack, Text } from 'soapbox/components/ui';
|
||||||
|
import { usePendingGroups } from 'soapbox/queries/groups';
|
||||||
|
|
||||||
|
import PlaceholderGroupCard from '../placeholder/components/placeholder-group-card';
|
||||||
|
|
||||||
|
const messages = defineMessages({
|
||||||
|
label: { id: 'groups.pending.label', defaultMessage: 'Pending Requests' },
|
||||||
|
});
|
||||||
|
|
||||||
|
export default () => {
|
||||||
|
const intl = useIntl();
|
||||||
|
|
||||||
|
const { groups, isLoading } = usePendingGroups();
|
||||||
|
|
||||||
|
|
||||||
|
const renderBlankslate = () => (
|
||||||
|
<Stack
|
||||||
|
space={4}
|
||||||
|
alignItems='center'
|
||||||
|
justifyContent='center'
|
||||||
|
className='py-6'
|
||||||
|
data-testid='pending-requests-blankslate'
|
||||||
|
>
|
||||||
|
<Stack space={2} className='max-w-sm'>
|
||||||
|
<Text size='2xl' weight='bold' tag='h2' align='center'>
|
||||||
|
<FormattedMessage
|
||||||
|
id='groups.pending.empty.title'
|
||||||
|
defaultMessage='No pending requests'
|
||||||
|
/>
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Text size='sm' theme='muted' align='center'>
|
||||||
|
<FormattedMessage
|
||||||
|
id='groups.pending.empty.subtitle'
|
||||||
|
defaultMessage='You have no pending requests at this time.'
|
||||||
|
/>
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Column label={intl.formatMessage(messages.label)}>
|
||||||
|
<ScrollableList
|
||||||
|
emptyMessage={renderBlankslate()}
|
||||||
|
emptyMessageCard={false}
|
||||||
|
isLoading={isLoading}
|
||||||
|
itemClassName='pb-4 last:pb-0'
|
||||||
|
placeholderComponent={PlaceholderGroupCard}
|
||||||
|
placeholderCount={3}
|
||||||
|
scrollKey='pending-group-requests'
|
||||||
|
showLoading={isLoading && groups.length === 0}
|
||||||
|
>
|
||||||
|
{groups.map((group) => (
|
||||||
|
<Link key={group.id} to={`/groups/${group.id}`}>
|
||||||
|
<GroupCard group={group} />
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</ScrollableList>
|
||||||
|
</Column>
|
||||||
|
);
|
||||||
|
};
|
|
@ -0,0 +1,33 @@
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import { Widget } from 'soapbox/components/ui';
|
||||||
|
import GroupListItem from 'soapbox/features/groups/components/discover/group-list-item';
|
||||||
|
import PlaceholderGroupSearch from 'soapbox/features/placeholder/components/placeholder-group-search';
|
||||||
|
import { useSuggestedGroups } from 'soapbox/queries/groups';
|
||||||
|
|
||||||
|
const SuggestedGroupsPanel = () => {
|
||||||
|
const { groups, isFetching, isFetched, isError } = useSuggestedGroups();
|
||||||
|
const isEmpty = (isFetched && groups.length === 0) || isError;
|
||||||
|
|
||||||
|
if (isEmpty) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Widget
|
||||||
|
title='Suggested Groups'
|
||||||
|
>
|
||||||
|
{isFetching ? (
|
||||||
|
new Array(3).fill(0).map((_, idx) => (
|
||||||
|
<PlaceholderGroupSearch key={idx} />
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
groups.slice(0, 3).map((group) => (
|
||||||
|
<GroupListItem group={group} withJoinAction={false} key={group.id} />
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</Widget>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SuggestedGroupsPanel;
|
|
@ -32,6 +32,7 @@ import EventPage from 'soapbox/pages/event-page';
|
||||||
import EventsPage from 'soapbox/pages/events-page';
|
import EventsPage from 'soapbox/pages/events-page';
|
||||||
import GroupPage from 'soapbox/pages/group-page';
|
import GroupPage from 'soapbox/pages/group-page';
|
||||||
import GroupsPage from 'soapbox/pages/groups-page';
|
import GroupsPage from 'soapbox/pages/groups-page';
|
||||||
|
import GroupsPendingPage from 'soapbox/pages/groups-pending-page';
|
||||||
import HomePage from 'soapbox/pages/home-page';
|
import HomePage from 'soapbox/pages/home-page';
|
||||||
import ProfilePage from 'soapbox/pages/profile-page';
|
import ProfilePage from 'soapbox/pages/profile-page';
|
||||||
import RemoteInstancePage from 'soapbox/pages/remote-instance-page';
|
import RemoteInstancePage from 'soapbox/pages/remote-instance-page';
|
||||||
|
@ -117,6 +118,7 @@ import {
|
||||||
Events,
|
Events,
|
||||||
Groups,
|
Groups,
|
||||||
GroupsDiscover,
|
GroupsDiscover,
|
||||||
|
PendingGroupRequests,
|
||||||
GroupMembers,
|
GroupMembers,
|
||||||
GroupTimeline,
|
GroupTimeline,
|
||||||
ManageGroup,
|
ManageGroup,
|
||||||
|
@ -287,6 +289,7 @@ const SwitchingColumnsArea: React.FC<ISwitchingColumnsArea> = ({ children }) =>
|
||||||
|
|
||||||
{features.groups && <WrappedRoute path='/groups' exact page={GroupsPage} component={Groups} content={children} />}
|
{features.groups && <WrappedRoute path='/groups' exact page={GroupsPage} component={Groups} content={children} />}
|
||||||
{features.groupsDiscovery && <WrappedRoute path='/groups/discover' exact page={GroupsPage} component={GroupsDiscover} content={children} />}
|
{features.groupsDiscovery && <WrappedRoute path='/groups/discover' exact page={GroupsPage} component={GroupsDiscover} content={children} />}
|
||||||
|
{features.groupsPending && <WrappedRoute path='/groups/pending-requests' exact page={GroupsPendingPage} component={PendingGroupRequests} content={children} />}
|
||||||
{features.groups && <WrappedRoute path='/groups/:id' exact page={GroupPage} component={GroupTimeline} content={children} />}
|
{features.groups && <WrappedRoute path='/groups/:id' exact page={GroupPage} component={GroupTimeline} content={children} />}
|
||||||
{features.groups && <WrappedRoute path='/groups/:id/members' exact page={GroupPage} component={GroupMembers} content={children} />}
|
{features.groups && <WrappedRoute path='/groups/:id/members' exact page={GroupPage} component={GroupMembers} content={children} />}
|
||||||
{features.groups && <WrappedRoute path='/groups/:id/manage' exact page={DefaultPage} component={ManageGroup} content={children} />}
|
{features.groups && <WrappedRoute path='/groups/:id/manage' exact page={DefaultPage} component={ManageGroup} content={children} />}
|
||||||
|
|
|
@ -550,6 +550,10 @@ export function GroupsDiscover() {
|
||||||
return import(/* webpackChunkName: "features/groups/discover" */'../../groups/discover');
|
return import(/* webpackChunkName: "features/groups/discover" */'../../groups/discover');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function PendingGroupRequests() {
|
||||||
|
return import(/* webpackChunkName: "features/groups/discover" */'../../groups/pending-requests');
|
||||||
|
}
|
||||||
|
|
||||||
export function GroupMembers() {
|
export function GroupMembers() {
|
||||||
return import(/* webpackChunkName: "features/groups" */'../../group/group-members');
|
return import(/* webpackChunkName: "features/groups" */'../../group/group-members');
|
||||||
}
|
}
|
||||||
|
@ -578,6 +582,10 @@ export function NewGroupPanel() {
|
||||||
return import(/* webpackChunkName: "features/groups" */'../components/panels/new-group-panel');
|
return import(/* webpackChunkName: "features/groups" */'../components/panels/new-group-panel');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function SuggestedGroupsPanel() {
|
||||||
|
return import(/* webpackChunkName: "features/groups" */'../components/panels/suggested-groups-panel');
|
||||||
|
}
|
||||||
|
|
||||||
export function GroupMediaPanel() {
|
export function GroupMediaPanel() {
|
||||||
return import(/* webpackChunkName: "features/groups" */'../components/group-media-panel');
|
return import(/* webpackChunkName: "features/groups" */'../components/group-media-panel');
|
||||||
}
|
}
|
||||||
|
|
|
@ -24,6 +24,7 @@ const GroupsPage: React.FC<IGroupsPage> = ({ children }) => (
|
||||||
<BundleContainer fetchComponent={NewGroupPanel}>
|
<BundleContainer fetchComponent={NewGroupPanel}>
|
||||||
{Component => <Component key='new-group-panel' />}
|
{Component => <Component key='new-group-panel' />}
|
||||||
</BundleContainer>
|
</BundleContainer>
|
||||||
|
|
||||||
<LinkFooter key='link-footer' />
|
<LinkFooter key='link-footer' />
|
||||||
</Layout.Aside>
|
</Layout.Aside>
|
||||||
</>
|
</>
|
||||||
|
|
|
@ -0,0 +1,33 @@
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import { Layout } from 'soapbox/components/ui';
|
||||||
|
import LinkFooter from 'soapbox/features/ui/components/link-footer';
|
||||||
|
import BundleContainer from 'soapbox/features/ui/containers/bundle-container';
|
||||||
|
import { NewGroupPanel, SuggestedGroupsPanel } from 'soapbox/features/ui/util/async-components';
|
||||||
|
|
||||||
|
interface IGroupsPage {
|
||||||
|
children: React.ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Page to display groups. */
|
||||||
|
const GroupsPendingPage: React.FC<IGroupsPage> = ({ children }) => (
|
||||||
|
<>
|
||||||
|
<Layout.Main>
|
||||||
|
{children}
|
||||||
|
</Layout.Main>
|
||||||
|
|
||||||
|
<Layout.Aside>
|
||||||
|
<BundleContainer fetchComponent={NewGroupPanel}>
|
||||||
|
{Component => <Component key='new-group-panel' />}
|
||||||
|
</BundleContainer>
|
||||||
|
|
||||||
|
<BundleContainer fetchComponent={SuggestedGroupsPanel}>
|
||||||
|
{Component => <Component key='suggested-groups-panel' />}
|
||||||
|
</BundleContainer>
|
||||||
|
|
||||||
|
<LinkFooter key='link-footer' />
|
||||||
|
</Layout.Aside>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default GroupsPendingPage;
|
|
@ -1,4 +1,5 @@
|
||||||
import { useInfiniteQuery, useMutation, useQuery } from '@tanstack/react-query';
|
import { useInfiniteQuery, useMutation, useQuery } from '@tanstack/react-query';
|
||||||
|
import { AxiosRequestConfig } from 'axios';
|
||||||
import { defineMessages, useIntl } from 'react-intl';
|
import { defineMessages, useIntl } from 'react-intl';
|
||||||
|
|
||||||
import { getNextLink } from 'soapbox/api';
|
import { getNextLink } from 'soapbox/api';
|
||||||
|
@ -19,6 +20,7 @@ const messages = defineMessages({
|
||||||
const GroupKeys = {
|
const GroupKeys = {
|
||||||
group: (id: string) => ['groups', 'group', id] as const,
|
group: (id: string) => ['groups', 'group', id] as const,
|
||||||
myGroups: (userId: string) => ['groups', userId] as const,
|
myGroups: (userId: string) => ['groups', userId] as const,
|
||||||
|
pendingGroups: (userId: string) => ['groups', userId, 'pending'] as const,
|
||||||
popularGroups: ['groups', 'popular'] as const,
|
popularGroups: ['groups', 'popular'] as const,
|
||||||
suggestedGroups: ['groups', 'suggested'] as const,
|
suggestedGroups: ['groups', 'suggested'] as const,
|
||||||
};
|
};
|
||||||
|
@ -33,8 +35,10 @@ const useGroupsApi = () => {
|
||||||
return data;
|
return data;
|
||||||
};
|
};
|
||||||
|
|
||||||
const fetchGroups = async (endpoint: string) => {
|
const fetchGroups = async (endpoint: string, params: AxiosRequestConfig['params'] = {}) => {
|
||||||
const response = await api.get<Group[]>(endpoint);
|
const response = await api.get<Group[]>(endpoint, {
|
||||||
|
params,
|
||||||
|
});
|
||||||
const groups = [response.data].flat();
|
const groups = [response.data].flat();
|
||||||
const relationships = await getGroupRelationships(groups.map((group) => group.id));
|
const relationships = await getGroupRelationships(groups.map((group) => group.id));
|
||||||
const result = groups.map((group) => {
|
const result = groups.map((group) => {
|
||||||
|
@ -99,6 +103,52 @@ const useGroups = () => {
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const usePendingGroups = () => {
|
||||||
|
const features = useFeatures();
|
||||||
|
const account = useOwnAccount();
|
||||||
|
const { fetchGroups } = useGroupsApi();
|
||||||
|
|
||||||
|
const getGroups = async (pageParam?: any): Promise<PaginatedResult<Group>> => {
|
||||||
|
const endpoint = '/api/v1/groups';
|
||||||
|
const nextPageLink = pageParam?.link;
|
||||||
|
const uri = nextPageLink || endpoint;
|
||||||
|
const { response, groups } = await fetchGroups(uri, {
|
||||||
|
pending: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const link = getNextLink(response);
|
||||||
|
const hasMore = !!link;
|
||||||
|
|
||||||
|
return {
|
||||||
|
result: groups,
|
||||||
|
hasMore,
|
||||||
|
link,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const queryInfo = useInfiniteQuery(
|
||||||
|
GroupKeys.pendingGroups(account?.id as string),
|
||||||
|
({ pageParam }: any) => getGroups(pageParam),
|
||||||
|
{
|
||||||
|
enabled: !!account && features.groupsPending,
|
||||||
|
keepPreviousData: true,
|
||||||
|
getNextPageParam: (config) => {
|
||||||
|
if (config?.hasMore) {
|
||||||
|
return { nextLink: config?.link };
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = flattenPages(queryInfo.data);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...queryInfo,
|
||||||
|
groups: data || [],
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
const usePopularGroups = () => {
|
const usePopularGroups = () => {
|
||||||
const features = useFeatures();
|
const features = useFeatures();
|
||||||
const { fetchGroups } = useGroupsApi();
|
const { fetchGroups } = useGroupsApi();
|
||||||
|
@ -199,4 +249,13 @@ const useCancelMembershipRequest = () => {
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
export { useGroups, useGroup, usePopularGroups, useSuggestedGroups, useJoinGroup, useLeaveGroup, useCancelMembershipRequest };
|
export {
|
||||||
|
useCancelMembershipRequest,
|
||||||
|
useGroup,
|
||||||
|
useGroups,
|
||||||
|
useJoinGroup,
|
||||||
|
useLeaveGroup,
|
||||||
|
usePendingGroups,
|
||||||
|
usePopularGroups,
|
||||||
|
useSuggestedGroups,
|
||||||
|
};
|
||||||
|
|
|
@ -516,6 +516,11 @@ const getInstanceFeatures = (instance: Instance) => {
|
||||||
*/
|
*/
|
||||||
groupsDiscovery: v.software === TRUTHSOCIAL,
|
groupsDiscovery: v.software === TRUTHSOCIAL,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Can query pending Group requests.
|
||||||
|
*/
|
||||||
|
groupsPending: v.software === TRUTHSOCIAL,
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Can hide follows/followers lists and counts.
|
* Can hide follows/followers lists and counts.
|
||||||
* @see PATCH /api/v1/accounts/update_credentials
|
* @see PATCH /api/v1/accounts/update_credentials
|
||||||
|
|
Loading…
Reference in New Issue