diff --git a/lib/components/user-identity-cache.react.js b/lib/components/user-identity-cache.react.js index 3a0167d09..547698d9a 100644 --- a/lib/components/user-identity-cache.react.js +++ b/lib/components/user-identity-cache.react.js @@ -1,256 +1,256 @@ // @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 { identityServiceQueryTimeout } from '../utils/identity-service.js'; import sleep from '../utils/sleep.js'; const cacheTimeout = 24 * 60 * 60 * 1000; // one day // If the query fails due to a timeout, we don't cache it // This forces a retry on the next request const failedQueryCacheTimeout = 0; -const queryTimeout = 20 * 1000; // twenty seconds async function throwOnTimeout(identifier: string) { - await sleep(queryTimeout); + await sleep(identityServiceQueryTimeout); 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, +invalidateCacheForUser: (userID: string) => void, }; 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, undefined); 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, + expirationTime: Date.now() + identityServiceQueryTimeout * 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 invalidateCacheForUser = React.useCallback((userID: string) => { const cache = userIdentityCacheRef.current; cache.delete(userID); }, []); const value = React.useMemo( () => ({ getUserIdentities, getCachedUserIdentity, invalidateCacheForUser, }), [getUserIdentities, getCachedUserIdentity, invalidateCacheForUser], ); return ( {props.children} ); } function useUserIdentityCache(): UserIdentityCache { const context = React.useContext(UserIdentityCacheContext); invariant(context, 'UserIdentityCacheContext not found'); return context; } export { UserIdentityCacheProvider, useUserIdentityCache }; diff --git a/lib/handlers/user-infos-handler.react.js b/lib/handlers/user-infos-handler.react.js index 579d55487..57839957a 100644 --- a/lib/handlers/user-infos-handler.react.js +++ b/lib/handlers/user-infos-handler.react.js @@ -1,190 +1,193 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { useFindUserIdentities, findUserIdentitiesActionTypes, } from '../actions/find-user-identities-actions.js'; import { updateRelationshipsActionTypes } from '../actions/relationship-actions.js'; import { useIsLoggedInToAuthoritativeKeyserver } from '../hooks/account-hooks.js'; import { useGetAndUpdateDeviceListsForUsers } from '../hooks/peer-list-hooks.js'; import { useUpdateRelationships } from '../hooks/relationship-hooks.js'; import { usersWithMissingDeviceListSelector } from '../selectors/user-selectors.js'; import { IdentityClientContext } from '../shared/identity-client-context.js'; import { useTunnelbroker } from '../tunnelbroker/tunnelbroker-context.js'; import { relationshipActions } from '../types/relationship-types.js'; import { getMessageForException, FetchTimeout } from '../utils/errors.js'; import { useDispatchActionPromise } from '../utils/redux-promise-utils.js'; import { useSelector } from '../utils/redux-utils.js'; import { relyingOnAuthoritativeKeyserver, usingCommServicesAccessToken, } from '../utils/services-utils.js'; function UserInfosHandler(): React.Node { const client = React.useContext(IdentityClientContext); invariant(client, 'Identity context should be set'); const { getAuthMetadata } = client; const userInfos = useSelector(state => state.userStore.userInfos); const userInfosWithMissingUsernames = React.useMemo(() => { const entriesWithoutUsernames = Object.entries(userInfos).filter( ([, value]) => !value.username, ); return Object.fromEntries(entriesWithoutUsernames); }, [userInfos]); const dispatchActionPromise = useDispatchActionPromise(); const findUserIdentities = useFindUserIdentities(); const requestedIDsRef = React.useRef(new Set()); const requestedAvatarsRef = React.useRef(new Set()); const updateRelationships = useUpdateRelationships(); const currentUserInfo = useSelector(state => state.currentUserInfo); const loggedInToAuthKeyserver = useIsLoggedInToAuthoritativeKeyserver(); React.useEffect(() => { if (!loggedInToAuthKeyserver) { return; } const newUserIDs = Object.keys(userInfosWithMissingUsernames).filter( id => !requestedIDsRef.current.has(id), ); if (!usingCommServicesAccessToken || newUserIDs.length === 0) { return; } void (async () => { const authMetadata = await getAuthMetadata(); if (!authMetadata) { return; } // 1. Fetch usernames from identity const promise = (async () => { newUserIDs.forEach(id => requestedIDsRef.current.add(id)); const { identities, reservedUserIdentifiers } = await findUserIdentities(newUserIDs); newUserIDs.forEach(id => requestedIDsRef.current.delete(id)); const newUserInfos = []; for (const id in identities) { newUserInfos.push({ id, username: identities[id].username, }); } for (const id in reservedUserIdentifiers) { newUserInfos.push({ id, username: reservedUserIdentifiers[id], }); } return { userInfos: newUserInfos }; })(); void dispatchActionPromise(findUserIdentitiesActionTypes, promise); // 2. Fetch avatars from auth keyserver if (relyingOnAuthoritativeKeyserver) { const userIDsWithoutOwnID = newUserIDs.filter( id => id !== currentUserInfo?.id && !requestedAvatarsRef.current.has(id), ); if (userIDsWithoutOwnID.length === 0) { return; } userIDsWithoutOwnID.forEach(id => requestedAvatarsRef.current.add(id)); const updateRelationshipsPromise = (async () => { try { return await updateRelationships( relationshipActions.ACKNOWLEDGE, userIDsWithoutOwnID, ); } catch (e) { if (e instanceof FetchTimeout) { userIDsWithoutOwnID.forEach(id => requestedAvatarsRef.current.delete(id), ); } throw e; } })(); void dispatchActionPromise( updateRelationshipsActionTypes, updateRelationshipsPromise, ); } })(); }, [ getAuthMetadata, updateRelationships, currentUserInfo?.id, dispatchActionPromise, findUserIdentities, userInfos, userInfosWithMissingUsernames, loggedInToAuthKeyserver, ]); const usersWithMissingDeviceListSelected = useSelector( usersWithMissingDeviceListSelector, ); const getAndUpdateDeviceListsForUsers = useGetAndUpdateDeviceListsForUsers(); const { socketState } = useTunnelbroker(); const requestedDeviceListsIDsRef = React.useRef(new Set()); React.useEffect(() => { const usersWithMissingDeviceList = usersWithMissingDeviceListSelected.filter( id => !requestedDeviceListsIDsRef.current.has(id), ); if ( !usingCommServicesAccessToken || usersWithMissingDeviceList.length === 0 || !socketState.isAuthorized ) { return; } void (async () => { const authMetadata = await getAuthMetadata(); if (!authMetadata) { return; } try { usersWithMissingDeviceList.forEach(id => requestedDeviceListsIDsRef.current.add(id), ); const foundDeviceListIDs = await getAndUpdateDeviceListsForUsers( usersWithMissingDeviceList, true, ); - Object.keys(foundDeviceListIDs).forEach(id => + const deviceListIDsToRemove = foundDeviceListIDs + ? Object.keys(foundDeviceListIDs) + : usersWithMissingDeviceList; + deviceListIDsToRemove.forEach(id => requestedDeviceListsIDsRef.current.delete(id), ); } catch (e) { console.log( `Error getting and setting peer device list: ${ getMessageForException(e) ?? 'unknown' }`, ); } })(); }, [ getAndUpdateDeviceListsForUsers, getAuthMetadata, socketState.isAuthorized, usersWithMissingDeviceListSelected, ]); } export { UserInfosHandler }; diff --git a/lib/hooks/peer-list-hooks.js b/lib/hooks/peer-list-hooks.js index 62e2df3b8..7ec748550 100644 --- a/lib/hooks/peer-list-hooks.js +++ b/lib/hooks/peer-list-hooks.js @@ -1,188 +1,208 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { setPeerDeviceListsActionType } from '../actions/aux-user-actions.js'; import { getAllPeerDevices, getAllPeerUserIDAndDeviceIDs, } from '../selectors/user-selectors.js'; import { IdentityClientContext } from '../shared/identity-client-context.js'; import { usePeerToPeerCommunication } from '../tunnelbroker/peer-to-peer-context.js'; import { useTunnelbroker } from '../tunnelbroker/tunnelbroker-context.js'; import type { UsersRawDeviceLists, UsersDevicesPlatformDetails, SignedDeviceList, RawDeviceList, } from '../types/identity-service-types.js'; import { type DeviceListUpdated, peerToPeerMessageTypes, } from '../types/tunnelbroker/peer-to-peer-message-types.js'; import { userActionsP2PMessageTypes, type AccountDeletionP2PMessage, } from '../types/tunnelbroker/user-actions-peer-to-peer-message-types.js'; import { getConfig } from '../utils/config.js'; import { getContentSigningKey } from '../utils/crypto-utils.js'; import { convertSignedDeviceListsToRawDeviceLists } from '../utils/device-list-utils.js'; +import { identityServiceQueryTimeout } from '../utils/identity-service.js'; import { values } from '../utils/objects.js'; import { useDispatch, useSelector } from '../utils/redux-utils.js'; +import sleep from '../utils/sleep.js'; function useGetDeviceListsForUsers(): ( userIDs: $ReadOnlyArray, ) => Promise<{ +deviceLists: UsersRawDeviceLists, +usersPlatformDetails: UsersDevicesPlatformDetails, }> { const client = React.useContext(IdentityClientContext); const identityClient = client?.identityClient; invariant(identityClient, 'Identity client should be set'); return React.useCallback( async (userIDs: $ReadOnlyArray) => { const peersDeviceLists = await identityClient.getDeviceListsForUsers(userIDs); return { deviceLists: convertSignedDeviceListsToRawDeviceLists( peersDeviceLists.usersSignedDeviceLists, ), usersPlatformDetails: peersDeviceLists.usersDevicesPlatformDetails, }; }, [identityClient], ); } +async function throwOnTimeout(userIDs: $ReadOnlyArray) { + await sleep(identityServiceQueryTimeout); + throw new Error(`Device list fetch for ${JSON.stringify(userIDs)} timed out`); +} + function useGetAndUpdateDeviceListsForUsers(): ( userIDs: $ReadOnlyArray, broadcastUpdates: ?boolean, -) => Promise { +) => Promise { const getDeviceListsForUsers = useGetDeviceListsForUsers(); const dispatch = useDispatch(); const broadcastDeviceListUpdates = useBroadcastDeviceListUpdates(); const allPeerDevices = useSelector(getAllPeerDevices); return React.useCallback( async (userIDs: $ReadOnlyArray, broadcastUpdates: ?boolean) => { - const { deviceLists, usersPlatformDetails } = - await getDeviceListsForUsers(userIDs); + let result; + try { + result = await Promise.race([ + getDeviceListsForUsers(userIDs), + throwOnTimeout(userIDs), + ]); + } catch (e) { + console.log(e); + } + + if (!result) { + return null; + } + + const { deviceLists, usersPlatformDetails } = result; if (Object.keys(deviceLists).length === 0) { return {}; } dispatch({ type: setPeerDeviceListsActionType, payload: { deviceLists, usersPlatformDetails }, }); if (!broadcastUpdates) { return deviceLists; } const thisDeviceID = await getContentSigningKey(); const newDevices = values(deviceLists) .map((deviceList: RawDeviceList) => deviceList.devices) .flat() .filter( deviceID => !allPeerDevices.includes(deviceID) && deviceID !== thisDeviceID, ); await broadcastDeviceListUpdates(newDevices); return deviceLists; }, [ allPeerDevices, broadcastDeviceListUpdates, dispatch, getDeviceListsForUsers, ], ); } function useBroadcastDeviceListUpdates(): ( deviceIDs: $ReadOnlyArray, signedDeviceList?: SignedDeviceList, ) => Promise { const { sendMessageToDevice } = useTunnelbroker(); const identityContext = React.useContext(IdentityClientContext); invariant(identityContext, 'identity context not set'); return React.useCallback( async ( deviceIDs: $ReadOnlyArray, signedDeviceList?: SignedDeviceList, ) => { const { getAuthMetadata } = identityContext; const { userID } = await getAuthMetadata(); if (!userID) { throw new Error('missing auth metadata'); } const messageToPeer: DeviceListUpdated = { type: peerToPeerMessageTypes.DEVICE_LIST_UPDATED, userID, signedDeviceList, }; const payload = JSON.stringify(messageToPeer); const promises = deviceIDs.map((deviceID: string) => sendMessageToDevice({ deviceID, payload, }), ); await Promise.all(promises); }, [identityContext, sendMessageToDevice], ); } function useBroadcastAccountDeletion(options: { +includeOwnDevices: boolean, }): () => Promise { const { includeOwnDevices } = options; const identityContext = React.useContext(IdentityClientContext); if (!identityContext) { throw new Error('Identity service client is not initialized'); } const { getAuthMetadata } = identityContext; const peers = useSelector(getAllPeerUserIDAndDeviceIDs); const { broadcastEphemeralMessage } = usePeerToPeerCommunication(); return React.useCallback(async () => { const authMetadata = await getAuthMetadata(); const { userID: thisUserID, deviceID: thisDeviceID } = authMetadata; if (!thisDeviceID || !thisUserID) { throw new Error('No auth metadata'); } // create and send Olm Tunnelbroker messages to peers const { olmAPI } = getConfig(); await olmAPI.initializeCryptoAccount(); const deletionMessage: AccountDeletionP2PMessage = { type: userActionsP2PMessageTypes.ACCOUNT_DELETION, }; const rawPayload = JSON.stringify(deletionMessage); const recipients = peers.filter( peer => peer.deviceID !== thisDeviceID && (includeOwnDevices || peer.userID !== thisUserID), ); await broadcastEphemeralMessage(rawPayload, recipients, authMetadata); }, [broadcastEphemeralMessage, getAuthMetadata, includeOwnDevices, peers]); } export { useGetDeviceListsForUsers, useBroadcastDeviceListUpdates, useGetAndUpdateDeviceListsForUsers, useBroadcastAccountDeletion, }; diff --git a/lib/shared/dm-ops/dm-op-utils.js b/lib/shared/dm-ops/dm-op-utils.js index 319caaeef..d88ef4607 100644 --- a/lib/shared/dm-ops/dm-op-utils.js +++ b/lib/shared/dm-ops/dm-op-utils.js @@ -1,499 +1,503 @@ // @flow import invariant from 'invariant'; import _groupBy from 'lodash/fp/groupBy.js'; import * as React from 'react'; import uuid from 'uuid'; import { type ProcessDMOperationUtilities } from './dm-op-spec.js'; import { dmOpSpecs } from './dm-op-specs.js'; import { useProcessAndSendDMOperation } from './process-dm-ops.js'; import { setMissingDeviceListsActionType, removePeerUsersActionType, } from '../../actions/aux-user-actions.js'; import { useFindUserIdentities } from '../../actions/find-user-identities-actions.js'; import { useLoggedInUserInfo } from '../../hooks/account-hooks.js'; import { useGetLatestMessageEdit } from '../../hooks/latest-message-edit.js'; import { useGetAndUpdateDeviceListsForUsers } from '../../hooks/peer-list-hooks.js'; import { mergeUpdatesWithMessageInfos } from '../../reducers/message-reducer.js'; import { getAllPeerUserIDAndDeviceIDs } from '../../selectors/user-selectors.js'; import { type P2PMessageRecipient } from '../../tunnelbroker/peer-to-peer-context.js'; import type { CreateThickRawThreadInfoInput, DMAddMembersOperation, DMAddViewerToThreadMembersOperation, DMOperation, ComposableDMOperation, } from '../../types/dm-ops.js'; import type { RawMessageInfo } from '../../types/message-types.js'; import type { ThickRawThreadInfo, ThreadInfo, } from '../../types/minimally-encoded-thread-permissions-types.js'; import type { InboundActionMetadata } from '../../types/redux-types.js'; import { outboundP2PMessageStatuses, type OutboundP2PMessage, } from '../../types/sqlite-types.js'; import { assertThickThreadType, thickThreadTypes, } from '../../types/thread-types-enum.js'; import type { RawThreadInfos } from '../../types/thread-types.js'; import { type DMOperationP2PMessage, userActionsP2PMessageTypes, } from '../../types/tunnelbroker/user-actions-peer-to-peer-message-types.js'; import { updateTypes } from '../../types/update-types-enum.js'; import type { AccountDeletionUpdateInfo, ClientUpdateInfo, } from '../../types/update-types.js'; import { getContentSigningKey } from '../../utils/crypto-utils.js'; import { useSelector, useDispatch } from '../../utils/redux-utils.js'; import { messageSpecs } from '../messages/message-specs.js'; import { expectedAccountDeletionUpdateTimeout, userHasDeviceList, deviceListCanBeRequestedForUser, } from '../thread-utils.js'; function generateMessagesToPeers( message: DMOperation, peers: $ReadOnlyArray<{ +userID: string, +deviceID: string, }>, ): $ReadOnlyArray { const opMessage: DMOperationP2PMessage = { type: userActionsP2PMessageTypes.DM_OPERATION, op: message, }; const plaintext = JSON.stringify(opMessage); const outboundP2PMessages = []; for (const peer of peers) { const messageToPeer: OutboundP2PMessage = { messageID: uuid.v4(), deviceID: peer.deviceID, userID: peer.userID, timestamp: new Date().getTime().toString(), plaintext, ciphertext: '', status: outboundP2PMessageStatuses.persisted, supportsAutoRetry: dmOpSpecs[message.type].supportsAutoRetry, }; outboundP2PMessages.push(messageToPeer); } return outboundP2PMessages; } export const dmOperationSpecificationTypes = Object.freeze({ OUTBOUND: 'OutboundDMOperationSpecification', INBOUND: 'InboundDMOperationSpecification', }); type OutboundDMOperationSpecificationRecipients = | { +type: 'all_peer_devices' | 'self_devices' } | { +type: 'some_users', +userIDs: $ReadOnlyArray } | { +type: 'all_thread_members', +threadID: string } | { +type: 'some_devices', +deviceIDs: $ReadOnlyArray }; // The operation generated on the sending client, causes changes to // the state and broadcasting information to peers. export type OutboundDMOperationSpecification = { +type: 'OutboundDMOperationSpecification', +op: DMOperation, +recipients: OutboundDMOperationSpecificationRecipients, +sendOnly?: boolean, }; export type OutboundComposableDMOperationSpecification = { +type: 'OutboundDMOperationSpecification', +op: ComposableDMOperation, +recipients: OutboundDMOperationSpecificationRecipients, // Composable DM Ops are created only to be sent, locally we use // dedicated mechanism for updating the store. +sendOnly: true, +composableMessageID: string, }; // The operation received from other peers, causes changes to // the state and after processing, sends confirmation to the sender. export type InboundDMOperationSpecification = { +type: 'InboundDMOperationSpecification', +op: DMOperation, +metadata: ?InboundActionMetadata, }; export type DMOperationSpecification = | OutboundDMOperationSpecification | InboundDMOperationSpecification; function useCreateMessagesToPeersFromDMOp(): ( operation: DMOperation, recipients: OutboundDMOperationSpecificationRecipients, ) => Promise<$ReadOnlyArray> { const allPeerUserIDAndDeviceIDs = useSelector(getAllPeerUserIDAndDeviceIDs); const utilities = useSendDMOperationUtils(); const auxUserInfos = useSelector(state => state.auxUserStore.auxUserInfos); const getAndUpdateDeviceListsForUsers = useGetAndUpdateDeviceListsForUsers(); const dispatch = useDispatch(); const getUsersWithoutDeviceList = React.useCallback( (userIDs: $ReadOnlyArray) => { const missingDeviceListsUserIDs: Array = []; for (const userID of userIDs) { const supportsThickThreads = userHasDeviceList(userID, auxUserInfos); const deviceListCanBeRequested = deviceListCanBeRequestedForUser( userID, auxUserInfos, ); if (!supportsThickThreads && deviceListCanBeRequested) { missingDeviceListsUserIDs.push(userID); } } return missingDeviceListsUserIDs; }, [auxUserInfos], ); const getMissingPeers = React.useCallback( async ( userIDs: $ReadOnlyArray, ): Promise<$ReadOnlyArray> => { const missingDeviceListsUserIDs = getUsersWithoutDeviceList(userIDs); if (missingDeviceListsUserIDs.length === 0) { return []; } const deviceLists = await getAndUpdateDeviceListsForUsers( missingDeviceListsUserIDs, true, ); + if (!deviceLists) { + return []; + } + const missingUsers: $ReadOnlyArray = missingDeviceListsUserIDs.filter(id => !deviceLists[id]); const time = Date.now(); const shouldDeleteUser = (userID: string) => !!auxUserInfos[userID]?.accountMissingStatus && auxUserInfos[userID].accountMissingStatus.missingSince < time - expectedAccountDeletionUpdateTimeout; const nonDeletedUsers = missingUsers.filter( userID => !shouldDeleteUser(userID), ); if (nonDeletedUsers.length > 0) { dispatch({ type: setMissingDeviceListsActionType, payload: { usersMissingFromIdentity: { userIDs: nonDeletedUsers, time, }, }, }); } const deletedUsers = missingUsers.filter(shouldDeleteUser); if (deletedUsers.length > 0) { const deleteUserUpdates: $ReadOnlyArray = deletedUsers.map(deletedUserID => ({ type: updateTypes.DELETE_ACCOUNT, time, id: uuid.v4(), deletedUserID, })); dispatch({ type: removePeerUsersActionType, payload: { updatesResult: { newUpdates: deleteUserUpdates } }, }); } const updatedPeers: Array = []; for (const userID of missingDeviceListsUserIDs) { if (deviceLists[userID] && deviceLists[userID].devices.length > 0) { updatedPeers.push( ...deviceLists[userID].devices.map(deviceID => ({ deviceID, userID, })), ); } } return updatedPeers; }, [ auxUserInfos, dispatch, getAndUpdateDeviceListsForUsers, getUsersWithoutDeviceList, ], ); return React.useCallback( async ( operation: DMOperation, recipients: OutboundDMOperationSpecificationRecipients, ): Promise<$ReadOnlyArray> => { const { viewerID, threadInfos } = utilities; if (!viewerID) { return []; } let peerUserIDAndDeviceIDs = allPeerUserIDAndDeviceIDs; if (recipients.type === 'self_devices') { peerUserIDAndDeviceIDs = allPeerUserIDAndDeviceIDs.filter( peer => peer.userID === viewerID, ); } else if (recipients.type === 'some_users') { const missingPeers = await getMissingPeers(recipients.userIDs); const updatedPeers = [...allPeerUserIDAndDeviceIDs, ...missingPeers]; const userIDs = new Set(recipients.userIDs); peerUserIDAndDeviceIDs = updatedPeers.filter(peer => userIDs.has(peer.userID), ); } else if (recipients.type === 'all_thread_members') { const { threadID } = recipients; if (!threadInfos[threadID]) { console.log( `all_thread_members called for threadID ${threadID}, which is ` + 'missing from the ThreadStore. if sending a message soon after ' + 'thread creation, consider some_users instead', ); } const members = threadInfos[recipients.threadID]?.members ?? []; const memberIDs = members.map(member => member.id); const missingPeers = await getMissingPeers(memberIDs); const updatedPeers = [...allPeerUserIDAndDeviceIDs, ...missingPeers]; const userIDs = new Set(memberIDs); peerUserIDAndDeviceIDs = updatedPeers.filter(peer => userIDs.has(peer.userID), ); } else if (recipients.type === 'some_devices') { const deviceIDs = new Set(recipients.deviceIDs); peerUserIDAndDeviceIDs = allPeerUserIDAndDeviceIDs.filter(peer => deviceIDs.has(peer.deviceID), ); } const thisDeviceID = await getContentSigningKey(); const targetPeers = peerUserIDAndDeviceIDs.filter( peer => peer.deviceID !== thisDeviceID, ); return generateMessagesToPeers(operation, targetPeers); }, [allPeerUserIDAndDeviceIDs, getMissingPeers, utilities], ); } function getCreateThickRawThreadInfoInputFromThreadInfo( threadInfo: ThickRawThreadInfo, ): CreateThickRawThreadInfoInput { const roleID = Object.keys(threadInfo.roles).pop(); const thickThreadType = assertThickThreadType(threadInfo.type); return { threadID: threadInfo.id, threadType: thickThreadType, creationTime: threadInfo.creationTime, parentThreadID: threadInfo.parentThreadID, allMemberIDsWithSubscriptions: threadInfo.members.map( ({ id, subscription }) => ({ id, subscription, }), ), roleID, unread: !!threadInfo.currentUser.unread, name: threadInfo.name, avatar: threadInfo.avatar, description: threadInfo.description, color: threadInfo.color, containingThreadID: threadInfo.containingThreadID, sourceMessageID: threadInfo.sourceMessageID, repliesCount: threadInfo.repliesCount, pinnedCount: threadInfo.pinnedCount, timestamps: threadInfo.timestamps, }; } function useAddDMThreadMembers(): ( newMemberIDs: $ReadOnlyArray, threadInfo: ThreadInfo, ) => Promise { const viewerID = useSelector( state => state.currentUserInfo && state.currentUserInfo.id, ); const processAndSendDMOperation = useProcessAndSendDMOperation(); const threadInfos = useSelector(state => state.threadStore.threadInfos); return React.useCallback( async (newMemberIDs: $ReadOnlyArray, threadInfo: ThreadInfo) => { const rawThreadInfo = threadInfos[threadInfo.id]; invariant(rawThreadInfo.thick, 'thread should be thick'); const existingThreadDetails = getCreateThickRawThreadInfoInputFromThreadInfo(rawThreadInfo); invariant(viewerID, 'viewerID should be set'); const addViewerToThreadMembersOperation: DMAddViewerToThreadMembersOperation = { type: 'add_viewer_to_thread_members', existingThreadDetails, editorID: viewerID, time: Date.now(), messageID: uuid.v4(), addedUserIDs: newMemberIDs, }; const viewerOperationSpecification: OutboundDMOperationSpecification = { type: dmOperationSpecificationTypes.OUTBOUND, op: addViewerToThreadMembersOperation, recipients: { type: 'some_users', userIDs: newMemberIDs, }, sendOnly: true, }; invariant(viewerID, 'viewerID should be set'); const addMembersOperation: DMAddMembersOperation = { type: 'add_members', threadID: threadInfo.id, editorID: viewerID, time: Date.now(), messageID: uuid.v4(), addedUserIDs: newMemberIDs, }; const newMemberIDsSet = new Set(newMemberIDs); const recipientsThreadID = threadInfo.type === thickThreadTypes.THICK_SIDEBAR && threadInfo.parentThreadID ? threadInfo.parentThreadID : threadInfo.id; const existingMembers = threadInfos[recipientsThreadID]?.members ?.map(member => member.id) ?.filter(memberID => !newMemberIDsSet.has(memberID)) ?? []; const addMembersOperationSpecification: OutboundDMOperationSpecification = { type: dmOperationSpecificationTypes.OUTBOUND, op: addMembersOperation, recipients: { type: 'some_users', userIDs: existingMembers, }, }; await Promise.all([ processAndSendDMOperation(viewerOperationSpecification), processAndSendDMOperation(addMembersOperationSpecification), ]); }, [processAndSendDMOperation, threadInfos, viewerID], ); } function getThreadUpdatesForNewMessages( rawMessageInfos: $ReadOnlyArray, updateInfos: $ReadOnlyArray, threadInfos: RawThreadInfos, viewerID: ?string, ): Array { if (!viewerID) { return []; } const { rawMessageInfos: allNewMessageInfos } = mergeUpdatesWithMessageInfos( rawMessageInfos, updateInfos, ); const messagesByThreadID = _groupBy(message => message.threadID)( allNewMessageInfos, ); const newUpdateInfos: Array = []; for (const threadID in messagesByThreadID) { const repliesCountIncreasingMessages = messagesByThreadID[threadID].filter( message => messageSpecs[message.type].includedInRepliesCount, ); let threadInfo = threadInfos[threadID]; if (repliesCountIncreasingMessages.length > 0) { const repliesCountIncreaseTime = Math.max( repliesCountIncreasingMessages.map(message => message.time), ); const oldRepliesCount = threadInfo?.repliesCount ?? 0; const newThreadInfo = { ...threadInfo, repliesCount: oldRepliesCount + repliesCountIncreasingMessages.length, }; newUpdateInfos.push({ type: updateTypes.UPDATE_THREAD, id: uuid.v4(), time: repliesCountIncreaseTime, threadInfo: newThreadInfo, }); threadInfo = newThreadInfo; } const messagesFromOtherPeers = messagesByThreadID[threadID].filter( message => message.creatorID !== viewerID, ); if (messagesFromOtherPeers.length === 0) { continue; } // We take the most recent timestamp to make sure that // change_thread_read_status operation older // than it won't flip the status to read. const time = Math.max(messagesFromOtherPeers.map(message => message.time)); invariant(threadInfo.thick, 'Thread should be thick'); // We aren't checking if the unread timestamp is lower than the time. // We're doing this because we want to flip the thread to unread after // any new message from a non-viewer. newUpdateInfos.push({ type: updateTypes.UPDATE_THREAD_READ_STATUS, id: uuid.v4(), time, threadID: threadInfo.id, unread: true, }); } return newUpdateInfos; } function useSendDMOperationUtils(): $ReadOnly<{ ...ProcessDMOperationUtilities, viewerID: ?string, }> { const fetchMessage = useGetLatestMessageEdit(); const threadInfos = useSelector(state => state.threadStore.threadInfos); const entryInfos = useSelector(state => state.entryStore.entryInfos); const findUserIdentities = useFindUserIdentities(); const loggedInUserInfo = useLoggedInUserInfo(); const viewerID = loggedInUserInfo?.id; return React.useMemo( () => ({ viewerID, fetchMessage, threadInfos, entryInfos, findUserIdentities, }), [viewerID, fetchMessage, threadInfos, entryInfos, findUserIdentities], ); } export { useCreateMessagesToPeersFromDMOp, useAddDMThreadMembers, getCreateThickRawThreadInfoInputFromThreadInfo, getThreadUpdatesForNewMessages, useSendDMOperationUtils, }; diff --git a/lib/utils/identity-service.js b/lib/utils/identity-service.js index 23840df4e..295918fce 100644 --- a/lib/utils/identity-service.js +++ b/lib/utils/identity-service.js @@ -1,48 +1,50 @@ // @flow import type { TInterface } from 'tcomb'; import t from 'tcomb'; import identityServiceConfig from '../facts/identity-service.js'; import { tShape } from '../utils/validation-utils.js'; export type InboundKeysForDeviceResponse = { identityKeyInfo: { keyPayload: string, keyPayloadSignature: string, }, contentPrekey: { prekey: string, prekeySignature: string, }, notifPrekey: { prekey: string, prekeySignature: string, }, }; export const inboundKeysForDeviceResponseValidator: TInterface = tShape({ identityKeyInfo: tShape({ keyPayload: t.String, keyPayloadSignature: t.String, }), contentPrekey: tShape({ prekey: t.String, prekeySignature: t.String, }), notifPrekey: tShape({ prekey: t.String, prekeySignature: t.String, }), }); function getInboundKeysForDeviceURL(deviceID: string): string { const urlSafeDeviceID = deviceID.replaceAll('+', '-').replaceAll('/', '_'); const endpointBasePath = identityServiceConfig.httpEndpoints.GET_INBOUND_KEYS.path; const path = `${endpointBasePath}${urlSafeDeviceID}`; return `${identityServiceConfig.defaultHttpURL}${path}`; } -export { getInboundKeysForDeviceURL }; +const identityServiceQueryTimeout = 20 * 1000; // twenty seconds + +export { getInboundKeysForDeviceURL, identityServiceQueryTimeout };