Merge remote-tracking branch 'origin/develop' into group-requests

This commit is contained in:
Alex Gleason 2023-03-23 10:23:06 -05:00
commit 1949651b9a
No known key found for this signature in database
GPG key ID: 7211D1F99744FBB7
21 changed files with 283 additions and 111 deletions

View file

@ -1,3 +1,5 @@
import { useState } from 'react';
import { useApi } from 'soapbox/hooks'; import { useApi } from 'soapbox/hooks';
import { useCreateEntity } from './useCreateEntity'; import { useCreateEntity } from './useCreateEntity';
@ -24,19 +26,24 @@ function useEntityActions<TEntity extends Entity = Entity, Params = any>(
const api = useApi(); const api = useApi();
const { entityType, path } = parseEntitiesPath(expandedPath); const { entityType, path } = parseEntitiesPath(expandedPath);
const [isLoading, setIsLoading] = useState<boolean>(false);
const deleteEntity = useDeleteEntity(entityType, (entityId) => { const deleteEntity = useDeleteEntity(entityType, (entityId) => {
if (!endpoints.delete) return Promise.reject(endpoints); if (!endpoints.delete) return Promise.reject(endpoints);
return api.delete(endpoints.delete.replace(':id', entityId)); return api.delete(endpoints.delete.replace(':id', entityId))
.finally(() => setIsLoading(false));
}); });
const createEntity = useCreateEntity(path, (params: Params) => { const createEntity = useCreateEntity(path, (params: Params) => {
if (!endpoints.post) return Promise.reject(endpoints); if (!endpoints.post) return Promise.reject(endpoints);
return api.post(endpoints.post, params); return api.post(endpoints.post, params)
.finally(() => setIsLoading(false));
}, opts); }, opts);
return { return {
createEntity, createEntity,
deleteEntity, deleteEntity,
isLoading,
}; };
} }

View file

