Add support for trending tags

This commit is contained in:
Chewbacca 2023-03-29 13:21:28 -04:00
parent 5bcd150c4b
commit 5d29666b41
12 changed files with 280 additions and 3 deletions

View File

@ -1,8 +1,9 @@
export enum Entities {
ACCOUNTS = 'Accounts',
GROUPS = 'Groups',
GROUP_RELATIONSHIPS = 'GroupRelationships',
GROUP_MEMBERSHIPS = 'GroupMemberships',
GROUP_RELATIONSHIPS = 'GroupRelationships',
GROUP_TAGS = 'GroupTags',
RELATIONSHIPS = 'Relationships',
STATUSES = 'Statuses',
STATUSES = 'Statuses'
}

View File

@ -0,0 +1,52 @@
import React from 'react';
import { FormattedMessage } from 'react-intl';
import Link from 'soapbox/components/link';
import { HStack, Stack, Text } from 'soapbox/components/ui';
import { usePopularTags } from 'soapbox/hooks/api';
import TagListItem from './tag-list-item';
const PopularTags = () => {
const { tags, isFetched, isError } = usePopularTags();
const isEmpty = (isFetched && tags.length === 0) || isError;
return (
<Stack space={4}>
<HStack alignItems='center' justifyContent='between'>
<Text size='xl' weight='bold'>
<FormattedMessage
id='groups.discover.tags.title'
defaultMessage='Browse Topics'
/>
</Text>
<Link to='/groups/tags'>
<Text tag='span' weight='medium' size='sm' theme='inherit'>
<FormattedMessage
id='groups.discover.tags.show_more'
defaultMessage='Show More'
/>
</Text>
</Link>
</HStack>
{isEmpty ? (
<Text theme='muted'>
<FormattedMessage
id='groups.discover.tags.empty'
defaultMessage='Unable to fetch popular topics at this time. Please check back later.'
/>
</Text>
) : (
<Stack space={4}>
{tags.slice(0, 10).map((tag) => (
<TagListItem key={tag.id} tag={tag} />
))}
</Stack>
)}
</Stack>
);
};
export default PopularTags;

View File

@ -0,0 +1,39 @@
import React from 'react';
import { FormattedMessage } from 'react-intl';
import { Link } from 'react-router-dom';
import { Stack, Text } from 'soapbox/components/ui';
import type { GroupTag } from 'soapbox/schemas';
interface ITagListItem {
tag: GroupTag
}
const TagListItem = (props: ITagListItem) => {
const { tag } = props;
return (
<Link to={`/groups/discover/tags/${tag.id}`} className='group'>
<Stack>
<Text
weight='bold'
className='group-hover:text-primary-600 group-hover:underline dark:group-hover:text-accent-blue'
>
#{tag.name}
</Text>
<Text size='sm' theme='muted' weight='medium'>
<FormattedMessage
id='groups.discovery.tags.no_of_groups'
defaultMessage='Number of groups'
/>
:{' '}
{tag.uses}
</Text>
</Stack>
</Link>
);
};
export default TagListItem;

View File

@ -4,6 +4,7 @@ import { defineMessages, useIntl } from 'react-intl';
import { HStack, Icon, IconButton, Input, Stack } from 'soapbox/components/ui';
import PopularGroups from './components/discover/popular-groups';
import PopularTags from './components/discover/popular-tags';
import Search from './components/discover/search/search';
import SuggestedGroups from './components/discover/suggested-groups';
import TabBar, { TabItems } from './components/tab-bar';
@ -71,6 +72,7 @@ const Discover: React.FC = () => {
<>
<PopularGroups />
<SuggestedGroups />
<PopularTags />
</>
)}
</Stack>

View File

@ -0,0 +1,112 @@
import clsx from 'clsx';
import React, { useCallback, useState } from 'react';
import { Components, Virtuoso, VirtuosoGrid } from 'react-virtuoso';
import { Column, HStack, Icon } from 'soapbox/components/ui';
import { useGroupsFromTag } from 'soapbox/hooks/api';
import GroupGridItem from './components/discover/group-grid-item';
import GroupListItem from './components/discover/group-list-item';
import type { Group } from 'soapbox/schemas';
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' />;
});
interface ITags {
params: { id: string }
}
const Tags: React.FC<ITags> = (props) => {
const tagId = props.params.id;
const [layout, setLayout] = useState<Layout>(Layout.LIST);
const { groups, hasNextPage, fetchNextPage } = useGroupsFromTag(tagId);
const handleLoadMore = () => {
if (hasNextPage) {
fetchNextPage();
}
};
const renderGroupList = useCallback((group: Group, index: number) => (
<div
className={
clsx({
'pt-4': index !== 0,
})
}
>
<GroupListItem group={group} withJoinAction />
</div>
), []);
const renderGroupGrid = useCallback((group: Group, index: number) => (
<div className='pb-4'>
<GroupGridItem group={group} />
</div>
), []);
return (
<Column
label={`#${tagId}`}
action={
<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>
}
>
{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}
/>
)}
</Column>
);
};
export default Tags;

