Add support for Group tags

This commit is contained in:
Chewbacca 2023-03-23 15:20:19 -04:00
parent 8ec8d4a2ca
commit 2d52c8c3e4
11 changed files with 324 additions and 11 deletions

View file

@ -12,8 +12,9 @@ interface UseEntityActionsOpts<TEntity extends Entity = Entity> {
}
interface EntityActionEndpoints {
post?: string
delete?: string
patch?: string
post?: string
}
function useEntityActions<TEntity extends Entity = Entity, Data = any>(

View file

@ -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 (
<Tooltip
text={
tag.pinned ?
intl.formatMessage(messages.unpinTag) :
intl.formatMessage(messages.pinTag)
}
>
<IconButton
onClick={togglePin}
transparent
src={
tag.pinned ?
require('@tabler/icons/pin-filled.svg') :
require('@tabler/icons/pin.svg')
}
iconClassName='h-5 w-5 text-primary-500 dark:text-accent-blue'
/>
</Tooltip>
);
}
if (!isPinnable && tag.pinned) {
return (
<Tooltip text={intl.formatMessage(messages.unpinTag)}>
<IconButton
onClick={togglePin}
transparent
src={require('@tabler/icons/pin-filled.svg')}
iconClassName='h-5 w-5 text-primary-500 dark:text-accent-blue'
/>
</Tooltip>
);
}
};
return (
<HStack alignItems='center' justifyContent='between'>
<Link to={`/groups/${group.id}/tag/${tag.id}`} className='group grow'>
<Stack>
<Text
weight='bold'
theme={tag.visible ? 'default' : 'subtle'}
className='group-hover:underline'
>
#{tag.name}
</Text>
<Text size='sm' theme={tag.visible ? 'muted' : 'subtle'}>
{intl.formatMessage(messages.total)}:
{' '}
<Text size='sm' theme='inherit' weight='semibold' tag='span'>
{shortNumberFormat(tag.uses)}
</Text>
</Text>
</Stack>
</Link>
<HStack alignItems='center' space={2}>
{tag.visible ? (
renderPinIcon()
) : null}
<Tooltip
text={
tag.visible ?
intl.formatMessage(messages.hideTag) :
intl.formatMessage(messages.showTag)
}
>
<IconButton
onClick={toggleVisibility}
transparent
src={
tag.visible ?
require('@tabler/icons/eye.svg') :
require('@tabler/icons/eye-off.svg')
}
iconClassName='h-5 w-5 text-primary-500 dark:text-accent-blue'
/>
</Tooltip>
</HStack>
</HStack>
);
};
export default GroupTagListItem;

View file

@ -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<IGroupTimeline> = (props) => {
const groupId = props.params.groupId;
const tagId = props.params.id;
const { group } = useGroup(groupId);
const { tag } = useGroupTag(tagId);
if (!group) {
return null;
}
return (
<Column label={`#${tag}`}>
{/* TODO */}
</Column>
);
};
export default GroupTagTimeline;

View file

@ -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<IGroupTopics> = (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 (
<>
<ScrollableList
scrollKey='group-tags'
hasMore={hasNextPage}
onLoadMore={fetchNextPage}
isLoading={isLoading || !group}
showLoading={!group || isLoading && tags.length === 0}
placeholderComponent={PlaceholderAccount}
placeholderCount={3}
className='divide-y divide-solid divide-gray-300'
itemClassName='py-3 last:pb-0'
>
{tags.map((tag) => (
<GroupTagListItem
key={tag.id}
group={group as Group}
isPinnable={isPinnable}
tag={tag}
/>
))}
</ScrollableList>
</>
);
};
export default GroupTopics;

View file

@ -126,6 +126,8 @@ import {
GroupsTags,
PendingGroupRequests,
GroupMembers,
GroupTags,
GroupTagTimeline,
GroupTimeline,
ManageGroup,
GroupBlockedMembers,
@ -301,6 +303,8 @@ const SwitchingColumnsArea: React.FC<ISwitchingColumnsArea> = ({ children }) =>
{features.groupsDiscovery && <WrappedRoute path='/groups/tags' exact page={GroupsPendingPage} component={GroupsTags} content={children} />}
{features.groupsDiscovery && <WrappedRoute path='/groups/discover/tags/:id' exact page={GroupsPendingPage} component={GroupsTag} content={children} />}
{features.groupsPending && <WrappedRoute path='/groups/pending-requests' exact page={GroupsPendingPage} component={PendingGroupRequests} content={children} />}
{features.groupsTags && <WrappedRoute path='/groups/:id/tags' exact page={GroupPage} component={GroupTags} content={children} />}
{features.groupsTags && <WrappedRoute path='/groups/:groupId/tag/:id' exact page={GroupsPendingPage} component={GroupTagTimeline} 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/media' publicRoute={!authenticatedProfile} component={GroupGallery} page={GroupPage} content={children} />}

View file

@ -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');
}

View file

@ -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<GroupTag>(
[Entities.GROUP_TAGS, groupId],
'/api/mock/groups/tags', // `api/v1/groups/${groupId}/tags`
{ schema: groupTagSchema },
);
return {
...result,
tags: entities,
};
}
export { useGroupTags };

View file

@ -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<GroupTag>(
[Entities.GROUP_TAGS, groupId, tagId],
{ patch: `/api/mock/truth/groups/${groupId}/tags/${tagId}` },
{ schema: groupTagSchema },
);
return updateEntity;
}
export { useUpdateGroupTag };

View file

@ -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';
export { useRelationships } from './useRelationships';

View file

@ -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<IGroupPage> = ({ params, children }) => {
const intl = useIntl();
const features = useFeatures();
const match = useRouteMatch();
const me = useOwnAccount();
@ -73,13 +75,29 @@ const GroupPage: React.FC<IGroupPage> = ({ params, children }) => {
const isBlocked = group?.relationship?.blocked_by;
const isPrivate = group?.locked;
const items = [
{
// if ((group as any) === false) {
// return (
// <MissingIndicator />
// );
// }
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<IGroupPage> = ({ 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<IGroupPage> = ({ params, children }) => {
<GroupHeader group={group} />
<Tabs
items={items}
items={tabItems}
activeItem={match.path}
/>

View file

@ -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.
*/