diff --git a/app/soapbox/actions/groups.ts b/app/soapbox/actions/groups.ts index 9715396f3e..974bd5ebb2 100644 --- a/app/soapbox/actions/groups.ts +++ b/app/soapbox/actions/groups.ts @@ -40,14 +40,6 @@ const GROUP_RELATIONSHIPS_FETCH_REQUEST = 'GROUP_RELATIONSHIPS_FETCH_REQUEST'; const GROUP_RELATIONSHIPS_FETCH_SUCCESS = 'GROUP_RELATIONSHIPS_FETCH_SUCCESS'; const GROUP_RELATIONSHIPS_FETCH_FAIL = 'GROUP_RELATIONSHIPS_FETCH_FAIL'; -const GROUP_JOIN_REQUEST = 'GROUP_JOIN_REQUEST'; -const GROUP_JOIN_SUCCESS = 'GROUP_JOIN_SUCCESS'; -const GROUP_JOIN_FAIL = 'GROUP_JOIN_FAIL'; - -const GROUP_LEAVE_REQUEST = 'GROUP_LEAVE_REQUEST'; -const GROUP_LEAVE_SUCCESS = 'GROUP_LEAVE_SUCCESS'; -const GROUP_LEAVE_FAIL = 'GROUP_LEAVE_FAIL'; - const GROUP_DELETE_STATUS_REQUEST = 'GROUP_DELETE_STATUS_REQUEST'; const GROUP_DELETE_STATUS_SUCCESS = 'GROUP_DELETE_STATUS_SUCCESS'; const GROUP_DELETE_STATUS_FAIL = 'GROUP_DELETE_STATUS_FAIL'; @@ -312,70 +304,6 @@ const fetchGroupRelationshipsFail = (error: AxiosError) => ({ skipNotFound: true, }); -const joinGroup = (id: string) => - (dispatch: AppDispatch, getState: () => RootState) => { - const locked = (getState().groups.items.get(id) as any).locked || false; - - dispatch(joinGroupRequest(id, locked)); - - return api(getState).post(`/api/v1/groups/${id}/join`).then(response => { - dispatch(joinGroupSuccess(response.data)); - toast.success(locked ? messages.joinRequestSuccess : messages.joinSuccess); - }).catch(error => { - dispatch(joinGroupFail(error, locked)); - }); - }; - -const leaveGroup = (id: string) => - (dispatch: AppDispatch, getState: () => RootState) => { - dispatch(leaveGroupRequest(id)); - - return api(getState).post(`/api/v1/groups/${id}/leave`).then(response => { - dispatch(leaveGroupSuccess(response.data)); - toast.success(messages.leaveSuccess); - }).catch(error => { - dispatch(leaveGroupFail(error)); - }); - }; - -const joinGroupRequest = (id: string, locked: boolean) => ({ - type: GROUP_JOIN_REQUEST, - id, - locked, - skipLoading: true, -}); - -const joinGroupSuccess = (relationship: APIEntity) => ({ - type: GROUP_JOIN_SUCCESS, - relationship, - skipLoading: true, -}); - -const joinGroupFail = (error: AxiosError, locked: boolean) => ({ - type: GROUP_JOIN_FAIL, - error, - locked, - skipLoading: true, -}); - -const leaveGroupRequest = (id: string) => ({ - type: GROUP_LEAVE_REQUEST, - id, - skipLoading: true, -}); - -const leaveGroupSuccess = (relationship: APIEntity) => ({ - type: GROUP_LEAVE_SUCCESS, - relationship, - skipLoading: true, -}); - -const leaveGroupFail = (error: AxiosError) => ({ - type: GROUP_LEAVE_FAIL, - error, - skipLoading: true, -}); - const groupDeleteStatus = (groupId: string, statusId: string) => (dispatch: AppDispatch, getState: () => RootState) => { dispatch(groupDeleteStatusRequest(groupId, statusId)); @@ -895,12 +823,6 @@ export { GROUP_RELATIONSHIPS_FETCH_REQUEST, GROUP_RELATIONSHIPS_FETCH_SUCCESS, GROUP_RELATIONSHIPS_FETCH_FAIL, - GROUP_JOIN_REQUEST, - GROUP_JOIN_SUCCESS, - GROUP_JOIN_FAIL, - GROUP_LEAVE_REQUEST, - GROUP_LEAVE_SUCCESS, - GROUP_LEAVE_FAIL, GROUP_DELETE_STATUS_REQUEST, GROUP_DELETE_STATUS_SUCCESS, GROUP_DELETE_STATUS_FAIL, @@ -973,14 +895,6 @@ export { fetchGroupRelationshipsRequest, fetchGroupRelationshipsSuccess, fetchGroupRelationshipsFail, - joinGroup, - leaveGroup, - joinGroupRequest, - joinGroupSuccess, - joinGroupFail, - leaveGroupRequest, - leaveGroupSuccess, - leaveGroupFail, groupDeleteStatus, groupDeleteStatusRequest, groupDeleteStatusSuccess, diff --git a/app/soapbox/features/group/components/group-action-button.tsx b/app/soapbox/features/group/components/group-action-button.tsx index 53f27f7099..8705846fc0 100644 --- a/app/soapbox/features/group/components/group-action-button.tsx +++ b/app/soapbox/features/group/components/group-action-button.tsx @@ -1,10 +1,10 @@ import React from 'react'; import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; -import { joinGroup, leaveGroup } from 'soapbox/actions/groups'; import { openModal } from 'soapbox/actions/modals'; import { Button } from 'soapbox/components/ui'; import { useAppDispatch } from 'soapbox/hooks'; +import { useCancelMembershipRequest, useJoinGroup, useLeaveGroup } from 'soapbox/queries/groups'; import { Group } from 'soapbox/types/entities'; interface IGroupActionButton { @@ -21,25 +21,32 @@ const GroupActionButton = ({ group }: IGroupActionButton) => { const dispatch = useAppDispatch(); const intl = useIntl(); - const isNonMember = !group.relationship || !group.relationship.member; + const joinGroup = useJoinGroup(); + const leaveGroup = useLeaveGroup(); + const cancelRequest = useCancelMembershipRequest(); + const isRequested = group.relationship?.requested; + const isNonMember = !group.relationship?.member && !isRequested; const isAdmin = group.relationship?.role === 'admin'; - const onJoinGroup = () => dispatch(joinGroup(group.id)); + const onJoinGroup = () => joinGroup.mutate(group); const onLeaveGroup = () => dispatch(openModal('CONFIRM', { heading: intl.formatMessage(messages.confirmationHeading), message: intl.formatMessage(messages.confirmationMessage), confirm: intl.formatMessage(messages.confirmationConfirm), - onConfirm: () => dispatch(leaveGroup(group.id)), + onConfirm: () => leaveGroup.mutate(group), })); + const onCancelRequest = () => cancelRequest.mutate(group); + if (isNonMember) { return ( @@ -74,6 +82,7 @@ const GroupActionButton = ({ group }: IGroupActionButton) => { diff --git a/app/soapbox/features/group/components/group-header.tsx b/app/soapbox/features/group/components/group-header.tsx index 7b532ddccc..4e1c50160d 100644 --- a/app/soapbox/features/group/components/group-header.tsx +++ b/app/soapbox/features/group/components/group-header.tsx @@ -125,7 +125,7 @@ const GroupHeader: React.FC = ({ group }) => { dangerouslySetInnerHTML={{ __html: group.display_name_html }} /> - + diff --git a/app/soapbox/locales/en.json b/app/soapbox/locales/en.json index a6e6a4ba04..00d7835392 100644 --- a/app/soapbox/locales/en.json +++ b/app/soapbox/locales/en.json @@ -789,7 +789,7 @@ "group.join.private": "Request Access", "group.join.public": "Join Group", "group.join.request_success": "Requested to join the group", - "group.join.success": "Joined the group", + "group.join.success": "Group joined successfully!", "group.leave": "Leave Group", "group.leave.success": "Left the group", "group.manage": "Manage Group", diff --git a/app/soapbox/normalizers/group-relationship.ts b/app/soapbox/normalizers/group-relationship.ts index 6f2f473106..c9326db916 100644 --- a/app/soapbox/normalizers/group-relationship.ts +++ b/app/soapbox/normalizers/group-relationship.ts @@ -10,7 +10,9 @@ import { export const GroupRelationshipRecord = ImmutableRecord({ id: '', + blocked_by: false, member: false, + notifying: null, requested: false, role: null as 'admin' | 'moderator' | 'user' | null, }); diff --git a/app/soapbox/pages/group-page.tsx b/app/soapbox/pages/group-page.tsx index f617b8bd7b..e15d9efcc6 100644 --- a/app/soapbox/pages/group-page.tsx +++ b/app/soapbox/pages/group-page.tsx @@ -37,7 +37,7 @@ const GroupPage: React.FC = ({ params, children }) => { const { group } = useGroup(id); - const isNonMember = !group?.relationship || !group.relationship.member; + const isNonMember = !group?.relationship?.member; const isPrivate = group?.locked; // if ((group as any) === false) { @@ -80,7 +80,6 @@ const GroupPage: React.FC = ({ params, children }) => { Content is only visible to group members - ) : children} diff --git a/app/soapbox/queries/groups.ts b/app/soapbox/queries/groups.ts index 5fb8ed2dc5..32b4189c4e 100644 --- a/app/soapbox/queries/groups.ts +++ b/app/soapbox/queries/groups.ts @@ -1,13 +1,21 @@ -import { useInfiniteQuery, useQuery } from '@tanstack/react-query'; +import { useInfiniteQuery, useMutation, useQuery } from '@tanstack/react-query'; +import { defineMessages, useIntl } from 'react-intl'; -import { fetchGroupRelationships } from 'soapbox/actions/groups'; -import { importFetchedGroups } from 'soapbox/actions/importer'; import { getNextLink } from 'soapbox/api'; -import { useApi, useAppDispatch, useFeatures, useOwnAccount } from 'soapbox/hooks'; -import { normalizeGroup } from 'soapbox/normalizers'; -import { Group } from 'soapbox/types/entities'; +import { useApi, useFeatures, useOwnAccount } from 'soapbox/hooks'; +import { normalizeGroup, normalizeGroupRelationship } from 'soapbox/normalizers'; +import toast from 'soapbox/toast'; +import { Group, GroupRelationship } from 'soapbox/types/entities'; 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 = { group: (id: string) => ['groups', 'group', id] as const, myGroups: (userId: string) => ['groups', userId] as const, @@ -15,29 +23,54 @@ const GroupKeys = { suggestedGroups: ['groups', 'suggested'] as const, }; -const useGroups = () => { +const useGroupsApi = () => { const api = useApi(); + + const getGroupRelationships = async (ids: string[]) => { + const queryString = ids.map((id) => `id[]=${id}`).join('&'); + const { data } = await api.get(`/api/v1/groups/relationships?${queryString}`); + + return data; + }; + + const fetchGroups = async (endpoint: string) => { + const response = await api.get(endpoint); + const groups = [response.data].flat(); + const relationships = await getGroupRelationships(groups.map((group) => group.id)); + const result = groups.map((group) => { + const relationship = relationships.find((relationship) => relationship.id === group.id); + + return normalizeGroup({ + ...group, + relationship: relationship ? normalizeGroupRelationship(relationship) : null, + }); + }); + + return { + response, + groups: result, + }; + }; + + return { fetchGroups }; +}; + +const useGroups = () => { const account = useOwnAccount(); - const dispatch = useAppDispatch(); const features = useFeatures(); + const { fetchGroups } = useGroupsApi(); const getGroups = async (pageParam?: any): Promise> => { const endpoint = '/api/v1/groups'; const nextPageLink = pageParam?.link; const uri = nextPageLink || endpoint; - const response = await api.get(uri); - const { data } = response; + const { response, groups } = await fetchGroups(uri); const link = getNextLink(response); const hasMore = !!link; - const result = data.map(normalizeGroup); - - // Note: Temporary while part of Groups is using Redux - dispatch(importFetchedGroups(result)); - dispatch(fetchGroupRelationships(result.map((item) => item.id))); return { - result, + result: groups, hasMore, link, }; @@ -67,14 +100,13 @@ const useGroups = () => { }; const usePopularGroups = () => { - const api = useApi(); const features = useFeatures(); + const { fetchGroups } = useGroupsApi(); const getQuery = async () => { - const { data } = await api.get('/api/v1/groups/search?q=group'); // '/api/v1/truth/trends/groups' - const result = data.map(normalizeGroup); + const { groups } = await fetchGroups('/api/v1/groups/search?q=group'); // '/api/v1/truth/trends/groups' - return result; + return groups; }; const queryInfo = useQuery(GroupKeys.popularGroups, getQuery, { @@ -89,14 +121,13 @@ const usePopularGroups = () => { }; const useSuggestedGroups = () => { - const api = useApi(); const features = useFeatures(); + const { fetchGroups } = useGroupsApi(); const getQuery = async () => { - const { data } = await api.get('/api/mock/groups'); // /api/v1/truth/suggestions/groups - const result = data.map(normalizeGroup); + const { groups } = await fetchGroups('/api/v1/groups/search?q=group'); // /api/v1/truth/suggestions/groups - return result; + return groups; }; const queryInfo = useQuery(GroupKeys.suggestedGroups, getQuery, { @@ -111,12 +142,12 @@ const useSuggestedGroups = () => { }; const useGroup = (id: string) => { - const api = useApi(); const features = useFeatures(); + const { fetchGroups } = useGroupsApi(); const getGroup = async () => { - const { data } = await api.get(`/api/v1/groups/${id}`); - return normalizeGroup(data); + const { groups } = await fetchGroups(`/api/v1/groups/${id}`); // /api/v1/truth/suggestions/groups + return groups[0]; }; const queryInfo = useQuery(GroupKeys.group(id), getGroup, { @@ -129,4 +160,43 @@ const useGroup = (id: string) => { }; }; -export { useGroups, useGroup, usePopularGroups, useSuggestedGroups }; +const useJoinGroup = () => { + const api = useApi(); + const intl = useIntl(); + + return useMutation((group: Group) => api.post(`/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(`/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 { useGroups, useGroup, usePopularGroups, useSuggestedGroups, useJoinGroup, useLeaveGroup, useCancelMembershipRequest }; diff --git a/app/soapbox/reducers/group-relationships.ts b/app/soapbox/reducers/group-relationships.ts index 90b9d802cf..eb61ae9535 100644 --- a/app/soapbox/reducers/group-relationships.ts +++ b/app/soapbox/reducers/group-relationships.ts @@ -5,12 +5,6 @@ import { GROUP_UPDATE_SUCCESS, GROUP_DELETE_SUCCESS, GROUP_RELATIONSHIPS_FETCH_SUCCESS, - GROUP_JOIN_REQUEST, - GROUP_JOIN_SUCCESS, - GROUP_JOIN_FAIL, - GROUP_LEAVE_REQUEST, - GROUP_LEAVE_SUCCESS, - GROUP_LEAVE_FAIL, } from 'soapbox/actions/groups'; import { normalizeGroupRelationship } from 'soapbox/normalizers'; @@ -37,17 +31,6 @@ export default function groupRelationships(state: State = ImmutableMap(), action return state.set(action.group.id, normalizeGroupRelationship({ id: action.group.id, member: true, requested: false, role: 'admin' })); case GROUP_DELETE_SUCCESS: return state.delete(action.id); - case GROUP_JOIN_REQUEST: - return state.getIn([action.id, 'member']) ? state : state.setIn([action.id, action.locked ? 'requested' : 'member'], true); - case GROUP_JOIN_FAIL: - return state.setIn([action.id, action.locked ? 'requested' : 'member'], false); - case GROUP_LEAVE_REQUEST: - return state.setIn([action.id, 'member'], false); - case GROUP_LEAVE_FAIL: - return state.setIn([action.id, 'member'], true); - case GROUP_JOIN_SUCCESS: - case GROUP_LEAVE_SUCCESS: - return normalizeRelationships(state, [action.relationship]); case GROUP_RELATIONSHIPS_FETCH_SUCCESS: return normalizeRelationships(state, action.relationships); default: