From 2d52c8c3e48e1bdf8ebec8a8e236e0c5cf951b56 Mon Sep 17 00:00:00 2001 From: Chewbacca Date: Thu, 23 Mar 2023 15:20:19 -0400 Subject: [PATCH] Add support for Group tags --- .../entity-store/hooks/useEntityActions.ts | 3 +- .../group/components/group-tag-list-item.tsx | 152 ++++++++++++++++++ .../features/group/group-tag-timeline.tsx | 30 ++++ app/soapbox/features/group/group-tags.tsx | 54 +++++++ app/soapbox/features/ui/index.tsx | 4 + .../features/ui/util/async-components.ts | 8 + app/soapbox/hooks/api/groups/useGroupTags.ts | 20 +++ .../hooks/api/groups/useUpdateGroupTag.ts | 17 ++ app/soapbox/hooks/api/index.ts | 4 +- app/soapbox/pages/group-page.tsx | 38 +++-- app/soapbox/utils/features.ts | 5 + 11 files changed, 324 insertions(+), 11 deletions(-) create mode 100644 app/soapbox/features/group/components/group-tag-list-item.tsx create mode 100644 app/soapbox/features/group/group-tag-timeline.tsx create mode 100644 app/soapbox/features/group/group-tags.tsx create mode 100644 app/soapbox/hooks/api/groups/useGroupTags.ts create mode 100644 app/soapbox/hooks/api/groups/useUpdateGroupTag.ts diff --git a/app/soapbox/entity-store/hooks/useEntityActions.ts b/app/soapbox/entity-store/hooks/useEntityActions.ts index 8b87c52fd..ff27af340 100644 --- a/app/soapbox/entity-store/hooks/useEntityActions.ts +++ b/app/soapbox/entity-store/hooks/useEntityActions.ts @@ -12,8 +12,9 @@ interface UseEntityActionsOpts { } interface EntityActionEndpoints { - post?: string delete?: string + patch?: string + post?: string } function useEntityActions( diff --git a/app/soapbox/features/group/components/group-tag-list-item.tsx b/app/soapbox/features/group/components/group-tag-list-item.tsx new file mode 100644 index 000000000..7792b3e81 --- /dev/null +++ b/app/soapbox/features/group/components/group-tag-list-item.tsx @@ -0,0 +1,152 @@ +import React from 'react'; +import { defineMessages, useIntl } from 'react-intl'; +import { Link } from 'react-router-dom'; + +import { HStack, IconButton, Stack, Text, Tooltip } from 'soapbox/components/ui'; +import { useUpdateGroupTag } from 'soapbox/hooks/api'; +import toast from 'soapbox/toast'; +import { shortNumberFormat } from 'soapbox/utils/numbers'; + +import type { Group, GroupTag } from 'soapbox/schemas'; + +const messages = defineMessages({ + hideTag: { id: 'group.tags.hide', defaultMessage: 'Hide topic' }, + showTag: { id: 'group.tags.show', defaultMessage: 'Show topic' }, + total: { id: 'group.tags.total', defaultMessage: 'Total Posts' }, + pinTag: { id: 'group.tags.pin', defaultMessage: 'Pin topic' }, + unpinTag: { id: 'group.tags.unpin', defaultMessage: 'Unpin topic' }, + pinSuccess: { id: 'group.tags.pin.success', defaultMessage: 'Pinned!' }, + unpinSuccess: { id: 'group.tags.unpin.success', defaultMessage: 'Unpinned!' }, + visibleSuccess: { id: 'group.tags.visible.success', defaultMessage: 'Topic marked as visible' }, + hiddenSuccess: { id: 'group.tags.hidden.success', defaultMessage: 'Topic marked as hidden' }, +}); + +interface IGroupMemberListItem { + tag: GroupTag + group: Group + isPinnable: boolean +} + +const GroupTagListItem = (props: IGroupMemberListItem) => { + const { group, tag, isPinnable } = props; + + const intl = useIntl(); + const updateGroupTag = useUpdateGroupTag(group.id, tag.id); + + const toggleVisibility = () => { + updateGroupTag({ + pinned: !tag.visible, + }, { + onSuccess(entity: GroupTag) { + toast.success( + entity.visible ? + intl.formatMessage(messages.visibleSuccess) : + intl.formatMessage(messages.hiddenSuccess), + ); + }, + }); + }; + + const togglePin = () => { + updateGroupTag({ + pinned: !tag.pinned, + }, { + onSuccess(entity: GroupTag) { + toast.success( + entity.pinned ? + intl.formatMessage(messages.pinSuccess) : + intl.formatMessage(messages.unpinSuccess), + ); + }, + }); + }; + + const renderPinIcon = () => { + if (isPinnable) { + return ( + + + + ); + } + + if (!isPinnable && tag.pinned) { + return ( + + + + + ); + } + }; + + return ( + + + + + #{tag.name} + + + {intl.formatMessage(messages.total)}: + {' '} + + {shortNumberFormat(tag.uses)} + + + + + + + {tag.visible ? ( + renderPinIcon() + ) : null} + + + + + + + ); +}; + +export default GroupTagListItem; \ No newline at end of file diff --git a/app/soapbox/features/group/group-tag-timeline.tsx b/app/soapbox/features/group/group-tag-timeline.tsx new file mode 100644 index 000000000..2663c59cf --- /dev/null +++ b/app/soapbox/features/group/group-tag-timeline.tsx @@ -0,0 +1,30 @@ +import React from 'react'; + +import { Column } from 'soapbox/components/ui'; +import { useGroup, useGroupTag } from 'soapbox/hooks/api'; + +type RouteParams = { id: string, groupId: string }; + +interface IGroupTimeline { + params: RouteParams +} + +const GroupTagTimeline: React.FC = (props) => { + const groupId = props.params.groupId; + const tagId = props.params.id; + + const { group } = useGroup(groupId); + const { tag } = useGroupTag(tagId); + + if (!group) { + return null; + } + + return ( + + {/* TODO */} + + ); +}; + +export default GroupTagTimeline; diff --git a/app/soapbox/features/group/group-tags.tsx b/app/soapbox/features/group/group-tags.tsx new file mode 100644 index 000000000..dc4c7d088 --- /dev/null +++ b/app/soapbox/features/group/group-tags.tsx @@ -0,0 +1,54 @@ +import React from 'react'; + +import ScrollableList from 'soapbox/components/scrollable-list'; +import { useGroupTags } from 'soapbox/hooks/api'; +import { useGroup } from 'soapbox/queries/groups'; + +import PlaceholderAccount from '../placeholder/components/placeholder-account'; + +import GroupTagListItem from './components/group-tag-list-item'; + +import type { Group } from 'soapbox/types/entities'; + +interface IGroupTopics { + params: { id: string } +} + +const GroupTopics: React.FC = (props) => { + const groupId = props.params.id; + + const { group, isFetching: isFetchingGroup } = useGroup(groupId); + const { tags, isFetching: isFetchingTags, hasNextPage, fetchNextPage } = useGroupTags(groupId); + + const isLoading = isFetchingGroup || isFetchingTags; + + const pinnedTags = tags.filter((tag) => tag.pinned); + const isPinnable = pinnedTags.length < 3; + + return ( + <> + + {tags.map((tag) => ( + + ))} + + + ); +}; + +export default GroupTopics; diff --git a/app/soapbox/features/ui/index.tsx b/app/soapbox/features/ui/index.tsx index b967f27de..bcd6d8d24 100644 --- a/app/soapbox/features/ui/index.tsx +++ b/app/soapbox/features/ui/index.tsx @@ -126,6 +126,8 @@ import { GroupsTags, PendingGroupRequests, GroupMembers, + GroupTags, + GroupTagTimeline, GroupTimeline, ManageGroup, GroupBlockedMembers, @@ -301,6 +303,8 @@ const SwitchingColumnsArea: React.FC = ({ children }) => {features.groupsDiscovery && } {features.groupsDiscovery && } {features.groupsPending && } + {features.groupsTags && } + {features.groupsTags && } {features.groups && } {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 654cab76d..e8187db46 100644 --- a/app/soapbox/features/ui/util/async-components.ts +++ b/app/soapbox/features/ui/util/async-components.ts @@ -578,6 +578,14 @@ export function GroupMembers() { return import(/* webpackChunkName: "features/groups" */'../../group/group-members'); } +export function GroupTags() { + return import(/* webpackChunkName: "features/groups" */'../../group/group-tags'); +} + +export function GroupTagTimeline() { + return import(/* webpackChunkName: "features/groups" */'../../group/group-tag-timeline'); +} + export function GroupTimeline() { return import(/* webpackChunkName: "features/groups" */'../../group/group-timeline'); } diff --git a/app/soapbox/hooks/api/groups/useGroupTags.ts b/app/soapbox/hooks/api/groups/useGroupTags.ts new file mode 100644 index 000000000..4b724352e --- /dev/null +++ b/app/soapbox/hooks/api/groups/useGroupTags.ts @@ -0,0 +1,20 @@ +import { Entities } from 'soapbox/entity-store/entities'; +import { useEntities } from 'soapbox/entity-store/hooks'; +import { groupTagSchema } from 'soapbox/schemas'; + +import type { GroupTag } from 'soapbox/schemas'; + +function useGroupTags(groupId: string) { + const { entities, ...result } = useEntities( + [Entities.GROUP_TAGS, groupId], + '/api/mock/groups/tags', // `api/v1/groups/${groupId}/tags` + { schema: groupTagSchema }, + ); + + return { + ...result, + tags: entities, + }; +} + +export { useGroupTags }; \ No newline at end of file diff --git a/app/soapbox/hooks/api/groups/useUpdateGroupTag.ts b/app/soapbox/hooks/api/groups/useUpdateGroupTag.ts new file mode 100644 index 000000000..2d4d61f69 --- /dev/null +++ b/app/soapbox/hooks/api/groups/useUpdateGroupTag.ts @@ -0,0 +1,17 @@ +import { Entities } from 'soapbox/entity-store/entities'; +import { useEntityActions } from 'soapbox/entity-store/hooks'; +import { groupTagSchema } from 'soapbox/schemas'; + +import type { GroupTag } from 'soapbox/schemas'; + +function useUpdateGroupTag(groupId: string, tagId: string) { + const { updateEntity } = useEntityActions( + [Entities.GROUP_TAGS, groupId, tagId], + { patch: `/api/mock/truth/groups/${groupId}/tags/${tagId}` }, + { schema: groupTagSchema }, + ); + + return updateEntity; +} + +export { useUpdateGroupTag }; \ No newline at end of file diff --git a/app/soapbox/hooks/api/index.ts b/app/soapbox/hooks/api/index.ts index 3ab7be9a5..c8e1f67c3 100644 --- a/app/soapbox/hooks/api/index.ts +++ b/app/soapbox/hooks/api/index.ts @@ -16,6 +16,7 @@ export { useGroupMedia } from './groups/useGroupMedia'; export { useGroupMembershipRequests } from './groups/useGroupMembershipRequests'; export { useGroupSearch } from './groups/useGroupSearch'; export { useGroupTag } from './groups/useGroupTag'; +export { useGroupTags } from './groups/useGroupTags'; export { useGroupValidation } from './groups/useGroupValidation'; export { useGroupsFromTag } from './groups/useGroupsFromTag'; export { useJoinGroup } from './groups/useJoinGroup'; @@ -23,8 +24,9 @@ export { useLeaveGroup } from './groups/useLeaveGroup'; export { usePopularTags } from './groups/usePopularTags'; export { usePromoteGroupMember } from './groups/usePromoteGroupMember'; export { useUpdateGroup } from './groups/useUpdateGroup'; +export { useUpdateGroupTag } from './groups/useUpdateGroupTag'; /** * Relationships */ -export { useRelationships } from './useRelationships'; \ No newline at end of file +export { useRelationships } from './useRelationships'; diff --git a/app/soapbox/pages/group-page.tsx b/app/soapbox/pages/group-page.tsx index 449e250f3..07b9eb4d5 100644 --- a/app/soapbox/pages/group-page.tsx +++ b/app/soapbox/pages/group-page.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useMemo } from 'react'; import { defineMessages, useIntl } from 'react-intl'; import { useRouteMatch } from 'react-router-dom'; @@ -12,7 +12,7 @@ import { SignUpPanel, SuggestedGroupsPanel, } from 'soapbox/features/ui/util/async-components'; -import { useOwnAccount } from 'soapbox/hooks'; +import { useFeatures, useOwnAccount } from 'soapbox/hooks'; import { useGroup } from 'soapbox/hooks/api'; import { useGroupMembershipRequests } from 'soapbox/hooks/api/groups/useGroupMembershipRequests'; import { Group } from 'soapbox/schemas'; @@ -23,6 +23,7 @@ const messages = defineMessages({ all: { id: 'group.tabs.all', defaultMessage: 'All' }, members: { id: 'group.tabs.members', defaultMessage: 'Members' }, media: { id: 'group.tabs.media', defaultMessage: 'Media' }, + tags: { id: 'group.tabs.tags', defaultMessage: 'Topics' }, }); interface IGroupPage { @@ -61,6 +62,7 @@ const BlockedBlankslate = ({ group }: { group: Group }) => ( /** Page to display a group. */ const GroupPage: React.FC = ({ params, children }) => { const intl = useIntl(); + const features = useFeatures(); const match = useRouteMatch(); const me = useOwnAccount(); @@ -73,13 +75,29 @@ const GroupPage: React.FC = ({ params, children }) => { const isBlocked = group?.relationship?.blocked_by; const isPrivate = group?.locked; - const items = [ - { + // if ((group as any) === false) { + // return ( + // + // ); + // } + + const tabItems = useMemo(() => { + const items = []; + items.push({ text: intl.formatMessage(messages.all), to: `/groups/${group?.id}`, name: '/groups/:id', - }, - { + }); + + if (features.groupsTags) { + items.push({ + text: intl.formatMessage(messages.tags), + to: `/groups/${group?.id}/tags`, + name: '/groups/:id/tags', + }); + } + + items.push({ text: intl.formatMessage(messages.members), to: `/groups/${group?.id}/members`, name: '/groups/:id/members', @@ -89,8 +107,10 @@ const GroupPage: React.FC = ({ params, children }) => { text: intl.formatMessage(messages.media), to: `/groups/${group?.id}/media`, name: '/groups/:id/media', - }, - ]; + }); + + return items; + }, [features.groupsTags]); const renderChildren = () => { if (!isMember && isPrivate) { @@ -109,7 +129,7 @@ const GroupPage: React.FC = ({ params, children }) => { diff --git a/app/soapbox/utils/features.ts b/app/soapbox/utils/features.ts index b24e75525..9c5a68bda 100644 --- a/app/soapbox/utils/features.ts +++ b/app/soapbox/utils/features.ts @@ -559,6 +559,11 @@ const getInstanceFeatures = (instance: Instance) => { */ groupsSearch: v.software === TRUTHSOCIAL, + /** + * Can see topics for Groups. + */ + groupsTags: v.software === TRUTHSOCIAL, + /** * Can validate group names. */