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/tags.tsx b/app/soapbox/features/groups/tags.tsx
new file mode 100644
index 000000000..052f5a6e3
--- /dev/null
+++ b/app/soapbox/features/groups/tags.tsx
@@ -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
;
+});
+
+interface ITags {
+ params: { id: string }
+}
+
+const Tags: React.FC = (props) => {
+ const tagId = props.params.id;
+
+ const [layout, setLayout] = useState(Layout.LIST);
+
+ 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) => (
+
+
+
+ ), []);
+
+ return (
+
+
+
+
+
+ }
+ >
+ {layout === Layout.LIST ? (
+ renderGroupList(group, index)}
+ endReached={handleLoadMore}
+ />
+ ) : (
+ renderGroupGrid(group, index)}
+ components={{
+ Item: (props) => (
+
+ ),
+ List: GridList,
+ }}
+ endReached={handleLoadMore}
+ />
+ )}
+
+ );
+};
+
+export default Tags;
diff --git a/app/soapbox/features/ui/index.tsx b/app/soapbox/features/ui/index.tsx
index b58f266ec..15cdc8033 100644
--- a/app/soapbox/features/ui/index.tsx
+++ b/app/soapbox/features/ui/index.tsx
@@ -122,6 +122,7 @@ import {
GroupsDiscover,
GroupsPopular,
GroupsSuggested,
+ GroupsTags,
PendingGroupRequests,
GroupMembers,
GroupTimeline,
@@ -296,6 +297,7 @@ const SwitchingColumnsArea: React.FC = ({ children }) =>
{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..01e44ca0a 100644
--- a/app/soapbox/features/ui/util/async-components.ts
+++ b/app/soapbox/features/ui/util/async-components.ts
@@ -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');
}
diff --git a/app/soapbox/hooks/api/groups/useGroupsFromTag.ts b/app/soapbox/hooks/api/groups/useGroupsFromTag.ts
new file mode 100644
index 000000000..7ebf871f4
--- /dev/null
+++ b/app/soapbox/hooks/api/groups/useGroupsFromTag.ts
@@ -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(
+ [Entities.GROUPS, 'tags', tagId],
+ () => api.get(`/api/mock/tags/${tagId}/groups`),
+ {
+ schema: groupSchema,
+ enabled: features.groupsDiscovery,
+ },
+ );
+
+ return {
+ ...result,
+ groups: entities,
+ };
+}
+
+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..d6fe5e0af
--- /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/mock/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..f28c6a5a5 100644
--- a/app/soapbox/hooks/api/index.ts
+++ b/app/soapbox/hooks/api/index.ts
@@ -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';
diff --git a/app/soapbox/schemas/group-tag.ts b/app/soapbox/schemas/group-tag.ts
index cc64deec6..4bd1ee66c 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(),
+ uses: z.number(),
name: z.string(),
+ url: z.string(),
+ pinned: z.boolean().catch(false),
+ visible: z.boolean().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';