diff --git a/lib/actions/user-actions.js b/lib/actions/user-actions.js index b1bbd35f7..afc1d6b2b 100644 --- a/lib/actions/user-actions.js +++ b/lib/actions/user-actions.js @@ -1,1392 +1,1391 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; +import { useUserIdentityCache } from '../components/user-identity-cache.react.js'; import { useBroadcastDeviceListUpdates, useBroadcastAccountDeletion, } from '../hooks/peer-list-hooks.js'; import type { CallSingleKeyserverEndpoint, CallSingleKeyserverEndpointOptions, } from '../keyserver-conn/call-single-keyserver-endpoint.js'; import { extractKeyserverIDFromID, sortThreadIDsPerKeyserver, sortCalendarQueryPerKeyserver, } from '../keyserver-conn/keyserver-call-utils.js'; import { useKeyserverCall } from '../keyserver-conn/keyserver-call.js'; import type { CallKeyserverEndpoint } from '../keyserver-conn/keyserver-conn-types.js'; import { usePreRequestUserState } from '../selectors/account-selectors.js'; import { getForeignPeerDeviceIDs } from '../selectors/user-selectors.js'; import { getOneTimeKeyValuesFromBlob, getPrekeyValueFromBlob, } from '../shared/crypto-utils.js'; import { fetchLatestDeviceList } from '../shared/device-list-utils.js'; import { IdentityClientContext } from '../shared/identity-client-context.js'; import threadWatcher from '../shared/thread-watcher.js'; import { permissionsAndAuthRelatedRequestTimeout, callIdentityServiceTimeout, } from '../shared/timeouts.js'; import { usePeerToPeerCommunication } from '../tunnelbroker/peer-to-peer-context.js'; import type { LegacyLogInInfo, LegacyLogInResult, LegacyRegisterResult, LegacyRegisterInfo, UpdateUserSettingsRequest, PolicyAcknowledgmentRequest, ClaimUsernameRequest, ClaimUsernameResponse, LogInRequest, KeyserverAuthResult, KeyserverAuthInfo, KeyserverAuthRequest, ClientLogInResponse, KeyserverLogOutResult, LogOutResult, } from '../types/account-types.js'; import type { UpdateUserAvatarRequest, UpdateUserAvatarResponse, } from '../types/avatar-types.js'; import type { RawEntryInfo, CalendarQuery } from '../types/entry-types.js'; import type { UserIdentitiesResponse, IdentityAuthResult, } from '../types/identity-service-types.js'; import type { RawMessageInfo, MessageTruncationStatuses, } from '../types/message-types.js'; import type { GetOlmSessionInitializationDataResponse } from '../types/olm-session-types.js'; import type { UserSearchResult, ExactUserSearchResult, } from '../types/search-types.js'; import type { PreRequestUserState } from '../types/session-types.js'; import type { SubscriptionUpdateRequest, SubscriptionUpdateResult, } from '../types/subscription-types.js'; import type { RawThreadInfos } from '../types/thread-types.js'; import { userActionsP2PMessageTypes, type DeviceLogoutP2PMessage, type SecondaryDeviceLogoutP2PMessage, } from '../types/tunnelbroker/user-actions-peer-to-peer-message-types.js'; import type { CurrentUserInfo, UserInfo, PasswordUpdate, LoggedOutUserInfo, } from '../types/user-types.js'; import { authoritativeKeyserverID } from '../utils/authoritative-keyserver.js'; import { getConfig } from '../utils/config.js'; import { getMessageForException } from '../utils/errors.js'; import { useSelector } from '../utils/redux-utils.js'; import { usingCommServicesAccessToken } from '../utils/services-utils.js'; import sleep from '../utils/sleep.js'; const loggedOutUserInfo: LoggedOutUserInfo = { anonymous: true, }; export type KeyserverLogOutInput = { +preRequestUserState: PreRequestUserState, +keyserverIDs?: $ReadOnlyArray, }; const logOutActionTypes = Object.freeze({ started: 'LOG_OUT_STARTED', success: 'LOG_OUT_SUCCESS', failed: 'LOG_OUT_FAILED', }); const keyserverLogOut = ( callKeyserverEndpoint: CallKeyserverEndpoint, allKeyserverIDs: $ReadOnlyArray, ): ((input: KeyserverLogOutInput) => Promise) => async input => { const { preRequestUserState } = input; const keyserverIDs = input.keyserverIDs ?? allKeyserverIDs; const requests: { [string]: {} } = {}; for (const keyserverID of keyserverIDs) { requests[keyserverID] = {}; } let response = null; try { response = await Promise.race([ callKeyserverEndpoint('log_out', requests), (async () => { await sleep(500); throw new Error('keyserver log_out took more than 500ms'); })(), ]); } catch {} const currentUserInfo = response ? loggedOutUserInfo : null; return { currentUserInfo, preRequestUserState, keyserverIDs }; }; type UseLogOutOptions = { +logOutType?: 'legacy' | 'primary_device' | 'secondary_device', +skipIdentityLogOut?: boolean, }; function useLogOut( options: UseLogOutOptions = {}, ): (keyserverIDs?: $ReadOnlyArray) => Promise { const client = React.useContext(IdentityClientContext); const identityClient = client?.identityClient; const preRequestUserState = usePreRequestUserState(); const callKeyserverLogOut = useKeyserverCall(keyserverLogOut); const commServicesAccessToken = useSelector( state => state.commServicesAccessToken, ); const { logOutType, skipIdentityLogOut } = options; return React.useCallback( async (keyserverIDs?: $ReadOnlyArray) => { const identityPromise = (async () => { if ( skipIdentityLogOut || !usingCommServicesAccessToken || !commServicesAccessToken ) { return; } if (!identityClient) { throw new Error('Identity service client is not initialized'); } let callIdentityClientLogOut; if (logOutType === 'primary_device') { if (!identityClient.logOutPrimaryDevice) { throw new Error( 'logOutPrimaryDevice not defined. ' + 'Are you calling it on non-primary device?', ); } callIdentityClientLogOut = identityClient.logOutPrimaryDevice; } else { callIdentityClientLogOut = logOutType === 'secondary_device' ? identityClient.logOutSecondaryDevice : identityClient.logOut; } try { await Promise.race([ callIdentityClientLogOut(), (async () => { await sleep(500); throw new Error('identity log_out took more than 500ms'); })(), ]); } catch {} })(); const [{ keyserverIDs: _, ...result }] = await Promise.all([ callKeyserverLogOut({ preRequestUserState, keyserverIDs, }), identityPromise, ]); return { ...result, preRequestUserState: { ...result.preRequestUserState, commServicesAccessToken, }, }; }, [ callKeyserverLogOut, commServicesAccessToken, identityClient, logOutType, preRequestUserState, skipIdentityLogOut, ], ); } function useIdentityLogOut(): () => Promise { const client = React.useContext(IdentityClientContext); const identityClient = client?.identityClient; const preRequestUserState = usePreRequestUserState(); const commServicesAccessToken = useSelector( state => state.commServicesAccessToken, ); return React.useCallback(async () => { invariant( usingCommServicesAccessToken, 'identityLogOut can only be called when usingCommServicesAccessToken', ); if (!identityClient) { throw new Error('Identity service client is not initialized'); } try { await Promise.race([ identityClient.logOut(), (async () => { await sleep(500); throw new Error('identity log_out took more than 500ms'); })(), ]); } catch {} return { currentUserInfo: null, preRequestUserState: { ...preRequestUserState, commServicesAccessToken, }, }; }, [commServicesAccessToken, identityClient, preRequestUserState]); } const primaryDeviceLogOutOptions = Object.freeze({ logOutType: 'primary_device', }); function usePrimaryDeviceLogOut(): () => Promise { const identityContext = React.useContext(IdentityClientContext); if (!identityContext) { throw new Error('Identity service client is not initialized'); } const broadcastDeviceListUpdates = useBroadcastDeviceListUpdates(); const { broadcastEphemeralMessage } = usePeerToPeerCommunication(); const foreignPeerDevices = useSelector(getForeignPeerDeviceIDs); const logOut = useLogOut(primaryDeviceLogOutOptions); return React.useCallback(async () => { const { identityClient, getAuthMetadata } = identityContext; const authMetadata = await getAuthMetadata(); const { userID, deviceID: thisDeviceID } = authMetadata; if (!thisDeviceID || !userID) { throw new Error('No auth metadata'); } const { devices: [primaryDeviceID, ...secondaryDevices], } = await fetchLatestDeviceList(identityClient, userID); if (thisDeviceID !== primaryDeviceID) { throw new Error('Used primary device logout on a non-primary device'); } const messageContents: DeviceLogoutP2PMessage = { type: userActionsP2PMessageTypes.LOG_OUT_DEVICE, }; const recipients = secondaryDevices.map(deviceID => ({ userID, deviceID })); await broadcastEphemeralMessage( JSON.stringify(messageContents), recipients, authMetadata, ); // - logOut() performs device list update by calling Identity RPC // - broadcastDeviceListUpdates asks peers to download it from identity // so we need to call them in this order to make sure peers have latest // device list. // We're relying on Tunnelbroker session stil existing after calling logout // and auth metadata not yet cleared at this point. const logOutResult = await logOut(); await broadcastDeviceListUpdates(foreignPeerDevices); return logOutResult; }, [ broadcastDeviceListUpdates, broadcastEphemeralMessage, foreignPeerDevices, identityContext, logOut, ]); } const secondaryDeviceLogOutOptions = Object.freeze({ logOutType: 'secondary_device', }); function useSecondaryDeviceLogOut(): () => Promise { const logOut = useLogOut(secondaryDeviceLogOutOptions); const identityContext = React.useContext(IdentityClientContext); if (!identityContext) { throw new Error('Identity service client is not initialized'); } const { broadcastEphemeralMessage } = usePeerToPeerCommunication(); return React.useCallback(async () => { const { identityClient, getAuthMetadata } = identityContext; const authMetadata = await getAuthMetadata(); const { userID, deviceID } = authMetadata; if (!deviceID || !userID) { throw new Error('No auth metadata'); } // get current device list and primary device ID const { devices } = await fetchLatestDeviceList(identityClient, userID); const primaryDeviceID = devices[0]; if (deviceID === primaryDeviceID) { throw new Error('Used secondary device logout on primary device'); } // create and send Olm Tunnelbroker message to primary device const { olmAPI } = getConfig(); await olmAPI.initializeCryptoAccount(); const messageContents: SecondaryDeviceLogoutP2PMessage = { type: userActionsP2PMessageTypes.LOG_OUT_SECONDARY_DEVICE, }; const recipient = { userID, deviceID: primaryDeviceID }; await broadcastEphemeralMessage( JSON.stringify(messageContents), [recipient], authMetadata, ); // log out of identity service, keyserver and visually return logOut(); }, [identityContext, broadcastEphemeralMessage, logOut]); } const claimUsernameActionTypes = Object.freeze({ started: 'CLAIM_USERNAME_STARTED', success: 'CLAIM_USERNAME_SUCCESS', failed: 'CLAIM_USERNAME_FAILED', }); const claimUsername = ( callKeyserverEndpoint: CallKeyserverEndpoint, ): (( claimUsernameRequest: ClaimUsernameRequest, ) => Promise) => async (claimUsernameRequest: ClaimUsernameRequest) => { const requests = { [authoritativeKeyserverID()]: claimUsernameRequest }; const responses = await callKeyserverEndpoint('claim_username', requests); const response = responses[authoritativeKeyserverID()]; return { message: response.message, signature: response.signature, }; }; function useClaimUsername(): ( claimUsernameRequest: ClaimUsernameRequest, ) => Promise { return useKeyserverCall(claimUsername); } const deleteKeyserverAccountActionTypes = Object.freeze({ started: 'DELETE_KEYSERVER_ACCOUNT_STARTED', success: 'DELETE_KEYSERVER_ACCOUNT_SUCCESS', failed: 'DELETE_KEYSERVER_ACCOUNT_FAILED', }); const deleteKeyserverAccount = ( callKeyserverEndpoint: CallKeyserverEndpoint, allKeyserverIDs: $ReadOnlyArray, ): ((input: KeyserverLogOutInput) => Promise) => async input => { const { preRequestUserState } = input; const keyserverIDs = input.keyserverIDs ?? allKeyserverIDs; const requests: { [string]: {} } = {}; for (const keyserverID of keyserverIDs) { requests[keyserverID] = {}; } await callKeyserverEndpoint('delete_account', requests); return { currentUserInfo: loggedOutUserInfo, preRequestUserState, keyserverIDs, }; }; function useDeleteKeyserverAccount(): ( keyserverIDs?: $ReadOnlyArray, ) => Promise { const preRequestUserState = usePreRequestUserState(); const callKeyserverDeleteAccount = useKeyserverCall(deleteKeyserverAccount); return React.useCallback( (keyserverIDs?: $ReadOnlyArray) => callKeyserverDeleteAccount({ preRequestUserState, keyserverIDs }), [callKeyserverDeleteAccount, preRequestUserState], ); } const deleteAccountActionTypes = Object.freeze({ started: 'DELETE_ACCOUNT_STARTED', success: 'DELETE_ACCOUNT_SUCCESS', failed: 'DELETE_ACCOUNT_FAILED', }); const accountDeletionBroadcastOptions = Object.freeze({ includeOwnDevices: true, }); function useDeleteAccount(): (password: ?string) => Promise { const client = React.useContext(IdentityClientContext); const identityClient = client?.identityClient; const broadcastAccountDeletion = useBroadcastAccountDeletion( accountDeletionBroadcastOptions, ); const preRequestUserState = usePreRequestUserState(); const callKeyserverDeleteAccount = useKeyserverCall(deleteKeyserverAccount); const commServicesAccessToken = useSelector( state => state.commServicesAccessToken, ); return React.useCallback( async password => { if (usingCommServicesAccessToken) { if (!identityClient) { throw new Error('Identity service client is not initialized'); } const { deleteWalletUser, deletePasswordUser } = identityClient; if (!deleteWalletUser || !deletePasswordUser) { throw new Error('Delete user method unimplemented'); } await broadcastAccountDeletion(); if (password) { await deletePasswordUser(password); } else { await deleteWalletUser(); } } try { const keyserverResult = await callKeyserverDeleteAccount({ preRequestUserState, }); const { keyserverIDs: _, ...result } = keyserverResult; return { ...result, preRequestUserState: { ...result.preRequestUserState, commServicesAccessToken, }, }; } catch (e) { if (!usingCommServicesAccessToken) { throw e; } console.log( 'Failed to delete account on keyserver:', getMessageForException(e), ); } return { currentUserInfo: null, preRequestUserState: { ...preRequestUserState, commServicesAccessToken, }, }; }, [ broadcastAccountDeletion, callKeyserverDeleteAccount, commServicesAccessToken, identityClient, preRequestUserState, ], ); } // useDeleteDiscardedIdentityAccount is used in a scenario where the user is // visibly logged-out, and it's only used to reset state (eg. Redux, SQLite) to // a logged-out state. function useDeleteDiscardedIdentityAccount(): ( password: ?string, ) => Promise { const client = React.useContext(IdentityClientContext); const identityClient = client?.identityClient; const preRequestUserState = usePreRequestUserState(); const commServicesAccessToken = useSelector( state => state.commServicesAccessToken, ); return React.useCallback( async password => { invariant( usingCommServicesAccessToken, 'deleteDiscardedIdentityAccount can only be called when ' + 'usingCommServicesAccessToken', ); if (!identityClient) { throw new Error('Identity service client is not initialized'); } if ( !identityClient.deleteWalletUser || !identityClient.deletePasswordUser ) { throw new Error('Delete user method unimplemented'); } const deleteUserPromise = password ? identityClient.deletePasswordUser(password) : identityClient.deleteWalletUser(); await Promise.race([ deleteUserPromise, (async () => { await sleep(callIdentityServiceTimeout); throw new Error('identity delete user call took more than 500ms'); })(), ]); return { currentUserInfo: null, preRequestUserState: { ...preRequestUserState, commServicesAccessToken, }, }; }, [commServicesAccessToken, identityClient, preRequestUserState], ); } const legacyKeyserverRegisterActionTypes = Object.freeze({ started: 'LEGACY_KEYSERVER_REGISTER_STARTED', success: 'LEGACY_KEYSERVER_REGISTER_SUCCESS', failed: 'LEGACY_KEYSERVER_REGISTER_FAILED', }); const legacyKeyserverRegisterCallSingleKeyserverEndpointOptions = { timeout: permissionsAndAuthRelatedRequestTimeout, }; const legacyKeyserverRegister = ( callSingleKeyserverEndpoint: CallSingleKeyserverEndpoint, ): (( registerInfo: LegacyRegisterInfo, options?: CallSingleKeyserverEndpointOptions, ) => Promise) => async (registerInfo, options) => { const deviceTokenUpdateRequest = registerInfo.deviceTokenUpdateRequest[authoritativeKeyserverID()]; const { preRequestUserInfo, ...rest } = registerInfo; const response = await callSingleKeyserverEndpoint( 'create_account', { ...rest, deviceTokenUpdateRequest, platformDetails: getConfig().platformDetails, }, { ...legacyKeyserverRegisterCallSingleKeyserverEndpointOptions, ...options, }, ); return { currentUserInfo: response.currentUserInfo, rawMessageInfos: response.rawMessageInfos, threadInfos: response.cookieChange.threadInfos, userInfos: response.cookieChange.userInfos, calendarQuery: registerInfo.calendarQuery, }; }; export type KeyserverAuthInput = $ReadOnly<{ ...KeyserverAuthInfo, +preRequestUserInfo: ?CurrentUserInfo, }>; const keyserverAuthActionTypes = Object.freeze({ started: 'KEYSERVER_AUTH_STARTED', success: 'KEYSERVER_AUTH_SUCCESS', failed: 'KEYSERVER_AUTH_FAILED', }); const keyserverAuthCallSingleKeyserverEndpointOptions = { timeout: permissionsAndAuthRelatedRequestTimeout, }; const keyserverAuth = ( callKeyserverEndpoint: CallKeyserverEndpoint, ): ((input: KeyserverAuthInput) => Promise) => async keyserverAuthInfo => { const watchedIDs = threadWatcher.getWatchedIDs(); const { authActionSource, calendarQuery, keyserverData, deviceTokenUpdateInput, preRequestUserInfo, ...restLogInInfo } = keyserverAuthInfo; const keyserverIDs = Object.keys(keyserverData); const authKeyserverID = authoritativeKeyserverID(); if (restLogInInfo.password) { invariant( keyserverIDs.length === 1 && keyserverIDs[0] === authKeyserverID, 'passing password to non-auth keyserver', ); } const watchedIDsPerKeyserver = sortThreadIDsPerKeyserver(watchedIDs); const calendarQueryPerKeyserver = sortCalendarQueryPerKeyserver( calendarQuery, keyserverIDs, ); const requests: { [string]: KeyserverAuthRequest } = {}; for (const keyserverID of keyserverIDs) { requests[keyserverID] = { ...restLogInInfo, deviceTokenUpdateRequest: deviceTokenUpdateInput[keyserverID], watchedIDs: watchedIDsPerKeyserver[keyserverID] ?? [], calendarQuery: calendarQueryPerKeyserver[keyserverID], platformDetails: getConfig().platformDetails, initialContentEncryptedMessage: keyserverData[keyserverID].initialContentEncryptedMessage, initialNotificationsEncryptedMessage: keyserverData[keyserverID].initialNotificationsEncryptedMessage, source: authActionSource, }; } const responses: { +[string]: ClientLogInResponse } = await callKeyserverEndpoint( 'keyserver_auth', requests, keyserverAuthCallSingleKeyserverEndpointOptions, ); let threadInfos: RawThreadInfos = {}; const calendarResult: WritableCalendarResult = { calendarQuery: keyserverAuthInfo.calendarQuery, rawEntryInfos: [], }; const messagesResult: WritableGenericMessagesResult = { messageInfos: [], truncationStatus: {}, watchedIDsAtRequestTime: watchedIDs, currentAsOf: {}, }; let updatesCurrentAsOf: { +[string]: number } = {}; for (const keyserverID in responses) { threadInfos = { ...responses[keyserverID].cookieChange.threadInfos, ...threadInfos, }; if (responses[keyserverID].rawEntryInfos) { calendarResult.rawEntryInfos = calendarResult.rawEntryInfos.concat( responses[keyserverID].rawEntryInfos, ); } messagesResult.messageInfos = messagesResult.messageInfos.concat( responses[keyserverID].rawMessageInfos, ); messagesResult.truncationStatus = { ...messagesResult.truncationStatus, ...responses[keyserverID].truncationStatuses, }; messagesResult.currentAsOf = { ...messagesResult.currentAsOf, [keyserverID]: responses[keyserverID].serverTime, }; updatesCurrentAsOf = { ...updatesCurrentAsOf, [keyserverID]: responses[keyserverID].serverTime, }; } let userInfos: $ReadOnlyArray = []; if (responses[authKeyserverID]) { const userInfosArrays = [ responses[authKeyserverID].userInfos, responses[authKeyserverID].cookieChange.userInfos, ]; userInfos = mergeUserInfos(...userInfosArrays); } return { threadInfos, currentUserInfo: responses[authKeyserverID]?.currentUserInfo, calendarResult, messagesResult, userInfos, updatesCurrentAsOf, authActionSource: keyserverAuthInfo.authActionSource, notAcknowledgedPolicies: responses[authKeyserverID]?.notAcknowledgedPolicies, preRequestUserInfo, }; }; const identityRegisterActionTypes = Object.freeze({ started: 'IDENTITY_REGISTER_STARTED', success: 'IDENTITY_REGISTER_SUCCESS', failed: 'IDENTITY_REGISTER_FAILED', }); function useIdentityPasswordRegister(): ( username: string, password: string, fid: ?string, ) => Promise { const client = React.useContext(IdentityClientContext); const identityClient = client?.identityClient; invariant(identityClient, 'Identity client should be set'); if (!identityClient.registerPasswordUser) { throw new Error('Register password user method unimplemented'); } const { registerPasswordUser } = identityClient; const { markPrekeysAsPublished } = getConfig().olmAPI; return React.useCallback( async (username: string, password: string, fid: ?string) => { const response = await registerPasswordUser(username, password, fid); try { await markPrekeysAsPublished(); } catch (e) { console.log( 'Failed to mark prekeys as published:', getMessageForException(e), ); } return response; }, [registerPasswordUser, markPrekeysAsPublished], ); } function useIdentityWalletRegister(): ( walletAddress: string, siweMessage: string, siweSignature: string, fid: ?string, ) => Promise { const client = React.useContext(IdentityClientContext); const identityClient = client?.identityClient; invariant(identityClient, 'Identity client should be set'); if (!identityClient.registerWalletUser) { throw new Error('Register wallet user method unimplemented'); } const { registerWalletUser } = identityClient; const { markPrekeysAsPublished } = getConfig().olmAPI; return React.useCallback( async ( walletAddress: string, siweMessage: string, siweSignature: string, fid: ?string, ) => { const response = await registerWalletUser( walletAddress, siweMessage, siweSignature, fid, ); try { await markPrekeysAsPublished(); } catch (e) { console.log( 'Failed to mark prekeys as published:', getMessageForException(e), ); } return response; }, [registerWalletUser, markPrekeysAsPublished], ); } const identityGenerateNonceActionTypes = Object.freeze({ started: 'IDENTITY_GENERATE_NONCE_STARTED', success: 'IDENTITY_GENERATE_NONCE_SUCCESS', failed: 'IDENTITY_GENERATE_NONCE_FAILED', }); function useIdentityGenerateNonce(): () => Promise { const client = React.useContext(IdentityClientContext); const identityClient = client?.identityClient; invariant(identityClient, 'Identity client should be set'); return identityClient.generateNonce; } function mergeUserInfos( ...userInfoArrays: Array<$ReadOnlyArray> ): UserInfo[] { const merged: { [string]: UserInfo } = {}; for (const userInfoArray of userInfoArrays) { for (const userInfo of userInfoArray) { merged[userInfo.id] = userInfo; } } const flattened = []; for (const id in merged) { flattened.push(merged[id]); } return flattened; } type WritableGenericMessagesResult = { messageInfos: RawMessageInfo[], truncationStatus: MessageTruncationStatuses, watchedIDsAtRequestTime: string[], currentAsOf: { [keyserverID: string]: number }, }; type WritableCalendarResult = { rawEntryInfos: RawEntryInfo[], calendarQuery: CalendarQuery, }; const identityLogInActionTypes = Object.freeze({ started: 'IDENTITY_LOG_IN_STARTED', success: 'IDENTITY_LOG_IN_SUCCESS', failed: 'IDENTITY_LOG_IN_FAILED', }); function useIdentityPasswordLogIn(): ( username: string, password: string, ) => Promise { const client = React.useContext(IdentityClientContext); const identityClient = client?.identityClient; const preRequestUserState = useSelector(state => state.currentUserInfo); const callClaimUsername = useClaimUsername(); const { markPrekeysAsPublished } = getConfig().olmAPI; return React.useCallback( (username, password) => { if (!identityClient) { throw new Error('Identity service client is not initialized'); } return (async () => { let result; try { result = await identityClient.logInPasswordUser(username, password); } catch (e) { const { registerReservedPasswordUser } = identityClient; if ( !registerReservedPasswordUser || getMessageForException(e) !== 'need_keyserver_message_to_claim_username' ) { throw e; } const { message, signature } = await callClaimUsername({ username, password, }); result = await registerReservedPasswordUser( username, password, message, signature, ); } try { await markPrekeysAsPublished(); } catch (e) { console.log( 'Failed to mark prekeys as published:', getMessageForException(e), ); } return { ...result, preRequestUserState, }; })(); }, [ identityClient, markPrekeysAsPublished, preRequestUserState, callClaimUsername, ], ); } function useIdentityWalletLogIn(): ( walletAddress: string, siweMessage: string, siweSignature: string, ) => Promise { const client = React.useContext(IdentityClientContext); const identityClient = client?.identityClient; invariant(identityClient, 'Identity client should be set'); const { logInWalletUser } = identityClient; const { markPrekeysAsPublished } = getConfig().olmAPI; return React.useCallback( async ( walletAddress: string, siweMessage: string, siweSignature: string, ) => { const response = await logInWalletUser( walletAddress, siweMessage, siweSignature, ); try { await markPrekeysAsPublished(); } catch (e) { console.log( 'Failed to mark prekeys as published:', getMessageForException(e), ); } return response; }, [logInWalletUser, markPrekeysAsPublished], ); } function useIdentitySecondaryDeviceLogIn(): ( userID: string, ) => Promise { const client = React.useContext(IdentityClientContext); const identityClient = client?.identityClient; invariant(identityClient, 'Identity client should be set'); const { generateNonce, uploadKeysForRegisteredDeviceAndLogIn } = identityClient; const { signMessage, markPrekeysAsPublished } = getConfig().olmAPI; return React.useCallback( async (userID: string) => { const nonce = await generateNonce(); const nonceSignature = await signMessage(nonce); const response = await uploadKeysForRegisteredDeviceAndLogIn(userID, { nonce, nonceSignature, }); try { await markPrekeysAsPublished(); } catch (e) { console.log( 'Failed to mark prekeys as published:', getMessageForException(e), ); } return response; }, [ generateNonce, markPrekeysAsPublished, signMessage, uploadKeysForRegisteredDeviceAndLogIn, ], ); } const legacyLogInActionTypes = Object.freeze({ started: 'LEGACY_LOG_IN_STARTED', success: 'LEGACY_LOG_IN_SUCCESS', failed: 'LEGACY_LOG_IN_FAILED', }); const legacyLogInCallSingleKeyserverEndpointOptions = { timeout: permissionsAndAuthRelatedRequestTimeout, }; const legacyLogIn = ( callKeyserverEndpoint: CallKeyserverEndpoint, ): ((input: LegacyLogInInfo) => Promise) => async logInInfo => { const watchedIDs = threadWatcher.getWatchedIDs(); const { authActionSource, calendarQuery, keyserverIDs: inputKeyserverIDs, preRequestUserInfo, ...restLogInInfo } = logInInfo; // Eventually the list of keyservers will be fetched from the // identity service const keyserverIDs = inputKeyserverIDs ?? [authoritativeKeyserverID()]; const watchedIDsPerKeyserver = sortThreadIDsPerKeyserver(watchedIDs); const calendarQueryPerKeyserver = sortCalendarQueryPerKeyserver( calendarQuery, keyserverIDs, ); const requests: { [string]: LogInRequest } = {}; for (const keyserverID of keyserverIDs) { requests[keyserverID] = { ...restLogInInfo, deviceTokenUpdateRequest: logInInfo.deviceTokenUpdateRequest[keyserverID], source: authActionSource, watchedIDs: watchedIDsPerKeyserver[keyserverID] ?? [], calendarQuery: calendarQueryPerKeyserver[keyserverID], platformDetails: getConfig().platformDetails, }; } const responses: { +[string]: ClientLogInResponse } = await callKeyserverEndpoint( 'log_in', requests, legacyLogInCallSingleKeyserverEndpointOptions, ); const userInfosArrays = []; let threadInfos: RawThreadInfos = {}; const calendarResult: WritableCalendarResult = { calendarQuery: logInInfo.calendarQuery, rawEntryInfos: [], }; const messagesResult: WritableGenericMessagesResult = { messageInfos: [], truncationStatus: {}, watchedIDsAtRequestTime: watchedIDs, currentAsOf: {}, }; let updatesCurrentAsOf: { +[string]: number } = {}; for (const keyserverID in responses) { threadInfos = { ...responses[keyserverID].cookieChange.threadInfos, ...threadInfos, }; if (responses[keyserverID].rawEntryInfos) { calendarResult.rawEntryInfos = calendarResult.rawEntryInfos.concat( responses[keyserverID].rawEntryInfos, ); } messagesResult.messageInfos = messagesResult.messageInfos.concat( responses[keyserverID].rawMessageInfos, ); messagesResult.truncationStatus = { ...messagesResult.truncationStatus, ...responses[keyserverID].truncationStatuses, }; messagesResult.currentAsOf = { ...messagesResult.currentAsOf, [keyserverID]: responses[keyserverID].serverTime, }; updatesCurrentAsOf = { ...updatesCurrentAsOf, [keyserverID]: responses[keyserverID].serverTime, }; userInfosArrays.push(responses[keyserverID].userInfos); userInfosArrays.push(responses[keyserverID].cookieChange.userInfos); } const userInfos = mergeUserInfos(...userInfosArrays); return { threadInfos, currentUserInfo: responses[authoritativeKeyserverID()].currentUserInfo, calendarResult, messagesResult, userInfos, updatesCurrentAsOf, authActionSource: logInInfo.authActionSource, notAcknowledgedPolicies: responses[authoritativeKeyserverID()].notAcknowledgedPolicies, preRequestUserInfo, }; }; function useLegacyLogIn(): ( input: LegacyLogInInfo, ) => Promise { return useKeyserverCall(legacyLogIn); } const changeKeyserverUserPasswordActionTypes = Object.freeze({ started: 'CHANGE_KEYSERVER_USER_PASSWORD_STARTED', success: 'CHANGE_KEYSERVER_USER_PASSWORD_SUCCESS', failed: 'CHANGE_KEYSERVER_USER_PASSWORD_FAILED', }); const changeKeyserverUserPassword = ( callSingleKeyserverEndpoint: CallSingleKeyserverEndpoint, ): ((passwordUpdate: PasswordUpdate) => Promise) => async passwordUpdate => { await callSingleKeyserverEndpoint('update_account', passwordUpdate); }; const changeIdentityUserPasswordActionTypes = Object.freeze({ started: 'CHANGE_IDENTITY_USER_PASSWORD_STARTED', success: 'CHANGE_IDENTITY_USER_PASSWORD_SUCCESS', failed: 'CHANGE_IDENTITY_USER_PASSWORD_FAILED', }); function useChangeIdentityUserPassword(): ( oldPassword: string, newPassword: string, ) => Promise { const client = React.useContext(IdentityClientContext); const identityClient = client?.identityClient; return React.useCallback( (oldPassword, newPassword) => { if (!identityClient) { throw new Error('Identity service client is not initialized'); } return identityClient.changePassword(oldPassword, newPassword); }, [identityClient], ); } const searchUsersActionTypes = Object.freeze({ started: 'SEARCH_USERS_STARTED', success: 'SEARCH_USERS_SUCCESS', failed: 'SEARCH_USERS_FAILED', }); const searchUsers = ( callSingleKeyserverEndpoint: CallSingleKeyserverEndpoint, ): ((usernamePrefix: string) => Promise) => async usernamePrefix => { const response = await callSingleKeyserverEndpoint('search_users', { prefix: usernamePrefix, }); return { userInfos: response.userInfos, }; }; const exactSearchUserActionTypes = Object.freeze({ started: 'EXACT_SEARCH_USER_STARTED', success: 'EXACT_SEARCH_USER_SUCCESS', failed: 'EXACT_SEARCH_USER_FAILED', }); const exactSearchUser = ( callSingleKeyserverEndpoint: CallSingleKeyserverEndpoint, ): ((username: string) => Promise) => async username => { const response = await callSingleKeyserverEndpoint('exact_search_user', { username, }); return { userInfo: response.userInfo, }; }; const updateSubscriptionActionTypes = Object.freeze({ started: 'UPDATE_SUBSCRIPTION_STARTED', success: 'UPDATE_SUBSCRIPTION_SUCCESS', failed: 'UPDATE_SUBSCRIPTION_FAILED', }); const updateSubscription = ( callKeyserverEndpoint: CallKeyserverEndpoint, ): (( input: SubscriptionUpdateRequest, ) => Promise) => async input => { const keyserverID = extractKeyserverIDFromID(input.threadID); const requests = { [keyserverID]: input }; const responses = await callKeyserverEndpoint( 'update_user_subscription', requests, ); const response = responses[keyserverID]; return { threadID: input.threadID, subscription: response.threadSubscription, }; }; function useUpdateSubscription(): ( input: SubscriptionUpdateRequest, ) => Promise { return useKeyserverCall(updateSubscription); } const setUserSettingsActionTypes = Object.freeze({ started: 'SET_USER_SETTINGS_STARTED', success: 'SET_USER_SETTINGS_SUCCESS', failed: 'SET_USER_SETTINGS_FAILED', }); const setUserSettings = ( callKeyserverEndpoint: CallKeyserverEndpoint, allKeyserverIDs: $ReadOnlyArray, ): ((input: UpdateUserSettingsRequest) => Promise) => async input => { const requests: { [string]: UpdateUserSettingsRequest } = {}; for (const keyserverID of allKeyserverIDs) { requests[keyserverID] = input; } await callKeyserverEndpoint('update_user_settings', requests); }; function useSetUserSettings(): ( input: UpdateUserSettingsRequest, ) => Promise { return useKeyserverCall(setUserSettings); } const getOlmSessionInitializationDataActionTypes = Object.freeze({ started: 'GET_OLM_SESSION_INITIALIZATION_DATA_STARTED', success: 'GET_OLM_SESSION_INITIALIZATION_DATA_SUCCESS', failed: 'GET_OLM_SESSION_INITIALIZATION_DATA_FAILED', }); const getOlmSessionInitializationData = ( callSingleKeyserverEndpoint: CallSingleKeyserverEndpoint, ): (( options?: ?CallSingleKeyserverEndpointOptions, ) => Promise) => async options => { const olmInitData = await callSingleKeyserverEndpoint( 'get_olm_session_initialization_data', {}, options, ); return { signedIdentityKeysBlob: olmInitData.signedIdentityKeysBlob, contentInitializationInfo: { ...olmInitData.contentInitializationInfo, oneTimeKey: getOneTimeKeyValuesFromBlob( olmInitData.contentInitializationInfo.oneTimeKey, )[0], prekey: getPrekeyValueFromBlob( olmInitData.contentInitializationInfo.prekey, ), }, notifInitializationInfo: { ...olmInitData.notifInitializationInfo, oneTimeKey: getOneTimeKeyValuesFromBlob( olmInitData.notifInitializationInfo.oneTimeKey, )[0], prekey: getPrekeyValueFromBlob( olmInitData.notifInitializationInfo.prekey, ), }, }; }; const policyAcknowledgmentActionTypes = Object.freeze({ started: 'POLICY_ACKNOWLEDGMENT_STARTED', success: 'POLICY_ACKNOWLEDGMENT_SUCCESS', failed: 'POLICY_ACKNOWLEDGMENT_FAILED', }); const policyAcknowledgment = ( callSingleKeyserverEndpoint: CallSingleKeyserverEndpoint, ): ((policyRequest: PolicyAcknowledgmentRequest) => Promise) => async policyRequest => { await callSingleKeyserverEndpoint('policy_acknowledgment', policyRequest); }; const updateUserAvatarActionTypes = Object.freeze({ started: 'UPDATE_USER_AVATAR_STARTED', success: 'UPDATE_USER_AVATAR_SUCCESS', failed: 'UPDATE_USER_AVATAR_FAILED', }); const updateUserAvatar = ( callSingleKeyserverEndpoint: CallSingleKeyserverEndpoint, ): (( avatarDBContent: UpdateUserAvatarRequest, ) => Promise) => async avatarDBContent => { const { updates }: UpdateUserAvatarResponse = await callSingleKeyserverEndpoint('update_user_avatar', avatarDBContent); return { updates }; }; const processNewUserIDsActionType = 'PROCESS_NEW_USER_IDS'; const findUserIdentitiesActionTypes = Object.freeze({ started: 'FIND_USER_IDENTITIES_STARTED', success: 'FIND_USER_IDENTITIES_SUCCESS', failed: 'FIND_USER_IDENTITIES_FAILED', }); function useFindUserIdentities(): ( userIDs: $ReadOnlyArray, ) => Promise { - const client = React.useContext(IdentityClientContext); - const identityClient = client?.identityClient; - invariant(identityClient, 'Identity client should be set'); - return identityClient.findUserIdentities; + const userIdentityCache = useUserIdentityCache(); + return userIdentityCache.getUserIdentities; } const versionSupportedByIdentityActionTypes = Object.freeze({ started: 'VERSION_SUPPORTED_BY_IDENTITY_STARTED', success: 'VERSION_SUPPORTED_BY_IDENTITY_SUCCESS', failed: 'VERSION_SUPPORTED_BY_IDENTITY_FAILED', }); function useVersionSupportedByIdentity(): () => Promise<{ +supported: boolean, }> { const client = React.useContext(IdentityClientContext); const identityClient = client?.identityClient; invariant(identityClient, 'Identity client should be set'); return async () => { const supported = await identityClient.versionSupported(); return { supported }; }; } export { changeKeyserverUserPasswordActionTypes, changeKeyserverUserPassword, changeIdentityUserPasswordActionTypes, useChangeIdentityUserPassword, claimUsernameActionTypes, useClaimUsername, useDeleteKeyserverAccount, deleteKeyserverAccountActionTypes, getOlmSessionInitializationDataActionTypes, getOlmSessionInitializationData, mergeUserInfos, legacyLogIn as legacyLogInRawAction, identityLogInActionTypes, useIdentityPasswordLogIn, useIdentityWalletLogIn, useIdentitySecondaryDeviceLogIn, useLegacyLogIn, legacyLogInActionTypes, useLogOut, useIdentityLogOut, usePrimaryDeviceLogOut, useSecondaryDeviceLogOut, logOutActionTypes, legacyKeyserverRegister, legacyKeyserverRegisterActionTypes, searchUsers, searchUsersActionTypes, exactSearchUser, exactSearchUserActionTypes, useSetUserSettings, setUserSettingsActionTypes, useUpdateSubscription, updateSubscriptionActionTypes, policyAcknowledgment, policyAcknowledgmentActionTypes, updateUserAvatarActionTypes, updateUserAvatar, deleteAccountActionTypes, useDeleteAccount, useDeleteDiscardedIdentityAccount, keyserverAuthActionTypes, keyserverAuth as keyserverAuthRawAction, identityRegisterActionTypes, useIdentityPasswordRegister, useIdentityWalletRegister, identityGenerateNonceActionTypes, useIdentityGenerateNonce, processNewUserIDsActionType, findUserIdentitiesActionTypes, useFindUserIdentities, versionSupportedByIdentityActionTypes, useVersionSupportedByIdentity, }; diff --git a/lib/components/user-identity-cache.react.js b/lib/components/user-identity-cache.react.js new file mode 100644 index 000000000..84296b3ef --- /dev/null +++ b/lib/components/user-identity-cache.react.js @@ -0,0 +1,247 @@ +// @flow + +import invariant from 'invariant'; +import * as React from 'react'; + +import { IdentityClientContext } from '../shared/identity-client-context.js'; +import type { + UserIdentitiesResponse, + Identity, +} from '../types/identity-service-types.js'; +import sleep from '../utils/sleep.js'; + +const cacheTimeout = 24 * 60 * 60 * 1000; // one day +const failedQueryCacheTimeout = 5 * 60 * 1000; // five minutes +const queryTimeout = 10 * 1000; // ten seconds + +async function throwOnTimeout(identifier: string) { + await sleep(queryTimeout); + throw new Error(`User identity fetch for ${identifier} timed out`); +} + +function getUserIdentitiesResponseFromResults( + userIDs: $ReadOnlyArray, + results: $ReadOnlyArray, +): UserIdentitiesResponse { + const response: { + identities: { [userID: string]: Identity }, + reservedUserIdentifiers: { [userID: string]: string }, + } = { + identities: {}, + reservedUserIdentifiers: {}, + }; + for (let i = 0; i < userIDs.length; i++) { + const userID = userIDs[i]; + const result = results[i]; + if (!result) { + continue; + } else if (result.type === 'registered') { + response.identities[userID] = result.identity; + } else if (result.type === 'reserved') { + response.reservedUserIdentifiers[userID] = result.identifier; + } + } + return response; +} + +type UserIdentityCache = { + +getUserIdentities: ( + userIDs: $ReadOnlyArray, + ) => Promise, + +getCachedUserIdentity: (userID: string) => ?UserIdentityResult, +}; + +type UserIdentityResult = + | { +type: 'registered', +identity: Identity } + | { +type: 'reserved', +identifier: string }; +type UserIdentityCacheEntry = { + +userID: string, + +expirationTime: number, + +result: ?UserIdentityResult | Promise, +}; + +const UserIdentityCacheContext: React.Context = + React.createContext(); + +type Props = { + +children: React.Node, +}; +function UserIdentityCacheProvider(props: Props): React.Node { + const userIdentityCacheRef = React.useRef< + Map, + >(new Map()); + + const getCachedUserIdentityEntry = React.useCallback( + (userID: string): ?UserIdentityCacheEntry => { + const cache = userIdentityCacheRef.current; + + const cacheResult = cache.get(userID); + if (!cacheResult) { + return undefined; + } + + const { expirationTime } = cacheResult; + if (expirationTime <= Date.now()) { + cache.delete(userID); + return undefined; + } + + return cacheResult; + }, + [], + ); + + const getCachedUserIdentity = React.useCallback( + (userID: string): ?UserIdentityResult => { + const cacheResult = getCachedUserIdentityEntry(userID); + if (!cacheResult) { + return undefined; + } + + const { result } = cacheResult; + if (typeof result !== 'object' || result instanceof Promise || !result) { + return undefined; + } + + return result; + }, + [getCachedUserIdentityEntry], + ); + + const client = React.useContext(IdentityClientContext); + const identityClient = client?.identityClient; + invariant(identityClient, 'Identity client should be set'); + const { findUserIdentities } = identityClient; + + const getUserIdentities = React.useCallback( + async ( + userIDs: $ReadOnlyArray, + ): Promise => { + const cacheMatches = userIDs.map(getCachedUserIdentityEntry); + const cacheResultsPromise = Promise.all( + cacheMatches.map(match => + Promise.resolve(match ? match.result : match), + ), + ); + if (cacheMatches.every(Boolean)) { + const results = await cacheResultsPromise; + return getUserIdentitiesResponseFromResults(userIDs, results); + } + + const needFetch = []; + for (let i = 0; i < userIDs.length; i++) { + const userID = userIDs[i]; + const cacheMatch = cacheMatches[i]; + if (!cacheMatch) { + needFetch.push(userID); + } + } + + const fetchUserIdentitiesPromise = (async () => { + let userIdentities: ?UserIdentitiesResponse; + try { + userIdentities = await Promise.race([ + findUserIdentities(needFetch), + throwOnTimeout(`user identities for ${JSON.stringify(needFetch)}`), + ]); + } catch (e) { + console.log(e); + } + + const resultMap = new Map(); + for (let i = 0; i < needFetch.length; i++) { + const userID = needFetch[i]; + if (!userIdentities) { + resultMap.set(userID, null); + continue; + } + const identityMatch = userIdentities.identities[userID]; + if (identityMatch) { + resultMap.set(userID, { + type: 'registered', + identity: identityMatch, + }); + continue; + } + const reservedIdentifierMatch = + userIdentities.reservedUserIdentifiers[userID]; + if (reservedIdentifierMatch) { + resultMap.set(userID, { + type: 'reserved', + identifier: reservedIdentifierMatch, + }); + continue; + } + resultMap.set(userID, null); + } + return resultMap; + })(); + + const cache = userIdentityCacheRef.current; + for (let i = 0; i < needFetch.length; i++) { + const userID = needFetch[i]; + const fetchUserIdentityPromise = (async () => { + const resultMap = await fetchUserIdentitiesPromise; + return resultMap.get(userID) ?? null; + })(); + cache.set(userID, { + userID, + expirationTime: Date.now() + queryTimeout * 2, + result: fetchUserIdentityPromise, + }); + } + + return (async () => { + const [resultMap, cacheResults] = await Promise.all([ + fetchUserIdentitiesPromise, + cacheResultsPromise, + ]); + for (let i = 0; i < needFetch.length; i++) { + const userID = needFetch[i]; + const userIdentity = resultMap.get(userID); + const timeout = + userIdentity === null ? failedQueryCacheTimeout : cacheTimeout; + cache.set(userID, { + userID, + expirationTime: Date.now() + timeout, + result: userIdentity, + }); + } + + const results = []; + for (let i = 0; i < userIDs.length; i++) { + const cachedResult = cacheResults[i]; + if (cachedResult) { + results.push(cachedResult); + } else { + results.push(resultMap.get(userIDs[i])); + } + } + return getUserIdentitiesResponseFromResults(userIDs, results); + })(); + }, + [getCachedUserIdentityEntry, findUserIdentities], + ); + + const value = React.useMemo( + () => ({ + getUserIdentities, + getCachedUserIdentity, + }), + [getUserIdentities, getCachedUserIdentity], + ); + + return ( + + {props.children} + + ); +} + +function useUserIdentityCache(): UserIdentityCache { + const context = React.useContext(UserIdentityCacheContext); + invariant(context, 'UserIdentityCacheContext not found'); + return context; +} + +export { UserIdentityCacheProvider, useUserIdentityCache }; diff --git a/native/root.react.js b/native/root.react.js index 98e34c799..7731fbf8c 100644 --- a/native/root.react.js +++ b/native/root.react.js @@ -1,423 +1,428 @@ // @flow import { ActionSheetProvider } from '@expo/react-native-action-sheet'; import { BottomSheetModalProvider } from '@gorhom/bottom-sheet'; import AsyncStorage from '@react-native-async-storage/async-storage'; import type { PossiblyStaleNavigationState, UnsafeContainerActionEvent, GenericNavigationAction, } from '@react-navigation/core'; import { useReduxDevToolsExtension } from '@react-navigation/devtools'; import { NavigationContainer } from '@react-navigation/native'; import * as SplashScreen from 'expo-splash-screen'; import invariant from 'invariant'; import * as React from 'react'; import { StyleSheet } from 'react-native'; import { GestureHandlerRootView } from 'react-native-gesture-handler'; import Orientation from 'react-native-orientation-locker'; import { SafeAreaProvider, initialWindowMetrics, } from 'react-native-safe-area-context'; import { Provider } from 'react-redux'; import { PersistGate as ReduxPersistGate } from 'redux-persist/es/integration/react.js'; import { ChatMentionContextProvider } from 'lib/components/chat-mention-provider.react.js'; import { EditUserAvatarProvider } from 'lib/components/edit-user-avatar-provider.react.js'; import { ENSCacheProvider } from 'lib/components/ens-cache-provider.react.js'; import { FarcasterDataHandler } from 'lib/components/farcaster-data-handler.react.js'; import IntegrityHandler from 'lib/components/integrity-handler.react.js'; import { MediaCacheProvider } from 'lib/components/media-cache-provider.react.js'; import { NeynarClientProvider } from 'lib/components/neynar-client-provider.react.js'; import PlatformDetailsSynchronizer from 'lib/components/platform-details-synchronizer.react.js'; import PrekeysHandler from 'lib/components/prekeys-handler.react.js'; import { QRAuthProvider } from 'lib/components/qr-auth-provider.react.js'; import { StaffContextProvider } from 'lib/components/staff-provider.react.js'; +import { UserIdentityCacheProvider } from 'lib/components/user-identity-cache.react.js'; import { DBOpsHandler } from 'lib/handlers/db-ops-handler.react.js'; import { TunnelbrokerDeviceTokenHandler } from 'lib/handlers/tunnelbroker-device-token-handler.react.js'; import { UserInfosHandler } from 'lib/handlers/user-infos-handler.react.js'; import { IdentitySearchProvider } from 'lib/identity-search/identity-search-context.js'; import { CallKeyserverEndpointProvider } from 'lib/keyserver-conn/call-keyserver-endpoint-provider.react.js'; import KeyserverConnectionsHandler from 'lib/keyserver-conn/keyserver-connections-handler.js'; import { TunnelbrokerProvider } from 'lib/tunnelbroker/tunnelbroker-context.js'; import { actionLogger } from 'lib/utils/action-logger.js'; import { RegistrationContextProvider } from './account/registration/registration-context-provider.react.js'; import NativeEditThreadAvatarProvider from './avatars/native-edit-thread-avatar-provider.react.js'; import BackupHandler from './backup/backup-handler.js'; import { BottomSheetProvider } from './bottom-sheet/bottom-sheet-provider.react.js'; import ChatContextProvider from './chat/chat-context-provider.react.js'; import MessageEditingContextProvider from './chat/message-editing-context-provider.react.js'; import AccessTokenHandler from './components/access-token-handler.react.js'; import { AutoJoinCommunityHandler } from './components/auto-join-community-handler.react.js'; import BackgroundIdentityLoginHandler from './components/background-identity-login-handler.react.js'; import ConnectFarcasterAlertHandler from './components/connect-farcaster-alert-handler.react.js'; import { FeatureFlagsProvider } from './components/feature-flags-provider.react.js'; import { NUXTipsContextProvider } from './components/nux-tips-context.react.js'; import PersistedStateGate from './components/persisted-state-gate.js'; import ReportHandler from './components/report-handler.react.js'; import VersionSupportedChecker from './components/version-supported.react.js'; import ConnectedStatusBar from './connected-status-bar.react.js'; import { SQLiteDataHandler } from './data/sqlite-data-handler.js'; import ErrorBoundary from './error-boundary.react.js'; import IdentityServiceContextProvider from './identity-service/identity-service-context-provider.react.js'; import InputStateContainer from './input/input-state-container.react.js'; import LifecycleHandler from './lifecycle/lifecycle-handler.react.js'; import MarkdownContextProvider from './markdown/markdown-context-provider.react.js'; import { filesystemMediaCache } from './media/media-cache.js'; import { DeepLinksContextProvider } from './navigation/deep-links-context-provider.react.js'; import { defaultNavigationState } from './navigation/default-state.js'; import { setGlobalNavContext } from './navigation/icky-global.js'; import KeyserverReachabilityHandler from './navigation/keyserver-reachability-handler.js'; import { NavContext, type NavContextType, } from './navigation/navigation-context.js'; import NavigationHandler from './navigation/navigation-handler.react.js'; import { validNavState } from './navigation/navigation-utils.js'; import OrientationHandler from './navigation/orientation-handler.react.js'; import { navStateAsyncStorageKey } from './navigation/persistance.js'; import RootNavigator from './navigation/root-navigator.react.js'; import { composeTunnelbrokerQRAuthMessage, handleSecondaryDeviceLogInError, parseTunnelbrokerQRAuthMessage, performBackupRestore, generateQRAuthAESKey, } from './qr-code/qr-code-utils.js'; import ConnectivityUpdater from './redux/connectivity-updater.react.js'; import { DimensionsUpdater } from './redux/dimensions-updater.react.js'; import { getPersistor } from './redux/persist.js'; import { store } from './redux/redux-setup.js'; import { useSelector } from './redux/redux-utils.js'; import { RootContext } from './root-context.js'; import { MessageSearchProvider } from './search/search-provider.react.js'; import Socket from './socket.react.js'; import { useLoadCommFonts } from './themes/fonts.js'; import { DarkTheme, LightTheme } from './themes/navigation.js'; import ThemeHandler from './themes/theme-handler.react.js'; import { provider } from './utils/ethers-utils.js'; import { neynarKey } from './utils/neynar-utils.js'; // Add custom items to expo-dev-menu import './dev-menu.js'; import './types/message-types-validator.js'; const navInitAction = Object.freeze({ type: 'NAV/@@INIT' }); const navUnknownAction = Object.freeze({ type: 'NAV/@@UNKNOWN' }); SplashScreen.preventAutoHideAsync().catch(console.log); function Root() { const navStateRef = React.useRef(); const navDispatchRef = React.useRef GenericNavigationAction), ) => void>(); const navStateInitializedRef = React.useRef(false); // We call this here to start the loading process // We gate the UI on the fonts loading in AppNavigator useLoadCommFonts(); const [navContext, setNavContext] = React.useState(null); const updateNavContext = React.useCallback(() => { if ( !navStateRef.current || !navDispatchRef.current || !navStateInitializedRef.current ) { return; } const updatedNavContext = { state: navStateRef.current, dispatch: navDispatchRef.current, }; setNavContext(updatedNavContext); setGlobalNavContext(updatedNavContext); }, []); const [initialState, setInitialState] = React.useState( __DEV__ ? undefined : defaultNavigationState, ); React.useEffect(() => { Orientation.lockToPortrait(); void (async () => { let loadedState = initialState; if (__DEV__) { try { const navStateString = await AsyncStorage.getItem( navStateAsyncStorageKey, ); if (navStateString) { const savedState = JSON.parse(navStateString); if (validNavState(savedState)) { loadedState = savedState; } } } catch {} } if (!loadedState) { loadedState = defaultNavigationState; } if (loadedState !== initialState) { setInitialState(loadedState); } navStateRef.current = loadedState; updateNavContext(); actionLogger.addOtherAction('navState', navInitAction, null, loadedState); })(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [updateNavContext]); const setNavStateInitialized = React.useCallback(() => { navStateInitializedRef.current = true; updateNavContext(); }, [updateNavContext]); const [rootContext, setRootContext] = React.useState(() => ({ setNavStateInitialized, })); const detectUnsupervisedBackgroundRef = React.useCallback( (detectUnsupervisedBackground: ?(alreadyClosed: boolean) => boolean) => { setRootContext(prevRootContext => ({ ...prevRootContext, detectUnsupervisedBackground, })); }, [], ); const frozen = useSelector(state => state.frozen); const queuedActionsRef = React.useRef>([]); const onNavigationStateChange = React.useCallback( (state: ?PossiblyStaleNavigationState) => { invariant(state, 'nav state should be non-null'); const prevState = navStateRef.current; navStateRef.current = state; updateNavContext(); const queuedActions = queuedActionsRef.current; queuedActionsRef.current = []; if (queuedActions.length === 0) { queuedActions.push(navUnknownAction); } for (const action of queuedActions) { actionLogger.addOtherAction('navState', action, prevState, state); } if (!__DEV__ || frozen) { return; } void (async () => { try { await AsyncStorage.setItem( navStateAsyncStorageKey, JSON.stringify(state), ); } catch (e) { console.log('AsyncStorage threw while trying to persist navState', e); } })(); }, [updateNavContext, frozen], ); const navContainerRef = React.useRef>(); const containerRef = React.useCallback( (navContainer: ?React.ElementRef) => { navContainerRef.current = navContainer; if (navContainer && !navDispatchRef.current) { navDispatchRef.current = navContainer.dispatch; updateNavContext(); } }, [updateNavContext], ); useReduxDevToolsExtension(navContainerRef); const navContainer = navContainerRef.current; React.useEffect(() => { if (!navContainer) { return undefined; } return navContainer.addListener( '__unsafe_action__', (event: { +data: UnsafeContainerActionEvent, ... }) => { const { action, noop } = event.data; const navState = navStateRef.current; if (noop) { actionLogger.addOtherAction('navState', action, navState, navState); return; } queuedActionsRef.current.push({ ...action, type: `NAV/${action.type}`, }); }, ); }, [navContainer]); const activeTheme = useSelector(state => state.globalThemeInfo.activeTheme); const theme = (() => { if (activeTheme === 'light') { return LightTheme; } else if (activeTheme === 'dark') { return DarkTheme; } return undefined; })(); const gated: React.Node = ( <> ); let navigation; if (initialState) { navigation = ( ); } return ( - - - - - - - - - - - - - - - - - - - - - - - {gated} - - - - - - - - - - - {navigation} - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + {gated} + + + + + + + + + + + {navigation} + + + + + + + + + + + + + + + + + + + + ); } const styles = StyleSheet.create({ app: { flex: 1, }, }); function AppRoot(): React.Node { return ( ); } export default AppRoot; diff --git a/web/root.js b/web/root.js index 3e85602f9..f4519bed7 100644 --- a/web/root.js +++ b/web/root.js @@ -1,88 +1,91 @@ // @flow import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import localforage from 'localforage'; import * as React from 'react'; import { Provider } from 'react-redux'; import { Router, Route } from 'react-router'; import { createStore, applyMiddleware, type Store } from 'redux'; import { composeWithDevTools } from 'redux-devtools-extension/logOnlyInProduction.js'; import { persistReducer, persistStore } from 'redux-persist'; import thunk from 'redux-thunk'; import { WagmiProvider } from 'wagmi'; import IntegrityHandler from 'lib/components/integrity-handler.react.js'; import PrekeysHandler from 'lib/components/prekeys-handler.react.js'; import ReportHandler from 'lib/components/report-handler.react.js'; +import { UserIdentityCacheProvider } from 'lib/components/user-identity-cache.react.js'; import { CallKeyserverEndpointProvider } from 'lib/keyserver-conn/call-keyserver-endpoint-provider.react.js'; import KeyserverConnectionsHandler from 'lib/keyserver-conn/keyserver-connections-handler.js'; import { reduxLoggerMiddleware } from 'lib/utils/action-logger.js'; import { getWagmiConfig } from 'lib/utils/wagmi-utils.js'; import App from './app.react.js'; import ErrorBoundary from './error-boundary.react.js'; import IdentityServiceContextProvider from './grpc/identity-service-context-provider.react.js'; import { defaultWebState } from './redux/default-state.js'; import InitialReduxStateGate from './redux/initial-state-gate.js'; import { persistConfig } from './redux/persist.js'; import { type AppState, type Action, reducer } from './redux/redux-setup.js'; import { synchronizeStoreWithOtherTabs, tabSynchronizationMiddleware, } from './redux/tab-synchronization.js'; import history from './router-history.js'; import { SQLiteDataHandler } from './shared-worker/sqlite-data-handler.js'; import { localforageConfig } from './shared-worker/utils/constants.js'; import Socket from './socket.react.js'; localforage.config(localforageConfig); const persistedReducer = persistReducer(persistConfig, reducer); const store: Store = createStore( persistedReducer, defaultWebState, composeWithDevTools({ maxAge: 200, })( applyMiddleware(thunk, reduxLoggerMiddleware, tabSynchronizationMiddleware), ), ); synchronizeStoreWithOtherTabs(store); const persistor = persistStore(store); const queryClient = new QueryClient(); const wagmiConfig = getWagmiConfig([ 'injected', 'rainbow', 'metamask', 'coinbase', 'walletconnect', ]); const RootProvider = (): React.Node => ( - - - - - - - - + + + + + + + + + + ); export default RootProvider;