Merge branch 'my-groups' into 'develop'
Add Trending and Suggested Groups to discovery See merge request soapbox-pub/soapbox!2312
This commit is contained in:
commit
4e2213aba8
51 changed files with 1974 additions and 244 deletions
|
@ -17,43 +17,53 @@ const GroupCard: React.FC<IGroupCard> = ({ group }) => {
|
|||
const intl = useIntl();
|
||||
|
||||
return (
|
||||
<div className='overflow-hidden'>
|
||||
<Stack className='rounded-lg border border-solid border-gray-300 bg-white dark:border-primary-800 dark:bg-primary-900 sm:rounded-xl'>
|
||||
<div className='relative -m-[1px] mb-0 h-[120px] rounded-t-lg bg-primary-100 dark:bg-gray-800 sm:rounded-t-xl'>
|
||||
{group.header && <img className='h-full w-full rounded-t-lg object-cover sm:rounded-t-xl' src={group.header} alt={intl.formatMessage(messages.groupHeader)} />}
|
||||
<div className='absolute left-1/2 bottom-0 -translate-x-1/2 translate-y-1/2'>
|
||||
<Avatar className='ring-2 ring-white dark:ring-primary-900' src={group.avatar} size={64} />
|
||||
</div>
|
||||
</div>
|
||||
<Stack className='p-3 pt-9' alignItems='center' space={3}>
|
||||
<Text size='lg' weight='bold' dangerouslySetInnerHTML={{ __html: group.display_name_html }} />
|
||||
<HStack className='text-gray-700 dark:text-gray-600' space={3} wrap>
|
||||
{group.relationship?.role === 'admin' ? (
|
||||
<HStack space={1} alignItems='center'>
|
||||
<Icon className='h-4 w-4' src={require('@tabler/icons/users.svg')} />
|
||||
<span><FormattedMessage id='group.role.admin' defaultMessage='Admin' /></span>
|
||||
</HStack>
|
||||
) : group.relationship?.role === 'moderator' && (
|
||||
<HStack space={1} alignItems='center'>
|
||||
<Icon className='h-4 w-4' src={require('@tabler/icons/gavel.svg')} />
|
||||
<span><FormattedMessage id='group.role.moderator' defaultMessage='Moderator' /></span>
|
||||
</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>
|
||||
</Stack>
|
||||
<Stack className='relative h-[240px] rounded-lg border border-solid border-gray-300 bg-white dark:border-primary-800 dark:bg-primary-900'>
|
||||
{/* Group Cover Image */}
|
||||
<Stack grow className='relative basis-1/2 rounded-t-lg bg-primary-100 dark:bg-gray-800'>
|
||||
{group.header && (
|
||||
<img
|
||||
className='absolute inset-0 h-full w-full rounded-t-lg object-cover'
|
||||
src={group.header} alt={intl.formatMessage(messages.groupHeader)}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
</div>
|
||||
|
||||
{/* Group Avatar */}
|
||||
<div className='absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2'>
|
||||
<Avatar className='ring-2 ring-white dark:ring-primary-900' src={group.avatar} size={64} />
|
||||
</div>
|
||||
|
||||
{/* Group Info */}
|
||||
<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 }} />
|
||||
|
||||
<HStack className='text-gray-700 dark:text-gray-600' space={3} wrap>
|
||||
{group.relationship?.role === 'admin' ? (
|
||||
<HStack space={1} alignItems='center'>
|
||||
<Icon className='h-4 w-4' src={require('@tabler/icons/users.svg')} />
|
||||
<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>
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -10,7 +10,7 @@ import { closeSidebar } from 'soapbox/actions/sidebar';
|
|||
import Account from 'soapbox/components/account';
|
||||
import { Stack } from 'soapbox/components/ui';
|
||||
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 { 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 settings = useAppSelector((state) => getSettings(state));
|
||||
const followRequestsCount = useAppSelector((state) => state.user_lists.follow_requests.items.count());
|
||||
const groupsPath = useGroupsPath();
|
||||
|
||||
const closeButtonRef = React.useRef(null);
|
||||
|
||||
|
@ -210,7 +211,7 @@ const SidebarMenu: React.FC = (): JSX.Element | null => {
|
|||
|
||||
{features.groups && (
|
||||
<SidebarLink
|
||||
to='/groups'
|
||||
to={groupsPath}
|
||||
icon={require('@tabler/icons/circles.svg')}
|
||||
text={intl.formatMessage(messages.groups)}
|
||||
onClick={onClose}
|
||||
|
|
|
@ -4,7 +4,7 @@ import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
|||
import { Stack } from 'soapbox/components/ui';
|
||||
import { useStatContext } from 'soapbox/contexts/stat-context';
|
||||
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 SidebarNavigationLink from './sidebar-navigation-link';
|
||||
|
@ -25,6 +25,8 @@ const SidebarNavigation = () => {
|
|||
const features = useFeatures();
|
||||
const settings = useSettings();
|
||||
const account = useOwnAccount();
|
||||
const groupsPath = useGroupsPath();
|
||||
|
||||
const notificationCount = useAppSelector((state) => state.notifications.unread);
|
||||
const followRequestsCount = useAppSelector((state) => state.user_lists.follow_requests.items.count());
|
||||
const dashboardCount = useAppSelector((state) => state.admin.openReports.count() + state.admin.awaitingApproval.count());
|
||||
|
@ -135,7 +137,7 @@ const SidebarNavigation = () => {
|
|||
|
||||
{features.groups && (
|
||||
<SidebarNavigationLink
|
||||
to='/groups'
|
||||
to={groupsPath}
|
||||
icon={require('@tabler/icons/circles.svg')}
|
||||
text={<FormattedMessage id='tabs_bar.groups' defaultMessage='Groups' />}
|
||||
/>
|
||||
|
|
109
app/soapbox/components/ui/carousel/carousel.tsx
Normal file
109
app/soapbox/components/ui/carousel/carousel.tsx
Normal file
|
@ -0,0 +1,109 @@
|
|||
import React, { useEffect, useState } from 'react';
|
||||
|
||||
import { useDimensions } from 'soapbox/hooks';
|
||||
|
||||
import HStack from '../hstack/hstack';
|
||||
import Icon from '../icon/icon';
|
||||
|
||||
interface ICarousel {
|
||||
children: any
|
||||
/** Optional height to force on controls */
|
||||
controlsHeight?: number
|
||||
/** How many items in the carousel */
|
||||
itemCount: number
|
||||
/** The minimum width per item */
|
||||
itemWidth: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Carousel
|
||||
*/
|
||||
const Carousel: React.FC<ICarousel> = (props): JSX.Element => {
|
||||
const { children, controlsHeight, itemCount, itemWidth } = props;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const [_ref, setContainerRef, { width: containerWidth }] = useDimensions();
|
||||
|
||||
const [pageSize, setPageSize] = useState<number>(0);
|
||||
const [currentPage, setCurrentPage] = useState<number>(1);
|
||||
|
||||
const numberOfPages = Math.ceil(itemCount / pageSize);
|
||||
const width = containerWidth / (Math.floor(containerWidth / itemWidth));
|
||||
|
||||
const hasNextPage = currentPage < numberOfPages && numberOfPages > 1;
|
||||
const hasPrevPage = currentPage > 1 && numberOfPages > 1;
|
||||
|
||||
const handleNextPage = () => setCurrentPage((prevPage) => prevPage + 1);
|
||||
const handlePrevPage = () => setCurrentPage((prevPage) => prevPage - 1);
|
||||
|
||||
const renderChildren = () => {
|
||||
if (typeof children === 'function') {
|
||||
return children({ width: width || 'auto' });
|
||||
}
|
||||
|
||||
return children;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (containerWidth) {
|
||||
setPageSize(Math.round(containerWidth / width));
|
||||
}
|
||||
}, [containerWidth, width]);
|
||||
|
||||
return (
|
||||
<HStack alignItems='stretch'>
|
||||
<div
|
||||
className='z-10 flex w-5 items-center justify-center self-stretch rounded-l-xl bg-white dark:bg-primary-900'
|
||||
style={{
|
||||
height: controlsHeight || 'auto',
|
||||
}}
|
||||
>
|
||||
<button
|
||||
data-testid='prev-page'
|
||||
onClick={handlePrevPage}
|
||||
className='flex h-full w-7 items-center justify-center transition-opacity duration-500 disabled:opacity-25'
|
||||
disabled={!hasPrevPage}
|
||||
>
|
||||
<Icon
|
||||
src={require('@tabler/icons/chevron-left.svg')}
|
||||
className='h-5 w-5 text-black dark:text-white'
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className='relative w-full overflow-hidden'>
|
||||
<HStack
|
||||
alignItems='center'
|
||||
style={{
|
||||
transform: `translateX(-${(currentPage - 1) * 100}%)`,
|
||||
}}
|
||||
className='transition-all duration-500 ease-out'
|
||||
ref={setContainerRef}
|
||||
>
|
||||
{renderChildren()}
|
||||
</HStack>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className='z-10 flex w-5 items-center justify-center self-stretch rounded-r-xl bg-white dark:bg-primary-900'
|
||||
style={{
|
||||
height: controlsHeight || 'auto',
|
||||
}}
|
||||
>
|
||||
<button
|
||||
data-testid='next-page'
|
||||
onClick={handleNextPage}
|
||||
className='flex h-full w-7 items-center justify-center transition-opacity duration-500 disabled:opacity-25'
|
||||
disabled={!hasNextPage}
|
||||
>
|
||||
<Icon
|
||||
src={require('@tabler/icons/chevron-right.svg')}
|
||||
className='h-5 w-5 text-black dark:text-white'
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</HStack>
|
||||
);
|
||||
};
|
||||
|
||||
export default Carousel;
|
|
@ -2,6 +2,7 @@ export { default as Accordion } from './accordion/accordion';
|
|||
export { default as Avatar } from './avatar/avatar';
|
||||
export { default as Banner } from './banner/banner';
|
||||
export { default as Button } from './button/button';
|
||||
export { default as Carousel } from './carousel/carousel';
|
||||
export { Card, CardBody, CardHeader, CardTitle } from './card/card';
|
||||
export { default as Checkbox } from './checkbox/checkbox';
|
||||
export { Column, ColumnHeader } from './column/column';
|
||||
|
|
|
@ -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 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 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 { normalizeAttachment } from 'soapbox/normalizers';
|
||||
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';
|
||||
|
||||
const messages = defineMessages({
|
||||
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 {
|
||||
|
@ -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 avatar = normalizeAttachment({
|
||||
type: 'image',
|
||||
|
@ -95,6 +86,7 @@ const GroupHeader: React.FC<IGroupHeader> = ({ group }) => {
|
|||
<StillImage
|
||||
src={group.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;
|
||||
};
|
||||
|
||||
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 (
|
||||
<div className='-mx-4 -mt-4'>
|
||||
<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()}
|
||||
</div>
|
||||
{renderHeader()}
|
||||
|
||||
<div className='absolute left-1/2 bottom-0 -translate-x-1/2 translate-y-1/2'>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Stack className='p-3 pt-12' alignItems='center' space={2}>
|
||||
<Text className='mb-1' size='xl' weight='bold' dangerouslySetInnerHTML={{ __html: group.display_name_html }} />
|
||||
<HStack className='text-gray-700 dark:text-gray-600' space={3} wrap>
|
||||
{group.relationship?.role === 'admin' ? (
|
||||
<HStack space={1} alignItems='center'>
|
||||
<Icon className='h-4 w-4' src={require('@tabler/icons/users.svg')} />
|
||||
<span><FormattedMessage id='group.role.admin' defaultMessage='Admin' /></span>
|
||||
</HStack>
|
||||
) : group.relationship?.role === 'moderator' && (
|
||||
<HStack space={1} alignItems='center'>
|
||||
<Icon className='h-4 w-4' src={require('@tabler/icons/gavel.svg')} />
|
||||
<span><FormattedMessage id='group.role.moderator' defaultMessage='Moderator' /></span>
|
||||
</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>
|
||||
<Text theme='muted' dangerouslySetInnerHTML={{ __html: group.note_emojified }} />
|
||||
{actionButton}
|
||||
<Stack alignItems='center' space={3} className='mt-10 py-4'>
|
||||
<Text
|
||||
size='xl'
|
||||
weight='bold'
|
||||
dangerouslySetInnerHTML={{ __html: group.display_name_html }}
|
||||
/>
|
||||
|
||||
<Stack space={1}>
|
||||
<HStack className='text-gray-700 dark:text-gray-600' space={2} wrap>
|
||||
<GroupRelationship group={group} />
|
||||
<GroupPrivacy group={group} />
|
||||
<GroupMemberCount group={group} />
|
||||
</HStack>
|
||||
|
||||
<Text theme='muted' dangerouslySetInnerHTML={{ __html: group.note_emojified }} />
|
||||
</Stack>
|
||||
|
||||
<GroupActionButton group={group} />
|
||||
</Stack>
|
||||
</div>
|
||||
);
|
||||
|
|
32
app/soapbox/features/group/components/group-member-count.tsx
Normal file
32
app/soapbox/features/group/components/group-member-count.tsx
Normal file
|
@ -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;
|
32
app/soapbox/features/group/components/group-privacy.tsx
Normal file
32
app/soapbox/features/group/components/group-privacy.tsx
Normal file
|
@ -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;
|
39
app/soapbox/features/group/components/group-relationship.tsx
Normal file
39
app/soapbox/features/group/components/group-relationship.tsx
Normal file
|
@ -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 { groupCompose } from 'soapbox/actions/compose';
|
||||
import { fetchGroup } from 'soapbox/actions/groups';
|
||||
import { connectGroupStream } from 'soapbox/actions/streaming';
|
||||
import { expandGroupTimeline } from 'soapbox/actions/timelines';
|
||||
import { Avatar, HStack, Stack } from 'soapbox/components/ui';
|
||||
|
@ -31,7 +30,6 @@ const GroupTimeline: React.FC<IGroupTimeline> = (props) => {
|
|||
};
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(fetchGroup(groupId));
|
||||
dispatch(expandGroupTimeline(groupId));
|
||||
|
||||
dispatch(groupCompose(`group:${groupId}`, groupId));
|
||||
|
|
81
app/soapbox/features/groups/components/discover/group.tsx
Normal file
81
app/soapbox/features/groups/components/discover/group.tsx
Normal file
|
@ -0,0 +1,81 @@
|
|||
import React, { forwardRef } from 'react';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
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';
|
||||
|
||||
interface IGroup {
|
||||
group: GroupEntity
|
||||
width?: number
|
||||
}
|
||||
|
||||
const Group = forwardRef((props: IGroup, ref: React.ForwardedRef<HTMLDivElement>) => {
|
||||
const { group, width = 'auto' } = props;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={group.id}
|
||||
className='relative flex shrink-0 flex-col space-y-2 px-0.5'
|
||||
style={{
|
||||
width,
|
||||
}}
|
||||
>
|
||||
<Link to={`/groups/${group.id}`}>
|
||||
<Stack
|
||||
className='aspect-w-10 aspect-h-7 h-full w-full overflow-hidden rounded-lg'
|
||||
ref={ref}
|
||||
style={{ minHeight: 180 }}
|
||||
>
|
||||
{group.header && (
|
||||
<img
|
||||
src={group.header}
|
||||
alt='Group cover'
|
||||
className='absolute inset-0 object-cover'
|
||||
/>
|
||||
)}
|
||||
|
||||
<Stack justifyContent='end' className='z-10 p-4 text-white' space={3}>
|
||||
<Avatar
|
||||
className='ring-2 ring-white'
|
||||
src={group.avatar}
|
||||
size={44}
|
||||
/>
|
||||
|
||||
<Stack space={1}>
|
||||
<Text
|
||||
weight='bold'
|
||||
dangerouslySetInnerHTML={{ __html: group.display_name_html }}
|
||||
theme='inherit'
|
||||
truncate
|
||||
/>
|
||||
|
||||
<HStack alignItems='center' space={1}>
|
||||
<GroupPrivacy group={group} />
|
||||
<span>•</span>
|
||||
<GroupMemberCount group={group} />
|
||||
</HStack>
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
||||
<div
|
||||
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>
|
||||
</Link>
|
||||
|
||||
<Button
|
||||
theme='primary'
|
||||
block
|
||||
>
|
||||
{group.locked
|
||||
? <FormattedMessage id='group.join.private' defaultMessage='Request Access' />
|
||||
: <FormattedMessage id='group.join.public' defaultMessage='Join Group' />}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default Group;
|
|
@ -0,0 +1,54 @@
|
|||
import React, { useState } from 'react';
|
||||
|
||||
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';
|
||||
|
||||
const PopularGroups = () => {
|
||||
const { groups, isFetching } = usePopularGroups();
|
||||
|
||||
const [groupCover, setGroupCover] = useState<HTMLDivElement | null>(null);
|
||||
|
||||
return (
|
||||
<Stack space={4}>
|
||||
<Text size='xl' weight='bold'>
|
||||
Popular Groups
|
||||
</Text>
|
||||
|
||||
<Carousel
|
||||
itemWidth={250}
|
||||
itemCount={groups.length}
|
||||
controlsHeight={groupCover?.clientHeight}
|
||||
>
|
||||
{({ width }: { width: number }) => (
|
||||
<>
|
||||
{isFetching ? (
|
||||
new Array(20).fill(0).map((_, idx) => (
|
||||
<div
|
||||
className='relative flex shrink-0 flex-col space-y-2 px-0.5'
|
||||
style={{ width: width || 'auto' }}
|
||||
key={idx}
|
||||
>
|
||||
<PlaceholderGroupDiscover />
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
groups.map((group) => (
|
||||
<Group
|
||||
key={group.id}
|
||||
group={group}
|
||||
width={width}
|
||||
ref={setGroupCover}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Carousel>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default PopularGroups;
|
|
@ -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>•</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} />
|
||||
);
|
||||
};
|
|
@ -0,0 +1,54 @@
|
|||
import React, { useState } from 'react';
|
||||
|
||||
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';
|
||||
|
||||
const SuggestedGroups = () => {
|
||||
const { groups, isFetching } = useSuggestedGroups();
|
||||
|
||||
const [groupCover, setGroupCover] = useState<HTMLDivElement | null>(null);
|
||||
|
||||
return (
|
||||
<Stack space={4}>
|
||||
<Text size='xl' weight='bold'>
|
||||
Suggested For You
|
||||
</Text>
|
||||
|
||||
<Carousel
|
||||
itemWidth={250}
|
||||
itemCount={groups.length}
|
||||
controlsHeight={groupCover?.clientHeight}
|
||||
>
|
||||
{({ width }: { width: number }) => (
|
||||
<>
|
||||
{isFetching ? (
|
||||
new Array(20).fill(0).map((_, idx) => (
|
||||
<div
|
||||
className='relative flex shrink-0 flex-col space-y-2 px-0.5'
|
||||
style={{ width: width || 'auto' }}
|
||||
key={idx}
|
||||
>
|
||||
<PlaceholderGroupDiscover />
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
groups.map((group) => (
|
||||
<Group
|
||||
key={group.id}
|
||||
group={group}
|
||||
width={width}
|
||||
ref={setGroupCover}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Carousel>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default SuggestedGroups;
|
41
app/soapbox/features/groups/components/tab-bar.tsx
Normal file
41
app/soapbox/features/groups/components/tab-bar.tsx
Normal file
|
@ -0,0 +1,41 @@
|
|||
import React, { useMemo } from 'react';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
|
||||
import { Tabs } from 'soapbox/components/ui';
|
||||
|
||||
import type { Item } from 'soapbox/components/ui/tabs/tabs';
|
||||
|
||||
export enum TabItems {
|
||||
MY_GROUPS = 'MY_GROUPS',
|
||||
FIND_GROUPS = 'FIND_GROUPS'
|
||||
}
|
||||
|
||||
interface ITabBar {
|
||||
activeTab: TabItems
|
||||
}
|
||||
|
||||
const TabBar = ({ activeTab }: ITabBar) => {
|
||||
const history = useHistory();
|
||||
|
||||
const tabItems: Item[] = useMemo(() => ([
|
||||
{
|
||||
text: 'My Groups',
|
||||
action: () => history.push('/groups'),
|
||||
name: TabItems.MY_GROUPS,
|
||||
},
|
||||
{
|
||||
text: 'Find Groups',
|
||||
action: () => history.push('/groups/discover'),
|
||||
name: TabItems.FIND_GROUPS,
|
||||
},
|
||||
]), []);
|
||||
|
||||
return (
|
||||
<Tabs
|
||||
items={tabItems}
|
||||
activeItem={activeTab}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default TabBar;
|
81
app/soapbox/features/groups/discover.tsx
Normal file
81
app/soapbox/features/groups/discover.tsx
Normal file
|
@ -0,0 +1,81 @@
|
|||
import React, { useState } from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
|
||||
import { HStack, Icon, IconButton, Input, Stack } from 'soapbox/components/ui';
|
||||
|
||||
import PopularGroups from './components/discover/popular-groups';
|
||||
import Search from './components/discover/search/search';
|
||||
import SuggestedGroups from './components/discover/suggested-groups';
|
||||
import TabBar, { TabItems } from './components/tab-bar';
|
||||
|
||||
const messages = defineMessages({
|
||||
placeholder: { id: 'groups.discover.search.placeholder', defaultMessage: 'Search' },
|
||||
});
|
||||
|
||||
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 (
|
||||
<Stack space={4}>
|
||||
<TabBar activeTab={TabItems.FIND_GROUPS} />
|
||||
|
||||
<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 />
|
||||
<SuggestedGroups />
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default Discover;
|
|
@ -1,78 +1,65 @@
|
|||
import React, { useEffect } from 'react';
|
||||
import React from 'react';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { createSelector } from 'reselect';
|
||||
|
||||
import { fetchGroups } from 'soapbox/actions/groups';
|
||||
import { openModal } from 'soapbox/actions/modals';
|
||||
import GroupCard from 'soapbox/components/group-card';
|
||||
import ScrollableList from 'soapbox/components/scrollable-list';
|
||||
import { Button, Column, Spinner, Stack, Text } from 'soapbox/components/ui';
|
||||
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
|
||||
import { Button, Stack, Text } from 'soapbox/components/ui';
|
||||
import { useAppDispatch, useAppSelector, useFeatures } from 'soapbox/hooks';
|
||||
import { useGroups } from 'soapbox/queries/groups';
|
||||
import { PERMISSION_CREATE_GROUPS, hasPermission } from 'soapbox/utils/permissions';
|
||||
|
||||
import PlaceholderGroupCard from '../placeholder/components/placeholder-group-card';
|
||||
|
||||
import type { List as ImmutableList } from 'immutable';
|
||||
import type { RootState } from 'soapbox/store';
|
||||
import TabBar, { TabItems } from './components/tab-bar';
|
||||
|
||||
import type { Group as GroupEntity } from 'soapbox/types/entities';
|
||||
|
||||
const getOrderedGroups = createSelector([
|
||||
(state: RootState) => state.groups.items,
|
||||
(state: RootState) => state.groups.isLoading,
|
||||
(state: RootState) => state.group_relationships,
|
||||
], (groups, isLoading, group_relationships) => ({
|
||||
groups: (groups.toList().filter((item: GroupEntity | false) => !!item) as ImmutableList<GroupEntity>)
|
||||
.map((item) => item.set('relationship', group_relationships.get(item.id) || null))
|
||||
.filter((item) => item.relationship?.member)
|
||||
.sort((a, b) => a.display_name.localeCompare(b.display_name)),
|
||||
isLoading,
|
||||
}));
|
||||
// const getOrderedGroups = createSelector([
|
||||
// (state: RootState) => state.groups.items,
|
||||
// (state: RootState) => state.group_relationships,
|
||||
// ], (groups, group_relationships) => ({
|
||||
// groups: (groups.toList().filter((item: GroupEntity | false) => !!item) as ImmutableList<GroupEntity>)
|
||||
// .map((item) => item.set('relationship', group_relationships.get(item.id) || null))
|
||||
// .filter((item) => item.relationship?.member)
|
||||
// .sort((a, b) => a.display_name.localeCompare(b.display_name)),
|
||||
// }));
|
||||
|
||||
const EmptyMessage = () => (
|
||||
<Stack space={6} alignItems='center' justifyContent='center' className='h-full p-6'>
|
||||
<Stack space={2} className='max-w-sm'>
|
||||
<Text size='2xl' weight='bold' tag='h2' align='center'>
|
||||
<FormattedMessage
|
||||
id='groups.empty.title'
|
||||
defaultMessage='No Groups yet'
|
||||
/>
|
||||
</Text>
|
||||
|
||||
<Text size='sm' theme='muted' align='center'>
|
||||
<FormattedMessage
|
||||
id='groups.empty.subtitle'
|
||||
defaultMessage='Start discovering groups to join or create your own.'
|
||||
/>
|
||||
</Text>
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
|
||||
const Groups: React.FC = () => {
|
||||
const dispatch = useAppDispatch();
|
||||
const features = useFeatures();
|
||||
|
||||
const { groups, isLoading } = useAppSelector((state) => getOrderedGroups(state));
|
||||
const canCreateGroup = useAppSelector((state) => hasPermission(state, PERMISSION_CREATE_GROUPS));
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(fetchGroups());
|
||||
}, []);
|
||||
const { groups, isLoading } = useGroups();
|
||||
|
||||
const createGroup = () => {
|
||||
dispatch(openModal('MANAGE_GROUP'));
|
||||
};
|
||||
|
||||
if (!groups) {
|
||||
return (
|
||||
<Column>
|
||||
<Spinner />
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
|
||||
const emptyMessage = (
|
||||
<Stack space={6} alignItems='center' justifyContent='center' className='h-full p-6'>
|
||||
<Stack space={2} className='max-w-sm'>
|
||||
<Text size='2xl' weight='bold' tag='h2' align='center'>
|
||||
<FormattedMessage
|
||||
id='groups.empty.title'
|
||||
defaultMessage='No Groups yet'
|
||||
/>
|
||||
</Text>
|
||||
|
||||
<Text size='sm' theme='muted' align='center'>
|
||||
<FormattedMessage
|
||||
id='groups.empty.subtitle'
|
||||
defaultMessage='Start discovering groups to join or create your own.'
|
||||
/>
|
||||
</Text>
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
|
||||
return (
|
||||
<Stack className='gap-4'>
|
||||
<Stack space={4}>
|
||||
{canCreateGroup && (
|
||||
<Button
|
||||
className='sm:w-fit sm:self-end xl:hidden'
|
||||
|
@ -81,15 +68,20 @@ const Groups: React.FC = () => {
|
|||
theme='secondary'
|
||||
block
|
||||
>
|
||||
<FormattedMessage id='new_group_panel.action' defaultMessage='Create group' />
|
||||
<FormattedMessage id='new_group_panel.action' defaultMessage='Create Group' />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{features.groupsDiscovery && (
|
||||
<TabBar activeTab={TabItems.MY_GROUPS} />
|
||||
)}
|
||||
|
||||
<ScrollableList
|
||||
scrollKey='groups'
|
||||
emptyMessage={emptyMessage}
|
||||
emptyMessage={<EmptyMessage />}
|
||||
itemClassName='py-3 first:pt-0 last:pb-0'
|
||||
isLoading={isLoading}
|
||||
showLoading={isLoading && !groups.count()}
|
||||
showLoading={isLoading && groups.length === 0}
|
||||
placeholderComponent={PlaceholderGroupCard}
|
||||
placeholderCount={3}
|
||||
>
|
||||
|
|
|
@ -5,23 +5,26 @@ import { HStack, Stack, Text } from 'soapbox/components/ui';
|
|||
import { generateText, randomIntFromInterval } from '../utils';
|
||||
|
||||
const PlaceholderGroupCard = () => {
|
||||
const groupNameLength = randomIntFromInterval(5, 25);
|
||||
const roleLength = randomIntFromInterval(5, 15);
|
||||
const privacyLength = randomIntFromInterval(5, 15);
|
||||
const groupNameLength = randomIntFromInterval(12, 20);
|
||||
|
||||
return (
|
||||
<div className='animate-pulse overflow-hidden'>
|
||||
<Stack className='rounded-lg border border-solid border-gray-300 bg-white dark:border-primary-800 dark:bg-primary-900 sm:rounded-xl'>
|
||||
<div className='relative m-[-1px] mb-0 h-[120px] rounded-t-lg bg-primary-100 dark:bg-gray-800 sm:rounded-t-xl'>
|
||||
<div className='absolute left-1/2 bottom-0 -translate-x-1/2 translate-y-1/2'>
|
||||
<div className='h-16 w-16 rounded-full bg-primary-500 ring-2 ring-white dark:ring-primary-900' />
|
||||
</div>
|
||||
<div className='animate-pulse'>
|
||||
<Stack className='relative h-[240px] rounded-lg border border-solid border-gray-300 bg-white dark:border-primary-800 dark:bg-primary-900'>
|
||||
{/* Group Cover Image */}
|
||||
<div className='relative grow basis-1/2 rounded-t-lg bg-gray-300 dark:bg-gray-800' />
|
||||
|
||||
{/* Group Avatar */}
|
||||
<div className='absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2'>
|
||||
<div className='h-16 w-16 rounded-full bg-gray-500 ring-2 ring-white dark:bg-primary-800 dark:ring-primary-900' />
|
||||
</div>
|
||||
<Stack className='p-3 pt-9' alignItems='center' space={3}>
|
||||
<Text size='lg' weight='bold'>{generateText(groupNameLength)}</Text>
|
||||
<HStack className='text-gray-700 dark:text-gray-600' space={3} wrap>
|
||||
<span>{generateText(roleLength)}</span>
|
||||
<span>{generateText(privacyLength)}</span>
|
||||
|
||||
{/* Group Info */}
|
||||
<Stack alignItems='center' justifyContent='end' grow className='basis-1/2 py-4' space={0.5}>
|
||||
<Text size='lg' theme='subtle' weight='bold'>{generateText(groupNameLength)}</Text>
|
||||
|
||||
<HStack className='text-gray-400 dark:text-gray-600' space={3} wrap>
|
||||
<span>{generateText(6)}</span>
|
||||
<span>{generateText(6)}</span>
|
||||
</HStack>
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
|
|
@ -0,0 +1,38 @@
|
|||
import React from 'react';
|
||||
|
||||
import { HStack, Stack, Text } from 'soapbox/components/ui';
|
||||
|
||||
import { generateText, randomIntFromInterval } from '../utils';
|
||||
|
||||
const PlaceholderGroupDiscover = () => {
|
||||
const groupNameLength = randomIntFromInterval(12, 20);
|
||||
|
||||
return (
|
||||
<Stack space={2} className='animate-pulse'>
|
||||
<Stack className='aspect-w-10 aspect-h-7 h-full w-full overflow-hidden rounded-lg'>
|
||||
{/* Group Cover Image */}
|
||||
<div className='absolute inset-0 rounded-t-lg bg-gray-300 object-cover dark:bg-gray-800' />
|
||||
|
||||
<Stack justifyContent='end' className='z-10 p-4 text-white' space={3}>
|
||||
{/* Group Avatar */}
|
||||
<div className='h-11 w-11 rounded-full bg-gray-500 dark:bg-gray-700 dark:ring-primary-900' />
|
||||
|
||||
{/* Group Info */}
|
||||
<Stack space={1} className='text-gray-500 dark:text-gray-700'>
|
||||
<Text theme='inherit' weight='bold' truncate>{generateText(groupNameLength)}</Text>
|
||||
|
||||
<HStack space={3} wrap>
|
||||
<Text tag='span' theme='inherit'>{generateText(6)}</Text>
|
||||
<Text tag='span' theme='inherit'>{generateText(6)}</Text>
|
||||
</HStack>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
||||
{/* Join Group Button */}
|
||||
<div className='h-10 w-full rounded-full bg-gray-300 dark:bg-gray-800' />
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default PlaceholderGroupDiscover;
|
|
@ -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>•</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>
|
||||
);
|
||||
};
|
|
@ -21,7 +21,7 @@ const NewGroupPanel = () => {
|
|||
<Stack space={2}>
|
||||
<Stack>
|
||||
<Text size='lg' weight='bold'>
|
||||
<FormattedMessage id='new_group_panel.title' defaultMessage='Create New Group' />
|
||||
<FormattedMessage id='new_group_panel.title' defaultMessage='Create Group' />
|
||||
</Text>
|
||||
|
||||
<Text theme='muted' size='sm'>
|
||||
|
@ -30,12 +30,11 @@ const NewGroupPanel = () => {
|
|||
</Stack>
|
||||
|
||||
<Button
|
||||
icon={require('@tabler/icons/circles.svg')}
|
||||
onClick={createGroup}
|
||||
theme='secondary'
|
||||
block
|
||||
>
|
||||
<FormattedMessage id='new_group_panel.action' defaultMessage='Create group' />
|
||||
<FormattedMessage id='new_group_panel.action' defaultMessage='Create Group' />
|
||||
</Button>
|
||||
</Stack>
|
||||
);
|
||||
|
|
|
@ -115,6 +115,7 @@ import {
|
|||
EventDiscussion,
|
||||
Events,
|
||||
Groups,
|
||||
GroupsDiscover,
|
||||
GroupMembers,
|
||||
GroupTimeline,
|
||||
ManageGroup,
|
||||
|
@ -282,6 +283,7 @@ const SwitchingColumnsArea: React.FC<ISwitchingColumnsArea> = ({ children }) =>
|
|||
<Redirect from='/@:username/:statusId' to='/@:username/posts/:statusId' />
|
||||
|
||||
{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.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} />}
|
||||
|
|
|
@ -542,6 +542,10 @@ export function Groups() {
|
|||
return import(/* webpackChunkName: "features/groups" */'../../groups');
|
||||
}
|
||||
|
||||
export function GroupsDiscover() {
|
||||
return import(/* webpackChunkName: "features/groups/discover" */'../../groups/discover');
|
||||
}
|
||||
|
||||
export function GroupMembers() {
|
||||
return import(/* webpackChunkName: "features/groups" */'../../group/group-members');
|
||||
}
|
||||
|
|
73
app/soapbox/hooks/__tests__/useGroupsPath.test.ts
Normal file
73
app/soapbox/hooks/__tests__/useGroupsPath.test.ts
Normal file
|
@ -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 { useCompose } from './useCompose';
|
||||
export { useDebounce } from './useDebounce';
|
||||
export { useGroupsPath } from './useGroupsPath';
|
||||
export { useDimensions } from './useDimensions';
|
||||
export { useFeatures } from './useFeatures';
|
||||
export { useInstance } from './useInstance';
|
||||
|
|
23
app/soapbox/hooks/useGroupsPath.ts
Normal file
23
app/soapbox/hooks/useGroupsPath.ts
Normal file
|
@ -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.success": "@{name} in der Gruppe entblockt",
|
||||
"group.header.alt": "Gruppentitel",
|
||||
"group.join": "Gruppe beitreten",
|
||||
"group.join.public": "Gruppe beitreten",
|
||||
"group.join.request_success": "Mitgliedschaft in der Gruppe angefragt",
|
||||
"group.join.success": "Gruppe beigetreten",
|
||||
"group.leave": "Gruppe verlassen",
|
||||
|
@ -746,7 +746,7 @@
|
|||
"group.moderator_subheading": "Moderator:innen der Gruppe",
|
||||
"group.privacy.locked": "Privat",
|
||||
"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.moderator": "Moderator:in",
|
||||
"group.tabs.all": "Alle",
|
||||
|
|
|
@ -755,7 +755,7 @@
|
|||
"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}).",
|
||||
"group.admin_subheading": "Group administrators",
|
||||
"group.cancel_request": "Cancel request",
|
||||
"group.cancel_request": "Cancel Request",
|
||||
"group.group_mod_authorize": "Accept",
|
||||
"group.group_mod_authorize.success": "Accepted @{name} to group",
|
||||
"group.group_mod_block": "Block @{name} from group",
|
||||
|
@ -773,21 +773,30 @@
|
|||
"group.group_mod_unblock": "Unblock",
|
||||
"group.group_mod_unblock.success": "Unblocked @{name} from group",
|
||||
"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.success": "Joined the group",
|
||||
"group.leave": "Leave group",
|
||||
"group.leave": "Leave Group",
|
||||
"group.leave.success": "Left the group",
|
||||
"group.manage": "Manage group",
|
||||
"group.manage": "Manage Group",
|
||||
"group.moderator_subheading": "Group moderators",
|
||||
"group.privacy.locked": "Private",
|
||||
"group.privacy.public": "Public",
|
||||
"group.request_join": "Request to join group",
|
||||
"group.role.admin": "Admin",
|
||||
"group.role.moderator": "Moderator",
|
||||
"group.tabs.all": "All",
|
||||
"group.tabs.members": "Members",
|
||||
"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.title": "No Groups yet",
|
||||
"hashtag.column_header.tag_mode.all": "and {additional}",
|
||||
|
@ -996,9 +1005,9 @@
|
|||
"new_event_panel.action": "Create event",
|
||||
"new_event_panel.subtitle": "Can't find what you're looking for? Schedule your own event.",
|
||||
"new_event_panel.title": "Create New Event",
|
||||
"new_group_panel.action": "Create group",
|
||||
"new_group_panel.action": "Create Group",
|
||||
"new_group_panel.subtitle": "Can't find what you're looking for? Start your own private or public group.",
|
||||
"new_group_panel.title": "Create New Group",
|
||||
"new_group_panel.title": "Create Group",
|
||||
"notification.favourite": "{name} liked your post",
|
||||
"notification.follow": "{name} followed you",
|
||||
"notification.follow_request": "{name} has requested to follow you",
|
||||
|
|
|
@ -738,7 +738,7 @@
|
|||
"group.group_mod_unblock": "Desbloquear",
|
||||
"group.group_mod_unblock.success": "Desbloquear a @{name} 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.success": "Se unió al grupo",
|
||||
"group.leave": "Dejar el grupo",
|
||||
|
@ -747,7 +747,7 @@
|
|||
"group.moderator_subheading": "Moderadores del grupo",
|
||||
"group.privacy.locked": "Privado",
|
||||
"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.moderator": "Moderador",
|
||||
"group.tabs.all": "Todos",
|
||||
|
|
|
@ -738,7 +738,7 @@
|
|||
"group.group_mod_unblock": "Sblocca",
|
||||
"group.group_mod_unblock.success": "Hai sbloccato @{name} dal 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.success": "Partecipazione nel gruppo",
|
||||
"group.leave": "Abbandona il gruppo",
|
||||
|
@ -747,7 +747,7 @@
|
|||
"group.moderator_subheading": "Moderazione del gruppo",
|
||||
"group.privacy.locked": "Privato",
|
||||
"group.privacy.public": "Pubblico",
|
||||
"group.request_join": "Richiesta di partecipazione",
|
||||
"group.join.private": "Richiesta di partecipazione",
|
||||
"group.role.admin": "Amministrazione",
|
||||
"group.role.moderator": "Moderazione",
|
||||
"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}).",
|
||||
"group.admin_subheading": "Administratorzy 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.manage": "Edytuj grupę",
|
||||
"group.moderator_subheading": "Moderatorzy grupy",
|
||||
"group.privacy.locked": "Prywatna",
|
||||
"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.moderator": "Moderator",
|
||||
"group.tabs.all": "Wszystko",
|
||||
|
|
|
@ -738,7 +738,7 @@
|
|||
"group.group_mod_unblock": "解除屏蔽",
|
||||
"group.group_mod_unblock.success": "已从群组中解除屏蔽 @{name}",
|
||||
"group.header.alt": "群组标题",
|
||||
"group.join": "加入群组",
|
||||
"group.join.public": "加入群组",
|
||||
"group.join.request_success": "已请求加入群组",
|
||||
"group.join.success": "已加入群组",
|
||||
"group.leave": "离开群组",
|
||||
|
@ -747,7 +747,7 @@
|
|||
"group.moderator_subheading": "群组监察员",
|
||||
"group.privacy.locked": "私有",
|
||||
"group.privacy.public": "公开",
|
||||
"group.request_join": "请求加入群组",
|
||||
"group.join.private": "请求加入群组",
|
||||
"group.role.admin": "管理员",
|
||||
"group.role.moderator": "监察员",
|
||||
"group.tabs.all": "全部",
|
||||
|
|
|
@ -29,6 +29,7 @@ export const GroupRecord = ImmutableRecord({
|
|||
id: '',
|
||||
locked: false,
|
||||
membership_required: false,
|
||||
members_count: undefined as number | undefined,
|
||||
note: '',
|
||||
statuses_visibility: 'public',
|
||||
uri: '',
|
||||
|
@ -127,6 +128,11 @@ const normalizeFqn = (group: ImmutableMap<string, any>) => {
|
|||
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. */
|
||||
const fixNote = (group: ImmutableMap<string, any>) => {
|
||||
|
@ -144,6 +150,7 @@ export const normalizeGroup = (group: Record<string, any>) => {
|
|||
normalizeAvatar(group);
|
||||
normalizeHeader(group);
|
||||
normalizeFqn(group);
|
||||
normalizeLocked(group);
|
||||
fixDisplayName(group);
|
||||
fixNote(group);
|
||||
addInternalFields(group);
|
||||
|
|
|
@ -1,10 +1,8 @@
|
|||
import React, { useCallback, useEffect } from 'react';
|
||||
import React from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import { useRouteMatch } from 'react-router-dom';
|
||||
|
||||
import { fetchGroup } from 'soapbox/actions/groups';
|
||||
import MissingIndicator from 'soapbox/components/missing-indicator';
|
||||
import { Column, Layout } from 'soapbox/components/ui';
|
||||
import { Column, Icon, Layout, Stack, Text } from 'soapbox/components/ui';
|
||||
import GroupHeader from 'soapbox/features/group/components/group-header';
|
||||
import LinkFooter from 'soapbox/features/ui/components/link-footer';
|
||||
import BundleContainer from 'soapbox/features/ui/containers/bundle-container';
|
||||
|
@ -13,8 +11,8 @@ import {
|
|||
GroupMediaPanel,
|
||||
SignUpPanel,
|
||||
} from 'soapbox/features/ui/util/async-components';
|
||||
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
|
||||
import { makeGetGroup } from 'soapbox/selectors';
|
||||
import { useOwnAccount } from 'soapbox/hooks';
|
||||
import { useGroup } from 'soapbox/queries/groups';
|
||||
|
||||
import { Tabs } from '../components/ui';
|
||||
|
||||
|
@ -34,23 +32,20 @@ interface IGroupPage {
|
|||
const GroupPage: React.FC<IGroupPage> = ({ params, children }) => {
|
||||
const intl = useIntl();
|
||||
const match = useRouteMatch();
|
||||
const dispatch = useAppDispatch();
|
||||
const me = useOwnAccount();
|
||||
|
||||
const id = params?.id || '';
|
||||
|
||||
const getGroup = useCallback(makeGetGroup(), []);
|
||||
const group = useAppSelector(state => getGroup(state, id));
|
||||
const me = useAppSelector(state => state.me);
|
||||
const { group } = useGroup(id);
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(fetchGroup(id));
|
||||
}, [id]);
|
||||
const isNonMember = !group?.relationship || !group.relationship.member;
|
||||
const isPrivate = group?.locked;
|
||||
|
||||
if ((group as any) === false) {
|
||||
return (
|
||||
<MissingIndicator />
|
||||
);
|
||||
}
|
||||
// if ((group as any) === false) {
|
||||
// return (
|
||||
// <MissingIndicator />
|
||||
// );
|
||||
// }
|
||||
|
||||
const items = [
|
||||
{
|
||||
|
@ -76,7 +71,18 @@ const GroupPage: React.FC<IGroupPage> = ({ params, children }) => {
|
|||
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>
|
||||
|
||||
{!me && (
|
||||
|
|
132
app/soapbox/queries/groups.ts
Normal file
132
app/soapbox/queries/groups.ts
Normal file
|
@ -0,0 +1,132 @@
|
|||
import { useInfiniteQuery, useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { fetchGroupRelationships } from 'soapbox/actions/groups';
|
||||
import { importFetchedGroups } from 'soapbox/actions/importer';
|
||||
import { getNextLink } from 'soapbox/api';
|
||||
import { useApi, useAppDispatch, useFeatures, useOwnAccount } from 'soapbox/hooks';
|
||||
import { normalizeGroup } from 'soapbox/normalizers';
|
||||
import { Group } from 'soapbox/types/entities';
|
||||
import { flattenPages, PaginatedResult } from 'soapbox/utils/queries';
|
||||
|
||||
const GroupKeys = {
|
||||
group: (id: string) => ['groups', 'group', id] as const,
|
||||
myGroups: (userId: string) => ['groups', userId] as const,
|
||||
popularGroups: ['groups', 'popular'] as const,
|
||||
suggestedGroups: ['groups', 'suggested'] as const,
|
||||
};
|
||||
|
||||
const useGroups = () => {
|
||||
const api = useApi();
|
||||
const account = useOwnAccount();
|
||||
const dispatch = useAppDispatch();
|
||||
const features = useFeatures();
|
||||
|
||||
const getGroups = async (pageParam?: any): Promise<PaginatedResult<Group>> => {
|
||||
const endpoint = '/api/v1/groups';
|
||||
const nextPageLink = pageParam?.link;
|
||||
const uri = nextPageLink || endpoint;
|
||||
const response = await api.get<Group[]>(uri);
|
||||
const { data } = response;
|
||||
|
||||
const link = getNextLink(response);
|
||||
const hasMore = !!link;
|
||||
const result = data.map(normalizeGroup);
|
||||
|
||||
// Note: Temporary while part of Groups is using Redux
|
||||
dispatch(importFetchedGroups(result));
|
||||
dispatch(fetchGroupRelationships(result.map((item) => item.id)));
|
||||
|
||||
return {
|
||||
result,
|
||||
hasMore,
|
||||
link,
|
||||
};
|
||||
};
|
||||
|
||||
const queryInfo = useInfiniteQuery(
|
||||
GroupKeys.myGroups(account?.id as string),
|
||||
({ pageParam }: any) => getGroups(pageParam),
|
||||
{
|
||||
enabled: !!account && features.groups,
|
||||
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 api = useApi();
|
||||
const features = useFeatures();
|
||||
|
||||
const getQuery = async () => {
|
||||
const { data } = await api.get<Group[]>('/api/v1/groups/search?q=group'); // '/api/v1/truth/trends/groups'
|
||||
const result = data.map(normalizeGroup);
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
const queryInfo = useQuery<Group[]>(GroupKeys.popularGroups, getQuery, {
|
||||
enabled: features.groupsDiscovery,
|
||||
placeholderData: [],
|
||||
});
|
||||
|
||||
return {
|
||||
groups: queryInfo.data || [],
|
||||
...queryInfo,
|
||||
};
|
||||
};
|
||||
|
||||
const useSuggestedGroups = () => {
|
||||
const api = useApi();
|
||||
const features = useFeatures();
|
||||
|
||||
const getQuery = async () => {
|
||||
const { data } = await api.get<Group[]>('/api/mock/groups'); // /api/v1/truth/suggestions/groups
|
||||
const result = data.map(normalizeGroup);
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
const queryInfo = useQuery<Group[]>(GroupKeys.suggestedGroups, getQuery, {
|
||||
enabled: features.groupsDiscovery,
|
||||
placeholderData: [],
|
||||
});
|
||||
|
||||
return {
|
||||
groups: queryInfo.data || [],
|
||||
...queryInfo,
|
||||
};
|
||||
};
|
||||
|
||||
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 };
|
67
app/soapbox/queries/groups/search.ts
Normal file
67
app/soapbox/queries/groups/search.ts
Normal file
|
@ -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. */
|
||||
export const tagHistory = new Settings('soapbox_tag_history');
|
||||
|
||||
/** Remember group usage. */
|
||||
export const groupSearchHistory = new Settings('soapbox_group_search_history');
|
||||
|
|
|
@ -497,7 +497,12 @@ const getInstanceFeatures = (instance: Instance) => {
|
|||
* @see POST /api/v1/admin/groups/:group_id/unsuspend
|
||||
* @see DELETE /api/v1/admin/groups/:group_id
|
||||
*/
|
||||
groups: false,
|
||||
groups: v.build === UNRELEASED,
|
||||
|
||||
/**
|
||||
* Can see trending/suggested Groups.
|
||||
*/
|
||||
groupsDiscovery: v.software === TRUTHSOCIAL,
|
||||
|
||||
/**
|
||||
* Can hide follows/followers lists and counts.
|
||||
|
|
35
app/soapbox/utils/groups.ts
Normal file
35
app/soapbox/utils/groups.ts
Normal file
|
@ -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 };
|
|
@ -209,6 +209,7 @@
|
|||
"@storybook/manager-webpack5": "^6.5.16",
|
||||
"@storybook/react": "^6.5.16",
|
||||
"@storybook/testing-library": "^0.0.13",
|
||||
"@tailwindcss/aspect-ratio": "^0.4.2",
|
||||
"@testing-library/jest-dom": "^5.16.4",
|
||||
"@testing-library/react-hooks": "^8.0.1",
|
||||
"@testing-library/user-event": "^14.0.3",
|
||||
|
|
|
@ -100,5 +100,6 @@ module.exports = {
|
|||
require('@tailwindcss/forms'),
|
||||
require('@tailwindcss/line-clamp'),
|
||||
require('@tailwindcss/typography'),
|
||||
require('@tailwindcss/aspect-ratio'),
|
||||
],
|
||||
};
|
||||
|
|
|
@ -3836,6 +3836,11 @@
|
|||
resolved "https://registry.yarnpkg.com/@tabler/icons/-/icons-2.4.0.tgz#34b1b0d818dc00926b956c3424bff48b89a5b439"
|
||||
integrity sha512-JZY9Kk3UsQoqp7Rw/BuWw1PrkRwv5h0psjJBbj+Cn9UVyhdzr5vztg2mywXBAJ+jFBUL/pjnVcIvOzKFw4CXng==
|
||||
|
||||
"@tailwindcss/aspect-ratio@^0.4.2":
|
||||
version "0.4.2"
|
||||
resolved "https://registry.yarnpkg.com/@tailwindcss/aspect-ratio/-/aspect-ratio-0.4.2.tgz#9ffd52fee8e3c8b20623ff0dcb29e5c21fb0a9ba"
|
||||
integrity sha512-8QPrypskfBa7QIMuKHg2TA7BqES6vhBrDLOv8Unb6FcFyd3TjKbc6lcmb9UPQHxfl24sXoJ41ux/H7qQQvfaSQ==
|
||||
|
||||
"@tailwindcss/forms@^0.5.3":
|
||||
version "0.5.3"
|
||||
resolved "https://registry.yarnpkg.com/@tailwindcss/forms/-/forms-0.5.3.tgz#e4d7989686cbcaf416c53f1523df5225332a86e7"
|
||||
|
|
Loading…
Reference in a new issue