@ -3,49 +3,84 @@ import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { openModal } from 'soapbox/actions/modals'; import { openModal } from 'soapbox/actions/modals';
import { Button } from 'soapbox/components/ui'; import { Button } from 'soapbox/components/ui';
import { deleteEntities } from 'soapbox/entity-store/actions';
import { Entities } from 'soapbox/entity-store/entities';
import { useAppDispatch } from 'soapbox/hooks'; import { useAppDispatch } from 'soapbox/hooks';
import { useCancelMembershipRequest, useJoinGroup, useLeaveGroup } from 'soapbox/queries/groups'; import { useCancelMembershipRequest, useJoinGroup, useLeaveGroup } from 'soapbox/hooks/api';
import { Group } from 'soapbox/types/entities'; import { GroupRoles } from 'soapbox/schemas/group-member';
import toast from 'soapbox/toast';
import type { Group } from 'soapbox/types/entities';
interface IGroupActionButton { interface IGroupActionButton {
group: Group group: Group
} }
const messages = defineMessages({ const messages = defineMessages({
confirmationConfirm: { id: 'confirmations.leave_group.confirm', defaultMessage: 'Leave' },
confirmationHeading: { id: 'confirmations.leave_group.heading', defaultMessage: 'Leave group' }, confirmationHeading: { id: 'confirmations.leave_group.heading', defaultMessage: 'Leave group' },
confirmationMessage: { id: 'confirmations.leave_group.message', defaultMessage: 'You are about to leave the group. Do you want to continue?' }, confirmationMessage: { id: 'confirmations.leave_group.message', defaultMessage: 'You are about to leave the group. Do you want to continue?' },
confirmationConfirm: { id: 'confirmations.leave_group.confirm', defaultMessage: 'Leave' }, joinRequestSuccess: { id: 'group.join.request_success', defaultMessage: 'Requested to join the group' },
joinSuccess: { id: 'group.join.success', defaultMessage: 'Group joined successfully!' },
leaveSuccess: { id: 'group.leave.success', defaultMessage: 'Left the group' },
}); });
const GroupActionButton = ({ group }: IGroupActionButton) => { const GroupActionButton = ({ group }: IGroupActionButton) => {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const intl = useIntl(); const intl = useIntl();
const joinGroup = useJoinGroup(); const joinGroup = useJoinGroup(group);
const leaveGroup = useLeaveGroup(); const leaveGroup = useLeaveGroup(group);
const cancelRequest = useCancelMembershipRequest(); const cancelRequest = useCancelMembershipRequest(group);
const isRequested = group.relationship?.requested; const isRequested = group.relationship?.requested;
const isNonMember = !group.relationship?.member && !isRequested; const isNonMember = !group.relationship?.member && !isRequested;
const isAdmin = group.relationship?.role === 'owner'; const isOwner = group.relationship?.role === GroupRoles.OWNER;
const isBlocked = group.relationship?.blocked_by; const isBlocked = group.relationship?.blocked_by;
const onJoinGroup = () => joinGroup.mutate(group); const onJoinGroup = () => joinGroup.mutate({}, {
onSuccess() {
toast.success(
group.locked
? intl.formatMessage(messages.joinRequestSuccess)
: intl.formatMessage(messages.joinSuccess),
);
},
});
const onLeaveGroup = () => const onLeaveGroup = () =>
dispatch(openModal('CONFIRM', { dispatch(openModal('CONFIRM', {
heading: intl.formatMessage(messages.confirmationHeading), heading: intl.formatMessage(messages.confirmationHeading),
message: intl.formatMessage(messages.confirmationMessage), message: intl.formatMessage(messages.confirmationMessage),
confirm: intl.formatMessage(messages.confirmationConfirm), confirm: intl.formatMessage(messages.confirmationConfirm),
onConfirm: () => leaveGroup.mutate(group), onConfirm: () => leaveGroup.mutate({}, {
onSuccess() {
toast.success(intl.formatMessage(messages.leaveSuccess));
},
}),
})); }));
const onCancelRequest = () => cancelRequest.mutate(group); const onCancelRequest = () => cancelRequest.mutate({}, {
onSuccess() {
dispatch(deleteEntities([group.id], Entities.GROUP_RELATIONSHIPS));
},
});
if (isBlocked) { if (isBlocked) {
return null; return null;
} }
if (isOwner) {
return (
<Button
theme='secondary'
to={`/groups/${group.id}/manage`}
>
<FormattedMessage id='group.manage' defaultMessage='Manage Group' />
</Button>
);
}
if (isNonMember) { if (isNonMember) {
return ( return (
<Button <Button
@ -72,17 +107,6 @@ const GroupActionButton = ({ group }: IGroupActionButton) => {
); );
} }
if (isAdmin) {
return (
<Button
theme='secondary'
to={`/groups/${group.id}/manage`}
>
<FormattedMessage id='group.manage' defaultMessage='Manage Group' />
</Button>
);
}
return ( return (
<Button <Button
theme='secondary' theme='secondary'

View file

@ -2,11 +2,14 @@ import React from 'react';
import { defineMessages, useIntl } from 'react-intl'; import { defineMessages, useIntl } from 'react-intl';
import { useHistory } from 'react-router-dom'; import { useHistory } from 'react-router-dom';
import { deleteGroup, editGroup } from 'soapbox/actions/groups'; import { editGroup } from 'soapbox/actions/groups';
import { openModal } from 'soapbox/actions/modals'; import { openModal } from 'soapbox/actions/modals';
import List, { ListItem } from 'soapbox/components/list'; import List, { ListItem } from 'soapbox/components/list';
import { CardBody, Column, Spinner } from 'soapbox/components/ui'; import { CardBody, CardHeader, CardTitle, Column, Spinner, Text } from 'soapbox/components/ui';
import { useAppDispatch, useGroup } from 'soapbox/hooks'; import { useAppDispatch, useGroup, useGroupsPath } from 'soapbox/hooks';
import { useDeleteGroup } from 'soapbox/hooks/api';
import { GroupRoles } from 'soapbox/schemas/group-member';
import toast from 'soapbox/toast';
import ColumnForbidden from '../ui/components/column-forbidden'; import ColumnForbidden from '../ui/components/column-forbidden';
@ -16,11 +19,14 @@ const messages = defineMessages({
heading: { id: 'column.manage_group', defaultMessage: 'Manage group' }, heading: { id: 'column.manage_group', defaultMessage: 'Manage group' },
editGroup: { id: 'manage_group.edit_group', defaultMessage: 'Edit group' }, editGroup: { id: 'manage_group.edit_group', defaultMessage: 'Edit group' },
pendingRequests: { id: 'manage_group.pending_requests', defaultMessage: 'Pending requests' }, pendingRequests: { id: 'manage_group.pending_requests', defaultMessage: 'Pending requests' },
blockedMembers: { id: 'manage_group.blocked_members', defaultMessage: 'Blocked members' }, blockedMembers: { id: 'manage_group.blocked_members', defaultMessage: 'Banned members' },
deleteGroup: { id: 'manage_group.delete_group', defaultMessage: 'Delete group' }, deleteGroup: { id: 'manage_group.delete_group', defaultMessage: 'Delete group' },
deleteConfirm: { id: 'confirmations.delete_group.confirm', defaultMessage: 'Delete' }, deleteConfirm: { id: 'confirmations.delete_group.confirm', defaultMessage: 'Delete' },
deleteHeading: { id: 'confirmations.delete_group.heading', defaultMessage: 'Delete group' }, deleteHeading: { id: 'confirmations.delete_group.heading', defaultMessage: 'Delete group' },
deleteMessage: { id: 'confirmations.delete_group.message', defaultMessage: 'Are you sure you want to delete this group? This is a permanent action that cannot be undone.' }, deleteMessage: { id: 'confirmations.delete_group.message', defaultMessage: 'Are you sure you want to delete this group? This is a permanent action that cannot be undone.' },
members: { id: 'group.tabs.members', defaultMessage: 'Members' },
other: { id: 'settings.other', defaultMessage: 'Other options' },
deleteSuccess: { id: 'group.delete.success', defaultMessage: 'Group successfully deleted' },
}); });
interface IManageGroup { interface IManageGroup {
@ -32,9 +38,14 @@ const ManageGroup: React.FC<IManageGroup> = ({ params }) => {
const intl = useIntl(); const intl = useIntl();
const history = useHistory(); const history = useHistory();
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const groupsPath = useGroupsPath();
const { group } = useGroup(id); const { group } = useGroup(id);
const deleteGroup = useDeleteGroup();
const isOwner = group?.relationship?.role === GroupRoles.OWNER;
if (!group || !group.relationship) { if (!group || !group.relationship) {
return ( return (
<Column label={intl.formatMessage(messages.heading)}> <Column label={intl.formatMessage(messages.heading)}>
@ -56,7 +67,14 @@ const ManageGroup: React.FC<IManageGroup> = ({ params }) => {
heading: intl.formatMessage(messages.deleteHeading), heading: intl.formatMessage(messages.deleteHeading),
message: intl.formatMessage(messages.deleteMessage), message: intl.formatMessage(messages.deleteMessage),
confirm: intl.formatMessage(messages.deleteConfirm), confirm: intl.formatMessage(messages.deleteConfirm),
onConfirm: () => dispatch(deleteGroup(id)), onConfirm: () => {
deleteGroup.mutate(group.id, {
onSuccess() {
toast.success(intl.formatMessage(messages.deleteSuccess));
history.push(groupsPath);
},
});
},
})); }));
const navigateToPending = () => history.push(`/groups/${id}/manage/requests`); const navigateToPending = () => history.push(`/groups/${id}/manage/requests`);
@ -65,21 +83,39 @@ const ManageGroup: React.FC<IManageGroup> = ({ params }) => {
return ( return (
<Column label={intl.formatMessage(messages.heading)} backHref={`/groups/${id}`}> <Column label={intl.formatMessage(messages.heading)} backHref={`/groups/${id}`}>
<CardBody className='space-y-4'> <CardBody className='space-y-4'>
{group.relationship.role === 'owner' && ( {isOwner && (
<List> <>
<ListItem label={intl.formatMessage(messages.editGroup)} onClick={onEditGroup}> <CardHeader>
<span dangerouslySetInnerHTML={{ __html: group.display_name_html }} /> <CardTitle title={intl.formatMessage(messages.editGroup)} />
</ListItem> </CardHeader>
</List>
<List>
<ListItem label={intl.formatMessage(messages.editGroup)} onClick={onEditGroup}>
<span dangerouslySetInnerHTML={{ __html: group.display_name_html }} />
</ListItem>
</List>
</>
)} )}
<CardHeader>
<CardTitle title={intl.formatMessage(messages.members)} />
</CardHeader>
<List> <List>
<ListItem label={intl.formatMessage(messages.pendingRequests)} onClick={navigateToPending} /> <ListItem label={intl.formatMessage(messages.pendingRequests)} onClick={navigateToPending} />
<ListItem label={intl.formatMessage(messages.blockedMembers)} onClick={navigateToBlocks} /> <ListItem label={intl.formatMessage(messages.blockedMembers)} onClick={navigateToBlocks} />
</List> </List>
{group.relationship.role === 'owner' && (
<List> {isOwner && (
<ListItem label={intl.formatMessage(messages.deleteGroup)} onClick={onDeleteGroup} /> <>
</List> <CardHeader>
<CardTitle title={intl.formatMessage(messages.other)} />
</CardHeader>
<List>
<ListItem label={<Text theme='danger'>{intl.formatMessage(messages.deleteGroup)}</Text>} onClick={onDeleteGroup} />
</List>
</>
)} )}
</CardBody> </CardBody>
</Column> </Column>

View file

@ -6,7 +6,7 @@ import GroupAvatar from 'soapbox/components/groups/group-avatar';
import { Button, HStack, Stack, Text } from 'soapbox/components/ui'; import { Button, HStack, Stack, Text } from 'soapbox/components/ui';
import GroupMemberCount from 'soapbox/features/group/components/group-member-count'; import GroupMemberCount from 'soapbox/features/group/components/group-member-count';
import GroupPrivacy from 'soapbox/features/group/components/group-privacy'; import GroupPrivacy from 'soapbox/features/group/components/group-privacy';
import { useJoinGroup } from 'soapbox/queries/groups'; import { useJoinGroup } from 'soapbox/hooks/api';
import { Group as GroupEntity } from 'soapbox/types/entities'; import { Group as GroupEntity } from 'soapbox/types/entities';
interface IGroup { interface IGroup {
@ -17,7 +17,7 @@ interface IGroup {
const GroupGridItem = forwardRef((props: IGroup, ref: React.ForwardedRef<HTMLDivElement>) => { const GroupGridItem = forwardRef((props: IGroup, ref: React.ForwardedRef<HTMLDivElement>) => {
const { group, width = 'auto' } = props; const { group, width = 'auto' } = props;
const joinGroup = useJoinGroup(); const joinGroup = useJoinGroup(group);
const onJoinGroup = () => joinGroup.mutate(group); const onJoinGroup = () => joinGroup.mutate(group);

View file

@ -4,7 +4,7 @@ import { Link } from 'react-router-dom';
import GroupAvatar from 'soapbox/components/groups/group-avatar'; import GroupAvatar from 'soapbox/components/groups/group-avatar';
import { Button, HStack, Icon, Stack, Text } from 'soapbox/components/ui'; import { Button, HStack, Icon, Stack, Text } from 'soapbox/components/ui';
import { useJoinGroup } from 'soapbox/queries/groups'; import { useJoinGroup } from 'soapbox/hooks/api';
import { Group as GroupEntity } from 'soapbox/types/entities'; import { Group as GroupEntity } from 'soapbox/types/entities';
import { shortNumberFormat } from 'soapbox/utils/numbers'; import { shortNumberFormat } from 'soapbox/utils/numbers';
@ -16,7 +16,7 @@ interface IGroup {
const GroupListItem = (props: IGroup) => { const GroupListItem = (props: IGroup) => {
const { group, withJoinAction = true } = props; const { group, withJoinAction = true } = props;
const joinGroup = useJoinGroup(); const joinGroup = useJoinGroup(group);
const onJoinGroup = () => joinGroup.mutate(group); const onJoinGroup = () => joinGroup.mutate(group);

View file

@ -4,7 +4,7 @@ import { useHistory } from 'react-router-dom';
import { fetchMfa } from 'soapbox/actions/mfa'; import { fetchMfa } from 'soapbox/actions/mfa';
import List, { ListItem } from 'soapbox/components/list'; import List, { ListItem } from 'soapbox/components/list';
import { Card, CardBody, CardHeader, CardTitle, Column } from 'soapbox/components/ui'; import { Card, CardBody, CardHeader, CardTitle, Column, Text } from 'soapbox/components/ui';
import { useAppDispatch, useAppSelector, useFeatures, useOwnAccount } from 'soapbox/hooks'; import { useAppDispatch, useAppSelector, useFeatures, useOwnAccount } from 'soapbox/hooks';
import Preferences from '../preferences'; import Preferences from '../preferences';
@ -155,7 +155,7 @@ const Settings = () => {
))} ))}
{features.security && ( {features.security && (
<ListItem label={intl.formatMessage(messages.deleteAccount)} onClick={navigateToDeleteAccount} /> <ListItem label={<Text theme='danger'>{intl.formatMessage(messages.deleteAccount)}</Text>} onClick={navigateToDeleteAccount} />
)} )}
</List> </List>
</CardBody> </CardBody>

View file

@ -114,7 +114,11 @@ export const getDescendantsIds = createSelector([
}); });
type DisplayMedia = 'default' | 'hide_all' | 'show_all'; type DisplayMedia = 'default' | 'hide_all' | 'show_all';
type RouteParams = { statusId: string };
type RouteParams = {
statusId: string
groupId?: string
};
interface IThread { interface IThread {
params: RouteParams params: RouteParams
@ -515,6 +519,10 @@ const Thread: React.FC<IThread> = (props) => {
children.push(...renderChildren(descendantsIds).toArray()); children.push(...renderChildren(descendantsIds).toArray());
} }
if (status.group && typeof status.group === 'object' && !props.params.groupId) {
return <Redirect to={`/groups/${status.group.id}/posts/${props.params.statusId}`} />;
}
return ( return (
<Column label={intl.formatMessage(titleMessage, { username })} transparent> <Column label={intl.formatMessage(titleMessage, { username })} transparent>
<PullToRefresh onRefresh={handleRefresh}> <PullToRefresh onRefresh={handleRefresh}>

View file

@ -0,0 +1,33 @@
import React from 'react';
import { Widget } from 'soapbox/components/ui';
import GroupListItem from 'soapbox/features/groups/components/discover/group-list-item';
import PlaceholderGroupSearch from 'soapbox/features/placeholder/components/placeholder-group-search';
import { useGroups } from 'soapbox/hooks';
const MyGroupsPanel = () => {
const { groups, isFetching, isFetched, isError } = useGroups();
const isEmpty = (isFetched && groups.length === 0) || isError;
if (isEmpty) {
return null;
}
return (
<Widget
title='My Groups'
>
{isFetching ? (
new Array(3).fill(0).map((_, idx) => (
<PlaceholderGroupSearch key={idx} />
))
) : (
groups.slice(0, 3).map((group) => (
<GroupListItem group={group} withJoinAction={false} key={group.id} />
))
)}
</Widget>
);
};
export default MyGroupsPanel;

View file

@ -299,6 +299,7 @@ const SwitchingColumnsArea: React.FC<ISwitchingColumnsArea> = ({ children }) =>
{features.groups && <WrappedRoute path='/groups/:id/manage' exact page={DefaultPage} component={ManageGroup} content={children} />} {features.groups && <WrappedRoute path='/groups/:id/manage' exact page={DefaultPage} component={ManageGroup} content={children} />}
{features.groups && <WrappedRoute path='/groups/:id/manage/blocks' exact page={DefaultPage} component={GroupBlockedMembers} content={children} />} {features.groups && <WrappedRoute path='/groups/:id/manage/blocks' exact page={DefaultPage} component={GroupBlockedMembers} content={children} />}
{features.groups && <WrappedRoute path='/groups/:id/manage/requests' exact page={DefaultPage} component={GroupMembershipRequests} content={children} />} {features.groups && <WrappedRoute path='/groups/:id/manage/requests' exact page={DefaultPage} component={GroupMembershipRequests} content={children} />}
{features.groups && <WrappedRoute path='/groups/:groupId/posts/:statusId' exact page={StatusPage} component={Status} content={children} />}
<WrappedRoute path='/statuses/new' page={DefaultPage} component={NewStatus} content={children} exact /> <WrappedRoute path='/statuses/new' page={DefaultPage} component={NewStatus} content={children} exact />
<WrappedRoute path='/statuses/:statusId' exact page={StatusPage} component={Status} content={children} /> <WrappedRoute path='/statuses/:statusId' exact page={StatusPage} component={Status} content={children} />

View file

@ -590,6 +590,10 @@ export function NewGroupPanel() {
return import(/* webpackChunkName: "features/groups" */'../components/panels/new-group-panel'); return import(/* webpackChunkName: "features/groups" */'../components/panels/new-group-panel');
} }
export function MyGroupsPanel() {
return import(/* webpackChunkName: "features/groups" */'../components/panels/my-groups-panel');
}
export function SuggestedGroupsPanel() { export function SuggestedGroupsPanel() {
return import(/* webpackChunkName: "features/groups" */'../components/panels/suggested-groups-panel'); return import(/* webpackChunkName: "features/groups" */'../components/panels/suggested-groups-panel');
} }

View file

@ -0,0 +1,21 @@
import { Entities } from 'soapbox/entity-store/entities';
import { useEntityActions } from 'soapbox/entity-store/hooks';
import { useOwnAccount } from 'soapbox/hooks';
import type { Group, GroupRelationship } from 'soapbox/schemas';
function useCancelMembershipRequest(group: Group) {
const me = useOwnAccount();
const { createEntity, isLoading } = useEntityActions<GroupRelationship>(
[Entities.GROUP_RELATIONSHIPS, group.id],
{ post: `/api/v1/groups/${group.id}/membership_requests/${me?.id}/reject` },
);
return {
mutate: createEntity,
isLoading,
};
}
export { useCancelMembershipRequest };

View file

@ -0,0 +1,18 @@
import { Entities } from 'soapbox/entity-store/entities';
import { useEntityActions } from 'soapbox/entity-store/hooks';
import type { Group } from 'soapbox/schemas';
function useDeleteGroup() {
const { deleteEntity, isLoading } = useEntityActions<Group>(
[Entities.GROUPS],
{ delete: '/api/v1/groups/:id' },
);
return {
mutate: deleteEntity,
isLoading,
};
}
export { useDeleteGroup };

View file

@ -0,0 +1,20 @@
import { Entities } from 'soapbox/entity-store/entities';
import { useEntityActions } from 'soapbox/entity-store/hooks';
import { groupRelationshipSchema } from 'soapbox/schemas';
import type { Group, GroupRelationship } from 'soapbox/schemas';
function useJoinGroup(group: Group) {
const { createEntity, isLoading } = useEntityActions<GroupRelationship>(
[Entities.GROUP_RELATIONSHIPS, group.id],
{ post: `/api/v1/groups/${group.id}/join` },
{ schema: groupRelationshipSchema },
);
return {
mutate: createEntity,
isLoading,
};
}
export { useJoinGroup };

View file

@ -0,0 +1,18 @@
import { Entities } from 'soapbox/entity-store/entities';
import { useEntityActions } from 'soapbox/entity-store/hooks';
import { Group, GroupRelationship, groupRelationshipSchema } from 'soapbox/schemas';
function useLeaveGroup(group: Group) {
const { createEntity, isLoading } = useEntityActions<GroupRelationship>(
[Entities.GROUP_RELATIONSHIPS, group.id],
{ post: `/api/v1/groups/${group.id}/leave` },
{ schema: groupRelationshipSchema },
);
return {
mutate: createEntity,
isLoading,
};
}
export { useLeaveGroup };

View file

@ -2,5 +2,9 @@
* Groups * Groups
*/ */
export { useBlockGroupMember } from './groups/useBlockGroupMember'; export { useBlockGroupMember } from './groups/useBlockGroupMember';
export { useCancelMembershipRequest } from './groups/useCancelMembershipRequest';
export { useDeleteGroup } from './groups/useDeleteGroup';
export { useDemoteGroupMember } from './groups/useDemoteGroupMember'; export { useDemoteGroupMember } from './groups/useDemoteGroupMember';
export { usePromoteGroupMember } from './groups/usePromoteGroupMember'; export { useJoinGroup } from './groups/useJoinGroup';
export { useLeaveGroup } from './groups/useLeaveGroup';
export { usePromoteGroupMember } from './groups/usePromoteGroupMember';

View file

@ -5,11 +5,15 @@ import { useEntities, useEntity } from 'soapbox/entity-store/hooks';
import { groupSchema, Group } from 'soapbox/schemas/group'; import { groupSchema, Group } from 'soapbox/schemas/group';
import { groupRelationshipSchema, GroupRelationship } from 'soapbox/schemas/group-relationship'; import { groupRelationshipSchema, GroupRelationship } from 'soapbox/schemas/group-relationship';
import { useFeatures } from './useFeatures';
function useGroups() { function useGroups() {
const features = useFeatures();
const { entities, ...result } = useEntities<Group>( const { entities, ...result } = useEntities<Group>(
[Entities.GROUPS], [Entities.GROUPS],
'/api/v1/groups', '/api/v1/groups',
{ schema: groupSchema }, { enabled: features.groups, schema: groupSchema },
); );
const { relationships } = useGroupRelationships(entities.map(entity => entity.id)); const { relationships } = useGroupRelationships(entities.map(entity => entity.id));

View file

@ -767,6 +767,7 @@
"gdpr.title": "{siteTitle} uses cookies", "gdpr.title": "{siteTitle} uses cookies",
"getting_started.open_source_notice": "{code_name} is open source software. You can contribute or report issues at {code_link} (v{code_version}).", "getting_started.open_source_notice": "{code_name} is open source software. You can contribute or report issues at {code_link} (v{code_version}).",
"group.cancel_request": "Cancel Request", "group.cancel_request": "Cancel Request",
"group.delete.success": "Group successfully deleted",
"group.demote.user.success": "@{name} is now a member", "group.demote.user.success": "@{name} is now a member",
"group.group_mod_authorize.fail": "Failed to approve @{name}", "group.group_mod_authorize.fail": "Failed to approve @{name}",
"group.group_mod_block": "Ban from group", "group.group_mod_block": "Ban from group",
@ -926,7 +927,7 @@
"login_external.errors.instance_fail": "The instance returned an error.", "login_external.errors.instance_fail": "The instance returned an error.",
"login_external.errors.network_fail": "Connection failed. Is a browser extension blocking it?", "login_external.errors.network_fail": "Connection failed. Is a browser extension blocking it?",
"login_form.header": "Sign In", "login_form.header": "Sign In",
"manage_group.blocked_members": "Blocked members", "manage_group.blocked_members": "Banned members",
"manage_group.confirmation.copy": "Copy link", "manage_group.confirmation.copy": "Copy link",
"manage_group.confirmation.info_1": "As the owner of this group, you can assign staff, delete posts and much more.", "manage_group.confirmation.info_1": "As the owner of this group, you can assign staff, delete posts and much more.",
"manage_group.confirmation.info_2": "Post the group's first post and get the conversation started.", "manage_group.confirmation.info_2": "Post the group's first post and get the conversation started.",

View file

@ -10,6 +10,7 @@ import {
CtaBanner, CtaBanner,
GroupMediaPanel, GroupMediaPanel,
SignUpPanel, SignUpPanel,
SuggestedGroupsPanel,
} from 'soapbox/features/ui/util/async-components'; } from 'soapbox/features/ui/util/async-components';
import { useGroup, useOwnAccount } from 'soapbox/hooks'; import { useGroup, useOwnAccount } from 'soapbox/hooks';
import { useGroupMembershipRequests } from 'soapbox/hooks/api/groups/useGroupMembershipRequests'; import { useGroupMembershipRequests } from 'soapbox/hooks/api/groups/useGroupMembershipRequests';
@ -124,6 +125,9 @@ const GroupPage: React.FC<IGroupPage> = ({ params, children }) => {
<BundleContainer fetchComponent={GroupMediaPanel}> <BundleContainer fetchComponent={GroupMediaPanel}>
{Component => <Component group={group} />} {Component => <Component group={group} />}
</BundleContainer> </BundleContainer>
<BundleContainer fetchComponent={SuggestedGroupsPanel}>
{Component => <Component />}
</BundleContainer>
<LinkFooter key='link-footer' /> <LinkFooter key='link-footer' />
</Layout.Aside> </Layout.Aside>
</> </>

View file

@ -1,9 +1,10 @@
import React from 'react'; import React from 'react';
import { Route, Routes } from 'react-router-dom-v5-compat';
import { Column, Layout } from 'soapbox/components/ui'; import { Column, Layout } from 'soapbox/components/ui';
import LinkFooter from 'soapbox/features/ui/components/link-footer'; import LinkFooter from 'soapbox/features/ui/components/link-footer';
import BundleContainer from 'soapbox/features/ui/containers/bundle-container'; import BundleContainer from 'soapbox/features/ui/containers/bundle-container';
import { NewGroupPanel } from 'soapbox/features/ui/util/async-components'; import { MyGroupsPanel, NewGroupPanel, SuggestedGroupsPanel } from 'soapbox/features/ui/util/async-components';
interface IGroupsPage { interface IGroupsPage {
children: React.ReactNode children: React.ReactNode
@ -22,10 +23,28 @@ const GroupsPage: React.FC<IGroupsPage> = ({ children }) => (
<Layout.Aside> <Layout.Aside>
<BundleContainer fetchComponent={NewGroupPanel}> <BundleContainer fetchComponent={NewGroupPanel}>
{Component => <Component key='new-group-panel' />} {Component => <Component />}
</BundleContainer> </BundleContainer>
<Routes>
<Route
path='/groups'
element={(
<BundleContainer fetchComponent={SuggestedGroupsPanel}>
{Component => <Component />}
</BundleContainer>
)}
/>
<Route
path='/groups/discover'
element={(
<BundleContainer fetchComponent={MyGroupsPanel}>
{Component => <Component />}
</BundleContainer>
)}
/>
</Routes>
<LinkFooter key='link-footer' /> <LinkFooter />
</Layout.Aside> </Layout.Aside>
</> </>
); );

View file

@ -1,22 +1,12 @@
import { useInfiniteQuery, useMutation, useQuery } from '@tanstack/react-query'; import { useInfiniteQuery, useQuery } from '@tanstack/react-query';
import { AxiosRequestConfig } from 'axios'; import { AxiosRequestConfig } from 'axios';
import { defineMessages, useIntl } from 'react-intl';
import { getNextLink } from 'soapbox/api'; import { getNextLink } from 'soapbox/api';
import { useApi, useFeatures, useOwnAccount } from 'soapbox/hooks'; import { useApi, useFeatures, useOwnAccount } from 'soapbox/hooks';
import { normalizeGroup, normalizeGroupRelationship } from 'soapbox/normalizers'; import { normalizeGroup, normalizeGroupRelationship } from 'soapbox/normalizers';
import toast from 'soapbox/toast';
import { Group, GroupRelationship } from 'soapbox/types/entities'; import { Group, GroupRelationship } from 'soapbox/types/entities';
import { flattenPages, PaginatedResult } from 'soapbox/utils/queries'; import { flattenPages, PaginatedResult } from 'soapbox/utils/queries';
import { queryClient } from './client';
const messages = defineMessages({
joinSuccess: { id: 'group.join.success', defaultMessage: 'Group joined successfully!' },
joinRequestSuccess: { id: 'group.join.request_success', defaultMessage: 'Requested to join the group' },
leaveSuccess: { id: 'group.leave.success', defaultMessage: 'Left the group' },
});
const GroupKeys = { const GroupKeys = {
group: (id: string) => ['groups', 'group', id] as const, group: (id: string) => ['groups', 'group', id] as const,
myGroups: (userId: string) => ['groups', userId] as const, myGroups: (userId: string) => ['groups', userId] as const,
@ -168,50 +158,8 @@ const useGroup = (id: string) => {
}; };
}; };
const useJoinGroup = () => {
const api = useApi();
const intl = useIntl();
return useMutation((group: Group) => api.post<GroupRelationship>(`/api/v1/groups/${group.id}/join`), {
onSuccess(_response, group) {
queryClient.invalidateQueries(['groups']);
toast.success(
group.locked
? intl.formatMessage(messages.joinRequestSuccess)
: intl.formatMessage(messages.joinSuccess),
);
},
});
};
const useLeaveGroup = () => {
const api = useApi();
const intl = useIntl();
return useMutation((group: Group) => api.post<GroupRelationship>(`/api/v1/groups/${group.id}/leave`), {
onSuccess() {
queryClient.invalidateQueries({ queryKey: ['groups'] });
toast.success(intl.formatMessage(messages.leaveSuccess));
},
});
};
const useCancelMembershipRequest = () => {
const api = useApi();
const me = useOwnAccount();
return useMutation((group: Group) => api.post(`/api/v1/groups/${group.id}/membership_requests/${me?.id}/reject`), {
onSuccess() {
queryClient.invalidateQueries({ queryKey: ['groups'] });
},
});
};
export { export {
useCancelMembershipRequest,
useGroup, useGroup,
useGroups, useGroups,
useJoinGroup,
useLeaveGroup,
usePendingGroups, usePendingGroups,
}; };

View file

@ -7,6 +7,7 @@ import {
import { createSelector } from 'reselect'; import { createSelector } from 'reselect';
import { getSettings } from 'soapbox/actions/settings'; import { getSettings } from 'soapbox/actions/settings';
import { Entities } from 'soapbox/entity-store/entities';
import { getDomain } from 'soapbox/utils/accounts'; import { getDomain } from 'soapbox/utils/accounts';
import { validId } from 'soapbox/utils/auth'; import { validId } from 'soapbox/utils/auth';
import ConfigDB from 'soapbox/utils/config-db'; import ConfigDB from 'soapbox/utils/config-db';
@ -14,9 +15,10 @@ import { getFeatures } from 'soapbox/utils/features';
import { shouldFilter } from 'soapbox/utils/timelines'; import { shouldFilter } from 'soapbox/utils/timelines';
import type { ContextType } from 'soapbox/normalizers/filter'; import type { ContextType } from 'soapbox/normalizers/filter';
import type { ReducerAccount } from 'soapbox/reducers/accounts';
import type { ReducerChat } from 'soapbox/reducers/chats'; import type { ReducerChat } from 'soapbox/reducers/chats';
import type { RootState } from 'soapbox/store'; import type { RootState } from 'soapbox/store';
import type { Filter as FilterEntity, Notification } from 'soapbox/types/entities'; import type { Filter as FilterEntity, Notification, Status, Group } from 'soapbox/types/entities';
const normalizeId = (id: any): string => typeof id === 'string' ? id : ''; const normalizeId = (id: any): string => typeof id === 'string' ? id : '';
@ -180,11 +182,11 @@ type APIStatus = { id: string, username?: string };
export const makeGetStatus = () => { export const makeGetStatus = () => {
return createSelector( return createSelector(
[ [
(state: RootState, { id }: APIStatus) => state.statuses.get(id), (state: RootState, { id }: APIStatus) => state.statuses.get(id) as Status | undefined,
(state: RootState, { id }: APIStatus) => state.statuses.get(state.statuses.get(id)?.reblog || ''), (state: RootState, { id }: APIStatus) => state.statuses.get(state.statuses.get(id)?.reblog || '') as Status | undefined,
(state: RootState, { id }: APIStatus) => state.accounts.get(state.statuses.get(id)?.account || ''), (state: RootState, { id }: APIStatus) => state.accounts.get(state.statuses.get(id)?.account || '') as ReducerAccount | undefined,
(state: RootState, { id }: APIStatus) => state.accounts.get(state.statuses.get(state.statuses.get(id)?.reblog || '')?.account || ''), (state: RootState, { id }: APIStatus) => state.accounts.get(state.statuses.get(state.statuses.get(id)?.reblog || '')?.account || '') as ReducerAccount | undefined,
(state: RootState, { id }: APIStatus) => state.groups.items.get(state.statuses.get(id)?.group || ''), (state: RootState, { id }: APIStatus) => state.entities[Entities.GROUPS]?.store[state.statuses.get(id)?.group || ''] as Group | undefined,
(_state: RootState, { username }: APIStatus) => username, (_state: RootState, { username }: APIStatus) => username,
getFilters, getFilters,
(state: RootState) => state.me, (state: RootState) => state.me,
@ -207,7 +209,7 @@ export const makeGetStatus = () => {
statusReblog = undefined; statusReblog = undefined;
} }
return statusBase.withMutations(map => { return statusBase.withMutations((map: Status) => {
map.set('reblog', statusReblog || null); map.set('reblog', statusReblog || null);
// @ts-ignore :( // @ts-ignore :(
map.set('account', accountBase || null); map.set('account', accountBase || null);