diff --git a/lib/hooks/invite-links.js b/lib/hooks/invite-links.js index c7f1b6847..f7c647d20 100644 --- a/lib/hooks/invite-links.js +++ b/lib/hooks/invite-links.js @@ -1,195 +1,310 @@ // @flow -import invariant from 'invariant'; import * as React from 'react'; +import { addKeyserverActionType } from '../actions/keyserver-actions.js'; import { useCreateOrUpdatePublicLink, createOrUpdatePublicLinkActionTypes, useDisableInviteLink, disableInviteLinkLinkActionTypes, } from '../actions/link-actions.js'; import { joinThreadActionTypes, useJoinThread, } from '../actions/thread-actions.js'; +import { extractKeyserverIDFromID } from '../keyserver-conn/keyserver-call-utils.js'; import { createLoadingStatusSelector } from '../selectors/loading-selectors.js'; +import { isLoggedInToKeyserver } from '../selectors/user-selectors.js'; +import type { KeyserverOverride } from '../shared/invite-links.js'; +import { useIsKeyserverURLValid } from '../shared/keyserver-utils.js'; +import { callSingleKeyserverEndpointTimeout } from '../shared/timeouts.js'; import type { CalendarQuery } from '../types/entry-types.js'; import type { SetState } from '../types/hook-types.js'; +import { defaultKeyserverInfo } from '../types/keyserver-types.js'; import type { InviteLink, InviteLinkVerificationResponse, } from '../types/link-types.js'; import type { LoadingStatus } from '../types/loading-types.js'; +import type { ThreadJoinPayload } from '../types/thread-types.js'; import { useDispatchActionPromise } from '../utils/redux-promise-utils.js'; -import { useSelector } from '../utils/redux-utils.js'; +import { useDispatch, useSelector } from '../utils/redux-utils.js'; const createOrUpdatePublicLinkStatusSelector = createLoadingStatusSelector( createOrUpdatePublicLinkActionTypes, ); const disableInviteLinkStatusSelector = createLoadingStatusSelector( disableInviteLinkLinkActionTypes, ); function useInviteLinksActions( communityID: string, inviteLink: ?InviteLink, ): { +error: ?string, +isLoading: boolean, +name: string, +setName: SetState, +createOrUpdateInviteLink: () => mixed, +disableInviteLink: () => mixed, } { const [name, setName] = React.useState( inviteLink?.name ?? Math.random().toString(36).slice(-9), ); const [error, setError] = React.useState(null); const dispatchActionPromise = useDispatchActionPromise(); const callCreateOrUpdatePublicLink = useCreateOrUpdatePublicLink(); const createCreateOrUpdateActionPromise = React.useCallback(async () => { setError(null); try { return await callCreateOrUpdatePublicLink({ name, communityID, }); } catch (e) { setError(e.message); throw e; } }, [callCreateOrUpdatePublicLink, communityID, name]); const createOrUpdateInviteLink = React.useCallback(() => { void dispatchActionPromise( createOrUpdatePublicLinkActionTypes, createCreateOrUpdateActionPromise(), ); }, [createCreateOrUpdateActionPromise, dispatchActionPromise]); const disableInviteLinkServerCall = useDisableInviteLink(); const createDisableLinkActionPromise = React.useCallback(async () => { setError(null); try { return await disableInviteLinkServerCall({ name, communityID, }); } catch (e) { setError(e.message); throw e; } }, [disableInviteLinkServerCall, communityID, name]); const disableInviteLink = React.useCallback(() => { void dispatchActionPromise( disableInviteLinkLinkActionTypes, createDisableLinkActionPromise(), ); }, [createDisableLinkActionPromise, dispatchActionPromise]); const disableInviteLinkStatus = useSelector(disableInviteLinkStatusSelector); const createOrUpdatePublicLinkStatus = useSelector( createOrUpdatePublicLinkStatusSelector, ); const isLoading = createOrUpdatePublicLinkStatus === 'loading' || disableInviteLinkStatus === 'loading'; return React.useMemo( () => ({ error, isLoading, name, setName, createOrUpdateInviteLink, disableInviteLink, }), [createOrUpdateInviteLink, disableInviteLink, error, isLoading, name], ); } const joinThreadLoadingStatusSelector = createLoadingStatusSelector( joinThreadActionTypes, ); type AcceptInviteLinkParams = { +verificationResponse: InviteLinkVerificationResponse, +inviteSecret: string, + +keyserverOverride: ?KeyserverOverride, +calendarQuery: () => CalendarQuery, +onFinish: () => mixed, +onInvalidLinkDetected: () => mixed, }; function useAcceptInviteLink(params: AcceptInviteLinkParams): { +joinCommunity: () => mixed, +joinThreadLoadingStatus: LoadingStatus, } { const { verificationResponse, inviteSecret, + keyserverOverride, calendarQuery, onFinish, onInvalidLinkDetected, } = params; React.useEffect(() => { if (verificationResponse.status === 'already_joined') { onFinish(); } }, [onFinish, verificationResponse.status]); + const dispatch = useDispatch(); const callJoinThread = useJoinThread(); + const communityID = verificationResponse.community?.id; - const createJoinCommunityAction = React.useCallback(async () => { - invariant( - communityID, - 'CommunityID should be present while calling this function', - ); - const query = calendarQuery(); - try { - const result = await callJoinThread({ - threadID: communityID, - calendarQuery: { - startDate: query.startDate, - endDate: query.endDate, - filters: [ - ...query.filters, - { type: 'threads', threadIDs: [communityID] }, - ], + let keyserverID = keyserverOverride?.keyserverID; + if (!keyserverID && communityID) { + keyserverID = extractKeyserverIDFromID(communityID); + } + + const isKeyserverKnown = useSelector(state => + keyserverID ? !!state.keyserverStore.keyserverInfos[keyserverID] : false, + ); + const isAuthenticated = useSelector(isLoggedInToKeyserver(keyserverID)); + + const keyserverURL = keyserverOverride?.keyserverURL; + const isKeyserverURLValid = useIsKeyserverURLValid(keyserverURL); + + const [ongoingJoinData, setOngoingJoinData] = React.useState mixed, + +reject: () => mixed, + +communityID: string, + }>(null); + const timeoutRef = React.useRef(); + + React.useEffect(() => { + return () => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + }; + }, []); + + const createJoinCommunityAction = React.useCallback(() => { + return new Promise((resolve, reject) => { + if ( + !keyserverID || + !communityID || + keyserverID !== extractKeyserverIDFromID(communityID) + ) { + reject(); + onInvalidLinkDetected(); + setOngoingJoinData(null); + return; + } + const timeoutID = setTimeout(() => { + reject(); + setOngoingJoinData(oldData => { + if (oldData) { + onInvalidLinkDetected(); + } + return null; + }); + }, callSingleKeyserverEndpointTimeout); + timeoutRef.current = timeoutID; + const resolveAndClearTimeout = (result: ThreadJoinPayload) => { + clearTimeout(timeoutID); + resolve(result); + }; + const rejectAndClearTimeout = () => { + clearTimeout(timeoutID); + reject(); + }; + setOngoingJoinData({ + resolve: resolveAndClearTimeout, + reject: rejectAndClearTimeout, + communityID, + }); + }); + }, [communityID, keyserverID, onInvalidLinkDetected]); + + React.useEffect(() => { + void (async () => { + if (!ongoingJoinData || isKeyserverKnown) { + return; + } + const isValid = await isKeyserverURLValid(); + if (!isValid || !keyserverURL) { + onInvalidLinkDetected(); + ongoingJoinData.reject(); + setOngoingJoinData(null); + return; + } + dispatch({ + type: addKeyserverActionType, + payload: { + keyserverAdminUserID: keyserverID, + newKeyserverInfo: defaultKeyserverInfo(keyserverURL), }, - inviteLinkSecret: inviteSecret, }); - onFinish(); - return result; - } catch (e) { - onInvalidLinkDetected(); - throw e; - } + })(); + }, [ + dispatch, + isKeyserverKnown, + isKeyserverURLValid, + ongoingJoinData, + keyserverID, + keyserverURL, + onInvalidLinkDetected, + ]); + + React.useEffect(() => { + void (async () => { + if (!ongoingJoinData || !isAuthenticated) { + return; + } + const threadID = ongoingJoinData.communityID; + const query = calendarQuery(); + + try { + const result = await callJoinThread({ + threadID, + calendarQuery: { + startDate: query.startDate, + endDate: query.endDate, + filters: [ + ...query.filters, + { type: 'threads', threadIDs: [threadID] }, + ], + }, + inviteLinkSecret: inviteSecret, + }); + onFinish(); + ongoingJoinData.resolve(result); + } catch (e) { + onInvalidLinkDetected(); + ongoingJoinData.reject(); + } finally { + setOngoingJoinData(null); + } + })(); }, [ calendarQuery, callJoinThread, communityID, inviteSecret, + isAuthenticated, + ongoingJoinData, onFinish, onInvalidLinkDetected, ]); + const dispatchActionPromise = useDispatchActionPromise(); const joinCommunity = React.useCallback(() => { void dispatchActionPromise( joinThreadActionTypes, createJoinCommunityAction(), ); }, [createJoinCommunityAction, dispatchActionPromise]); const joinThreadLoadingStatus = useSelector(joinThreadLoadingStatusSelector); return React.useMemo( () => ({ joinCommunity, joinThreadLoadingStatus, }), [joinCommunity, joinThreadLoadingStatus], ); } export { useInviteLinksActions, useAcceptInviteLink }; diff --git a/lib/selectors/user-selectors.js b/lib/selectors/user-selectors.js index 657af7628..bd0932daf 100644 --- a/lib/selectors/user-selectors.js +++ b/lib/selectors/user-selectors.js @@ -1,203 +1,206 @@ // @flow import _memoize from 'lodash/memoize.js'; import { createSelector } from 'reselect'; import { getAvatarForUser, getRandomDefaultEmojiAvatar, } from '../shared/avatar-utils.js'; import { getSingleOtherUser } from '../shared/thread-utils.js'; import type { ClientEmojiAvatar } from '../types/avatar-types'; import type { RelativeMemberInfo, RawThreadInfo, } from '../types/minimally-encoded-thread-permissions-types.js'; import type { BaseAppState } from '../types/redux-types.js'; import { userRelationshipStatus } from '../types/relationship-types.js'; import { threadTypes } from '../types/thread-types-enum.js'; import type { RawThreadInfos } from '../types/thread-types.js'; import type { UserInfos, RelativeUserInfo, AccountUserInfo, CurrentUserInfo, } from '../types/user-types.js'; // Used for specific message payloads that include an array of user IDs, ie. // array of initial users, array of added users function userIDsToRelativeUserInfos( userIDs: $ReadOnlyArray, viewerID: ?string, userInfos: UserInfos, ): RelativeUserInfo[] { const relativeUserInfos: RelativeUserInfo[] = []; for (const userID of userIDs) { const username = userInfos[userID] ? userInfos[userID].username : null; const relativeUserInfo = { id: userID, username, isViewer: userID === viewerID, }; if (userID === viewerID) { relativeUserInfos.unshift(relativeUserInfo); } else { relativeUserInfos.push(relativeUserInfo); } } return relativeUserInfos; } function getRelativeMemberInfos( threadInfo: ?RawThreadInfo, currentUserID: ?string, userInfos: UserInfos, ): $ReadOnlyArray { const relativeMemberInfos: RelativeMemberInfo[] = []; if (!threadInfo) { return relativeMemberInfos; } const memberInfos = threadInfo.members; for (const memberInfo of memberInfos) { if (!memberInfo.role) { continue; } const username = userInfos[memberInfo.id] ? userInfos[memberInfo.id].username : null; if (memberInfo.id === currentUserID) { relativeMemberInfos.unshift({ ...memberInfo, username, isViewer: true, }); } else { relativeMemberInfos.push({ ...memberInfo, username, isViewer: false, }); } } return relativeMemberInfos; } const emptyArray: $ReadOnlyArray = []; // Includes current user at the start const baseRelativeMemberInfoSelectorForMembersOfThread: ( threadID: ?string, ) => (state: BaseAppState<>) => $ReadOnlyArray = ( threadID: ?string, ) => { if (!threadID) { return () => emptyArray; } return createSelector( (state: BaseAppState<>) => state.threadStore.threadInfos[threadID], (state: BaseAppState<>) => state.currentUserInfo && state.currentUserInfo.id, (state: BaseAppState<>) => state.userStore.userInfos, getRelativeMemberInfos, ); }; const relativeMemberInfoSelectorForMembersOfThread: ( threadID: ?string, ) => (state: BaseAppState<>) => $ReadOnlyArray = _memoize( baseRelativeMemberInfoSelectorForMembersOfThread, ); const userInfoSelectorForPotentialMembers: (state: BaseAppState<>) => { [id: string]: AccountUserInfo, } = createSelector( (state: BaseAppState<>) => state.userStore.userInfos, (state: BaseAppState<>) => state.currentUserInfo && state.currentUserInfo.id, ( userInfos: UserInfos, currentUserID: ?string, ): { [id: string]: AccountUserInfo } => { const availableUsers: { [id: string]: AccountUserInfo } = {}; for (const id in userInfos) { const { username, relationshipStatus } = userInfos[id]; if (id === currentUserID || !username) { continue; } if ( relationshipStatus !== userRelationshipStatus.BLOCKED_VIEWER && relationshipStatus !== userRelationshipStatus.BOTH_BLOCKED ) { availableUsers[id] = { id, username, relationshipStatus }; } } return availableUsers; }, ); const isLoggedIn = (state: BaseAppState<>): boolean => !!( state.currentUserInfo && !state.currentUserInfo.anonymous && state.dataLoaded ); const isLoggedInToKeyserver: ( - keyserverID: string, + keyserverID: ?string, ) => (state: BaseAppState<>) => boolean = _memoize( - (keyserverID: string) => (state: BaseAppState<>) => { + (keyserverID: ?string) => (state: BaseAppState<>) => { + if (!keyserverID) { + return false; + } const cookie = state.keyserverStore.keyserverInfos[keyserverID]?.cookie; return !!cookie && cookie.startsWith('user='); }, ); const usersWithPersonalThreadSelector: ( state: BaseAppState<>, ) => $ReadOnlySet = createSelector( (state: BaseAppState<>) => state.currentUserInfo && state.currentUserInfo.id, (state: BaseAppState<>) => state.threadStore.threadInfos, (viewerID: ?string, threadInfos: RawThreadInfos) => { const personalThreadMembers = new Set(); for (const threadID in threadInfos) { const thread = threadInfos[threadID]; if ( thread.type !== threadTypes.PERSONAL || !thread.members.find(member => member.id === viewerID) ) { continue; } const otherMemberID = getSingleOtherUser(thread, viewerID); if (otherMemberID) { personalThreadMembers.add(otherMemberID); } } return personalThreadMembers; }, ); const savedEmojiAvatarSelectorForCurrentUser: ( state: BaseAppState<>, ) => () => ClientEmojiAvatar = createSelector( (state: BaseAppState<>) => state.currentUserInfo && state.currentUserInfo, (currentUser: ?CurrentUserInfo) => { return () => { let userAvatar = getAvatarForUser(currentUser); if (userAvatar.type !== 'emoji') { userAvatar = getRandomDefaultEmojiAvatar(); } return userAvatar; }; }, ); export { userIDsToRelativeUserInfos, getRelativeMemberInfos, relativeMemberInfoSelectorForMembersOfThread, userInfoSelectorForPotentialMembers, isLoggedIn, isLoggedInToKeyserver, usersWithPersonalThreadSelector, savedEmojiAvatarSelectorForCurrentUser, }; diff --git a/native/navigation/deep-links-context-provider.react.js b/native/navigation/deep-links-context-provider.react.js index e983851df..bf81d2c30 100644 --- a/native/navigation/deep-links-context-provider.react.js +++ b/native/navigation/deep-links-context-provider.react.js @@ -1,183 +1,184 @@ // @flow import { useNavigation } from '@react-navigation/native'; import * as Application from 'expo-application'; import * as React from 'react'; import { Linking, Platform } from 'react-native'; import { useVerifyInviteLink, verifyInviteLinkActionTypes, } from 'lib/actions/link-actions.js'; import { parseInstallReferrerFromInviteLinkURL, parseDataFromDeepLink, type ParsedDeepLinkData, } from 'lib/facts/links.js'; import { isLoggedIn } from 'lib/selectors/user-selectors.js'; import { getKeyserverOverrideForAnInviteLink } from 'lib/shared/invite-links.js'; import type { KeyserverOverride } from 'lib/shared/invite-links.js'; import type { SetState } from 'lib/types/hook-types.js'; import { useDispatchActionPromise } from 'lib/utils/redux-promise-utils.js'; import { InviteLinkModalRouteName, SecondaryDeviceQRCodeScannerRouteName, } from './route-names.js'; import { useSelector } from '../redux/redux-utils.js'; import { useOnFirstLaunchEffect } from '../utils/hooks.js'; type DeepLinksContextType = { +setCurrentLinkUrl: SetState, }; const defaultContext = { setCurrentLinkUrl: () => {}, }; const DeepLinksContext: React.Context = React.createContext(defaultContext); type Props = { +children: React.Node, }; function DeepLinksContextProvider(props: Props): React.Node { const { children } = props; const [currentLink, setCurrentLink] = React.useState(null); React.useEffect(() => { // This listener listens for an event where a user clicked a link when the // app was running const subscription = Linking.addEventListener('url', ({ url }) => setCurrentLink(url), ); // We're also checking if the app was opened by using a link. // In that case the listener won't be called and we're instead checking // if the initial URL is set. void (async () => { const initialURL = await Linking.getInitialURL(); if (initialURL) { setCurrentLink(initialURL); } })(); return () => { subscription.remove(); }; }, []); const checkInstallReferrer = React.useCallback(async () => { if (Platform.OS !== 'android') { return; } const installReferrer = await Application.getInstallReferrerAsync(); if (!installReferrer) { return; } const linkSecret = parseInstallReferrerFromInviteLinkURL(installReferrer); if (linkSecret) { setCurrentLink(linkSecret); } }, []); useOnFirstLaunchEffect('ANDROID_REFERRER', checkInstallReferrer); const [keyserverOverride, setKeyserverOverride] = React.useState(undefined); const inviteLinkSecret = React.useRef(null); const loggedIn = useSelector(isLoggedIn); const dispatchActionPromise = useDispatchActionPromise(); const validateLink = useVerifyInviteLink(keyserverOverride); const navigation = useNavigation(); React.useEffect(() => { void (async () => { if (!loggedIn || !currentLink) { return; } // We're setting this to null so that we ensure that each link click // results in at most one validation and navigation. setCurrentLink(null); setKeyserverOverride(undefined); inviteLinkSecret.current = null; const parsedData: ParsedDeepLinkData = parseDataFromDeepLink(currentLink); if (!parsedData) { return; } if (parsedData.type === 'invite-link') { const { secret } = parsedData.data; inviteLinkSecret.current = secret; try { const newKeyserverOverride = await getKeyserverOverrideForAnInviteLink(secret); setKeyserverOverride(newKeyserverOverride); } catch (e) { console.log('Error while downloading an invite link blob', e); navigation.navigate<'InviteLinkModal'>({ name: InviteLinkModalRouteName, params: { invitationDetails: { status: 'invalid', }, secret, }, }); } } else if (parsedData.type === 'qr-code') { navigation.navigate(SecondaryDeviceQRCodeScannerRouteName); } })(); }, [currentLink, loggedIn, navigation]); React.useEffect(() => { const secret = inviteLinkSecret.current; if (keyserverOverride === undefined || !secret) { return; } setKeyserverOverride(undefined); void (async () => { let result; try { const validateLinkPromise = validateLink({ secret }); void dispatchActionPromise( verifyInviteLinkActionTypes, validateLinkPromise, ); result = await validateLinkPromise; if (result.status === 'already_joined') { return; } } catch (e) { console.log(e); result = { status: 'invalid', }; } navigation.navigate<'InviteLinkModal'>({ name: InviteLinkModalRouteName, params: { invitationDetails: result, secret, + keyserverOverride, }, }); })(); }, [dispatchActionPromise, keyserverOverride, navigation, validateLink]); const contextValue = React.useMemo( () => ({ setCurrentLinkUrl: setCurrentLink, }), [], ); return ( {children} ); } export { DeepLinksContext, DeepLinksContextProvider }; diff --git a/native/navigation/invite-link-modal.react.js b/native/navigation/invite-link-modal.react.js index 4ff821a3d..59a492210 100644 --- a/native/navigation/invite-link-modal.react.js +++ b/native/navigation/invite-link-modal.react.js @@ -1,214 +1,217 @@ // @flow import * as React from 'react'; import { View, Text, ActivityIndicator } from 'react-native'; import { useAcceptInviteLink } from 'lib/hooks/invite-links.js'; +import type { KeyserverOverride } from 'lib/shared/invite-links'; import type { InviteLinkVerificationResponse } from 'lib/types/link-types.js'; import { nonThreadCalendarQuery } from './nav-selectors.js'; import { NavContext } from './navigation-context.js'; import type { RootNavigationProp } from './root-navigator.react.js'; import type { NavigationRoute } from './route-names.js'; import Button from '../components/button.react.js'; import Modal from '../components/modal.react.js'; import { useSelector } from '../redux/redux-utils.js'; import { useStyles } from '../themes/colors.js'; export type InviteLinkModalParams = { +invitationDetails: InviteLinkVerificationResponse, +secret: string, + +keyserverOverride?: ?KeyserverOverride, }; type Props = { +navigation: RootNavigationProp<'InviteLinkModal'>, +route: NavigationRoute<'InviteLinkModal'>, }; function InviteLinkModal(props: Props): React.Node { const styles = useStyles(unboundStyles); - const { invitationDetails, secret } = props.route.params; + const { invitationDetails, secret, keyserverOverride } = props.route.params; const navContext = React.useContext(NavContext); const calendarQuery = useSelector(state => nonThreadCalendarQuery({ redux: state, navContext, }), ); const onInvalidLinkDetected = React.useCallback( () => props.navigation.setParams({ invitationDetails: { status: 'invalid', }, secret, }), [props.navigation, secret], ); const { joinCommunity, joinThreadLoadingStatus } = useAcceptInviteLink({ verificationResponse: invitationDetails, inviteSecret: secret, + keyserverOverride, calendarQuery, onFinish: props.navigation.goBack, onInvalidLinkDetected, }); const header = React.useMemo(() => { if (invitationDetails.status === 'valid') { return ( <> You have been invited to join {invitationDetails.community.name} ); } return ( <> Invite invalid This invite link may be expired. Please try again with another invite link. ); }, [ invitationDetails, styles.communityName, styles.invalidInviteExplanation, styles.invalidInviteTitle, styles.invitation, ]); const buttons = React.useMemo(() => { if (invitationDetails.status === 'valid') { const joinButtonContent = joinThreadLoadingStatus === 'loading' ? ( ) : ( Accept invite ); return ( <> ); } return ( ); }, [ invitationDetails.status, joinCommunity, joinThreadLoadingStatus, props.navigation.goBack, styles.activityIndicatorStyle, styles.button, styles.buttonPrimary, styles.buttonSecondary, styles.buttonText, styles.gap, ]); return ( {header} {buttons} ); } const unboundStyles = { modal: { backgroundColor: 'modalForeground', paddingVertical: 24, paddingHorizontal: 16, flex: 0, }, invitation: { color: 'whiteText', textAlign: 'center', fontSize: 14, fontWeight: '400', lineHeight: 22, marginBottom: 24, }, communityName: { color: 'whiteText', textAlign: 'center', fontSize: 18, fontWeight: '500', lineHeight: 24, }, invalidInviteTitle: { color: 'whiteText', textAlign: 'center', fontSize: 22, fontWeight: '500', lineHeight: 28, marginBottom: 24, }, invalidInviteExplanation: { color: 'whiteText', textAlign: 'center', fontSize: 14, fontWeight: '400', lineHeight: 22, }, separator: { height: 1, backgroundColor: 'modalSeparator', marginVertical: 24, }, gap: { marginBottom: 16, }, button: { borderRadius: 8, paddingVertical: 12, paddingHorizontal: 24, }, buttonPrimary: { backgroundColor: 'purpleButton', }, buttonSecondary: { borderColor: 'secondaryButtonBorder', borderWidth: 1, }, buttonText: { color: 'whiteText', textAlign: 'center', fontSize: 16, fontWeight: '500', lineHeight: 24, }, activityIndicatorStyle: { paddingVertical: 2, }, }; export default InviteLinkModal; diff --git a/web/invite-links/accept-invite-modal.react.js b/web/invite-links/accept-invite-modal.react.js index b438d1d79..6d88554e5 100644 --- a/web/invite-links/accept-invite-modal.react.js +++ b/web/invite-links/accept-invite-modal.react.js @@ -1,92 +1,95 @@ // @flow import * as React from 'react'; import ModalOverlay from 'lib/components/modal-overlay.react.js'; import { useModalContext } from 'lib/components/modal-provider.react.js'; import { useAcceptInviteLink } from 'lib/hooks/invite-links.js'; +import type { KeyserverOverride } from 'lib/shared/invite-links.js'; import { type InviteLinkVerificationResponse } from 'lib/types/link-types.js'; import css from './accept-invite-modal.css'; import Button, { buttonThemes } from '../components/button.react.js'; import { useSelector } from '../redux/redux-utils.js'; import { nonThreadCalendarQuery } from '../selectors/nav-selectors.js'; type Props = { +verificationResponse: InviteLinkVerificationResponse, +inviteSecret: string, + +keyserverOverride?: ?KeyserverOverride, }; function AcceptInviteModal(props: Props): React.Node { - const { verificationResponse, inviteSecret } = props; + const { verificationResponse, inviteSecret, keyserverOverride } = props; const [isLinkValid, setIsLinkValid] = React.useState( verificationResponse.status === 'valid', ); const onInvalidLinkDetected = React.useCallback( () => setIsLinkValid(false), [], ); const { popModal } = useModalContext(); const calendarQuery = useSelector(nonThreadCalendarQuery); const { joinCommunity, joinThreadLoadingStatus } = useAcceptInviteLink({ verificationResponse, inviteSecret, + keyserverOverride, calendarQuery, onFinish: popModal, onInvalidLinkDetected, }); let content; if (verificationResponse.status === 'valid' && isLinkValid) { const { community } = verificationResponse; content = ( <>
You have been invited to join
{community.name}

); } else { content = ( <>
Invite invalid
This invite link may be expired. Please try again with another invite link.

); } return (
{content}
); } export default AcceptInviteModal; diff --git a/web/invite-links/invite-link-handler.react.js b/web/invite-links/invite-link-handler.react.js index 01cfb59de..dd04862aa 100644 --- a/web/invite-links/invite-link-handler.react.js +++ b/web/invite-links/invite-link-handler.react.js @@ -1,100 +1,101 @@ // @flow import * as React from 'react'; import { useVerifyInviteLink, verifyInviteLinkActionTypes, } from 'lib/actions/link-actions.js'; import { useModalContext } from 'lib/components/modal-provider.react.js'; import { isLoggedIn } from 'lib/selectors/user-selectors.js'; import { getKeyserverOverrideForAnInviteLink } from 'lib/shared/invite-links.js'; import type { KeyserverOverride } from 'lib/shared/invite-links.js'; import { useDispatchActionPromise } from 'lib/utils/redux-promise-utils.js'; import { useDispatch } from 'lib/utils/redux-utils.js'; import AcceptInviteModal from './accept-invite-modal.react.js'; import { updateNavInfoActionType } from '../redux/action-types.js'; import { useSelector } from '../redux/redux-utils.js'; function InviteLinkHandler(): null { const inviteSecret = useSelector(state => state.navInfo.inviteSecret); const inviteLinkSecret = React.useRef(null); const [keyserverOverride, setKeyserverOverride] = React.useState(undefined); const loggedIn = useSelector(isLoggedIn); const dispatchActionPromise = useDispatchActionPromise(); const dispatch = useDispatch(); const { pushModal } = useModalContext(); React.useEffect(() => { void (async () => { if (!inviteSecret || !loggedIn) { return; } dispatch({ type: updateNavInfoActionType, payload: { inviteSecret: null }, }); setKeyserverOverride(undefined); inviteLinkSecret.current = inviteSecret; try { const newKeyserverOverride = await getKeyserverOverrideForAnInviteLink(inviteSecret); setKeyserverOverride(newKeyserverOverride); } catch (e) { console.error('Error while downloading an invite link blob', e); pushModal( , ); } })(); }, [dispatch, inviteSecret, loggedIn, pushModal]); const validateLink = useVerifyInviteLink(keyserverOverride); React.useEffect(() => { const secret = inviteLinkSecret.current; if (keyserverOverride === undefined || !secret) { return; } setKeyserverOverride(undefined); void (async () => { let result; try { const validateLinkPromise = validateLink({ secret }); void dispatchActionPromise( verifyInviteLinkActionTypes, validateLinkPromise, ); result = await validateLinkPromise; if (result.status === 'already_joined') { return; } } catch (e) { console.error('Error while verifying an invite link', e); result = { status: 'invalid', }; } pushModal( , ); })(); }, [dispatchActionPromise, keyserverOverride, pushModal, validateLink]); return null; } export default InviteLinkHandler;