diff --git a/lib/actions/user-actions.js b/lib/actions/user-actions.js --- a/lib/actions/user-actions.js +++ b/lib/actions/user-actions.js @@ -3,6 +3,7 @@ import invariant from 'invariant'; import * as React from 'react'; +import { useUserIdentityCache } from '../components/user-identity-cache.react.js'; import { useBroadcastDeviceListUpdates, useBroadcastAccountDeletion, @@ -1312,10 +1313,8 @@ 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({ diff --git a/lib/components/user-identity-cache.react.js b/lib/components/user-identity-cache.react.js new file mode 100644 --- /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 --- a/native/root.react.js +++ b/native/root.react.js @@ -34,6 +34,7 @@ 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'; @@ -324,78 +325,82 @@ - - - - - - - - - - - - - - - - - - - - - - - {gated} - - - - - - - - - - - {navigation} - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + {gated} + + + + + + + + + + + {navigation} + + + + + + + + + + + + + + + + + + + + diff --git a/web/root.js b/web/root.js --- a/web/root.js +++ b/web/root.js @@ -14,6 +14,7 @@ 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'; @@ -68,14 +69,16 @@ - - - - - - - - + + + + + + + + + +