Merge branch 'pending-groups' into 'develop'

Add support for pending Group Requests

See merge request soapbox-pub/soapbox!2327
This commit is contained in:
Chewbacca
2023-03-13 19:20:16 +00:00
19 changed files with 564 additions and 98 deletions

View File

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

View File

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

View File

@ -12,7 +12,7 @@ interface IGroup {
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;
return (
@ -78,4 +78,4 @@ const Group = forwardRef((props: IGroup, ref: React.ForwardedRef<HTMLDivElement>
);
});
export default Group;
export default GroupGridItem;

View File

@ -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>&bull;</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;

View File

@ -5,7 +5,7 @@ import { Carousel, Stack, Text } from 'soapbox/components/ui';
import PlaceholderGroupDiscover from 'soapbox/features/placeholder/components/placeholder-group-discover';
import { usePopularGroups } from 'soapbox/queries/groups';
import Group from './group';
import GroupGridItem from './group-grid-item';
const PopularGroups = () => {
const { groups, isFetching, isFetched, isError } = usePopularGroups();
@ -49,7 +49,7 @@ const PopularGroups = () => {
))
) : (
groups.map((group) => (
<Group
<GroupGridItem
key={group.id}
group={group}
width={width}

View File

@ -3,12 +3,12 @@ import React, { useCallback, useState } from 'react';
import { FormattedMessage } from 'react-intl';
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 { 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 {
groupSearchResult: ReturnType<typeof useGroupSearch>
@ -38,73 +38,20 @@ export default (props: Props) => {
};
const renderGroupList = useCallback((group: Group, index: number) => (
<HStack
alignItems='center'
justifyContent='between'
<div
className={
clsx({
'pt-4': index !== 0,
})
}
>
<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>&bull;</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>
<GroupListItem group={group} withJoinAction />
</div>
), []);
const renderGroupGrid = useCallback((group: Group, index: number) => (
<div className='pb-4'>
<GroupComp group={group} />
<GroupGridItem group={group} />
</div>
), []);

View File

@ -5,7 +5,7 @@ import { Carousel, Stack, Text } from 'soapbox/components/ui';
import PlaceholderGroupDiscover from 'soapbox/features/placeholder/components/placeholder-group-discover';
import { useSuggestedGroups } from 'soapbox/queries/groups';
import Group from './group';
import GroupGridItem from './group-grid-item';
const SuggestedGroups = () => {
const { groups, isFetching, isFetched, isError } = useSuggestedGroups();
@ -49,7 +49,7 @@ const SuggestedGroups = () => {
))
) : (
groups.map((group) => (
<Group
<GroupGridItem
key={group.id}
group={group}
width={width}

View File

@ -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 />
</>
);
};

View File

@ -11,6 +11,7 @@ import { PERMISSION_CREATE_GROUPS, hasPermission } from 'soapbox/utils/permissio
import PlaceholderGroupCard from '../placeholder/components/placeholder-group-card';
import PendingGroupsRow from './components/pending-groups-row';
import TabBar, { TabItems } from './components/tab-bar';
import type { Group as GroupEntity } from 'soapbox/types/entities';
@ -57,7 +58,6 @@ const Groups: React.FC = () => {
</Stack>
);
return (
<Stack space={4}>
{canCreateGroup && (
@ -76,11 +76,13 @@ const Groups: React.FC = () => {
<TabBar activeTab={TabItems.MY_GROUPS} />
)}
<PendingGroupsRow />
<ScrollableList
scrollKey='groups'
emptyMessage={renderBlankslate()}
emptyMessageCard={false}
itemClassName='py-3 first:pt-0 last:pb-0'
itemClassName='pb-4 last:pb-0'
isLoading={isLoading}
showLoading={isLoading && groups.length === 0}
placeholderComponent={PlaceholderGroupCard}

View File

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

View File

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

View File

@ -32,6 +32,7 @@ import EventPage from 'soapbox/pages/event-page';
import EventsPage from 'soapbox/pages/events-page';
import GroupPage from 'soapbox/pages/group-page';
import GroupsPage from 'soapbox/pages/groups-page';
import GroupsPendingPage from 'soapbox/pages/groups-pending-page';
import HomePage from 'soapbox/pages/home-page';
import ProfilePage from 'soapbox/pages/profile-page';
import RemoteInstancePage from 'soapbox/pages/remote-instance-page';
@ -117,6 +118,7 @@ import {
Events,
Groups,
GroupsDiscover,
PendingGroupRequests,
GroupMembers,
GroupTimeline,
ManageGroup,
@ -287,6 +289,7 @@ const SwitchingColumnsArea: React.FC<ISwitchingColumnsArea> = ({ 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.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/members' exact page={GroupPage} component={GroupMembers} content={children} />}
{features.groups && <WrappedRoute path='/groups/:id/manage' exact page={DefaultPage} component={ManageGroup} content={children} />}

View File

@ -550,6 +550,10 @@ export function GroupsDiscover() {
return import(/* webpackChunkName: "features/groups/discover" */'../../groups/discover');
}
export function PendingGroupRequests() {
return import(/* webpackChunkName: "features/groups/discover" */'../../groups/pending-requests');
}
export function GroupMembers() {
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');
}
export function SuggestedGroupsPanel() {
return import(/* webpackChunkName: "features/groups" */'../components/panels/suggested-groups-panel');
}
export function GroupMediaPanel() {
return import(/* webpackChunkName: "features/groups" */'../components/group-media-panel');
}