diff --git a/lib/keyserver-conn/call-keyserver-endpoint-provider.react.js b/lib/keyserver-conn/call-keyserver-endpoint-provider.react.js index 697413ce3..c30e13e07 100644 --- a/lib/keyserver-conn/call-keyserver-endpoint-provider.react.js +++ b/lib/keyserver-conn/call-keyserver-endpoint-provider.react.js @@ -1,273 +1,278 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { createSelector } from 'reselect'; import { setNewSession } from './keyserver-conn-types.js'; import { canResolveKeyserverSessionInvalidation, resolveKeyserverSessionInvalidation, } from './recovery-utils.js'; import { logInActionSources } from '../types/account-types.js'; import type { PlatformDetails } from '../types/device-types.js'; import type { Endpoint, SocketAPIHandler } from '../types/endpoints.js'; import type { Dispatch } from '../types/redux-types.js'; import type { ClientSessionChange } from '../types/session-types.js'; import type { CurrentUserInfo } from '../types/user-types.js'; import type { CallServerEndpoint, CallServerEndpointOptions, } from '../utils/call-server-endpoint.js'; import callServerEndpoint from '../utils/call-server-endpoint.js'; export type ActionFunc = (callServerEndpoint: CallServerEndpoint) => F; -type CreateBoundServerCallsSelectorType = ( - ActionFunc, -) => BindServerCallsParams => F; +type CreateBoundServerCallsSelectorType = ( + keyserverID: string, +) => (ActionFunc) => ServerCallSelectorParams => F; type CallKeyserverEndpointContextType = { +bindCookieAndUtilsIntoCallServerEndpoint: ( params: BindServerCallsParams, ) => CallServerEndpoint, +createBoundServerCallsSelector: CreateBoundServerCallsSelectorType, }; const CallKeyserverEndpointContext: React.Context = React.createContext(); type OngoingRecoveryAttempt = { +waitingCalls: Array<(callServerEndpoint: ?CallServerEndpoint) => mixed>, }; -export type BindServerCallsParams = { +export type ServerCallSelectorParams = { +dispatch: Dispatch, +cookie: ?string, +urlPrefix: string, +sessionID: ?string, +currentUserInfo: ?CurrentUserInfo, +isSocketConnected: boolean, +lastCommunicatedPlatformDetails: ?PlatformDetails, - +keyserverID: string, }; +export type BindServerCallsParams = $ReadOnly<{ + ...ServerCallSelectorParams, + +keyserverID: string, +}>; type Props = { +children: React.Node, }; function CallKeyserverEndpointProvider(props: Props): React.Node { const ongoingRecoveryAttemptsRef = React.useRef< Map, >(new Map()); const bindCookieAndUtilsIntoCallServerEndpoint: ( params: BindServerCallsParams, ) => CallServerEndpoint = React.useCallback(params => { const { dispatch, cookie, urlPrefix, sessionID, currentUserInfo, isSocketConnected, lastCommunicatedPlatformDetails, keyserverID, } = params; const loggedIn = !!(currentUserInfo && !currentUserInfo.anonymous && true); const boundSetNewSession = ( sessionChange: ClientSessionChange, error: ?string, ) => setNewSession( dispatch, sessionChange, { currentUserInfo, cookiesAndSessions: { [keyserverID]: { cookie, sessionID } }, }, error, undefined, keyserverID, ); const canResolveInvalidation = canResolveKeyserverSessionInvalidation(); // This function gets called before callServerEndpoint sends a request, // to make sure that we're not in the middle of trying to recover // an invalidated cookie const waitIfCookieInvalidated = () => { if (!canResolveInvalidation) { // If there is no way to resolve the session invalidation, // just let the caller callServerEndpoint instance continue return Promise.resolve(null); } const ongoingRecoveryAttempt = ongoingRecoveryAttemptsRef.current.get(keyserverID); if (!ongoingRecoveryAttempt) { // Our cookie seems to be valid return Promise.resolve(null); } // Wait to run until we get our new cookie return new Promise(r => ongoingRecoveryAttempt.waitingCalls.push(r), ); }; // This function is a helper for the next function defined below const attemptToResolveInvalidation = async ( sessionChange: ClientSessionChange, ) => { const newAnonymousCookie = sessionChange.cookie; const newSessionChange = await resolveKeyserverSessionInvalidation( dispatch, newAnonymousCookie, urlPrefix, logInActionSources.cookieInvalidationResolutionAttempt, keyserverID, ); const ongoingRecoveryAttempt = ongoingRecoveryAttemptsRef.current.get(keyserverID); ongoingRecoveryAttemptsRef.current.delete(keyserverID); const currentWaitingCalls = ongoingRecoveryAttempt?.waitingCalls ?? []; const newCallServerEndpoint = newSessionChange ? bindCookieAndUtilsIntoCallServerEndpoint({ ...params, cookie: newSessionChange.cookie, sessionID: newSessionChange.sessionID, currentUserInfo: newSessionChange.currentUserInfo, }) : null; for (const func of currentWaitingCalls) { func(newCallServerEndpoint); } return newCallServerEndpoint; }; // If this function is called, callServerEndpoint got a response // invalidating its cookie, and is wondering if it should just like... // give up? Or if there's a chance at redemption const cookieInvalidationRecovery = (sessionChange: ClientSessionChange) => { if (!canResolveInvalidation) { // If there is no way to resolve the session invalidation, // just let the caller callServerEndpoint instance continue return Promise.resolve(null); } if (!loggedIn) { // We don't want to attempt any use native credentials of a logged out // user to log-in after a cookieInvalidation while logged out return Promise.resolve(null); } const ongoingRecoveryAttempt = ongoingRecoveryAttemptsRef.current.get(keyserverID); if (ongoingRecoveryAttempt) { return new Promise(r => ongoingRecoveryAttempt.waitingCalls.push(r), ); } ongoingRecoveryAttemptsRef.current.set(keyserverID, { waitingCalls: [] }); return attemptToResolveInvalidation(sessionChange); }; return ( endpoint: Endpoint, data: Object, options?: ?CallServerEndpointOptions, ) => callServerEndpoint( cookie, boundSetNewSession, waitIfCookieInvalidated, cookieInvalidationRecovery, urlPrefix, sessionID, isSocketConnected, lastCommunicatedPlatformDetails, socketAPIHandler, endpoint, data, dispatch, options, loggedIn, keyserverID, ); }, []); // All server calls needs to include some information from the Redux state // (namely, the cookie). This information is used deep in the server call, // at the point where callServerEndpoint is called. We don't want to bother // propagating the cookie (and any future config info that callServerEndpoint // needs) through to the server calls so they can pass it to // callServerEndpoint. Instead, we "curry" the cookie onto callServerEndpoint // within react-redux's connect's mapStateToProps function, and then pass that // "bound" callServerEndpoint that no longer needs the cookie as a parameter // on to the server call. const createBoundServerCallsSelector = React.useCallback( - (actionFunc: ActionFunc): (BindServerCallsParams => F) => - createSelector( - (state: BindServerCallsParams) => state.dispatch, - (state: BindServerCallsParams) => state.cookie, - (state: BindServerCallsParams) => state.urlPrefix, - (state: BindServerCallsParams) => state.sessionID, - (state: BindServerCallsParams) => state.currentUserInfo, - (state: BindServerCallsParams) => state.isSocketConnected, - (state: BindServerCallsParams) => state.lastCommunicatedPlatformDetails, - (state: BindServerCallsParams) => state.keyserverID, - ( - dispatch: Dispatch, - cookie: ?string, - urlPrefix: string, - sessionID: ?string, - currentUserInfo: ?CurrentUserInfo, - isSocketConnected: boolean, - lastCommunicatedPlatformDetails: ?PlatformDetails, - keyserverID: string, - ) => { - const boundCallServerEndpoint = - bindCookieAndUtilsIntoCallServerEndpoint({ - dispatch, - cookie, - urlPrefix, - sessionID, - currentUserInfo, - isSocketConnected, - lastCommunicatedPlatformDetails, - keyserverID, - }); - return actionFunc(boundCallServerEndpoint); - }, - ), + ( + keyserverID: string, + ): ((actionFunc: ActionFunc) => ServerCallSelectorParams => F) => + actionFunc => + createSelector( + (state: ServerCallSelectorParams) => state.dispatch, + (state: ServerCallSelectorParams) => state.cookie, + (state: ServerCallSelectorParams) => state.urlPrefix, + (state: ServerCallSelectorParams) => state.sessionID, + (state: ServerCallSelectorParams) => state.currentUserInfo, + (state: ServerCallSelectorParams) => state.isSocketConnected, + (state: ServerCallSelectorParams) => + state.lastCommunicatedPlatformDetails, + ( + dispatch: Dispatch, + cookie: ?string, + urlPrefix: string, + sessionID: ?string, + currentUserInfo: ?CurrentUserInfo, + isSocketConnected: boolean, + lastCommunicatedPlatformDetails: ?PlatformDetails, + ) => { + const boundCallServerEndpoint = + bindCookieAndUtilsIntoCallServerEndpoint({ + dispatch, + cookie, + urlPrefix, + sessionID, + currentUserInfo, + isSocketConnected, + lastCommunicatedPlatformDetails, + keyserverID, + }); + return actionFunc(boundCallServerEndpoint); + }, + ), [bindCookieAndUtilsIntoCallServerEndpoint], ); const value = React.useMemo( () => ({ bindCookieAndUtilsIntoCallServerEndpoint, createBoundServerCallsSelector, }), [bindCookieAndUtilsIntoCallServerEndpoint, createBoundServerCallsSelector], ); return ( {props.children} ); } function useCallKeyserverEndpointContext(): CallKeyserverEndpointContextType { const callKeyserverEndpointContext = React.useContext( CallKeyserverEndpointContext, ); invariant( callKeyserverEndpointContext, 'callKeyserverEndpointContext should be set', ); return callKeyserverEndpointContext; } let socketAPIHandler: ?SocketAPIHandler = null; function registerActiveSocket(passedSocketAPIHandler: ?SocketAPIHandler) { socketAPIHandler = passedSocketAPIHandler; } export { CallKeyserverEndpointProvider, useCallKeyserverEndpointContext, registerActiveSocket, }; diff --git a/lib/utils/action-utils.js b/lib/utils/action-utils.js index 6ea1aead4..46e9cc5b9 100644 --- a/lib/utils/action-utils.js +++ b/lib/utils/action-utils.js @@ -1,49 +1,48 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { useSelector, useDispatch } from './redux-utils.js'; import { ashoatKeyserverID } from './validation-utils.js'; import { type ActionFunc, - type BindServerCallsParams, + type ServerCallSelectorParams, useCallKeyserverEndpointContext, } from '../keyserver-conn/call-keyserver-endpoint-provider.react.js'; import { serverCallStateSelector } from '../selectors/server-calls.js'; function useServerCall( serverCall: ActionFunc, - paramOverride?: ?Partial, + paramOverride?: ?Partial, ): F { const dispatch = useDispatch(); const serverCallState = useSelector( serverCallStateSelector(ashoatKeyserverID), ); const { createBoundServerCallsSelector } = useCallKeyserverEndpointContext(); const selector = React.useMemo( - () => createBoundServerCallsSelector(serverCall), + () => createBoundServerCallsSelector(ashoatKeyserverID)(serverCall), [createBoundServerCallsSelector, serverCall], ); return React.useMemo(() => { const { urlPrefix, isSocketConnected } = serverCallState; invariant( !!urlPrefix && isSocketConnected !== undefined && isSocketConnected !== null, 'keyserver missing from keyserverStore', ); return selector({ ...serverCallState, urlPrefix, isSocketConnected, dispatch, ...paramOverride, - keyserverID: ashoatKeyserverID, }); }, [serverCallState, dispatch, paramOverride, selector]); } export { useServerCall }; diff --git a/native/account/siwe-panel.react.js b/native/account/siwe-panel.react.js index fe4fd412d..0510af1ad 100644 --- a/native/account/siwe-panel.react.js +++ b/native/account/siwe-panel.react.js @@ -1,226 +1,226 @@ // @flow import BottomSheet from '@gorhom/bottom-sheet'; import * as React from 'react'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import WebView from 'react-native-webview'; import { getSIWENonce, getSIWENonceActionTypes, siweAuthActionTypes, } from 'lib/actions/siwe-actions.js'; -import type { BindServerCallsParams } from 'lib/keyserver-conn/call-keyserver-endpoint-provider.react.js'; +import type { ServerCallSelectorParams } from 'lib/keyserver-conn/call-keyserver-endpoint-provider.react.js'; import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors.js'; import type { SIWEWebViewMessage, SIWEResult } from 'lib/types/siwe-types.js'; import { useServerCall } from 'lib/utils/action-utils.js'; import { useDispatchActionPromise } from 'lib/utils/redux-promise-utils.js'; import { useKeyboardHeight } from '../keyboard/keyboard-hooks.js'; import { useSelector } from '../redux/redux-utils.js'; import type { BottomSheetRef } from '../types/bottom-sheet.js'; import Alert from '../utils/alert.js'; import { getContentSigningKey } from '../utils/crypto-utils.js'; import { defaultLandingURLPrefix } from '../utils/url-utils.js'; const commSIWE = `${defaultLandingURLPrefix}/siwe`; const getSIWENonceLoadingStatusSelector = createLoadingStatusSelector( getSIWENonceActionTypes, ); const siweAuthLoadingStatusSelector = createLoadingStatusSelector(siweAuthActionTypes); type WebViewMessageEvent = { +nativeEvent: { +data: string, ... }, ... }; type Props = { +onClosed: () => mixed, +onClosing: () => mixed, +onSuccessfulWalletSignature: SIWEResult => mixed, +closing: boolean, +setLoading: boolean => mixed, - +keyserverCallParamOverride?: Partial, + +keyserverCallParamOverride?: Partial, }; function SIWEPanel(props: Props): React.Node { const dispatchActionPromise = useDispatchActionPromise(); const getSIWENonceCall = useServerCall( getSIWENonce, props.keyserverCallParamOverride, ); const getSIWENonceCallFailed = useSelector( state => getSIWENonceLoadingStatusSelector(state) === 'error', ); const { onClosing } = props; React.useEffect(() => { if (getSIWENonceCallFailed) { Alert.alert( 'Unknown error', 'Uhh... try again?', [{ text: 'OK', onPress: onClosing }], { cancelable: false }, ); } }, [getSIWENonceCallFailed, onClosing]); const siweAuthCallLoading = useSelector( state => siweAuthLoadingStatusSelector(state) === 'loading', ); const [nonce, setNonce] = React.useState(null); const [primaryIdentityPublicKey, setPrimaryIdentityPublicKey] = React.useState(null); React.useEffect(() => { void (async () => { void dispatchActionPromise( getSIWENonceActionTypes, (async () => { const response = await getSIWENonceCall(); setNonce(response); })(), ); const ed25519 = await getContentSigningKey(); setPrimaryIdentityPublicKey(ed25519); })(); }, [dispatchActionPromise, getSIWENonceCall]); const [isLoading, setLoading] = React.useState(true); const [walletConnectModalHeight, setWalletConnectModalHeight] = React.useState(0); const insets = useSafeAreaInsets(); const keyboardHeight = useKeyboardHeight(); const bottomInset = insets.bottom; const snapPoints = React.useMemo(() => { if (isLoading) { return [1]; } else if (walletConnectModalHeight) { const baseHeight = bottomInset + walletConnectModalHeight + keyboardHeight; if (baseHeight < 400) { return [baseHeight - 10]; } else { return [baseHeight + 5]; } } else { const baseHeight = bottomInset + keyboardHeight; return [baseHeight + 435, baseHeight + 600]; } }, [isLoading, walletConnectModalHeight, bottomInset, keyboardHeight]); const bottomSheetRef = React.useRef(); const snapToIndex = bottomSheetRef.current?.snapToIndex; React.useEffect(() => { // When the snapPoints change, always reset to the first one // Without this, when we close the WalletConnect modal we don't resize snapToIndex?.(0); }, [snapToIndex, snapPoints]); const closeBottomSheet = bottomSheetRef.current?.close; const { closing, onSuccessfulWalletSignature } = props; const handleMessage = React.useCallback( async (event: WebViewMessageEvent) => { const data: SIWEWebViewMessage = JSON.parse(event.nativeEvent.data); if (data.type === 'siwe_success') { const { address, message, signature } = data; if (address && signature) { closeBottomSheet?.(); await onSuccessfulWalletSignature({ address, message, signature }); } } else if (data.type === 'siwe_closed') { onClosing(); closeBottomSheet?.(); } else if (data.type === 'walletconnect_modal_update') { const height = data.state === 'open' ? data.height : 0; setWalletConnectModalHeight(height); } }, [onSuccessfulWalletSignature, onClosing, closeBottomSheet], ); const prevClosingRef = React.useRef(); React.useEffect(() => { if (closing && !prevClosingRef.current) { closeBottomSheet?.(); } prevClosingRef.current = closing; }, [closing, closeBottomSheet]); const source = React.useMemo( () => ({ uri: commSIWE, headers: { 'siwe-nonce': nonce, 'siwe-primary-identity-public-key': primaryIdentityPublicKey, }, }), [nonce, primaryIdentityPublicKey], ); const onWebViewLoaded = React.useCallback(() => { setLoading(false); }, []); const walletConnectModalOpen = walletConnectModalHeight !== 0; const backgroundStyle = React.useMemo( () => ({ backgroundColor: walletConnectModalOpen ? '#3396ff' : '#242529', }), [walletConnectModalOpen], ); const bottomSheetHandleIndicatorStyle = React.useMemo( () => ({ backgroundColor: 'white', }), [], ); const { onClosed } = props; const onBottomSheetChange = React.useCallback( (index: number) => { if (index === -1) { onClosed(); } }, [onClosed], ); let bottomSheet; if (nonce && primaryIdentityPublicKey) { bottomSheet = ( ); } const setLoadingProp = props.setLoading; const loading = !getSIWENonceCallFailed && (isLoading || siweAuthCallLoading); React.useEffect(() => { setLoadingProp(loading); }, [setLoadingProp, loading]); return bottomSheet; } export default SIWEPanel;