View File

@ -122,6 +122,7 @@ import {
GroupsDiscover,
GroupsPopular,
GroupsSuggested,
GroupsTags,
PendingGroupRequests,
GroupMembers,
GroupTimeline,
@ -296,6 +297,7 @@ const SwitchingColumnsArea: React.FC<ISwitchingColumnsArea> = ({ children }) =>
{features.groupsDiscovery && <WrappedRoute path='/groups/discover' exact page={GroupsPage} component={GroupsDiscover} content={children} />}
{features.groupsDiscovery && <WrappedRoute path='/groups/popular' exact page={GroupsPendingPage} component={GroupsPopular} content={children} />}
{features.groupsDiscovery && <WrappedRoute path='/groups/suggested' exact page={GroupsPendingPage} component={GroupsSuggested} content={children} />}
{features.groupsDiscovery && <WrappedRoute path='/groups/discover/tags/:id' exact page={GroupsPendingPage} component={GroupsTags} content={children} />}
{features.groupsPending && <WrappedRoute path='/groups/pending-requests' exact page={GroupsPendingPage} component={PendingGroupRequests} content={children} />}
{features.groups && <WrappedRoute path='/groups/:id' exact page={GroupPage} component={GroupTimeline} content={children} />}
{features.groups && <WrappedRoute path='/groups/:id/members' exact page={GroupPage} component={GroupMembers} content={children} />}

View File

@ -562,6 +562,10 @@ export function GroupsSuggested() {
return import(/* webpackChunkName: "features/groups" */'../../groups/suggested');
}
export function GroupsTags() {
return import(/* webpackChunkName: "features/groups" */'../../groups/tags');
}
export function PendingGroupRequests() {
return import(/* webpackChunkName: "features/groups" */'../../groups/pending-requests');
}

View File

@ -0,0 +1,29 @@
import { Entities } from 'soapbox/entity-store/entities';
import { useEntities } from 'soapbox/entity-store/hooks';
import { groupSchema } from 'soapbox/schemas';
import { useApi } from '../../useApi';
import { useFeatures } from '../../useFeatures';
import type { Group } from 'soapbox/schemas';
function useGroupsFromTag(tagId: string) {
const api = useApi();
const features = useFeatures();
const { entities, ...result } = useEntities<Group>(
[Entities.GROUPS, 'tags', tagId],
() => api.get(`/api/mock/tags/${tagId}/groups`),
{
schema: groupSchema,
enabled: features.groupsDiscovery,
},
);
return {
...result,
groups: entities,
};
}
export { useGroupsFromTag };

View File

@ -0,0 +1,27 @@
import { Entities } from 'soapbox/entity-store/entities';
import { useEntities } from 'soapbox/entity-store/hooks';
import { GroupTag, groupTagSchema } from 'soapbox/schemas';
import { useApi } from '../../useApi';
import { useFeatures } from '../../useFeatures';
function usePopularTags() {
const api = useApi();
const features = useFeatures();
const { entities, ...result } = useEntities<GroupTag>(
[Entities.GROUP_TAGS],
() => api.get('/api/mock/groups/tags'),
{
schema: groupTagSchema,
enabled: features.groupsDiscovery,
},
);
return {
...result,
tags: entities,
};
}
export { usePopularTags };

View File

@ -16,8 +16,10 @@ export { useGroup, useGroups } from './groups/useGroups';
export { useGroupMembershipRequests } from './groups/useGroupMembershipRequests';
export { useGroupSearch } from './groups/useGroupSearch';
export { useGroupValidation } from './groups/useGroupValidation';
export { useGroupsFromTag } from './groups/useGroupsFromTag';
export { useJoinGroup } from './groups/useJoinGroup';
export { useLeaveGroup } from './groups/useLeaveGroup';
export { usePopularTags } from './groups/usePopularTags';
export { usePromoteGroupMember } from './groups/usePromoteGroupMember';
export { useUpdateGroup } from './groups/useUpdateGroup';

View File

@ -1,7 +1,12 @@
import { z } from 'zod';
import z from 'zod';
const groupTagSchema = z.object({
id: z.string(),
uses: z.number(),
name: z.string(),
url: z.string(),
pinned: z.boolean().catch(false),
visible: z.boolean().default(true),
});
type GroupTag = z.infer<typeof groupTagSchema>;

View File

@ -6,6 +6,7 @@ export { customEmojiSchema } from './custom-emoji';
export { groupSchema } from './group';
export { groupMemberSchema } from './group-member';
export { groupRelationshipSchema } from './group-relationship';
export { groupTagSchema } from './group-tag';
export { relationshipSchema } from './relationship';
/**
@ -16,4 +17,5 @@ export type { CustomEmoji } from './custom-emoji';
export type { Group } from './group';
export type { GroupMember } from './group-member';
export type { GroupRelationship } from './group-relationship';
export type { GroupTag } from './group-tag';
export type { Relationship } from './relationship';