Add Trending and Suggested Groups to discovery
This commit is contained in:
parent
6413bed23f
commit
0414c36a1e
21 changed files with 655 additions and 109 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>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
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';
|
||||
|
|
93
app/soapbox/features/groups/components/discover/group.tsx
Normal file
93
app/soapbox/features/groups/components/discover/group.tsx
Normal file
|
@ -0,0 +1,93 @@
|
|||
import React, { forwardRef } from 'react';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import { Avatar, Button, HStack, Icon, Stack, Text } from 'soapbox/components/ui';
|
||||
import { Group as GroupEntity } from 'soapbox/types/entities';
|
||||
import { shortNumberFormat } from 'soapbox/utils/numbers';
|
||||
|
||||
|
||||
interface IGroup {
|
||||
group: GroupEntity
|
||||
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,
|
||||
}}
|
||||
>
|
||||
<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 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')}
|
||||
/>
|
||||
|
||||
{typeof group.members_count === 'undefined' ? (
|
||||
<Text theme='inherit' tag='span' size='sm'>
|
||||
{group.locked ? (
|
||||
<FormattedMessage id='group.privacy.locked' defaultMessage='Private' />
|
||||
) : (
|
||||
<FormattedMessage id='group.privacy.public' defaultMessage='Public' />
|
||||
)}
|
||||
</Text>
|
||||
) : (
|
||||
<Text theme='inherit' tag='span' size='sm'>
|
||||
{shortNumberFormat(group.members_count)}
|
||||
{' '}
|
||||
members
|
||||
</Text>
|
||||
)}
|
||||
</HStack>
|
||||
</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>
|
||||
|
||||
<Button
|
||||
theme='primary'
|
||||
block
|
||||
>
|
||||
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,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;
|
22
app/soapbox/features/groups/discover.tsx
Normal file
22
app/soapbox/features/groups/discover.tsx
Normal file
|
@ -0,0 +1,22 @@
|
|||
import React from 'react';
|
||||
|
||||
import { Stack } from 'soapbox/components/ui';
|
||||
|
||||
import PopularGroups from './components/discover/popular-groups';
|
||||
import SuggestedGroups from './components/discover/suggested-groups';
|
||||
import TabBar, { TabItems } from './components/tab-bar';
|
||||
|
||||
const Discover: React.FC = () => {
|
||||
return (
|
||||
<Stack space={4}>
|
||||
<TabBar activeTab={TabItems.FIND_GROUPS} />
|
||||
|
||||
<Stack space={6}>
|
||||
<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;
|
|
@ -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');
|
||||
}
|
||||
|
|
|
@ -987,9 +987,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",
|
||||
|
|
|
@ -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: '',
|
||||
|
|
111
app/soapbox/queries/groups.ts
Normal file
111
app/soapbox/queries/groups.ts
Normal file
|
@ -0,0 +1,111 @@
|
|||
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 = {
|
||||
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 getGroups = async (pageParam?: any): Promise<PaginatedResult<Group>> => {
|
||||
const endpoint = '/api/mock/groups'; // '/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,
|
||||
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/mock/groups'); // '/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,
|
||||
};
|
||||
};
|
||||
|
||||
export { useGroups, usePopularGroups, useSuggestedGroups };
|
|
@ -499,6 +499,11 @@ const getInstanceFeatures = (instance: Instance) => {
|
|||
*/
|
||||
groups: false,
|
||||
|
||||
/**
|
||||
* Can see trending/suggested Groups.
|
||||
*/
|
||||
groupsDiscovery: v.software === TRUTHSOCIAL,
|
||||
|
||||
/**
|
||||
* Can hide follows/followers lists and counts.
|
||||
* @see PATCH /api/v1/accounts/update_credentials
|
||||
|
|
|
@ -205,6 +205,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'),
|
||||
],
|
||||
};
|
||||
|
|
|
@ -3831,6 +3831,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