Add support for Group tags
This commit is contained in:
parent
8ec8d4a2ca
commit
2d52c8c3e4
11 changed files with 324 additions and 11 deletions
|
@ -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>(
|
||||
|
|
152
app/soapbox/features/group/components/group-tag-list-item.tsx
Normal file
152
app/soapbox/features/group/components/group-tag-list-item.tsx
Normal 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;
|
30
app/soapbox/features/group/group-tag-timeline.tsx
Normal file
30
app/soapbox/features/group/group-tag-timeline.tsx
Normal 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;
|
54
app/soapbox/features/group/group-tags.tsx
Normal file
54
app/soapbox/features/group/group-tags.tsx
Normal 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;
|
|
@ -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} />}
|
||||
|
|
|
@ -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');
|
||||
}
|
||||
|
|
20
app/soapbox/hooks/api/groups/useGroupTags.ts
Normal file
20
app/soapbox/hooks/api/groups/useGroupTags.ts
Normal 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 };
|
17
app/soapbox/hooks/api/groups/useUpdateGroupTag.ts
Normal file
17
app/soapbox/hooks/api/groups/useUpdateGroupTag.ts
Normal 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 };
|
|
@ -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';
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
|
||||
|
|
|
@ -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.
|
||||
*/
|
||||
|
|
Loading…
Reference in a new issue