Merge branch 'group-search' into 'my-groups'

Add support for Group search

See merge request soapbox-pub/soapbox!2314
environments/review-my-groups-80rb64/deployments/2745
Chewbacca 2 years ago
commit 721b5dafcd

@ -10,7 +10,7 @@ import { closeSidebar } from 'soapbox/actions/sidebar';
import Account from 'soapbox/components/account'; import Account from 'soapbox/components/account';
import { Stack } from 'soapbox/components/ui'; import { Stack } from 'soapbox/components/ui';
import ProfileStats from 'soapbox/features/ui/components/profile-stats'; import ProfileStats from 'soapbox/features/ui/components/profile-stats';
import { useAppDispatch, useAppSelector, useFeatures } from 'soapbox/hooks'; import { useAppDispatch, useAppSelector, useGroupsPath, useFeatures } from 'soapbox/hooks';
import { makeGetAccount, makeGetOtherAccounts } from 'soapbox/selectors'; import { makeGetAccount, makeGetOtherAccounts } from 'soapbox/selectors';
import { Divider, HStack, Icon, IconButton, Text } from './ui'; import { Divider, HStack, Icon, IconButton, Text } from './ui';
@ -90,6 +90,7 @@ const SidebarMenu: React.FC = (): JSX.Element | null => {
const sidebarOpen = useAppSelector((state) => state.sidebar.sidebarOpen); const sidebarOpen = useAppSelector((state) => state.sidebar.sidebarOpen);
const settings = useAppSelector((state) => getSettings(state)); const settings = useAppSelector((state) => getSettings(state));
const followRequestsCount = useAppSelector((state) => state.user_lists.follow_requests.items.count()); const followRequestsCount = useAppSelector((state) => state.user_lists.follow_requests.items.count());
const groupsPath = useGroupsPath();
const closeButtonRef = React.useRef(null); const closeButtonRef = React.useRef(null);
@ -210,7 +211,7 @@ const SidebarMenu: React.FC = (): JSX.Element | null => {
{features.groups && ( {features.groups && (
<SidebarLink <SidebarLink
to='/groups' to={groupsPath}
icon={require('@tabler/icons/circles.svg')} icon={require('@tabler/icons/circles.svg')}
text={intl.formatMessage(messages.groups)} text={intl.formatMessage(messages.groups)}
onClick={onClose} onClick={onClose}

@ -4,7 +4,7 @@ import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { Stack } from 'soapbox/components/ui'; import { Stack } from 'soapbox/components/ui';
import { useStatContext } from 'soapbox/contexts/stat-context'; import { useStatContext } from 'soapbox/contexts/stat-context';
import ComposeButton from 'soapbox/features/ui/components/compose-button'; import ComposeButton from 'soapbox/features/ui/components/compose-button';
import { useAppSelector, useFeatures, useOwnAccount, useSettings } from 'soapbox/hooks'; import { useAppSelector, useGroupsPath, useFeatures, useOwnAccount, useSettings } from 'soapbox/hooks';
import DropdownMenu, { Menu } from './dropdown-menu'; import DropdownMenu, { Menu } from './dropdown-menu';
import SidebarNavigationLink from './sidebar-navigation-link'; import SidebarNavigationLink from './sidebar-navigation-link';
@ -25,6 +25,8 @@ const SidebarNavigation = () => {
const features = useFeatures(); const features = useFeatures();
const settings = useSettings(); const settings = useSettings();
const account = useOwnAccount(); const account = useOwnAccount();
const groupsPath = useGroupsPath();
const notificationCount = useAppSelector((state) => state.notifications.unread); const notificationCount = useAppSelector((state) => state.notifications.unread);
const followRequestsCount = useAppSelector((state) => state.user_lists.follow_requests.items.count()); const followRequestsCount = useAppSelector((state) => state.user_lists.follow_requests.items.count());
const dashboardCount = useAppSelector((state) => state.admin.openReports.count() + state.admin.awaitingApproval.count()); const dashboardCount = useAppSelector((state) => state.admin.openReports.count() + state.admin.awaitingApproval.count());
@ -135,7 +137,7 @@ const SidebarNavigation = () => {
{features.groups && ( {features.groups && (
<SidebarNavigationLink <SidebarNavigationLink
to='/groups' to={groupsPath}
icon={require('@tabler/icons/circles.svg')} icon={require('@tabler/icons/circles.svg')}
text={<FormattedMessage id='tabs_bar.groups' defaultMessage='Groups' />} text={<FormattedMessage id='tabs_bar.groups' defaultMessage='Groups' />}
/> />

@ -0,0 +1,130 @@
import React from 'react';
import { render, screen } from 'soapbox/jest/test-helpers';
import { normalizeGroup, normalizeGroupRelationship } from 'soapbox/normalizers';
import { Group } from 'soapbox/types/entities';
import GroupActionButton from '../group-action-button';
let group: Group;
describe('<GroupActionButton />', () => {
describe('with no group relationship', () => {
beforeEach(() => {
group = normalizeGroup({
relationship: null,
});
});
describe('with a private group', () => {
beforeEach(() => {
group = group.set('locked', true);
});
it('should render the Request Access button', () => {
render(<GroupActionButton group={group} />);
expect(screen.getByRole('button')).toHaveTextContent('Request Access');
});
});
describe('with a public group', () => {
beforeEach(() => {
group = group.set('locked', false);
});
it('should render the Join Group button', () => {
render(<GroupActionButton group={group} />);
expect(screen.getByRole('button')).toHaveTextContent('Join Group');
});
});
});
describe('with no group relationship member', () => {
beforeEach(() => {
group = normalizeGroup({
relationship: normalizeGroupRelationship({
member: null,
}),
});
});
describe('with a private group', () => {
beforeEach(() => {
group = group.set('locked', true);
});
it('should render the Request Access button', () => {
render(<GroupActionButton group={group} />);
expect(screen.getByRole('button')).toHaveTextContent('Request Access');
});
});
describe('with a public group', () => {
beforeEach(() => {
group = group.set('locked', false);
});
it('should render the Join Group button', () => {
render(<GroupActionButton group={group} />);
expect(screen.getByRole('button')).toHaveTextContent('Join Group');
});
});
});
describe('when the user has requested to join', () => {
beforeEach(() => {
group = normalizeGroup({
relationship: normalizeGroupRelationship({
requested: true,
member: true,
}),
});
});
it('should render the Cancel Request button', () => {
render(<GroupActionButton group={group} />);
expect(screen.getByRole('button')).toHaveTextContent('Cancel Request');
});
});
describe('when the user is an Admin', () => {
beforeEach(() => {
group = normalizeGroup({
relationship: normalizeGroupRelationship({
requested: false,
member: true,
role: 'admin',
}),
});
});
it('should render the Manage Group button', () => {
render(<GroupActionButton group={group} />);
expect(screen.getByRole('button')).toHaveTextContent('Manage Group');
});
});
describe('when the user is just a member', () => {
beforeEach(() => {
group = normalizeGroup({
relationship: normalizeGroupRelationship({
requested: false,
member: true,
role: 'user',
}),
});
});
it('should render the Leave Group button', () => {
render(<GroupActionButton group={group} />);
expect(screen.getByRole('button')).toHaveTextContent('Leave Group');
});
});
});

@ -0,0 +1,69 @@
import React from 'react';
import { render, screen } from 'soapbox/jest/test-helpers';
import { normalizeGroup } from 'soapbox/normalizers';
import { Group } from 'soapbox/types/entities';
import GroupMemberCount from '../group-member-count';
let group: Group;
describe('<GroupMemberCount />', () => {
describe('without support for "members_count"', () => {
beforeEach(() => {
group = normalizeGroup({
members_count: undefined,
});
});
it('should return null', () => {
render(<GroupMemberCount group={group} />);
expect(screen.queryAllByTestId('group-member-count')).toHaveLength(0);
});
});
describe('with support for "members_count"', () => {
describe('with 1 member', () => {
beforeEach(() => {
group = normalizeGroup({
members_count: 1,
});
});
it('should render correctly', () => {
render(<GroupMemberCount group={group} />);
expect(screen.getByTestId('group-member-count').textContent).toEqual('1 member');
});
});
describe('with 2 members', () => {
beforeEach(() => {
group = normalizeGroup({
members_count: 2,
});
});
it('should render correctly', () => {
render(<GroupMemberCount group={group} />);
expect(screen.getByTestId('group-member-count').textContent).toEqual('2 members');
});
});
describe('with 1000 members', () => {
beforeEach(() => {
group = normalizeGroup({
members_count: 1000,
});
});
it('should render correctly', () => {
render(<GroupMemberCount group={group} />);
expect(screen.getByTestId('group-member-count').textContent).toEqual('1k members');
});
});
});
});

@ -0,0 +1,39 @@
import React from 'react';
import { render, screen } from 'soapbox/jest/test-helpers';
import { normalizeGroup } from 'soapbox/normalizers';
import { Group } from 'soapbox/types/entities';
import GroupPrivacy from '../group-privacy';
let group: Group;
describe('<GroupPrivacy />', () => {
describe('with a Private group', () => {
beforeEach(() => {
group = normalizeGroup({
locked: true,
});
});
it('should render the correct text', () => {
render(<GroupPrivacy group={group} />);
expect(screen.getByTestId('group-privacy')).toHaveTextContent('Private');
});
});
describe('with a Public group', () => {
beforeEach(() => {
group = normalizeGroup({
locked: false,
});
});
it('should render the correct text', () => {
render(<GroupPrivacy group={group} />);
expect(screen.getByTestId('group-privacy')).toHaveTextContent('Public');
});
});
});

@ -0,0 +1,83 @@
import React from 'react';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { joinGroup, leaveGroup } from 'soapbox/actions/groups';
import { openModal } from 'soapbox/actions/modals';
import { Button } from 'soapbox/components/ui';
import { useAppDispatch } from 'soapbox/hooks';
import { Group } from 'soapbox/types/entities';
interface IGroupActionButton {
group: Group
}
const messages = defineMessages({
confirmationHeading: { id: 'confirmations.leave_group.heading', defaultMessage: 'Leave group' },
confirmationMessage: { id: 'confirmations.leave_group.message', defaultMessage: 'You are about to leave the group. Do you want to continue?' },
confirmationConfirm: { id: 'confirmations.leave_group.confirm', defaultMessage: 'Leave' },
});
const GroupActionButton = ({ group }: IGroupActionButton) => {
const dispatch = useAppDispatch();
const intl = useIntl();
const isNonMember = !group.relationship || !group.relationship.member;
const isRequested = group.relationship?.requested;
const isAdmin = group.relationship?.role === 'admin';
const onJoinGroup = () => dispatch(joinGroup(group.id));
const onLeaveGroup = () =>
dispatch(openModal('CONFIRM', {
heading: intl.formatMessage(messages.confirmationHeading),
message: intl.formatMessage(messages.confirmationMessage),
confirm: intl.formatMessage(messages.confirmationConfirm),
onConfirm: () => dispatch(leaveGroup(group.id)),
}));
if (isNonMember) {
return (
<Button
theme='primary'
onClick={onJoinGroup}
>
{group.locked
? <FormattedMessage id='group.join.private' defaultMessage='Request Access' />
: <FormattedMessage id='group.join.public' defaultMessage='Join Group' />}
</Button>
);
}
if (isRequested) {
return (
<Button
theme='secondary'
onClick={onLeaveGroup}
>
<FormattedMessage id='group.cancel_request' defaultMessage='Cancel Request' />
</Button>
);
}
if (isAdmin) {
return (
<Button
theme='secondary'
to={`/groups/${group.id}/manage`}
>
<FormattedMessage id='group.manage' defaultMessage='Manage Group' />
</Button>
);
}
return (
<Button
theme='secondary'
onClick={onLeaveGroup}
>
<FormattedMessage id='group.leave' defaultMessage='Leave Group' />
</Button>
);
};
export default GroupActionButton;

@ -1,22 +1,23 @@
import { List as ImmutableList } from 'immutable'; import { List as ImmutableList } from 'immutable';
import React from 'react'; import React from 'react';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; import { defineMessages, useIntl } from 'react-intl';
import { joinGroup, leaveGroup } from 'soapbox/actions/groups';
import { openModal } from 'soapbox/actions/modals'; import { openModal } from 'soapbox/actions/modals';
import StillImage from 'soapbox/components/still-image'; import StillImage from 'soapbox/components/still-image';
import { Avatar, Button, HStack, Icon, Stack, Text } from 'soapbox/components/ui'; import { Avatar, HStack, Stack, Text } from 'soapbox/components/ui';
import { useAppDispatch } from 'soapbox/hooks'; import { useAppDispatch } from 'soapbox/hooks';
import { normalizeAttachment } from 'soapbox/normalizers'; import { normalizeAttachment } from 'soapbox/normalizers';
import { isDefaultHeader } from 'soapbox/utils/accounts'; import { isDefaultHeader } from 'soapbox/utils/accounts';
import GroupActionButton from './group-action-button';
import GroupMemberCount from './group-member-count';
import GroupPrivacy from './group-privacy';
import GroupRelationship from './group-relationship';
import type { Group } from 'soapbox/types/entities'; import type { Group } from 'soapbox/types/entities';
const messages = defineMessages({ const messages = defineMessages({
header: { id: 'group.header.alt', defaultMessage: 'Group header' }, header: { id: 'group.header.alt', defaultMessage: 'Group header' },
confirmationHeading: { id: 'confirmations.leave_group.heading', defaultMessage: 'Leave group' },
confirmationMessage: { id: 'confirmations.leave_group.message', defaultMessage: 'You are about to leave the group. Do you want to continue?' },
confirmationConfirm: { id: 'confirmations.leave_group.confirm', defaultMessage: 'Leave' },
}); });
interface IGroupHeader { interface IGroupHeader {
@ -47,16 +48,6 @@ const GroupHeader: React.FC<IGroupHeader> = ({ group }) => {
); );
} }
const onJoinGroup = () => dispatch(joinGroup(group.id));
const onLeaveGroup = () =>
dispatch(openModal('CONFIRM', {
heading: intl.formatMessage(messages.confirmationHeading),
message: intl.formatMessage(messages.confirmationMessage),
confirm: intl.formatMessage(messages.confirmationConfirm),
onConfirm: () => dispatch(leaveGroup(group.id)),
}));
const onAvatarClick = () => { const onAvatarClick = () => {
const avatar = normalizeAttachment({ const avatar = normalizeAttachment({
type: 'image', type: 'image',
@ -95,6 +86,7 @@ const GroupHeader: React.FC<IGroupHeader> = ({ group }) => {
<StillImage <StillImage
src={group.header} src={group.header}
alt={intl.formatMessage(messages.header)} alt={intl.formatMessage(messages.header)}
className='h-32 w-full bg-gray-200 object-center dark:bg-gray-900/50 md:rounded-t-xl lg:h-52'
/> />
); );
@ -110,93 +102,40 @@ const GroupHeader: React.FC<IGroupHeader> = ({ group }) => {
return header; return header;
}; };
const makeActionButton = () => {
if (!group.relationship || !group.relationship.member) {
return (
<Button
theme='primary'
onClick={onJoinGroup}
>
{group.locked ? <FormattedMessage id='group.request_join' defaultMessage='Request to join group' /> : <FormattedMessage id='group.join' defaultMessage='Join group' />}
</Button>
);
}
if (group.relationship.requested) {
return (
<Button
theme='secondary'
onClick={onLeaveGroup}
>
<FormattedMessage id='group.cancel_request' defaultMessage='Cancel request' />
</Button>
);
}
if (group.relationship?.role === 'admin') {
return (
<Button
theme='secondary'
to={`/groups/${group.id}/manage`}
>
<FormattedMessage id='group.manage' defaultMessage='Manage group' />
</Button>
);
}
return (
<Button
theme='secondary'
onClick={onLeaveGroup}
>
<FormattedMessage id='group.leave' defaultMessage='Leave group' />
</Button>
);
};
const actionButton = makeActionButton();
return ( return (
<div className='-mx-4 -mt-4'> <div className='-mx-4 -mt-4'>
<div className='relative'> <div className='relative'>
<div className='relative isolate flex h-32 w-full flex-col justify-center overflow-hidden bg-gray-200 dark:bg-gray-900/50 md:rounded-t-xl lg:h-[200px]'>
{renderHeader()} {renderHeader()}
</div>
<div className='absolute left-1/2 bottom-0 -translate-x-1/2 translate-y-1/2'> <div className='absolute left-1/2 bottom-0 -translate-x-1/2 translate-y-1/2'>
<a href={group.avatar} onClick={handleAvatarClick} target='_blank'> <a href={group.avatar} onClick={handleAvatarClick} target='_blank'>
<Avatar className='ring-[3px] ring-white dark:ring-primary-900' src={group.avatar} size={72} /> <Avatar
className='ring-[3px] ring-white dark:ring-primary-900'
src={group.avatar}
size={80}
/>
</a> </a>
</div> </div>
</div> </div>
<Stack className='p-3 pt-12' alignItems='center' space={2}> <Stack alignItems='center' space={3} className='mt-10 py-4'>
<Text className='mb-1' size='xl' weight='bold' dangerouslySetInnerHTML={{ __html: group.display_name_html }} /> <Text
<HStack className='text-gray-700 dark:text-gray-600' space={3} wrap> size='xl'
{group.relationship?.role === 'admin' ? ( weight='bold'
<HStack space={1} alignItems='center'> dangerouslySetInnerHTML={{ __html: group.display_name_html }}
<Icon className='h-4 w-4' src={require('@tabler/icons/users.svg')} /> />
<span><FormattedMessage id='group.role.admin' defaultMessage='Admin' /></span>
</HStack> <Stack space={1}>
) : group.relationship?.role === 'moderator' && ( <HStack className='text-gray-700 dark:text-gray-600' space={2} wrap>
<HStack space={1} alignItems='center'> <GroupRelationship group={group} />
<Icon className='h-4 w-4' src={require('@tabler/icons/gavel.svg')} /> <GroupPrivacy group={group} />
<span><FormattedMessage id='group.role.moderator' defaultMessage='Moderator' /></span> <GroupMemberCount group={group} />
</HStack>
)}
{group.locked ? (
<HStack space={1} alignItems='center'>
<Icon className='h-4 w-4' src={require('@tabler/icons/lock.svg')} />
<span><FormattedMessage id='group.privacy.locked' defaultMessage='Private' /></span>
</HStack>
) : (
<HStack space={1} alignItems='center'>
<Icon className='h-4 w-4' src={require('@tabler/icons/world.svg')} />
<span><FormattedMessage id='group.privacy.public' defaultMessage='Public' /></span>
</HStack>
)}
</HStack> </HStack>
<Text theme='muted' dangerouslySetInnerHTML={{ __html: group.note_emojified }} /> <Text theme='muted' dangerouslySetInnerHTML={{ __html: group.note_emojified }} />
{actionButton} </Stack>
<GroupActionButton group={group} />
</Stack> </Stack>
</div> </div>
); );

@ -0,0 +1,32 @@
import React from 'react';
import { FormattedMessage } from 'react-intl';
import { Text } from 'soapbox/components/ui';
import { Group } from 'soapbox/types/entities';
import { shortNumberFormat } from 'soapbox/utils/numbers';
interface IGroupMemberCount {
group: Group
}
const GroupMemberCount = ({ group }: IGroupMemberCount) => {
if (typeof group.members_count === 'undefined') {
return null;
}
return (
<Text theme='inherit' tag='span' size='sm' weight='medium' data-testid='group-member-count'>
{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>
);
};
export default GroupMemberCount;

@ -0,0 +1,32 @@
import React from 'react';
import { FormattedMessage } from 'react-intl';
import { HStack, Icon, Text } from 'soapbox/components/ui';
import { Group } from 'soapbox/types/entities';
interface IGroupPolicy {
group: Group
}
const GroupPrivacy = ({ group }: IGroupPolicy) => (
<HStack space={1} alignItems='center' data-testid='group-privacy'>
<Icon
className='h-4 w-4'
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>
</HStack>
);
export default GroupPrivacy;

@ -0,0 +1,39 @@
import React from 'react';
import { FormattedMessage } from 'react-intl';
import { HStack, Icon, Text } from 'soapbox/components/ui';
import { Group } from 'soapbox/types/entities';
interface IGroupRelationship {
group: Group
}
const GroupRelationship = ({ group }: IGroupRelationship) => {
const isAdmin = group.relationship?.role === 'admin';
const isModerator = group.relationship?.role === 'moderator';
if (!isAdmin || !isModerator) {
return null;
}
return (
<HStack space={1} alignItems='center'>
<Icon
className='h-4 w-4'
src={
isAdmin
? require('@tabler/icons/users.svg')
: require('@tabler/icons/gavel.svg')
}
/>
<Text tag='span' weight='medium' size='sm' theme='inherit'>
{isAdmin
? <FormattedMessage id='group.role.admin' defaultMessage='Admin' />
: <FormattedMessage id='group.role.moderator' defaultMessage='Moderator' />}
</Text>
</HStack>
);
};
export default GroupRelationship;

@ -3,7 +3,6 @@ import { FormattedMessage } from 'react-intl';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { groupCompose } from 'soapbox/actions/compose'; import { groupCompose } from 'soapbox/actions/compose';
import { fetchGroup } from 'soapbox/actions/groups';
import { connectGroupStream } from 'soapbox/actions/streaming'; import { connectGroupStream } from 'soapbox/actions/streaming';
import { expandGroupTimeline } from 'soapbox/actions/timelines'; import { expandGroupTimeline } from 'soapbox/actions/timelines';
import { Avatar, HStack, Stack } from 'soapbox/components/ui'; import { Avatar, HStack, Stack } from 'soapbox/components/ui';
@ -31,7 +30,6 @@ const GroupTimeline: React.FC<IGroupTimeline> = (props) => {
}; };
useEffect(() => { useEffect(() => {
dispatch(fetchGroup(groupId));
dispatch(expandGroupTimeline(groupId)); dispatch(expandGroupTimeline(groupId));
dispatch(groupCompose(`group:${groupId}`, groupId)); dispatch(groupCompose(`group:${groupId}`, groupId));

@ -1,14 +1,15 @@
import React, { forwardRef } from 'react'; import React, { forwardRef } from 'react';
import { FormattedMessage } from 'react-intl'; import { FormattedMessage } from 'react-intl';
import { Link } from 'react-router-dom';
import { Avatar, Button, HStack, Icon, Stack, Text } from 'soapbox/components/ui'; import { Avatar, Button, HStack, Stack, Text } from 'soapbox/components/ui';
import GroupMemberCount from 'soapbox/features/group/components/group-member-count';
import GroupPrivacy from 'soapbox/features/group/components/group-privacy';
import { Group as GroupEntity } from 'soapbox/types/entities'; import { Group as GroupEntity } from 'soapbox/types/entities';
import { shortNumberFormat } from 'soapbox/utils/numbers';
interface IGroup { interface IGroup {
group: GroupEntity group: GroupEntity
width: number width?: number
} }
const Group = forwardRef((props: IGroup, ref: React.ForwardedRef<HTMLDivElement>) => { const Group = forwardRef((props: IGroup, ref: React.ForwardedRef<HTMLDivElement>) => {
@ -22,6 +23,7 @@ const Group = forwardRef((props: IGroup, ref: React.ForwardedRef<HTMLDivElement>
width, width,
}} }}
> >
<Link to={`/groups/${group.id}`}>
<Stack <Stack
className='aspect-w-10 aspect-h-7 h-full w-full overflow-hidden rounded-lg' className='aspect-w-10 aspect-h-7 h-full w-full overflow-hidden rounded-lg'
ref={ref} ref={ref}
@ -50,27 +52,10 @@ const Group = forwardRef((props: IGroup, ref: React.ForwardedRef<HTMLDivElement>
truncate truncate
/> />
<HStack space={1} alignItems='center'> <HStack alignItems='center' space={1}>
<Icon <GroupPrivacy group={group} />
className='h-4.5 w-4.5' <span>&bull;</span>
src={group.locked ? require('@tabler/icons/lock.svg') : require('@tabler/icons/world.svg')} <GroupMemberCount group={group} />
/>
{typeof group.members_count === 'undefined' ? (
<Text theme='inherit' tag='span' size='sm'>
{group.locked ? (
<FormattedMessage id='group.privacy.locked' defaultMessage='Private' />
) : (
<FormattedMessage id='group.privacy.public' defaultMessage='Public' />
)}
</Text>
) : (
<Text theme='inherit' tag='span' size='sm'>
{shortNumberFormat(group.members_count)}
{' '}
members
</Text>
)}
</HStack> </HStack>
</Stack> </Stack>
</Stack> </Stack>
@ -79,12 +64,15 @@ const Group = forwardRef((props: IGroup, ref: React.ForwardedRef<HTMLDivElement>
className='absolute inset-x-0 bottom-0 z-0 flex justify-center rounded-b-lg bg-gradient-to-t from-gray-900 to-transparent pt-12 pb-8 transition-opacity duration-500' className='absolute inset-x-0 bottom-0 z-0 flex justify-center rounded-b-lg bg-gradient-to-t from-gray-900 to-transparent pt-12 pb-8 transition-opacity duration-500'
/> />
</Stack> </Stack>
</Link>
<Button <Button
theme='primary' theme='primary'
block block
> >
Join Group {group.locked
? <FormattedMessage id='group.join.private' defaultMessage='Request Access' />
: <FormattedMessage id='group.join.public' defaultMessage='Join Group' />}
</Button> </Button>
</div> </div>
); );

@ -0,0 +1,79 @@
import userEvent from '@testing-library/user-event';
import { Map as ImmutableMap } from 'immutable';
import React from 'react';
import { VirtuosoMockContext } from 'react-virtuoso';
import { render, screen, waitFor } from 'soapbox/jest/test-helpers';
import { normalizeAccount } from 'soapbox/normalizers';
import { groupSearchHistory } from 'soapbox/settings';
import { clearRecentGroupSearches, saveGroupSearch } from 'soapbox/utils/groups';
import RecentSearches from '../recent-searches';
const userId = '1';
const store = {
me: userId,
accounts: ImmutableMap({
[userId]: normalizeAccount({
id: userId,
acct: 'justin-username',
display_name: 'Justin L',
avatar: 'test.jpg',
chats_onboarded: false,
}),
}),
};
const renderApp = (children: React.ReactNode) => (
render(
<VirtuosoMockContext.Provider value={{ viewportHeight: 300, itemHeight: 100 }}>
{children}
</VirtuosoMockContext.Provider>,
undefined,
store,
)
);
describe('<RecentSearches />', () => {
describe('with recent searches', () => {
beforeEach(() => {
saveGroupSearch(userId, 'foobar');
});
afterEach(() => {
clearRecentGroupSearches(userId);
});
it('should render the recent searches', async () => {
renderApp(<RecentSearches onSelect={jest.fn()} />);
await waitFor(() => {
expect(screen.getByTestId('recent-search')).toBeInTheDocument();
});
});
it('should support clearing recent searches', async () => {
renderApp(<RecentSearches onSelect={jest.fn()} />);
expect(groupSearchHistory.get(userId)).toHaveLength(1);
await userEvent.click(screen.getByTestId('clear-recent-searches'));
expect(groupSearchHistory.get(userId)).toBeNull();
});
it('should support click events on the results', async () => {
const handler = jest.fn();
renderApp(<RecentSearches onSelect={handler} />);
expect(handler.mock.calls.length).toEqual(0);
await userEvent.click(screen.getByTestId('recent-search-result'));
expect(handler.mock.calls.length).toEqual(1);
});
});
describe('without recent searches', () => {
it('should render the blankslate', async () => {
renderApp(<RecentSearches onSelect={jest.fn()} />);
expect(screen.getByTestId('recent-searches-blankslate')).toBeInTheDocument();
});
});
});

@ -0,0 +1,62 @@
import React from 'react';
import { __stub } from 'soapbox/api';
import { render, screen, waitFor } from 'soapbox/jest/test-helpers';
import { normalizeGroup, normalizeInstance } from 'soapbox/normalizers';
import Search from '../search';
const store = {
instance: normalizeInstance({
version: '3.4.1 (compatible; TruthSocial 1.0.0+unreleased)',
}),
};
const renderApp = (children: React.ReactElement) => render(children, undefined, store);
describe('<Search />', () => {
describe('with no results', () => {
beforeEach(() => {
__stub((mock) => {
mock.onGet('/api/v1/groups/search').reply(200, []);
});
});
it('should render the blankslate', async () => {
renderApp(<Search searchValue={'some-search'} onSelect={jest.fn()} />);
await waitFor(() => {
expect(screen.getByTestId('no-results')).toBeInTheDocument();
});
});
});
describe('with results', () => {
beforeEach(() => {
__stub((mock) => {
mock.onGet('/api/v1/groups/search').reply(200, [
normalizeGroup({
display_name: 'Group',
id: '1',
}),
]);
});
});
it('should render the results', async () => {
renderApp(<Search searchValue={'some-search'} onSelect={jest.fn()} />);
await waitFor(() => {
expect(screen.getByTestId('results')).toBeInTheDocument();
});
});
});
describe('before starting a search', () => {
it('should render the RecentSearches component', () => {
renderApp(<Search searchValue={''} onSelect={jest.fn()} />);
expect(screen.getByTestId('recent-searches')).toBeInTheDocument();
});
});
});

@ -0,0 +1,22 @@
import React from 'react';
import { FormattedMessage } from 'react-intl';
import { Stack, Text } from 'soapbox/components/ui';
export default () => (
<Stack space={2} className='px-4 py-2' data-testid='no-results'>
<Text weight='bold' size='lg'>
<FormattedMessage
id='groups.discover.search.no_results.title'
defaultMessage='No matches found'
/>
</Text>
<Text theme='muted'>
<FormattedMessage
id='groups.discover.search.no_results.subtitle'
defaultMessage='Try searching for another group.'
/>
</Text>
</Stack>
);

@ -0,0 +1,90 @@
import React, { useState } from 'react';
import { FormattedMessage } from 'react-intl';
import { Virtuoso } from 'react-virtuoso';
import { HStack, Icon, Stack, Text } from 'soapbox/components/ui';
import { useOwnAccount } from 'soapbox/hooks';
import { groupSearchHistory } from 'soapbox/settings';
import { clearRecentGroupSearches } from 'soapbox/utils/groups';
interface Props {
onSelect(value: string): void
}
export default (props: Props) => {
const { onSelect } = props;
const me = useOwnAccount();
const [recentSearches, setRecentSearches] = useState<string[]>(groupSearchHistory.get(me?.id as string) || []);
const onClearRecentSearches = () => {
clearRecentGroupSearches(me?.id as string);
setRecentSearches([]);
};
return (
<Stack space={2} data-testid='recent-searches'>
{recentSearches.length > 0 ? (
<>
<HStack
alignItems='center'
justifyContent='between'
className='bg-white dark:bg-gray-900'
>
<Text theme='muted' weight='semibold' size='sm'>
<FormattedMessage
id='groups.discover.search.recent_searches.title'
defaultMessage='Recent searches'
/>
</Text>
<button onClick={onClearRecentSearches} data-testid='clear-recent-searches'>
<Text theme='primary' size='sm' className='hover:underline'>
<FormattedMessage
id='groups.discover.search.recent_searches.clear_all'
defaultMessage='Clear all'
/>
</Text>
</button>
</HStack>
<Virtuoso
useWindowScroll
data={recentSearches}
itemContent={(_index, recentSearch) => (
<div key={recentSearch} data-testid='recent-search'>
<button
onClick={() => onSelect(recentSearch)}
className='group flex w-full flex-col rounded-lg p-2 hover:bg-gray-100 dark:hover:bg-gray-800'
data-testid='recent-search-result'
>
<HStack alignItems='center' space={2}>
<div className='flex h-10 w-10 items-center justify-center rounded-full bg-gray-200 p-2 dark:bg-gray-800 dark:group-hover:bg-gray-700/20'>
<Icon
src={require('@tabler/icons/hash.svg')}
className='h-5 w-5 text-gray-600'
/>
</div>
<Text weight='bold' size='sm' align='left'>{recentSearch}</Text>
</HStack>
</button>
</div>
)}
/>
</>
) : (
<Stack space={2} data-testid='recent-searches-blankslate'>
<Text weight='bold' size='lg'>
<FormattedMessage id='groups.discover.search.recent_searches.blankslate.title' defaultMessage='No recent searches' />
</Text>
<Text theme='muted'>
<FormattedMessage id='groups.discover.search.recent_searches.blankslate.subtitle' defaultMessage='Search group names, topics or keywords' />
</Text>
</Stack>
)}
</Stack>
);
};

@ -0,0 +1,169 @@
import clsx from 'clsx';
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 { useGroupSearch } from 'soapbox/queries/groups/search';
import { Group } from 'soapbox/types/entities';
import { shortNumberFormat } from 'soapbox/utils/numbers';
import GroupComp from '../group';
interface Props {
groupSearchResult: ReturnType<typeof useGroupSearch>
}
enum Layout {
LIST = 'LIST',
GRID = 'GRID'
}
const GridList: Components['List'] = React.forwardRef((props, ref) => {
const { context, ...rest } = props;
return <div ref={ref} {...rest} className='flex flex-wrap' />;
});
export default (props: Props) => {
const { groupSearchResult } = props;
const [layout, setLayout] = useState<Layout>(Layout.LIST);
const { groups, hasNextPage, isFetching, fetchNextPage } = groupSearchResult;
const handleLoadMore = () => {
if (hasNextPage && !isFetching) {
fetchNextPage();
}
};
const renderGroupList = useCallback((group: Group, index: number) => (
<HStack
alignItems='center'
justifyContent='between'
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>
), []);
const renderGroupGrid = useCallback((group: Group, index: number) => (
<div className='pb-4'>
<GroupComp group={group} />
</div>
), []);
return (
<Stack space={4} data-testid='results'>
<HStack alignItems='center' justifyContent='between'>
<Text weight='semibold'>
<FormattedMessage
id='groups.discover.search.results.groups'
defaultMessage='Groups'
/>
</Text>
<HStack alignItems='center'>
<button onClick={() => setLayout(Layout.LIST)}>
<Icon
src={require('@tabler/icons/layout-list.svg')}
className={
clsx('h-5 w-5 text-gray-600', {
'text-primary-600': layout === Layout.LIST,
})
}
/>
</button>
<button onClick={() => setLayout(Layout.GRID)}>
<Icon
src={require('@tabler/icons/layout-grid.svg')}
className={
clsx('h-5 w-5 text-gray-600', {
'text-primary-600': layout === Layout.GRID,
})
}
/>
</button>
</HStack>
</HStack>
{layout === Layout.LIST ? (
<Virtuoso
useWindowScroll
data={groups}
itemContent={(index, group) => renderGroupList(group, index)}
endReached={handleLoadMore}
/>
) : (
<VirtuosoGrid
useWindowScroll
data={groups}
itemContent={(index, group) => renderGroupGrid(group, index)}
components={{
Item: (props) => (
<div {...props} className='w-1/2 flex-none' />
),
List: GridList,
}}
endReached={handleLoadMore}
/>
)}
</Stack>
);
};

@ -0,0 +1,64 @@
import React, { useEffect } from 'react';
import { Stack } from 'soapbox/components/ui';
import PlaceholderGroupSearch from 'soapbox/features/placeholder/components/placeholder-group-search';
import { useDebounce, useOwnAccount } from 'soapbox/hooks';
import { useGroupSearch } from 'soapbox/queries/groups/search';
import { saveGroupSearch } from 'soapbox/utils/groups';
import NoResultsBlankslate from './no-results-blankslate';
import RecentSearches from './recent-searches';
import Results from './results';
interface Props {
onSelect(value: string): void
searchValue: string
}
export default (props: Props) => {
const { onSelect, searchValue } = props;
const me = useOwnAccount();
const debounce = useDebounce;
const debouncedValue = debounce(searchValue as string, 300);
const debouncedValueToSave = debounce(searchValue as string, 1000);
const groupSearchResult = useGroupSearch(debouncedValue);
const { groups, isFetching, isFetched } = groupSearchResult;
const hasSearchResults = isFetched && groups.length > 0;
const hasNoSearchResults = isFetched && groups.length === 0;
useEffect(() => {
if (debouncedValueToSave && debouncedValueToSave.length >= 0) {
saveGroupSearch(me?.id as string, debouncedValueToSave);
}
}, [debouncedValueToSave]);
if (isFetching) {
return (
<Stack space={4}>
<PlaceholderGroupSearch />
<PlaceholderGroupSearch />
<PlaceholderGroupSearch />
</Stack>
);
}
if (hasNoSearchResults) {
return <NoResultsBlankslate />;
}
if (hasSearchResults) {
return (
<Results
groupSearchResult={groupSearchResult}
/>
);
}
return (
<RecentSearches onSelect={onSelect} />
);
};

@ -1,19 +1,78 @@
import React from 'react'; import React, { useState } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { Stack } from 'soapbox/components/ui'; import { HStack, Icon, IconButton, Input, Stack } from 'soapbox/components/ui';
import PopularGroups from './components/discover/popular-groups'; import PopularGroups from './components/discover/popular-groups';
import Search from './components/discover/search/search';
import SuggestedGroups from './components/discover/suggested-groups'; import SuggestedGroups from './components/discover/suggested-groups';
import TabBar, { TabItems } from './components/tab-bar'; import TabBar, { TabItems } from './components/tab-bar';
const messages = defineMessages({
placeholder: { id: 'groups.discover.search.placeholder', defaultMessage: 'Search' },
});
const Discover: React.FC = () => { const Discover: React.FC = () => {
const intl = useIntl();
const [isSearching, setIsSearching] = useState<boolean>(false);
const [value, setValue] = useState<string>('');
const hasSearchValue = value && value.length > 0;
const cancelSearch = () => {
clearValue();
setIsSearching(false);
};
const clearValue = () => setValue('');
return ( return (
<Stack space={4}> <Stack space={4}>
<TabBar activeTab={TabItems.FIND_GROUPS} /> <TabBar activeTab={TabItems.FIND_GROUPS} />
<Stack space={6}> <Stack space={6}>
<HStack alignItems='center'>
{isSearching ? (
<IconButton
src={require('@tabler/icons/arrow-left.svg')}
iconClassName='mr-2 h-5 w-5 fill-current text-gray-600'
onClick={cancelSearch}
/>
) : null}
<Input
data-testid='search'
type='text'
placeholder={intl.formatMessage(messages.placeholder)}
value={value}
onChange={(event) => setValue(event.target.value)}
onFocus={() => setIsSearching(true)}
outerClassName='mt-0 w-full'
theme='search'
append={
<button onClick={clearValue}>
<Icon
src={hasSearchValue ? require('@tabler/icons/x.svg') : require('@tabler/icons/search.svg')}
className='h-4 w-4 text-gray-700 dark:text-gray-600'
aria-hidden='true'
/>
</button>
}
/>
</HStack>
{isSearching ? (
<Search
searchValue={value}
onSelect={(newValue) => setValue(newValue)}
/>
) : (
<>
<PopularGroups /> <PopularGroups />
<SuggestedGroups /> <SuggestedGroups />
</>
)}
</Stack> </Stack>
</Stack> </Stack>
); );

@ -0,0 +1,43 @@
import React from 'react';
import { HStack, Stack, Text } from 'soapbox/components/ui';
import { generateText, randomIntFromInterval } from '../utils';
export default () => {
const groupNameLength = randomIntFromInterval(12, 20);
return (
<HStack
alignItems='center'
justifyContent='between'
className='animate-pulse'
>
<HStack alignItems='center' space={2}>
{/* Group Avatar */}
<div className='h-11 w-11 rounded-full bg-gray-500 dark:bg-gray-700 dark:ring-primary-900' />
<Stack className='text-gray-500 dark:text-gray-700'>
<Text theme='inherit' weight='bold'>
{generateText(groupNameLength)}
</Text>
<HStack space={1} alignItems='center'>
<Text theme='inherit' tag='span' size='sm' weight='medium'>
{generateText(6)}
</Text>
<span>&bull;</span>
<Text theme='inherit' tag='span' size='sm' weight='medium'>
{generateText(6)}
</Text>
</HStack>
</Stack>
</HStack>
{/* Join Group Button */}
<div className='h-10 w-36 rounded-full bg-gray-300 dark:bg-gray-800' />
</HStack>
);
};

@ -0,0 +1,73 @@
import { Map as ImmutableMap } from 'immutable';
import { __stub } from 'soapbox/api';
import { renderHook, waitFor } from 'soapbox/jest/test-helpers';
import { normalizeAccount, normalizeGroup, normalizeInstance } from 'soapbox/normalizers';
import { useGroupsPath } from '../useGroupsPath';
describe('useGroupsPath()', () => {
test('without the groupsDiscovery feature', () => {
const store = {
instance: normalizeInstance({
version: '2.7.2 (compatible; Pleroma 2.3.0)',
}),
};
const { result } = renderHook(useGroupsPath, undefined, store);
expect(result.current).toEqual('/groups');
});
describe('with the "groupsDiscovery" feature', () => {
let store: any;
beforeEach(() => {
const userId = '1';
store = {
instance: normalizeInstance({
version: '3.4.1 (compatible; TruthSocial 1.0.0+unreleased)',
}),
me: userId,
accounts: ImmutableMap({
[userId]: normalizeAccount({
id: userId,
acct: 'justin-username',
display_name: 'Justin L',
avatar: 'test.jpg',
chats_onboarded: false,
}),
}),
};
});
describe('when the user has no groups', () => {
test('should default to the discovery page', () => {
const { result } = renderHook(useGroupsPath, undefined, store);
expect(result.current).toEqual('/groups/discover');
});
});
describe('when the user has groups', () => {
beforeEach(() => {
__stub((mock) => {
mock.onGet('/api/v1/groups').reply(200, [
normalizeGroup({
display_name: 'Group',
id: '1',
}),
]);
});
});
test('should default to the discovery page', async () => {
const { result } = renderHook(useGroupsPath, undefined, store);
await waitFor(() => {
expect(result.current).toEqual('/groups');
});
});
});
});
});

@ -5,6 +5,7 @@ export { useAppSelector } from './useAppSelector';
export { useClickOutside } from './useClickOutside'; export { useClickOutside } from './useClickOutside';
export { useCompose } from './useCompose'; export { useCompose } from './useCompose';
export { useDebounce } from './useDebounce'; export { useDebounce } from './useDebounce';
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';

@ -0,0 +1,23 @@
import { useGroups } from 'soapbox/queries/groups';
import { useFeatures } from './useFeatures';
/**
* Determine the correct URL to use for /groups.
* If the user does not have any Groups, let's default to the discovery tab.
* Otherwise, let's default to My Groups.
*
* @returns String (as link)
*/
const useGroupsPath = () => {
const features = useFeatures();
const { groups } = useGroups();
if (!features.groupsDiscovery) {
return '/groups';
}
return groups.length > 0 ? '/groups' : '/groups/discover';
};
export { useGroupsPath };

@ -737,7 +737,7 @@
"group.group_mod_unblock": "Entblocken", "group.group_mod_unblock": "Entblocken",
"group.group_mod_unblock.success": "@{name} in der Gruppe entblockt", "group.group_mod_unblock.success": "@{name} in der Gruppe entblockt",
"group.header.alt": "Gruppentitel", "group.header.alt": "Gruppentitel",
"group.join": "Gruppe beitreten", "group.join.public": "Gruppe beitreten",
"group.join.request_success": "Mitgliedschaft in der Gruppe angefragt", "group.join.request_success": "Mitgliedschaft in der Gruppe angefragt",
"group.join.success": "Gruppe beigetreten", "group.join.success": "Gruppe beigetreten",
"group.leave": "Gruppe verlassen", "group.leave": "Gruppe verlassen",
@ -746,7 +746,7 @@
"group.moderator_subheading": "Moderator:innen der Gruppe", "group.moderator_subheading": "Moderator:innen der Gruppe",
"group.privacy.locked": "Privat", "group.privacy.locked": "Privat",
"group.privacy.public": "Öffentlich", "group.privacy.public": "Öffentlich",
"group.request_join": "Mitgliedschaft in der Gruppe anfragen", "group.join.private": "Mitgliedschaft in der Gruppe anfragen",
"group.role.admin": "Administrator:in", "group.role.admin": "Administrator:in",
"group.role.moderator": "Moderator:in", "group.role.moderator": "Moderator:in",
"group.tabs.all": "Alle", "group.tabs.all": "Alle",

@ -745,7 +745,7 @@
"gdpr.title": "{siteTitle} uses cookies", "gdpr.title": "{siteTitle} uses cookies",
"getting_started.open_source_notice": "{code_name} is open source software. You can contribute or report issues at {code_link} (v{code_version}).", "getting_started.open_source_notice": "{code_name} is open source software. You can contribute or report issues at {code_link} (v{code_version}).",
"group.admin_subheading": "Group administrators", "group.admin_subheading": "Group administrators",
"group.cancel_request": "Cancel request", "group.cancel_request": "Cancel Request",
"group.group_mod_authorize": "Accept", "group.group_mod_authorize": "Accept",
"group.group_mod_authorize.success": "Accepted @{name} to group", "group.group_mod_authorize.success": "Accepted @{name} to group",
"group.group_mod_block": "Block @{name} from group", "group.group_mod_block": "Block @{name} from group",
@ -763,21 +763,30 @@
"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",
"group.join": "Join group", "group.join.private": "Request Access",
"group.join.public": "Join Group",
"group.join.request_success": "Requested to join the group", "group.join.request_success": "Requested to join the group",
"group.join.success": "Joined the group", "group.join.success": "Joined the group",
"group.leave": "Leave group", "group.leave": "Leave Group",
"group.leave.success": "Left the group", "group.leave.success": "Left the group",
"group.manage": "Manage group", "group.manage": "Manage Group",
"group.moderator_subheading": "Group moderators", "group.moderator_subheading": "Group moderators",
"group.privacy.locked": "Private", "group.privacy.locked": "Private",
"group.privacy.public": "Public", "group.privacy.public": "Public",
"group.request_join": "Request to join group",
"group.role.admin": "Admin", "group.role.admin": "Admin",
"group.role.moderator": "Moderator", "group.role.moderator": "Moderator",
"group.tabs.all": "All", "group.tabs.all": "All",
"group.tabs.members": "Members", "group.tabs.members": "Members",
"group.user_subheading": "Users", "group.user_subheading": "Users",
"groups.discover.search.no_results.subtitle": "Try searching for another group.",
"groups.discover.search.no_results.title": "No matches found",
"groups.discover.search.placeholder": "Search",
"groups.discover.search.recent_searches.blankslate.subtitle": "Search group names, topics or keywords",
"groups.discover.search.recent_searches.blankslate.title": "No recent searches",
"groups.discover.search.recent_searches.clear_all": "Clear all",
"groups.discover.search.recent_searches.title": "Recent searches",
"groups.discover.search.results.groups": "Groups",
"groups.discover.search.results.member_count": "{members, plural, one {member} other {members}}",
"groups.empty.subtitle": "Start discovering groups to join or create your own.", "groups.empty.subtitle": "Start discovering groups to join or create your own.",
"groups.empty.title": "No Groups yet", "groups.empty.title": "No Groups yet",
"hashtag.column_header.tag_mode.all": "and {additional}", "hashtag.column_header.tag_mode.all": "and {additional}",

@ -738,7 +738,7 @@
"group.group_mod_unblock": "Desbloquear", "group.group_mod_unblock": "Desbloquear",
"group.group_mod_unblock.success": "Desbloquear a @{name} del grupo", "group.group_mod_unblock.success": "Desbloquear a @{name} del grupo",
"group.header.alt": "Encabezado del grupo", "group.header.alt": "Encabezado del grupo",
"group.join": "Unirse al grupo", "group.join.public": "Unirse al grupo",
"group.join.request_success": "Solicitud de unión al grupo", "group.join.request_success": "Solicitud de unión al grupo",
"group.join.success": "Se unió al grupo", "group.join.success": "Se unió al grupo",
"group.leave": "Dejar el grupo", "group.leave": "Dejar el grupo",
@ -747,7 +747,7 @@
"group.moderator_subheading": "Moderadores del grupo", "group.moderator_subheading": "Moderadores del grupo",
"group.privacy.locked": "Privado", "group.privacy.locked": "Privado",
"group.privacy.public": "Público", "group.privacy.public": "Público",
"group.request_join": "Solicitud de ingreso en el grupo", "group.join.private": "Solicitud de ingreso en el grupo",
"group.role.admin": "Administrador", "group.role.admin": "Administrador",
"group.role.moderator": "Moderador", "group.role.moderator": "Moderador",
"group.tabs.all": "Todos", "group.tabs.all": "Todos",

@ -738,7 +738,7 @@
"group.group_mod_unblock": "Sblocca", "group.group_mod_unblock": "Sblocca",
"group.group_mod_unblock.success": "Hai sbloccato @{name} dal gruppo", "group.group_mod_unblock.success": "Hai sbloccato @{name} dal gruppo",
"group.header.alt": "Testata del gruppo", "group.header.alt": "Testata del gruppo",
"group.join": "Entra nel gruppo", "group.join.public": "Entra nel gruppo",
"group.join.request_success": "Richiesta di partecipazione", "group.join.request_success": "Richiesta di partecipazione",
"group.join.success": "Partecipazione nel gruppo", "group.join.success": "Partecipazione nel gruppo",
"group.leave": "Abbandona il gruppo", "group.leave": "Abbandona il gruppo",
@ -747,7 +747,7 @@
"group.moderator_subheading": "Moderazione del gruppo", "group.moderator_subheading": "Moderazione del gruppo",
"group.privacy.locked": "Privato", "group.privacy.locked": "Privato",
"group.privacy.public": "Pubblico", "group.privacy.public": "Pubblico",
"group.request_join": "Richiesta di partecipazione", "group.join.private": "Richiesta di partecipazione",
"group.role.admin": "Amministrazione", "group.role.admin": "Amministrazione",
"group.role.moderator": "Moderazione", "group.role.moderator": "Moderazione",
"group.tabs.all": "Tutto", "group.tabs.all": "Tutto",

@ -591,13 +591,13 @@
"getting_started.open_source_notice": "{code_name} jest oprogramowaniem o otwartym źródle. Możesz pomóc w rozwoju lub zgłaszać błędy na GitLabie tutaj: {code_link} (v{code_version}).", "getting_started.open_source_notice": "{code_name} jest oprogramowaniem o otwartym źródle. Możesz pomóc w rozwoju lub zgłaszać błędy na GitLabie tutaj: {code_link} (v{code_version}).",
"group.admin_subheading": "Administratorzy grupy", "group.admin_subheading": "Administratorzy grupy",
"group.header.alt": "Nagłówek grupy", "group.header.alt": "Nagłówek grupy",
"group.join": "Dołącz do grupy", "group.join.public": "Dołącz do grupy",
"group.leave": "Opuść grupę", "group.leave": "Opuść grupę",
"group.manage": "Edytuj grupę", "group.manage": "Edytuj grupę",
"group.moderator_subheading": "Moderatorzy grupy", "group.moderator_subheading": "Moderatorzy grupy",
"group.privacy.locked": "Prywatna", "group.privacy.locked": "Prywatna",
"group.privacy.public": "Publiczna", "group.privacy.public": "Publiczna",
"group.request_join": "Poproś o dołączenie do grupy", "group.join.private": "Poproś o dołączenie do grupy",
"group.role.admin": "Administrator", "group.role.admin": "Administrator",
"group.role.moderator": "Moderator", "group.role.moderator": "Moderator",
"group.tabs.all": "Wszystko", "group.tabs.all": "Wszystko",

@ -738,7 +738,7 @@
"group.group_mod_unblock": "解除屏蔽", "group.group_mod_unblock": "解除屏蔽",
"group.group_mod_unblock.success": "已从群组中解除屏蔽 @{name}", "group.group_mod_unblock.success": "已从群组中解除屏蔽 @{name}",
"group.header.alt": "群组标题", "group.header.alt": "群组标题",
"group.join": "加入群组", "group.join.public": "加入群组",
"group.join.request_success": "已请求加入群组", "group.join.request_success": "已请求加入群组",
"group.join.success": "已加入群组", "group.join.success": "已加入群组",
"group.leave": "离开群组", "group.leave": "离开群组",
@ -747,7 +747,7 @@
"group.moderator_subheading": "群组监察员", "group.moderator_subheading": "群组监察员",
"group.privacy.locked": "私有", "group.privacy.locked": "私有",
"group.privacy.public": "公开", "group.privacy.public": "公开",
"group.request_join": "请求加入群组", "group.join.private": "请求加入群组",
"group.role.admin": "管理员", "group.role.admin": "管理员",
"group.role.moderator": "监察员", "group.role.moderator": "监察员",
"group.tabs.all": "全部", "group.tabs.all": "全部",

@ -128,6 +128,11 @@ const normalizeFqn = (group: ImmutableMap<string, any>) => {
return group.set('fqn', fqn); return group.set('fqn', fqn);
}; };
const normalizeLocked = (group: ImmutableMap<string, any>) => {
const locked = group.get('locked') || group.get('group_visibility') === 'members_only';
return group.set('locked', locked);
};
/** Rewrite `<p></p>` to empty string. */ /** Rewrite `<p></p>` to empty string. */
const fixNote = (group: ImmutableMap<string, any>) => { const fixNote = (group: ImmutableMap<string, any>) => {
@ -145,6 +150,7 @@ export const normalizeGroup = (group: Record<string, any>) => {
normalizeAvatar(group); normalizeAvatar(group);
normalizeHeader(group); normalizeHeader(group);
normalizeFqn(group); normalizeFqn(group);
normalizeLocked(group);
fixDisplayName(group); fixDisplayName(group);
fixNote(group); fixNote(group);
addInternalFields(group); addInternalFields(group);

@ -1,10 +1,8 @@
import React, { useCallback, useEffect } from 'react'; import React from 'react';
import { defineMessages, useIntl } from 'react-intl'; import { defineMessages, useIntl } from 'react-intl';
import { useRouteMatch } from 'react-router-dom'; import { useRouteMatch } from 'react-router-dom';
import { fetchGroup } from 'soapbox/actions/groups'; import { Column, Icon, Layout, Stack, Text } from 'soapbox/components/ui';
import MissingIndicator from 'soapbox/components/missing-indicator';
import { Column, Layout } from 'soapbox/components/ui';
import GroupHeader from 'soapbox/features/group/components/group-header'; import GroupHeader from 'soapbox/features/group/components/group-header';
import LinkFooter from 'soapbox/features/ui/components/link-footer'; import LinkFooter from 'soapbox/features/ui/components/link-footer';
import BundleContainer from 'soapbox/features/ui/containers/bundle-container'; import BundleContainer from 'soapbox/features/ui/containers/bundle-container';
@ -13,8 +11,8 @@ import {
GroupMediaPanel, GroupMediaPanel,
SignUpPanel, SignUpPanel,
} from 'soapbox/features/ui/util/async-components'; } from 'soapbox/features/ui/util/async-components';
import { useAppDispatch, useAppSelector } from 'soapbox/hooks'; import { useOwnAccount } from 'soapbox/hooks';
import { makeGetGroup } from 'soapbox/selectors'; import { useGroup } from 'soapbox/queries/groups';
import { Tabs } from '../components/ui'; import { Tabs } from '../components/ui';
@ -34,23 +32,20 @@ interface IGroupPage {
const GroupPage: React.FC<IGroupPage> = ({ params, children }) => { const GroupPage: React.FC<IGroupPage> = ({ params, children }) => {
const intl = useIntl(); const intl = useIntl();
const match = useRouteMatch(); const match = useRouteMatch();
const dispatch = useAppDispatch(); const me = useOwnAccount();
const id = params?.id || ''; const id = params?.id || '';
const getGroup = useCallback(makeGetGroup(), []); const { group } = useGroup(id);
const group = useAppSelector(state => getGroup(state, id));
const me = useAppSelector(state => state.me);
useEffect(() => { const isNonMember = !group?.relationship || !group.relationship.member;
dispatch(fetchGroup(id)); const isPrivate = group?.locked;
}, [id]);
if ((group as any) === false) { // if ((group as any) === false) {
return ( // return (
<MissingIndicator /> // <MissingIndicator />
); // );
} // }
const items = [ const items = [
{ {
@ -76,7 +71,18 @@ const GroupPage: React.FC<IGroupPage> = ({ params, children }) => {
activeItem={match.path} activeItem={match.path}
/> />
{children} {(isNonMember && isPrivate) ? (
<Stack space={4} className='py-10' alignItems='center'>
<div className='rounded-full bg-gray-200 p-3'>
<Icon src={require('@tabler/icons/eye-off.svg')} className='h-6 w-6 text-gray-600' />
</div>
<Text theme='muted'>
Content is only visible to group members
</Text>
</Stack>
) : children}
</Column> </Column>
{!me && ( {!me && (

@ -9,6 +9,7 @@ import { Group } from 'soapbox/types/entities';
import { flattenPages, PaginatedResult } from 'soapbox/utils/queries'; import { flattenPages, PaginatedResult } from 'soapbox/utils/queries';
const GroupKeys = { const GroupKeys = {
group: (id: string) => ['groups', 'group', id] as const,
myGroups: (userId: string) => ['groups', userId] as const, myGroups: (userId: string) => ['groups', userId] as const,
popularGroups: ['groups', 'popular'] as const, popularGroups: ['groups', 'popular'] as const,
suggestedGroups: ['groups', 'suggested'] as const, suggestedGroups: ['groups', 'suggested'] as const,
@ -18,9 +19,10 @@ const useGroups = () => {
const api = useApi(); const api = useApi();
const account = useOwnAccount(); const account = useOwnAccount();
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const features = useFeatures();
const getGroups = async (pageParam?: any): Promise<PaginatedResult<Group>> => { const getGroups = async (pageParam?: any): Promise<PaginatedResult<Group>> => {
const endpoint = '/api/mock/groups'; // '/api/v1/groups'; const endpoint = '/api/v1/groups';
const nextPageLink = pageParam?.link; const nextPageLink = pageParam?.link;
const uri = nextPageLink || endpoint; const uri = nextPageLink || endpoint;
const response = await api.get<Group[]>(uri); const response = await api.get<Group[]>(uri);
@ -45,7 +47,7 @@ const useGroups = () => {
GroupKeys.myGroups(account?.id as string), GroupKeys.myGroups(account?.id as string),
({ pageParam }: any) => getGroups(pageParam), ({ pageParam }: any) => getGroups(pageParam),
{ {
enabled: !!account, enabled: !!account && features.groups,
keepPreviousData: true, keepPreviousData: true,
getNextPageParam: (config) => { getNextPageParam: (config) => {
if (config?.hasMore) { if (config?.hasMore) {
@ -69,7 +71,7 @@ const usePopularGroups = () => {
const features = useFeatures(); const features = useFeatures();
const getQuery = async () => { const getQuery = async () => {
const { data } = await api.get<Group[]>('/api/mock/groups'); // '/api/v1/truth/trends/groups' const { data } = await api.get<Group[]>('/api/v1/groups/search?q=group'); // '/api/v1/truth/trends/groups'
const result = data.map(normalizeGroup); const result = data.map(normalizeGroup);
return result; return result;
@ -108,4 +110,23 @@ const useSuggestedGroups = () => {
}; };
}; };
export { useGroups, usePopularGroups, useSuggestedGroups }; const useGroup = (id: string) => {
const api = useApi();
const features = useFeatures();
const getGroup = async () => {
const { data } = await api.get(`/api/v1/groups/${id}`);
return normalizeGroup(data);
};
const queryInfo = useQuery(GroupKeys.group(id), getGroup, {
enabled: features.groups && !!id,
});
return {
...queryInfo,
group: queryInfo.data,
};
};
export { useGroups, useGroup, usePopularGroups, useSuggestedGroups };

@ -0,0 +1,67 @@
import { useInfiniteQuery } from '@tanstack/react-query';
import { getNextLink } from 'soapbox/api';
import { useApi, useFeatures } from 'soapbox/hooks';
import { normalizeGroup } from 'soapbox/normalizers';
import { Group } from 'soapbox/types/entities';
import { flattenPages, PaginatedResult } from 'soapbox/utils/queries';
const GroupSearchKeys = {
search: (query?: string) => query ? ['groups', 'search', query] : ['groups', 'search'] as const,
};
type PageParam = {
link: string
}
const useGroupSearch = (search?: string) => {
const api = useApi();
const features = useFeatures();
const getSearchResults = async (pageParam: PageParam): Promise<PaginatedResult<Group>> => {
const nextPageLink = pageParam?.link;
const uri = nextPageLink || '/api/v1/groups/search';
const response = await api.get<Group[]>(uri, {
params: search ? {
q: search,
} : undefined,
});
const { data } = response;
const link = getNextLink(response);
const hasMore = !!link;
const result = data.map(normalizeGroup);
return {
result,
hasMore,
link,
};
};
const queryInfo = useInfiniteQuery(
GroupSearchKeys.search(search),
({ pageParam }) => getSearchResults(pageParam),
{
keepPreviousData: true,
enabled: features.groups && !!search,
getNextPageParam: (config) => {
if (config.hasMore) {
return { link: config.link };
}
return undefined;
},
});
const data = flattenPages(queryInfo.data);
return {
...queryInfo,
groups: data || [],
};
};
export {
useGroupSearch,
};

@ -53,3 +53,6 @@ export const pushNotificationsSetting = new Settings('soapbox_push_notification_
/** Remember hashtag usage. */ /** Remember hashtag usage. */
export const tagHistory = new Settings('soapbox_tag_history'); export const tagHistory = new Settings('soapbox_tag_history');
/** Remember group usage. */
export const groupSearchHistory = new Settings('soapbox_group_search_history');

@ -0,0 +1,35 @@
import { groupSearchHistory } from 'soapbox/settings';
const RECENT_SEARCHES_KEY = 'soapbox:recent-group-searches';
const clearRecentGroupSearches = (currentUserId: string) => groupSearchHistory.remove(currentUserId);
const saveGroupSearch = (currentUserId: string, search: string) => {
let currentSearches: string[] = [];
if (groupSearchHistory.get(currentUserId)) {
currentSearches = groupSearchHistory.get(currentUserId);
}
if (currentSearches.indexOf(search) === -1) {
currentSearches.unshift(search);
if (currentSearches.length > 10) {
currentSearches.pop();
}
groupSearchHistory.set(currentUserId, currentSearches);
return currentSearches;
} else {
// The search term has already been searched. Move it to the beginning
// of the cached list.
const indexOfSearch = currentSearches.indexOf(search);
const nextCurrentSearches = [...currentSearches];
nextCurrentSearches.splice(0, 0, ...nextCurrentSearches.splice(indexOfSearch, 1));
localStorage.setItem(RECENT_SEARCHES_KEY, JSON.stringify(nextCurrentSearches));
return nextCurrentSearches;
}
};
export { clearRecentGroupSearches, saveGroupSearch };
Loading…
Cancel
Save