diff --git a/lib/components/farcaster-data-handler.react.js b/lib/components/farcaster-data-handler.react.js index e304aa042..1d786e63e 100644 --- a/lib/components/farcaster-data-handler.react.js +++ b/lib/components/farcaster-data-handler.react.js @@ -1,189 +1,207 @@ // @flow import * as React from 'react'; import { setAuxUserFIDsActionType } from '../actions/aux-user-actions.js'; import { updateRelationships as serverUpdateRelationships, updateRelationshipsActionTypes, } from '../actions/relationship-actions.js'; import { setSyncedMetadataEntryActionType } from '../actions/synced-metadata-actions.js'; import { NeynarClientContext } from '../components/neynar-client-provider.react.js'; import { useLegacyAshoatKeyserverCall } from '../keyserver-conn/legacy-keyserver-call.js'; import { isLoggedInToIdentityAndAuthoritativeKeyserver } from '../selectors/user-selectors.js'; import { IdentityClientContext } from '../shared/identity-client-context.js'; import { relationshipActions } from '../types/relationship-types.js'; import { syncedMetadataNames } from '../types/synced-metadata-types.js'; -import { useCurrentUserFID } from '../utils/farcaster-utils.js'; +import { useCurrentUserFID, useUnlinkFID } from '../utils/farcaster-utils.js'; import { useDispatchActionPromise } from '../utils/redux-promise-utils.js'; import { useSelector, useDispatch } from '../utils/redux-utils.js'; import { usingCommServicesAccessToken } from '../utils/services-utils.js'; function FarcasterDataHandler(): React.Node { const isActive = useSelector(state => state.lifecycleState !== 'background'); const currentUserID = useSelector(state => state.currentUserInfo?.id); const loggedIn = useSelector(isLoggedInToIdentityAndAuthoritativeKeyserver); const neynarClient = React.useContext(NeynarClientContext)?.client; const identityServiceClient = React.useContext(IdentityClientContext); const getFarcasterUsers = identityServiceClient?.identityClient.getFarcasterUsers; const findUserIdentities = identityServiceClient?.identityClient.findUserIdentities; const dispatch = useDispatch(); const dispatchActionPromise = useDispatchActionPromise(); const updateRelationships = useLegacyAshoatKeyserverCall( serverUpdateRelationships, ); const createThreadsAndRobotextForFarcasterMutuals = React.useCallback( (userIDsToFID: { +[userID: string]: string }) => updateRelationships({ action: relationshipActions.FARCASTER_MUTUAL, userIDsToFID, }), [updateRelationships], ); const userInfos = useSelector(state => state.userStore.userInfos); const fid = useCurrentUserFID(); + const unlinkFID = useUnlinkFID(); + const prevCanQueryRef = React.useRef(); const handleFarcasterMutuals = React.useCallback(async () => { const canQuery = isActive && !!fid && loggedIn; if (canQuery === prevCanQueryRef.current) { return; } prevCanQueryRef.current = canQuery; if ( !loggedIn || !isActive || !fid || !neynarClient || !getFarcasterUsers || !currentUserID ) { return; } const followerFIDs = await neynarClient.fetchFriendFIDs(fid); const commFCUsers = await getFarcasterUsers(followerFIDs); const newCommUsers = commFCUsers.filter(({ userID }) => !userInfos[userID]); if (newCommUsers.length === 0) { return; } const userIDsToFID: { +[userID: string]: string } = Object.fromEntries( newCommUsers.map(({ userID, farcasterID }) => [userID, farcasterID]), ); const userIDsToFIDIncludingCurrentUser: { +[userID: string]: string } = { ...userIDsToFID, [(currentUserID: string)]: fid, }; void dispatchActionPromise( updateRelationshipsActionTypes, createThreadsAndRobotextForFarcasterMutuals( userIDsToFIDIncludingCurrentUser, ), ); }, [ isActive, fid, loggedIn, neynarClient, getFarcasterUsers, userInfos, dispatchActionPromise, createThreadsAndRobotextForFarcasterMutuals, currentUserID, ]); const handleUserStoreFIDs = React.useCallback(async () => { if (!loggedIn || !isActive || !findUserIdentities) { return; } const userStoreIDs = Object.keys(userInfos); const userIdentities = await findUserIdentities(userStoreIDs); const userStoreFarcasterUsers = Object.entries(userIdentities) .filter(([, identity]) => identity.farcasterID !== null) .map(([userID, identity]) => ({ userID, username: identity.username, farcasterID: identity.farcasterID, })); dispatch({ type: setAuxUserFIDsActionType, payload: { farcasterUsers: userStoreFarcasterUsers, }, }); }, [loggedIn, isActive, findUserIdentities, userInfos, dispatch]); const prevCanQueryHandleCurrentUserFIDRef = React.useRef(); - const canQueryHandleCurrentUserFID = isActive && !fid && loggedIn; + const canQueryHandleCurrentUserFID = isActive && loggedIn; const handleCurrentUserFID = React.useCallback(async () => { if ( canQueryHandleCurrentUserFID === prevCanQueryHandleCurrentUserFIDRef.current ) { return; } prevCanQueryHandleCurrentUserFIDRef.current = canQueryHandleCurrentUserFID; if ( !canQueryHandleCurrentUserFID || !findUserIdentities || - !currentUserID + !currentUserID || + !neynarClient ) { return; } + if (fid) { + const isCurrentUserFIDValid = + await neynarClient.checkIfCurrentUserFIDIsValid(fid); + + if (!isCurrentUserFIDValid) { + await unlinkFID(); + return; + } + + return; + } + const currentUserIdentityObj = await findUserIdentities([currentUserID]); const identityFID = currentUserIdentityObj[currentUserID]?.farcasterID; if (identityFID) { dispatch({ type: setSyncedMetadataEntryActionType, payload: { name: syncedMetadataNames.CURRENT_USER_FID, data: identityFID, }, }); } }, [ canQueryHandleCurrentUserFID, findUserIdentities, currentUserID, + neynarClient, + fid, + unlinkFID, dispatch, ]); React.useEffect(() => { if (!usingCommServicesAccessToken) { return; } void handleFarcasterMutuals(); void handleUserStoreFIDs(); void handleCurrentUserFID(); }, [handleCurrentUserFID, handleFarcasterMutuals, handleUserStoreFIDs]); return null; } export { FarcasterDataHandler }; diff --git a/lib/utils/neynar-client.js b/lib/utils/neynar-client.js index adbd25e9f..ca2155747 100644 --- a/lib/utils/neynar-client.js +++ b/lib/utils/neynar-client.js @@ -1,313 +1,335 @@ // @flow import invariant from 'invariant'; import { getMessageForException } from './errors.js'; import type { NeynarChannel, NeynarUser, NeynarUserWithViewerContext, } from '../types/farcaster-types.js'; type FetchFollowersResponse = { +result: { +users: $ReadOnlyArray, +next: { +cursor: ?string, }, }, }; type FetchFarcasterChannelsResponse = { +channels: $ReadOnlyArray, +next: { +cursor: ?string, }, }; type FetchFarcasterChannelByNameResponse = { +channels: $ReadOnlyArray, }; type FetchUsersResponse = { +users: $ReadOnlyArray, }; type FetchFarcasterChannelInfoResponse = { +channel: NeynarChannel, }; const neynarBaseURL = 'https://api.neynar.com/'; const neynarURLs = { '1': `${neynarBaseURL}v1/farcaster/`, '2': `${neynarBaseURL}v2/farcaster/`, }; function getNeynarURL( apiVersion: string, apiCall: string, params: { [string]: string }, ): string { const neynarURL = neynarURLs[apiVersion]; invariant( neynarURL, `could not find Neynar URL for apiVersion ${apiVersion}`, ); return `${neynarURL}${apiCall}?${new URLSearchParams(params).toString()}`; } const fetchFollowerLimit = 150; const fetchFollowedChannelsLimit = 100; const fetchChannelsLimit = 50; class NeynarClient { apiKey: string; constructor(apiKey: string) { this.apiKey = apiKey; } // We're using the term "friend" for a bidirectional follow async fetchFriendFIDs(fid: string): Promise { const fids = []; let paginationCursor = null; do { const params: { [string]: string } = { fid, viewerFid: fid, limit: fetchFollowerLimit.toString(), ...(paginationCursor ? { cursor: paginationCursor } : null), }; const url = getNeynarURL('1', 'followers', params); try { const response = await fetch(url, { method: 'GET', headers: { Accept: 'application/json', api_key: this.apiKey, }, }); const json: FetchFollowersResponse = await response.json(); const { users } = json.result; for (const user of users) { if (user.viewerContext.following) { fids.push(user.fid.toString()); } } paginationCursor = json.result.next.cursor; } catch (error) { console.log( 'Failed to fetch friend FIDs:', getMessageForException(error) ?? 'unknown', ); throw error; } } while (paginationCursor); return fids; } async fetchFollowedFarcasterChannels(fid: string): Promise { const farcasterChannels = []; let paginationCursor = null; do { const params: { [string]: string } = { fid, limit: fetchFollowedChannelsLimit.toString(), ...(paginationCursor ? { cursor: paginationCursor } : null), }; const url = getNeynarURL('2', 'user/channels', params); try { const response = await fetch(url, { method: 'GET', headers: { Accept: 'application/json', api_key: this.apiKey, }, }); const json: FetchFarcasterChannelsResponse = await response.json(); const { channels, next } = json; channels.forEach(channel => { farcasterChannels.push(channel); }); paginationCursor = next.cursor; } catch (error) { console.log( 'Failed to fetch followed Farcaster channels:', getMessageForException(error) ?? 'unknown', ); throw error; } } while (paginationCursor); return farcasterChannels; } async fetchFarcasterChannelByName( channelName: string, ): Promise { const params: { [string]: string } = { q: channelName, }; const url = getNeynarURL('2', 'channel/search', params); try { const response = await fetch(url, { method: 'GET', headers: { Accept: 'application/json', api_key: this.apiKey, }, }); const json: FetchFarcasterChannelByNameResponse = await response.json(); const { channels } = json; for (const channel of channels) { if (channel.name.toLowerCase() === channelName.toLowerCase()) { return channel; } } return null; } catch (error) { console.log( 'Failed to search Farcaster channel by name:', getMessageForException(error) ?? 'unknown', ); throw error; } } async getFarcasterUsernames( fids: $ReadOnlyArray, ): Promise> { const fidsLeft = [...fids]; const results: Array = []; do { // Neynar API allows querying 100 at a time const batch = fidsLeft.splice(0, 100); const url = getNeynarURL('2', 'user/bulk', { fids: batch.join(',') }); try { const response = await fetch(url, { method: 'GET', headers: { Accept: 'application/json', api_key: this.apiKey, }, }); const json: FetchUsersResponse = await response.json(); const { users } = json; const neynarUserMap = new Map(); for (const neynarUser of users) { neynarUserMap.set(neynarUser.fid, neynarUser); } for (const fid of batch) { const neynarUser = neynarUserMap.get(parseInt(fid)); results.push(neynarUser ? neynarUser.username : null); } } catch (error) { console.log( 'Failed to fetch Farcaster usernames:', getMessageForException(error) ?? 'unknown', ); throw error; } } while (fidsLeft.length > 0); return results; } async getAllChannels(): Promise> { const farcasterChannels = []; let paginationCursor = null; do { const params: { [string]: string } = { limit: fetchChannelsLimit.toString(), ...(paginationCursor ? { cursor: paginationCursor } : null), }; const url = getNeynarURL('2', 'channel/list', params); try { const response = await fetch(url, { method: 'GET', headers: { Accept: 'application/json', api_key: this.apiKey, }, }); const json: FetchFarcasterChannelsResponse = await response.json(); const { channels, next } = json; channels.forEach(channel => { farcasterChannels.push(channel); }); paginationCursor = next.cursor; } catch (error) { console.log( 'Failed to fetch all Farcaster channels:', getMessageForException(error) ?? 'unknown', ); throw error; } } while (paginationCursor); return farcasterChannels; } async fetchFarcasterChannelInfo( channelID: string, viewerFID: string, ): Promise { const params: { [string]: string } = { id: channelID, type: 'id', viewer_fid: viewerFID, }; const url = getNeynarURL('2', 'channel', params); try { const response = await fetch(url, { method: 'GET', headers: { Accept: 'application/json', api_key: this.apiKey, }, }); const json: FetchFarcasterChannelInfoResponse = await response.json(); return json.channel; } catch (error) { console.log( 'Failed to fetch Farcaster channel info:', getMessageForException(error) ?? 'unknown', ); throw error; } } + + async checkIfCurrentUserFIDIsValid(fid: string): Promise { + const url = getNeynarURL('2', 'user/bulk', { fids: fid }); + + try { + const response = await fetch(url, { + method: 'GET', + headers: { + Accept: 'application/json', + api_key: this.apiKey, + }, + }); + + return response.ok; + } catch (error) { + console.log( + 'Failed to check if current user FID is valid:', + getMessageForException(error) ?? 'unknown', + ); + throw error; + } + } } export { NeynarClient };