diff --git a/app/soapbox/entity-store/entities.ts b/app/soapbox/entity-store/entities.ts index 719cc7f1c..9878cbbf2 100644 --- a/app/soapbox/entity-store/entities.ts +++ b/app/soapbox/entity-store/entities.ts @@ -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' } \ No newline at end of file diff --git a/app/soapbox/features/groups/components/discover/popular-tags.tsx b/app/soapbox/features/groups/components/discover/popular-tags.tsx new file mode 100644 index 000000000..3b0296191 --- /dev/null +++ b/app/soapbox/features/groups/components/discover/popular-tags.tsx @@ -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 ( + + + + + + + + + + + + + + {isEmpty ? ( + + + + ) : ( + + {tags.slice(0, 10).map((tag) => ( + + ))} + + )} + + ); +}; + +export default PopularTags; \ No newline at end of file diff --git a/app/soapbox/features/groups/components/discover/tag-list-item.tsx b/app/soapbox/features/groups/components/discover/tag-list-item.tsx new file mode 100644 index 000000000..c899e1085 --- /dev/null +++ b/app/soapbox/features/groups/components/discover/tag-list-item.tsx @@ -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 ( + + + + #{tag.name} + + + + + :{' '} + {tag.uses} + + + + ); +}; + +export default TagListItem; \ No newline at end of file diff --git a/app/soapbox/features/groups/discover.tsx b/app/soapbox/features/groups/discover.tsx index 4e0c0c70a..6e26c0671 100644 --- a/app/soapbox/features/groups/discover.tsx +++ b/app/soapbox/features/groups/discover.tsx @@ -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 = () => { <> + )} diff --git a/app/soapbox/features/groups/tag.tsx b/app/soapbox/features/groups/tag.tsx new file mode 100644 index 000000000..4774a4700 --- /dev/null +++ b/app/soapbox/features/groups/tag.tsx @@ -0,0 +1,117 @@ +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 { useGroupTag, 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
; +}); + +interface ITag { + params: { id: string } +} + +const Tag: React.FC = (props) => { + const tagId = props.params.id; + + const [layout, setLayout] = useState(Layout.LIST); + + const { tag, isLoading } = useGroupTag(tagId); + const { groups, hasNextPage, fetchNextPage } = useGroupsFromTag(tagId); + + const handleLoadMore = () => { + if (hasNextPage) { + fetchNextPage(); + } + }; + + const renderGroupList = useCallback((group: Group, index: number) => ( +
+ +
+ ), []); + + const renderGroupGrid = useCallback((group: Group, index: number) => ( +
+ +
+ ), []); + + if (isLoading || !tag) { + return null; + } + + return ( + + + + + + } + > + {layout === Layout.LIST ? ( + renderGroupList(group, index)} + endReached={handleLoadMore} + /> + ) : ( + renderGroupGrid(group, index)} + components={{ + Item: (props) => ( +
+ ), + List: GridList, + }} + endReached={handleLoadMore} + /> + )} + + ); +}; + +export default Tag; diff --git a/app/soapbox/features/groups/tags.tsx b/app/soapbox/features/groups/tags.tsx new file mode 100644 index 000000000..1484665e4 --- /dev/null +++ b/app/soapbox/features/groups/tags.tsx @@ -0,0 +1,62 @@ +import clsx from 'clsx'; +import React from 'react'; +import { FormattedMessage, defineMessages, useIntl } from 'react-intl'; +import { Virtuoso } from 'react-virtuoso'; + +import { Column, Text } from 'soapbox/components/ui'; +import { usePopularTags } from 'soapbox/hooks/api'; + +import TagListItem from './components/discover/tag-list-item'; + +import type { GroupTag } from 'soapbox/schemas'; + +const messages = defineMessages({ + title: { id: 'groups.tags.title', defaultMessage: 'Browse Topics' }, +}); + +const Tags: React.FC = () => { + const intl = useIntl(); + + const { tags, isFetched, isError, hasNextPage, fetchNextPage } = usePopularTags(); + const isEmpty = (isFetched && tags.length === 0) || isError; + + const handleLoadMore = () => { + if (hasNextPage) { + fetchNextPage(); + } + }; + + const renderItem = (index: number, tag: GroupTag) => ( +
+ +
+ ); + + return ( + + {isEmpty ? ( + + + + ) : ( + + )} + + ); +}; + +export default Tags; diff --git a/app/soapbox/features/ui/index.tsx b/app/soapbox/features/ui/index.tsx index b58f266ec..b967f27de 100644 --- a/app/soapbox/features/ui/index.tsx +++ b/app/soapbox/features/ui/index.tsx @@ -122,6 +122,8 @@ import { GroupsDiscover, GroupsPopular, GroupsSuggested, + GroupsTag, + GroupsTags, PendingGroupRequests, GroupMembers, GroupTimeline, @@ -296,6 +298,8 @@ const SwitchingColumnsArea: React.FC = ({ children }) => {features.groupsDiscovery && } {features.groupsDiscovery && } {features.groupsDiscovery && } + {features.groupsDiscovery && } + {features.groupsDiscovery && } {features.groupsPending && } {features.groups && } {features.groups && } diff --git a/app/soapbox/features/ui/util/async-components.ts b/app/soapbox/features/ui/util/async-components.ts index f915571d2..654cab76d 100644 --- a/app/soapbox/features/ui/util/async-components.ts +++ b/app/soapbox/features/ui/util/async-components.ts @@ -562,6 +562,14 @@ export function GroupsSuggested() { return import(/* webpackChunkName: "features/groups" */'../../groups/suggested'); } +export function GroupsTag() { + return import(/* webpackChunkName: "features/groups" */'../../groups/tag'); +} + +export function GroupsTags() { + return import(/* webpackChunkName: "features/groups" */'../../groups/tags'); +} + export function PendingGroupRequests() { return import(/* webpackChunkName: "features/groups" */'../../groups/pending-requests'); } diff --git a/app/soapbox/hooks/api/groups/useGroupTag.ts b/app/soapbox/hooks/api/groups/useGroupTag.ts new file mode 100644 index 000000000..d0e63d74d --- /dev/null +++ b/app/soapbox/hooks/api/groups/useGroupTag.ts @@ -0,0 +1,21 @@ +import { Entities } from 'soapbox/entity-store/entities'; +import { useEntity } from 'soapbox/entity-store/hooks'; +import { useApi } from 'soapbox/hooks'; +import { type GroupTag, groupTagSchema } from 'soapbox/schemas'; + +function useGroupTag(tagId: string) { + const api = useApi(); + + const { entity: tag, ...result } = useEntity( + [Entities.GROUP_TAGS, tagId], + () => api.get(`/api/v1/tags/${tagId }`), + { schema: groupTagSchema }, + ); + + return { + ...result, + tag, + }; +} + +export { useGroupTag }; \ No newline at end of file diff --git a/app/soapbox/hooks/api/groups/useGroupsFromTag.ts b/app/soapbox/hooks/api/groups/useGroupsFromTag.ts new file mode 100644 index 000000000..a6b8540dc --- /dev/null +++ b/app/soapbox/hooks/api/groups/useGroupsFromTag.ts @@ -0,0 +1,37 @@ +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 { useGroupRelationships } from './useGroups'; + +import type { Group } from 'soapbox/schemas'; + +function useGroupsFromTag(tagId: string) { + const api = useApi(); + const features = useFeatures(); + + const { entities, ...result } = useEntities( + [Entities.GROUPS, 'tags', tagId], + () => api.get(`/api/v1/tags/${tagId}/groups`), + { + schema: groupSchema, + enabled: features.groupsDiscovery, + }, + ); + const { relationships } = useGroupRelationships(entities.map(entity => entity.id)); + + const groups = entities.map((group) => ({ + ...group, + relationship: relationships[group.id] || null, + })); + + return { + ...result, + groups, + }; +} + +export { useGroupsFromTag }; \ No newline at end of file diff --git a/app/soapbox/hooks/api/groups/usePopularTags.ts b/app/soapbox/hooks/api/groups/usePopularTags.ts new file mode 100644 index 000000000..0bd272a2d --- /dev/null +++ b/app/soapbox/hooks/api/groups/usePopularTags.ts @@ -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( + [Entities.GROUP_TAGS], + () => api.get('/api/v1/groups/tags'), + { + schema: groupTagSchema, + enabled: features.groupsDiscovery, + }, + ); + + return { + ...result, + tags: entities, + }; +} + +export { usePopularTags }; \ No newline at end of file diff --git a/app/soapbox/hooks/api/index.ts b/app/soapbox/hooks/api/index.ts index b5927bf6b..800ea5857 100644 --- a/app/soapbox/hooks/api/index.ts +++ b/app/soapbox/hooks/api/index.ts @@ -15,9 +15,12 @@ export { useGroupMedia } from './groups/useGroupMedia'; export { useGroup, useGroups } from './groups/useGroups'; export { useGroupMembershipRequests } from './groups/useGroupMembershipRequests'; export { useGroupSearch } from './groups/useGroupSearch'; +export { useGroupTag } from './groups/useGroupTag'; 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'; diff --git a/app/soapbox/locales/en.json b/app/soapbox/locales/en.json index 42914d64d..e762b040c 100644 --- a/app/soapbox/locales/en.json +++ b/app/soapbox/locales/en.json @@ -830,6 +830,10 @@ "groups.discover.suggested.empty": "Unable to fetch suggested groups at this time. Please check back later.", "groups.discover.suggested.show_more": "Show More", "groups.discover.suggested.title": "Suggested For You", + "groups.discover.tags.empty": "Unable to fetch popular topics at this time. Please check back later.", + "groups.discover.tags.show_more": "Show More", + "groups.discover.tags.title": "Browse Topics", + "groups.discovery.tags.no_of_groups": "Number of groups", "groups.empty.subtitle": "Start discovering groups to join or create your own.", "groups.empty.title": "No Groups yet", "groups.pending.count": "{number, plural, one {# pending request} other {# pending requests}}", @@ -838,6 +842,7 @@ "groups.pending.label": "Pending Requests", "groups.popular.label": "Suggested Groups", "groups.search.placeholder": "Search My Groups", + "groups.tags.title": "Browse Topics", "hashtag.column_header.tag_mode.all": "and {additional}", "hashtag.column_header.tag_mode.any": "or {additional}", "hashtag.column_header.tag_mode.none": "without {additional}", diff --git a/app/soapbox/schemas/group-tag.ts b/app/soapbox/schemas/group-tag.ts index cc64deec6..9fa885569 100644 --- a/app/soapbox/schemas/group-tag.ts +++ b/app/soapbox/schemas/group-tag.ts @@ -1,7 +1,12 @@ -import { z } from 'zod'; +import z from 'zod'; const groupTagSchema = z.object({ + id: z.string(), name: z.string(), + uses: z.number().optional(), + url: z.string().optional(), + pinned: z.boolean().optional().catch(false), + visible: z.boolean().optional().default(true), }); type GroupTag = z.infer; diff --git a/app/soapbox/schemas/index.ts b/app/soapbox/schemas/index.ts index 1eed8905b..a675b52d2 100644 --- a/app/soapbox/schemas/index.ts +++ b/app/soapbox/schemas/index.ts @@ -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';