diff --git a/keyserver/src/responders/relationship-responders.js b/keyserver/src/responders/relationship-responders.js index a5683c462..718f49cf4 100644 --- a/keyserver/src/responders/relationship-responders.js +++ b/keyserver/src/responders/relationship-responders.js @@ -1,82 +1,100 @@ // @flow import t, { type TInterface, type TUnion } from 'tcomb'; import { type LegacyTraditionalRelationshipRequest, type RelationshipErrors, traditionalRelationshipActionsList, type LegacyRelationshipRequest, legacyFarcasterRelationshipRequestValidator, relationshipActions, type RelationshipRequestUserInfo, + type RelationshipRequestWithRobotext, + type RelationshipRequestWithoutRobotext, type RelationshipRequest, - relationshipActionsList, } from 'lib/types/relationship-types.js'; import { tShape, tUserID } from 'lib/utils/validation-utils.js'; import type { Viewer } from '../session/viewer.js'; import { updateRelationships } from '../updaters/relationship-updaters.js'; const legacyTraditionalRelationshipRequestValidator: TInterface = tShape({ action: t.enums.of( traditionalRelationshipActionsList, 'relationship action', ), userIDs: t.list(tUserID), }); export const legacyUpdateRelationshipInputValidator: TUnion = t.union([ legacyTraditionalRelationshipRequestValidator, legacyFarcasterRelationshipRequestValidator, ]); async function legacyUpdateRelationshipsResponder( viewer: Viewer, legacyRequest: LegacyRelationshipRequest, ): Promise { let requestUserIDs; const viewerID = viewer.userID; if (legacyRequest.action === relationshipActions.FARCASTER_MUTUAL) { requestUserIDs = Object.keys(legacyRequest.userIDsToFID).filter( userID => userID !== viewerID, ); } else { requestUserIDs = legacyRequest.userIDs; } const requestUserInfos: { [userID: string]: RelationshipRequestUserInfo } = {}; for (const userID of requestUserIDs) { requestUserInfos[userID] = { createRobotextInThinThread: true, }; } - const request = { - action: legacyRequest.action, - users: requestUserInfos, - }; + const { action } = legacyRequest; + let request: RelationshipRequest; + if (action === 'farcaster' || action === 'friend') { + request = { action, users: requestUserInfos }; + } else { + request = { action, users: requestUserInfos }; + } return await updateRelationships(viewer, request); } -export const updateRelationshipInputValidator: TInterface = - tShape({ - action: t.enums.of(relationshipActionsList, 'relationship action'), - users: t.dict( - tUserID, - tShape({ - createRobotextInThinThread: t.Boolean, - }), - ), - }); +export const updateRelationshipInputValidator: TUnion = + t.union([ + tShape({ + action: t.enums.of(['farcaster', 'friend'], 'relationship action'), + users: t.dict( + tUserID, + tShape({ + createRobotextInThinThread: t.Boolean, + }), + ), + }), + tShape({ + action: t.enums.of( + ['unfriend', 'block', 'unblock', 'acknowledge'], + 'relationship action', + ), + users: t.dict( + tUserID, + tShape({ + createRobotextInThinThread: t.maybe(t.Boolean), + }), + ), + }), + ]); async function updateRelationshipsResponder( viewer: Viewer, request: RelationshipRequest, ): Promise { return await updateRelationships(viewer, request); } export { legacyUpdateRelationshipsResponder, updateRelationshipsResponder }; diff --git a/lib/hooks/relationship-hooks.js b/lib/hooks/relationship-hooks.js index 03280bede..a22fcc427 100644 --- a/lib/hooks/relationship-hooks.js +++ b/lib/hooks/relationship-hooks.js @@ -1,383 +1,392 @@ // @flow import * as React from 'react'; import uuid from 'uuid'; import { useAllowOlmViaTunnelbrokerForDMs } from './flag-hooks.js'; import { useGetAndUpdateDeviceListsForUsers } from './peer-list-hooks.js'; import { useNewThickThread } from './thread-hooks.js'; import { updateRelationships as serverUpdateRelationships } from '../actions/relationship-actions.js'; import { useLegacyAshoatKeyserverCall } from '../keyserver-conn/legacy-keyserver-call.js'; import { pendingToRealizedThreadIDsSelector } from '../selectors/thread-selectors.js'; import { dmOperationSpecificationTypes } from '../shared/dm-ops/dm-op-utils.js'; import { useProcessAndSendDMOperation } from '../shared/dm-ops/process-dm-ops.js'; import { userSupportsThickThreads, getPendingThreadID, } from '../shared/thread-utils.js'; import type { RelationshipOperation } from '../types/messages/update-relationship.js'; import type { AppState } from '../types/redux-types.js'; import { type RelationshipAction, type RelationshipErrors, type RelationshipRequestUserInfo, + type RelationshipRequest, relationshipActions, userRelationshipStatus, } from '../types/relationship-types.js'; import { threadTypes } from '../types/thread-types-enum.js'; import { useSelector } from '../utils/redux-utils.js'; import sleep from '../utils/sleep.js'; type RobotextPlanForUser = | { +plan: 'send_to_thin_thread' } | { +plan: 'send_to_existing_thick_thread', +thickThreadID: string, } | { +plan: 'send_to_new_thick_thread' }; // We can't call processAndSendDMOperation until device lists are in // AuxUserStore, but this hook needs to support users who haven't been fetched // yet. We implement an effect that watches AuxUserStore after a fetch, so we // know when we're ready to call processAndSendDMOperation. type Step = | { +step: 'ongoing' } | { +step: 'waiting_for_updated_device_lists', +action: RelationshipAction, +userIDs: $ReadOnlyArray, +waitingForUserIDs: $ReadOnlyArray, +resolve: RelationshipErrors => void, +reject: Error => mixed, }; const deviceListTimeout = 10 * 1000; // ten seconds function useUpdateRelationships(): ( action: RelationshipAction, userIDs: $ReadOnlyArray, ) => Promise { const viewerID = useSelector( state => state.currentUserInfo && state.currentUserInfo.id, ); const processAndSendDMOperation = useProcessAndSendDMOperation(); const sendRobotextToThickThread = React.useCallback( async ( userID: string, thickThreadID: string, relationshipOperation: RelationshipOperation, ): Promise => { if (!viewerID) { console.log('skipping sendRobotextToThickThread since logged out'); return; } const op = { type: 'update_relationship', threadID: thickThreadID, creatorID: viewerID, time: Date.now(), operation: relationshipOperation, targetUserID: userID, messageID: uuid.v4(), }; const opSpecification = { type: dmOperationSpecificationTypes.OUTBOUND, op, // We need to use a different mechanism than `all_thread_members` // because when creating a thread, the thread might not yet // be in the store. recipients: { type: 'some_users', userIDs: [viewerID, userID], }, }; await processAndSendDMOperation(opSpecification); }, [viewerID, processAndSendDMOperation], ); const updateRelationships = useLegacyAshoatKeyserverCall( serverUpdateRelationships, ); const auxUserInfos = useSelector(state => state.auxUserStore.auxUserInfos); const pendingToRealizedThreadIDs = useSelector((state: AppState) => pendingToRealizedThreadIDsSelector(state.threadStore.threadInfos), ); const userInfos = useSelector(state => state.userStore.userInfos); const rawThreadInfos = useSelector( (state: AppState) => state.threadStore.threadInfos, ); const createNewThickThread = useNewThickThread(); // This callback contains the core of the logic. We extract it here because // before we run it, we need to make sure auxUserInfos is correctly populated, // and that might require waiting on a Redux action to be reduced const updateRelationshipsAndSendRobotext = React.useCallback( async (action: RelationshipAction, userIDs: $ReadOnlyArray) => { if (!viewerID) { console.log( 'skipping updateRelationshipsAndSendRobotext since logged out', ); return {}; } const planForUsers = new Map(); for (const userID of userIDs) { const supportsThickThreads = userSupportsThickThreads( userID, auxUserInfos, ); if (!supportsThickThreads) { planForUsers.set(userID, { plan: 'send_to_thin_thread' }); continue; } const pendingThreadID = getPendingThreadID( threadTypes.PERSONAL, [userID, viewerID], null, ); const realizedThreadID = pendingToRealizedThreadIDs.get(pendingThreadID); if (!realizedThreadID) { planForUsers.set(userID, { plan: 'send_to_new_thick_thread' }); continue; } const rawThreadInfo = rawThreadInfos[realizedThreadID]; if (!rawThreadInfo) { console.log( `could not find rawThreadInfo for realizedThreadID ` + `${realizedThreadID} found for pendingThreadID ` + pendingThreadID, ); planForUsers.set(userID, { plan: 'send_to_new_thick_thread' }); continue; } if (rawThreadInfo.type === threadTypes.PERSONAL) { planForUsers.set(userID, { plan: 'send_to_existing_thick_thread', thickThreadID: realizedThreadID, }); } else { planForUsers.set(userID, { plan: 'send_to_thin_thread' }); } } const usersForKeyserverCall: { [userID: string]: RelationshipRequestUserInfo, } = {}; for (const [userID, planForUser] of planForUsers) { usersForKeyserverCall[userID] = { createRobotextInThinThread: planForUser.plan === 'send_to_thin_thread', }; } - const keyserverResultPromise = updateRelationships({ - action, - users: usersForKeyserverCall, - }); + + let request: RelationshipRequest; + if (action === 'farcaster' || action === 'friend') { + request = { action, users: usersForKeyserverCall }; + } else { + request = { action, users: usersForKeyserverCall }; + } + const keyserverResultPromise = updateRelationships(request); const thickThreadPromises: Array> = []; for (const [userID, planForUser] of planForUsers) { if (planForUser.plan === 'send_to_thin_thread') { // Keyserver calls handles creating robotext for thin threads continue; } if ( action !== relationshipActions.FRIEND && action !== relationshipActions.FARCASTER_MUTUAL ) { // We only create robotext for FRIEND and FARCASTER_MUTUAL continue; } const relationshipStatus = userInfos[userID]?.relationshipStatus; let relationshipOperation; if (action === relationshipActions.FARCASTER_MUTUAL) { relationshipOperation = 'farcaster_mutual'; } else if ( relationshipStatus === userRelationshipStatus.FRIEND || relationshipStatus === userRelationshipStatus.REQUEST_RECEIVED ) { relationshipOperation = 'request_accepted'; } else { relationshipOperation = 'request_sent'; } if (planForUser.plan === 'send_to_existing_thick_thread') { const { thickThreadID } = planForUser; thickThreadPromises.push( sendRobotextToThickThread( userID, thickThreadID, relationshipOperation, ), ); continue; } const createThickThreadAndSendRobotextPromise = (async () => { const thickThreadID = await createNewThickThread({ type: threadTypes.PERSONAL, initialMemberIDs: [userID], }); return await sendRobotextToThickThread( userID, thickThreadID, relationshipOperation, ); })(); thickThreadPromises.push(createThickThreadAndSendRobotextPromise); } const [keyserverResult] = await Promise.all([ keyserverResultPromise, Promise.all(thickThreadPromises), ]); return keyserverResult; }, [ viewerID, updateRelationships, auxUserInfos, pendingToRealizedThreadIDs, sendRobotextToThickThread, userInfos, rawThreadInfos, createNewThickThread, ], ); const [step, setStep] = React.useState(); // This hook watches AuxUserStore after a fetch to make sure we're ready to // call processAndSendDMOperation. We can't do that from the returned // callback, as it will have an old version of auxUserInfos bound into it. React.useEffect(() => { if (step?.step !== 'waiting_for_updated_device_lists') { return; } const { action, userIDs, waitingForUserIDs, resolve, reject } = step; for (const userID of waitingForUserIDs) { const supportsThickThreads = userSupportsThickThreads( userID, auxUserInfos, ); if (!supportsThickThreads) { // It's safe to wait until every single user ID in waitingForUserIDs // passes this check because we make the same check when populating // waitingForUserIDs in the callback below return; } } setStep({ step: 'ongoing' }); updateRelationshipsAndSendRobotext(action, userIDs).then(resolve, reject); }, [step, auxUserInfos, updateRelationshipsAndSendRobotext]); const usingOlmViaTunnelbrokerForDMs = useAllowOlmViaTunnelbrokerForDMs(); const getAndUpdateDeviceListsForUsers = useGetAndUpdateDeviceListsForUsers(); const coreFunctionality = React.useCallback( async (action: RelationshipAction, userIDs: $ReadOnlyArray) => { // We only need to create robotext for FRIEND and FARCASTER_MUTUAL, // so we skip the complexity below for other RelationshipActions if ( !usingOlmViaTunnelbrokerForDMs || (action !== relationshipActions.FRIEND && action !== relationshipActions.FARCASTER_MUTUAL) ) { - return await updateRelationships({ - action, - users: Object.fromEntries( + let request: RelationshipRequest; + if (action === 'farcaster' || action === 'friend') { + const users = Object.fromEntries( userIDs.map(userID => [ userID, { - // this param only matters for FRIEND and FARCASTER_MUTUAL createRobotextInThinThread: true, }, ]), - ), - }); + ); + request = { action, users }; + } else { + const users = Object.fromEntries(userIDs.map(userID => [userID, {}])); + request = { action, users }; + } + return await updateRelationships(request); } const missingDeviceListsUserIDs: Array = []; for (const userID of userIDs) { const supportsThickThreads = userSupportsThickThreads( userID, auxUserInfos, ); if (!supportsThickThreads) { missingDeviceListsUserIDs.push(userID); } } if (missingDeviceListsUserIDs.length > 0) { const deviceLists = await getAndUpdateDeviceListsForUsers( missingDeviceListsUserIDs, true, ); const waitingForUserIDs: Array = []; for (const userID of missingDeviceListsUserIDs) { if (deviceLists[userID] && deviceLists[userID].devices.length > 0) { waitingForUserIDs.push(userID); } } if (waitingForUserIDs.length > 0) { const nextStepPromise = new Promise( (resolve, reject) => { setStep({ step: 'waiting_for_updated_device_lists', action, userIDs, waitingForUserIDs, resolve, reject, }); }, ); return await Promise.race([ nextStepPromise, (async () => { await sleep(deviceListTimeout); throw new Error(`Fetch device lists timed out`); })(), ]); } } return await updateRelationshipsAndSendRobotext(action, userIDs); }, [ getAndUpdateDeviceListsForUsers, updateRelationshipsAndSendRobotext, updateRelationships, auxUserInfos, usingOlmViaTunnelbrokerForDMs, ], ); return React.useCallback( async (action: RelationshipAction, userIDs: $ReadOnlyArray) => { if (step) { console.log( 'updateRelationships called from same component before last call ' + 'finished. ignoring', ); return {}; } setStep({ step: 'ongoing' }); try { return await coreFunctionality(action, userIDs); } finally { setStep(null); } }, [step, coreFunctionality], ); } export { useUpdateRelationships }; diff --git a/lib/types/relationship-types.js b/lib/types/relationship-types.js index 5df707382..1c1847d6a 100644 --- a/lib/types/relationship-types.js +++ b/lib/types/relationship-types.js @@ -1,128 +1,135 @@ // @flow import type { TInterface, TRefinement } from 'tcomb'; import t from 'tcomb'; import type { AccountUserInfo } from './user-types.js'; import { values } from '../utils/objects.js'; import { tUserID, tNumEnum, tShape, tString, } from '../utils/validation-utils.js'; export const undirectedStatus = Object.freeze({ KNOW_OF: 0, FRIEND: 2, }); export type UndirectedStatus = $Values; export const directedStatus = Object.freeze({ PENDING_FRIEND: 1, BLOCKED: 3, }); export type DirectedStatus = $Values; export const userRelationshipStatus = Object.freeze({ REQUEST_SENT: 1, REQUEST_RECEIVED: 2, FRIEND: 3, BLOCKED_BY_VIEWER: 4, BLOCKED_VIEWER: 5, BOTH_BLOCKED: 6, }); export type UserRelationshipStatus = $Values; export const userRelationshipStatusValidator: TRefinement = tNumEnum( values(userRelationshipStatus), ); const traditionalRelationshipActions = Object.freeze({ FRIEND: 'friend', UNFRIEND: 'unfriend', BLOCK: 'block', UNBLOCK: 'unblock', ACKNOWLEDGE: 'acknowledge', }); const farcasterRelationshipActions = Object.freeze({ FARCASTER_MUTUAL: 'farcaster', }); export const relationshipActions = Object.freeze({ ...traditionalRelationshipActions, ...farcasterRelationshipActions, }); export type RelationshipAction = $Values; export const relationshipActionsList: $ReadOnlyArray = values(relationshipActions); export type TraditionalRelationshipAction = $Values< typeof traditionalRelationshipActions, >; export const traditionalRelationshipActionsList: $ReadOnlyArray = values(traditionalRelationshipActions); export const relationshipButtons = Object.freeze({ FRIEND: 'friend', UNFRIEND: 'unfriend', BLOCK: 'block', UNBLOCK: 'unblock', ACCEPT: 'accept', WITHDRAW: 'withdraw', REJECT: 'reject', }); export type RelationshipButton = $Values; export type LegacyTraditionalRelationshipRequest = { +action: TraditionalRelationshipAction, +userIDs: $ReadOnlyArray, }; export type LegacyFarcasterRelationshipRequest = { +action: 'farcaster', +userIDsToFID: { +[userID: string]: string }, }; export type LegacyRelationshipRequest = | LegacyTraditionalRelationshipRequest | LegacyFarcasterRelationshipRequest; export type RelationshipRequestUserInfo = { +createRobotextInThinThread: boolean, }; -export type RelationshipRequest = { - +action: RelationshipAction, +export type RelationshipRequestWithRobotext = { + +action: 'farcaster' | 'friend', +users: { +[userID: string]: RelationshipRequestUserInfo }, }; +export type RelationshipRequestWithoutRobotext = { + +action: 'unfriend' | 'block' | 'unblock' | 'acknowledge', + +users: { +[userID: string]: $Partial }, +}; +export type RelationshipRequest = + | RelationshipRequestWithRobotext + | RelationshipRequestWithoutRobotext; export const legacyFarcasterRelationshipRequestValidator: TInterface = tShape({ action: tString('farcaster'), userIDsToFID: t.dict(tUserID, t.String), }); type SharedRelationshipRow = { user1: string, user2: string, }; export type DirectedRelationshipRow = { ...SharedRelationshipRow, status: DirectedStatus, }; export type UndirectedRelationshipRow = { ...SharedRelationshipRow, status: UndirectedStatus, }; export type RelationshipErrors = Partial<{ invalid_user: string[], already_friends: string[], user_blocked: string[], }>; export type UserRelationships = { +friends: $ReadOnlyArray, +blocked: $ReadOnlyArray, };