diff --git a/messages/en.json b/messages/en.json index 6f74e89b9..8534d05ca 100644 --- a/messages/en.json +++ b/messages/en.json @@ -570,9 +570,15 @@ "screens.Settings.YourTeam.close": { "message": "Close" }, + "screens.Settings.YourTeam.deviceHasJoined": { + "message": "Device Has Joined {projectName}" + }, "screens.Settings.YourTeam.inviteDeclinedDes": { "message": "This device has declined your invitation. They have not joined the project." }, + "screens.Settings.YourTeam.unableToCancel": { + "message": "Unable to Cancel Invitation" + }, "screens.Settings.aboutMapeo": { "description": "Primary text for 'About Mapeo' link (version info)", "message": "About Mapeo" diff --git a/src/frontend/Navigation/ScreenGroups/AppScreens.tsx b/src/frontend/Navigation/ScreenGroups/AppScreens.tsx index 9271af9d7..919b29e19 100644 --- a/src/frontend/Navigation/ScreenGroups/AppScreens.tsx +++ b/src/frontend/Navigation/ScreenGroups/AppScreens.tsx @@ -54,6 +54,7 @@ import {useLocation} from '../../hooks/useLocation'; import {useLocationProviderStatus} from '../../hooks/useLocationProviderStatus'; import {getLocationStatus} from '../../lib/utils'; import {InviteDeclined} from '../../screens/Settings/ProjectSettings/YourTeam/InviteDeclined'; +import {UnableToCancelInvite} from '../../screens/Settings/ProjectSettings/YourTeam/ReviewAndInvite/UnableToCancelInvite'; export type HomeTabsList = { Map: undefined; @@ -127,6 +128,7 @@ export type AppList = { ReviewAndInvite: InviteProps; InviteAccepted: InviteProps; InviteDeclined: InviteProps; + UnableToCancelInvite: InviteProps; DeviceNameDisplay: undefined; DeviceNameEdit: undefined; }; @@ -340,5 +342,10 @@ export const createDefaultScreenGroup = ( component={InviteDeclined} options={{headerShown: false}} /> + ); diff --git a/src/frontend/contexts/ProjectContext.tsx b/src/frontend/contexts/ProjectContext.tsx index 4550c6090..09a2d3e63 100644 --- a/src/frontend/contexts/ProjectContext.tsx +++ b/src/frontend/contexts/ProjectContext.tsx @@ -67,7 +67,7 @@ export const ActiveProjectProvider = ({ return () => { cancelled = true; }; - }, [activeProjectId, setActiveProjectId]); + }, [activeProjectId, setActiveProjectId, mapeoApi]); if (!activeProject) { return ; diff --git a/src/frontend/hooks/server/invites.ts b/src/frontend/hooks/server/invites.ts index 6394a1265..9f73f7d8f 100644 --- a/src/frontend/hooks/server/invites.ts +++ b/src/frontend/hooks/server/invites.ts @@ -4,6 +4,7 @@ import { useSuspenseQuery, } from '@tanstack/react-query'; import {useApi} from '../../contexts/ApiContext'; +import {PROJECTS_KEY, useProject, useUpdateActiveProjectId} from './projects'; export const INVITE_KEY = 'pending_invites'; @@ -17,16 +18,27 @@ export function usePendingInvites() { }); } -export function useAcceptInvite() { +export function useAcceptInvite(projectId?: string) { const mapeoApi = useApi(); const queryClient = useQueryClient(); + const switchActiveProject = useUpdateActiveProjectId(); + return useMutation({ mutationFn: async ({inviteId}: {inviteId: string}) => { if (!inviteId) return; mapeoApi.invite.accept({inviteId}); }, onSuccess: () => { - queryClient.invalidateQueries({queryKey: [INVITE_KEY]}); + // This is a workaround. There is a race condition where the project in not available when the invite is accepted. This is temporary and is currently being worked on. + setTimeout(() => { + queryClient + .invalidateQueries({queryKey: [INVITE_KEY, PROJECTS_KEY]}) + .then(() => { + if (projectId) { + switchActiveProject(projectId); + } + }); + }, 5000); }, }); } @@ -61,3 +73,33 @@ export function useClearAllPendingInvites() { }, }); } + +export function useSendInvite() { + const queryClient = useQueryClient(); + const project = useProject(); + type InviteParams = Parameters; + return useMutation({ + mutationFn: ({ + deviceId, + role, + }: { + deviceId: InviteParams[0]; + role: InviteParams[1]; + }) => project.$member.invite(deviceId, role), + onSuccess: () => { + queryClient.invalidateQueries({queryKey: [INVITE_KEY]}); + }, + }); +} + +export function useRequestCancelInvite() { + const queryClient = useQueryClient(); + const project = useProject(); + return useMutation({ + mutationFn: (deviceId: string) => + project.$member.requestCancelInvite(deviceId), + onSuccess: () => { + queryClient.invalidateQueries({queryKey: [INVITE_KEY]}); + }, + }); +} diff --git a/src/frontend/hooks/server/projects.ts b/src/frontend/hooks/server/projects.ts index e54f721f6..7f5c39e90 100644 --- a/src/frontend/hooks/server/projects.ts +++ b/src/frontend/hooks/server/projects.ts @@ -2,6 +2,8 @@ import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'; import {useApi} from '../../contexts/ApiContext'; import {useActiveProjectContext} from '../../contexts/ProjectContext'; +export const PROJECTS_KEY = 'all_projects'; + export function useUpdateActiveProjectId() { const projectContext = useActiveProjectContext(); return projectContext.switchProject; @@ -17,7 +19,7 @@ export function useAllProjects() { return useQuery({ queryFn: async () => await api.listProjects(), - queryKey: ['projects'], + queryKey: [PROJECTS_KEY], }); } diff --git a/src/frontend/hooks/useProjectInvite.ts b/src/frontend/hooks/useProjectInvite.ts index db0c74e20..67da14ec0 100644 --- a/src/frontend/hooks/useProjectInvite.ts +++ b/src/frontend/hooks/useProjectInvite.ts @@ -9,7 +9,7 @@ export function useProjectInvite() { const invites = usePendingInvites().data; // this will eventually sort invite by date const invite = invites[0]; - const acceptMutation = useAcceptInvite(); + const acceptMutation = useAcceptInvite(invite?.projectPublicId); const rejectMutation = useRejectInvite(); const clearAllInvites = useClearAllPendingInvites(); diff --git a/src/frontend/screens/Settings/ProjectSettings/YourTeam/ReviewAndInvite/UnableToCancelInvite.tsx b/src/frontend/screens/Settings/ProjectSettings/YourTeam/ReviewAndInvite/UnableToCancelInvite.tsx new file mode 100644 index 000000000..e3531c1ac --- /dev/null +++ b/src/frontend/screens/Settings/ProjectSettings/YourTeam/ReviewAndInvite/UnableToCancelInvite.tsx @@ -0,0 +1,76 @@ +import * as React from 'react'; +import {StyleSheet, View} from 'react-native'; +import {Button} from '../../../../../sharedComponents/Button'; +import ErrorIcon from '../../../../../images/Error.svg'; +import {defineMessages, useIntl} from 'react-intl'; +import {Text} from '../../../../../sharedComponents/Text'; +import {DeviceNameWithIcon} from '../../../../../sharedComponents/DeviceNameWithIcon'; +import {RoleWithIcon} from '../../../../../sharedComponents/RoleWithIcon'; +import { + COORDINATOR_ROLE_ID, + NativeRootNavigationProps, +} from '../../../../../sharedTypes'; +import {useProjectSettings} from '../../../../../hooks/server/projects'; + +const m = defineMessages({ + unableToCancel: { + id: 'screens.Settings.YourTeam.unableToCancel', + defaultMessage: 'Unable to Cancel Invitation', + }, + deviceHasJoined: { + id: 'screens.Settings.YourTeam.deviceHasJoined', + defaultMessage: 'Device Has Joined {projectName}', + }, + close: { + id: 'screens.Settings.YourTeam.close', + defaultMessage: 'Close', + }, +}); + +export const UnableToCancelInvite = ({ + navigation, + route, +}: NativeRootNavigationProps<'UnableToCancelInvite'>) => { + const {formatMessage} = useIntl(); + const {role, ...deviceInfo} = route.params; + const {data} = useProjectSettings(); + + return ( + + + + + {formatMessage(m.unableToCancel)} + + {data?.name && ( + + {formatMessage(m.deviceHasJoined, {projectName: data.name})} + + )} + + + + + + ); +}; + +const styles = StyleSheet.create({ + container: { + padding: 20, + paddingTop: 80, + alignItems: 'center', + justifyContent: 'space-between', + flex: 1, + }, +}); diff --git a/src/frontend/screens/Settings/ProjectSettings/YourTeam/ReviewAndInvite/WaitingForInviteAccept.tsx b/src/frontend/screens/Settings/ProjectSettings/YourTeam/ReviewAndInvite/WaitingForInviteAccept.tsx index 2db3322af..a8f7745fb 100644 --- a/src/frontend/screens/Settings/ProjectSettings/YourTeam/ReviewAndInvite/WaitingForInviteAccept.tsx +++ b/src/frontend/screens/Settings/ProjectSettings/YourTeam/ReviewAndInvite/WaitingForInviteAccept.tsx @@ -22,7 +22,11 @@ const m = defineMessages({ }, }); -export const WaitingForInviteAccept = () => { +export const WaitingForInviteAccept = ({ + cancelInvite, +}: { + cancelInvite: () => void; +}) => { const {formatMessage: t} = useIntl(); const [time, setTime] = React.useState(0); const navigation = useNavigationFromRoot(); @@ -53,12 +57,7 @@ export const WaitingForInviteAccept = () => { {t(m.waitingMessage)} {t(m.timerMessage, {seconds: time})} - { - navigation.navigate('YourTeam'); - }} - /> + ); }; diff --git a/src/frontend/screens/Settings/ProjectSettings/YourTeam/ReviewAndInvite/index.tsx b/src/frontend/screens/Settings/ProjectSettings/YourTeam/ReviewAndInvite/index.tsx index ed0dca8c6..0a4230183 100644 --- a/src/frontend/screens/Settings/ProjectSettings/YourTeam/ReviewAndInvite/index.tsx +++ b/src/frontend/screens/Settings/ProjectSettings/YourTeam/ReviewAndInvite/index.tsx @@ -2,11 +2,13 @@ import * as React from 'react'; import {NativeNavigationComponent} from '../../../../../sharedTypes'; import {defineMessages} from 'react-intl'; import {useBottomSheetModal} from '../../../../../sharedComponents/BottomSheetModal'; -import {useQueryClient} from '@tanstack/react-query'; -import {useProject} from '../../../../../hooks/server/projects'; import {ErrorModal} from '../../../../../sharedComponents/ErrorModal'; import {ReviewInvitation} from './ReviewInvitation'; import {WaitingForInviteAccept} from './WaitingForInviteAccept'; +import { + useRequestCancelInvite, + useSendInvite, +} from '../../../../../hooks/server/invites'; const m = defineMessages({ title: { @@ -19,41 +21,55 @@ export const ReviewAndInvite: NativeNavigationComponent<'ReviewAndInvite'> = ({ route, navigation, }) => { - const [inviteStatus, setInviteStatus] = React.useState< - 'reviewing' | 'waiting' - >('reviewing'); const {role, deviceId, deviceType, name} = route.params; const {openSheet, sheetRef, closeSheet, isOpen} = useBottomSheetModal({ openOnMount: false, }); - const project = useProject(); - const queryClient = useQueryClient(); + const sendInviteMutation = useSendInvite(); + const requestCancelInviteMutation = useRequestCancelInvite(); function sendInvite() { - setInviteStatus('waiting'); - project.$member - .invite(deviceId, {roleId: role}) - .then(val => { - if (val === 'ACCEPT') { - queryClient.invalidateQueries({queryKey: ['projectMembers']}); - navigation.navigate('InviteAccepted', route.params); - return; - } + sendInviteMutation.mutate( + {deviceId, role: {roleId: role}}, + { + onSuccess: val => { + // If user has attempted to cancel an invite, but an invite has already been accepted, let user know their cancellation was unsuccessful + if (val === 'ACCEPT' && requestCancelInviteMutation.isPending) { + navigation.navigate('UnableToCancelInvite', {...route.params}); + return; + } + if (val === 'ACCEPT') { + navigation.navigate('InviteAccepted', route.params); + return; + } - if (val === 'REJECT') { - navigation.navigate('InviteDeclined', route.params); - return; - } - }) - .catch(() => { + if (val === 'REJECT') { + navigation.navigate('InviteDeclined', route.params); + return; + } + }, + onError: () => { + openSheet(); + }, + }, + ); + } + + function cancelInvite() { + requestCancelInviteMutation.mutate(deviceId, { + onSuccess: () => { + navigation.navigate('YourTeam'); + }, + onError: () => { openSheet(); - }); + }, + }); } return ( - {inviteStatus === 'reviewing' ? ( + {sendInviteMutation.isIdle ? ( = ({ role={role} /> ) : ( - + )}