diff --git a/lib/handlers/user-infos-handler.react.js b/lib/handlers/user-infos-handler.react.js index 776880b0d..dcf76c734 100644 --- a/lib/handlers/user-infos-handler.react.js +++ b/lib/handlers/user-infos-handler.react.js @@ -1,95 +1,95 @@ // @flow import * as React from 'react'; import { useFindUserIdentities, findUserIdentitiesActionTypes, } from '../actions/user-actions.js'; import { useGetAndUpdateDeviceListsForUsers } from '../hooks/peer-list-hooks.js'; import { usersWithMissingDeviceListSelector } from '../selectors/user-selectors.js'; import { getMessageForException } 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 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()); React.useEffect(() => { const newUserIDs = Object.keys(userInfosWithMissingUsernames).filter( id => !requestedIDsRef.current.has(id), ); if (!usingCommServicesAccessToken || newUserIDs.length === 0) { return; } const promise = (async () => { newUserIDs.forEach(id => requestedIDsRef.current.add(id)); // 1. Fetch usernames from identity const identities = await findUserIdentities(newUserIDs); // 2. Fetch avatars and settings from auth keyserver if (relyingOnAuthoritativeKeyserver) { // TODO } newUserIDs.forEach(id => requestedIDsRef.current.delete(id)); const newUserInfos = []; for (const id in identities) { newUserInfos.push({ id, username: identities[id].username, }); } return { userInfos: newUserInfos }; })(); void dispatchActionPromise(findUserIdentitiesActionTypes, promise); }, [ dispatchActionPromise, findUserIdentities, userInfos, userInfosWithMissingUsernames, ]); const usersWithMissingDeviceList = useSelector( usersWithMissingDeviceListSelector, ); const getAndUpdateDeviceListsForUsers = useGetAndUpdateDeviceListsForUsers(); React.useEffect(() => { if ( !usingCommServicesAccessToken || usersWithMissingDeviceList.length === 0 ) { return; } void (async () => { try { - await getAndUpdateDeviceListsForUsers(usersWithMissingDeviceList); + await getAndUpdateDeviceListsForUsers(usersWithMissingDeviceList, true); } catch (e) { console.log( `Error getting and setting peer device list: ${ getMessageForException(e) ?? 'unknown' }`, ); } })(); }, [getAndUpdateDeviceListsForUsers, usersWithMissingDeviceList]); } export { UserInfosHandler }; diff --git a/lib/hooks/peer-list-hooks.js b/lib/hooks/peer-list-hooks.js index 178bc2936..e86b60a1f 100644 --- a/lib/hooks/peer-list-hooks.js +++ b/lib/hooks/peer-list-hooks.js @@ -1,141 +1,173 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { setPeerDeviceListsActionType } from '../actions/aux-user-actions.js'; -import { getRelativeUserIDs } from '../selectors/user-selectors.js'; +import { + getAllPeerDevices, + getRelativeUserIDs, +} from '../selectors/user-selectors.js'; import { IdentityClientContext } from '../shared/identity-client-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 { getContentSigningKey } from '../utils/crypto-utils.js'; import { convertSignedDeviceListsToRawDeviceLists } from '../utils/device-list-utils.js'; +import { values } from '../utils/objects.js'; import { useDispatch, useSelector } from '../utils/redux-utils.js'; function useCreateInitialPeerList(): () => Promise { const dispatch = useDispatch(); const relativeUserIDs = useSelector(getRelativeUserIDs); const identityContext = React.useContext(IdentityClientContext); invariant(identityContext, 'Identity context should be set'); return React.useCallback(async () => { if (!identityContext) { return; } try { const peersDeviceLists = await identityContext.identityClient.getDeviceListsForUsers( relativeUserIDs, ); const usersRawDeviceLists = convertSignedDeviceListsToRawDeviceLists( peersDeviceLists.usersSignedDeviceLists, ); const usersPlatformDetails = peersDeviceLists.usersDevicesPlatformDetails; dispatch({ type: setPeerDeviceListsActionType, payload: { deviceLists: usersRawDeviceLists, usersPlatformDetails }, }); } catch (e) { console.log(`Error creating initial peer list: ${e.message}`); } }, [dispatch, identityContext, relativeUserIDs]); } 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], ); } function useGetAndUpdateDeviceListsForUsers(): ( userIDs: $ReadOnlyArray, + broadcastUpdates: ?boolean, ) => Promise { const getDeviceListsForUsers = useGetDeviceListsForUsers(); const dispatch = useDispatch(); + const broadcastDeviceListUpdates = useBroadcastDeviceListUpdates(); + + const allPeerDevices = useSelector(getAllPeerDevices); + return React.useCallback( - async (userIDs: $ReadOnlyArray) => { + async (userIDs: $ReadOnlyArray, broadcastUpdates: ?boolean) => { const { deviceLists, usersPlatformDetails } = await getDeviceListsForUsers(userIDs); if (Object.keys(deviceLists).length === 0) { return; } dispatch({ type: setPeerDeviceListsActionType, payload: { deviceLists, usersPlatformDetails }, }); + + if (!broadcastUpdates) { + return; + } + + const thisDeviceID = await getContentSigningKey(); + + const newDevices = values(deviceLists) + .map((deviceList: RawDeviceList) => deviceList.devices) + .flat() + .filter( + deviceID => + !allPeerDevices.includes(deviceID) && deviceID !== thisDeviceID, + ); + + await broadcastDeviceListUpdates(newDevices); }, - [dispatch, getDeviceListsForUsers], + [ + allPeerDevices, + broadcastDeviceListUpdates, + dispatch, + getDeviceListsForUsers, + ], ); } function useBroadcastDeviceListUpdates(): ( deviceIDs: $ReadOnlyArray, signedDeviceList?: SignedDeviceList, ) => Promise { const { sendMessage } = 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) => sendMessage({ deviceID, payload, }), ); await Promise.all(promises); }, [identityContext, sendMessage], ); } export { useCreateInitialPeerList, useGetDeviceListsForUsers, useBroadcastDeviceListUpdates, useGetAndUpdateDeviceListsForUsers, }; diff --git a/lib/selectors/user-selectors.js b/lib/selectors/user-selectors.js index 97c9b03ff..218b63e7a 100644 --- a/lib/selectors/user-selectors.js +++ b/lib/selectors/user-selectors.js @@ -1,257 +1,269 @@ // @flow import _memoize from 'lodash/memoize.js'; import { createSelector } from 'reselect'; import bots from '../facts/bots.js'; import { getAvatarForUser, getRandomDefaultEmojiAvatar, } from '../shared/avatar-utils.js'; import { getSingleOtherUser } from '../shared/thread-utils.js'; import { type AuxUserInfos, type AuxUserInfo, } from '../types/aux-user-types.js'; import type { ClientEmojiAvatar } from '../types/avatar-types'; import type { RelativeMemberInfo, RawThreadInfo, } from '../types/minimally-encoded-thread-permissions-types.js'; import type { BaseAppState } from '../types/redux-types.js'; import { userRelationshipStatus } from '../types/relationship-types.js'; import { threadTypes } from '../types/thread-types-enum.js'; import type { RawThreadInfos } from '../types/thread-types.js'; import type { UserInfos, RelativeUserInfo, AccountUserInfo, CurrentUserInfo, } from '../types/user-types.js'; -import { entries } from '../utils/objects.js'; +import { entries, values } from '../utils/objects.js'; // Used for specific message payloads that include an array of user IDs, ie. // array of initial users, array of added users function userIDsToRelativeUserInfos( userIDs: $ReadOnlyArray, viewerID: ?string, userInfos: UserInfos, ): RelativeUserInfo[] { const relativeUserInfos: RelativeUserInfo[] = []; for (const userID of userIDs) { const username = userInfos[userID] ? userInfos[userID].username : null; const relativeUserInfo = { id: userID, username, isViewer: userID === viewerID, }; if (userID === viewerID) { relativeUserInfos.unshift(relativeUserInfo); } else { relativeUserInfos.push(relativeUserInfo); } } return relativeUserInfos; } function getRelativeMemberInfos( threadInfo: ?RawThreadInfo, currentUserID: ?string, userInfos: UserInfos, ): $ReadOnlyArray { const relativeMemberInfos: RelativeMemberInfo[] = []; if (!threadInfo) { return relativeMemberInfos; } const memberInfos = threadInfo.members; for (const memberInfo of memberInfos) { if (!memberInfo.role) { continue; } const username = userInfos[memberInfo.id] ? userInfos[memberInfo.id].username : null; if (memberInfo.id === currentUserID) { relativeMemberInfos.unshift({ ...memberInfo, username, isViewer: true, }); } else { relativeMemberInfos.push({ ...memberInfo, username, isViewer: false, }); } } return relativeMemberInfos; } const emptyArray: $ReadOnlyArray = []; // Includes current user at the start const baseRelativeMemberInfoSelectorForMembersOfThread: ( threadID: ?string, ) => (state: BaseAppState<>) => $ReadOnlyArray = ( threadID: ?string, ) => { if (!threadID) { return () => emptyArray; } return createSelector( (state: BaseAppState<>) => state.threadStore.threadInfos[threadID], (state: BaseAppState<>) => state.currentUserInfo && state.currentUserInfo.id, (state: BaseAppState<>) => state.userStore.userInfos, getRelativeMemberInfos, ); }; const relativeMemberInfoSelectorForMembersOfThread: ( threadID: ?string, ) => (state: BaseAppState<>) => $ReadOnlyArray = _memoize( baseRelativeMemberInfoSelectorForMembersOfThread, ); const userInfoSelectorForPotentialMembers: (state: BaseAppState<>) => { [id: string]: AccountUserInfo, } = createSelector( (state: BaseAppState<>) => state.userStore.userInfos, (state: BaseAppState<>) => state.currentUserInfo && state.currentUserInfo.id, ( userInfos: UserInfos, currentUserID: ?string, ): { [id: string]: AccountUserInfo } => { const availableUsers: { [id: string]: AccountUserInfo } = {}; for (const id in userInfos) { const { username, relationshipStatus } = userInfos[id]; if (id === currentUserID || !username) { continue; } if ( relationshipStatus !== userRelationshipStatus.BLOCKED_VIEWER && relationshipStatus !== userRelationshipStatus.BOTH_BLOCKED ) { availableUsers[id] = { id, username, relationshipStatus }; } } return availableUsers; }, ); const isLoggedIn = (state: BaseAppState<>): boolean => !!( state.currentUserInfo && !state.currentUserInfo.anonymous && state.dataLoaded ); const isLoggedInToKeyserver: ( keyserverID: ?string, ) => (state: BaseAppState<>) => boolean = _memoize( (keyserverID: ?string) => (state: BaseAppState<>) => { if (!keyserverID) { return false; } const cookie = state.keyserverStore.keyserverInfos[keyserverID]?.cookie; return !!cookie && cookie.startsWith('user='); }, ); const usersWithPersonalThreadSelector: ( state: BaseAppState<>, ) => $ReadOnlySet = createSelector( (state: BaseAppState<>) => state.currentUserInfo && state.currentUserInfo.id, (state: BaseAppState<>) => state.threadStore.threadInfos, (viewerID: ?string, threadInfos: RawThreadInfos) => { const personalThreadMembers = new Set(); for (const threadID in threadInfos) { const thread = threadInfos[threadID]; if ( thread.type !== threadTypes.PERSONAL || !thread.members.find(member => member.id === viewerID) ) { continue; } const otherMemberID = getSingleOtherUser(thread, viewerID); if (otherMemberID) { personalThreadMembers.add(otherMemberID); } } return personalThreadMembers; }, ); const savedEmojiAvatarSelectorForCurrentUser: ( state: BaseAppState<>, ) => () => ClientEmojiAvatar = createSelector( (state: BaseAppState<>) => state.currentUserInfo && state.currentUserInfo, (currentUser: ?CurrentUserInfo) => { return () => { let userAvatar = getAvatarForUser(currentUser); if (userAvatar.type !== 'emoji') { userAvatar = getRandomDefaultEmojiAvatar(); } return userAvatar; }; }, ); const getRelativeUserIDs: (state: BaseAppState<>) => $ReadOnlyArray = createSelector( (state: BaseAppState<>) => state.userStore.userInfos, (userInfos: UserInfos): $ReadOnlyArray => Object.keys(userInfos), ); const usersWithMissingDeviceListSelector: ( state: BaseAppState<>, ) => $ReadOnlyArray = createSelector( getRelativeUserIDs, (state: BaseAppState<>) => state.auxUserStore.auxUserInfos, ( userIDs: $ReadOnlyArray, auxUserInfos: AuxUserInfos, ): $ReadOnlyArray => userIDs.filter( userID => (!auxUserInfos[userID] || !auxUserInfos[userID].deviceList) && userID !== bots.commbot.userID, ), ); // Foreign Peer Devices are all devices of users we are aware of, // but not our own devices. const getForeignPeerDevices: (state: BaseAppState<>) => $ReadOnlyArray = createSelector( (state: BaseAppState<>) => state.auxUserStore.auxUserInfos, (state: BaseAppState<>) => state.currentUserInfo && state.currentUserInfo.id, ( auxUserInfos: AuxUserInfos, currentUserID: ?string, ): $ReadOnlyArray => entries(auxUserInfos) .map(([userID, auxUserInfo]: [string, AuxUserInfo]) => userID !== currentUserID && auxUserInfo.deviceList?.devices ? auxUserInfo.deviceList.devices : [], ) .flat(), ); +const getAllPeerDevices: (state: BaseAppState<>) => $ReadOnlyArray = + createSelector( + (state: BaseAppState<>) => state.auxUserStore.auxUserInfos, + (auxUserInfos: AuxUserInfos): $ReadOnlyArray => + values(auxUserInfos) + .map( + (auxUserInfo: AuxUserInfo) => auxUserInfo.deviceList?.devices ?? [], + ) + .flat(), + ); + export { userIDsToRelativeUserInfos, getRelativeMemberInfos, relativeMemberInfoSelectorForMembersOfThread, userInfoSelectorForPotentialMembers, isLoggedIn, isLoggedInToKeyserver, usersWithPersonalThreadSelector, savedEmojiAvatarSelectorForCurrentUser, getRelativeUserIDs, usersWithMissingDeviceListSelector, getForeignPeerDevices, + getAllPeerDevices, };