Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Es/unable to cancel invite #263

Merged
merged 12 commits into from
Apr 22, 2024
6 changes: 6 additions & 0 deletions messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
7 changes: 7 additions & 0 deletions src/frontend/Navigation/ScreenGroups/AppScreens.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -127,6 +128,7 @@ export type AppList = {
ReviewAndInvite: InviteProps;
InviteAccepted: InviteProps;
InviteDeclined: InviteProps;
UnableToCancelInvite: InviteProps;
DeviceNameDisplay: undefined;
DeviceNameEdit: undefined;
};
Expand Down Expand Up @@ -340,5 +342,10 @@ export const createDefaultScreenGroup = (
component={InviteDeclined}
options={{headerShown: false}}
/>
<RootStack.Screen
name="UnableToCancelInvite"
component={UnableToCancelInvite}
options={{headerShown: false}}
/>
</RootStack.Group>
);
2 changes: 1 addition & 1 deletion src/frontend/contexts/ProjectContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ export const ActiveProjectProvider = ({
return () => {
cancelled = true;
};
}, [activeProjectId, setActiveProjectId]);
}, [activeProjectId, setActiveProjectId, mapeoApi]);

if (!activeProject) {
return <Loading />;
Expand Down
46 changes: 44 additions & 2 deletions src/frontend/hooks/server/invites.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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);
Comment on lines +33 to +41
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This setTimeout is a workaround.

Race condition is happening where:

  1. invitee accepts invite
  2. invitation resolves
  3. when invitation resolves, the cache of allprojects is refetched
  4. user is switched to the new project.

Between steps 2 and 3 there is a race condition, where the project has not been created yet, so the project in unavailable. This was discussed with @EvanHahn

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I could see adding a comment explaining this, but I hope to fix this soon in any case.

},
});
}
Expand Down Expand Up @@ -61,3 +73,33 @@ export function useClearAllPendingInvites() {
},
});
}

export function useSendInvite() {
const queryClient = useQueryClient();
const project = useProject();
type InviteParams = Parameters<typeof project.$member.invite>;
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]});
},
});
}
4 changes: 3 additions & 1 deletion src/frontend/hooks/server/projects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -17,7 +19,7 @@ export function useAllProjects() {

return useQuery({
queryFn: async () => await api.listProjects(),
queryKey: ['projects'],
queryKey: [PROJECTS_KEY],
});
}

Expand Down
2 changes: 1 addition & 1 deletion src/frontend/hooks/useProjectInvite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down
Original file line number Diff line number Diff line change
@@ -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 (
<View style={styles.container}>
<View style={{alignItems: 'center'}}>
<ErrorIcon />
<Text style={{marginTop: 20, fontSize: 20, fontWeight: 'bold'}}>
{formatMessage(m.unableToCancel)}
</Text>
{data?.name && (
<Text style={{marginTop: 10}}>
{formatMessage(m.deviceHasJoined, {projectName: data.name})}
</Text>
)}
<DeviceNameWithIcon {...deviceInfo} style={{marginTop: 10}} />
<RoleWithIcon
style={{marginTop: 20}}
role={role === COORDINATOR_ROLE_ID ? 'coordinator' : 'participant'}
/>
</View>
<Button
style={{marginTop: 10}}
fullWidth
onPress={() => {
navigation.navigate('YourTeam');
}}>
{formatMessage(m.close)}
</Button>
</View>
);
};

const styles = StyleSheet.create({
container: {
padding: 20,
paddingTop: 80,
alignItems: 'center',
justifyContent: 'space-between',
flex: 1,
},
});
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -53,12 +57,7 @@ export const WaitingForInviteAccept = () => {
<InviteSent />
<Text style={{marginTop: 10}}>{t(m.waitingMessage)}</Text>
<Text style={{marginTop: 20}}>{t(m.timerMessage, {seconds: time})}</Text>
<TextButton
title={t(m.cancelInvite)}
onPress={() => {
navigation.navigate('YourTeam');
}}
/>
<TextButton title={t(m.cancelInvite)} onPress={cancelInvite} />
</View>
);
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand All @@ -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 (
<React.Fragment>
{inviteStatus === 'reviewing' ? (
{sendInviteMutation.isIdle ? (
<ReviewInvitation
sendInvite={sendInvite}
deviceId={deviceId}
Expand All @@ -62,7 +78,7 @@ export const ReviewAndInvite: NativeNavigationComponent<'ReviewAndInvite'> = ({
role={role}
/>
) : (
<WaitingForInviteAccept />
<WaitingForInviteAccept cancelInvite={cancelInvite} />
)}
<ErrorModal
sheetRef={sheetRef}
Expand Down
Loading