diff --git a/lib/types/farcaster-types.js b/lib/types/farcaster-types.js index e1fd94908..269364941 100644 --- a/lib/types/farcaster-types.js +++ b/lib/types/farcaster-types.js @@ -1,36 +1,37 @@ // @flow // This is a message that the rendered webpage // (landing/connect-farcaster.react.js) uses to communicate back // to the React Native WebView that is rendering it // (native/components/farcaster-web-view.react.js) export type FarcasterWebViewMessage = | { +type: 'farcaster_url', +url: string, } | { +type: 'farcaster_data', +fid: string, }; export type NeynarUser = { +fid: number, +username: string, + +pfp_url: string, ... }; export type NeynarUserWithViewerContext = $ReadOnly<{ ...NeynarUser, +viewerContext: { +following: boolean, }, ... }>; export type NeynarChannel = { +id: string, +name: string, +follower_count: number, ... }; diff --git a/lib/utils/fc-cache.js b/lib/utils/fc-cache.js index 04c860587..491b3e3fd 100644 --- a/lib/utils/fc-cache.js +++ b/lib/utils/fc-cache.js @@ -1,152 +1,155 @@ // @flow import { NeynarClient } from './neynar-client.js'; import sleep from './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(`Farcaster fetch for ${identifier} timed out`); } type FarcasterUsernameQueryCacheEntry = { +fid: string, +expirationTime: number, +farcasterUsername: ?string | Promise, }; class FCCache { client: NeynarClient; // Maps from FIDs to a cache entry for its Farcaster username farcasterUsernameQueryCache: Map = new Map(); constructor(client: NeynarClient) { this.client = client; } getFarcasterUsernamesForFIDs( fids: $ReadOnlyArray, ): Promise> { const cacheMatches = fids.map(fid => this.getCachedFarcasterUsernameEntryForFID(fid), ); const cacheResultsPromise = Promise.all( cacheMatches.map(match => Promise.resolve(match ? match.farcasterUsername : match), ), ); if (cacheMatches.every(Boolean)) { return cacheResultsPromise; } const needFetch = []; for (let i = 0; i < fids.length; i++) { const fid = fids[i]; const cacheMatch = cacheMatches[i]; if (!cacheMatch) { needFetch.push(fid); } } const fetchFarcasterUsernamesPromise = (async () => { let farcasterUsernames: $ReadOnlyArray; try { - farcasterUsernames = await Promise.race([ - this.client.getFarcasterUsernames(needFetch), + const farcasterUsers = await Promise.race([ + this.client.getFarcasterUsers(needFetch), throwOnTimeout(`usernames for ${JSON.stringify(needFetch)}`), ]); + farcasterUsernames = farcasterUsers.map( + farcasterUser => farcasterUser?.username, + ); } catch (e) { console.log(e); farcasterUsernames = new Array(needFetch.length).fill(null); } const resultMap = new Map(); for (let i = 0; i < needFetch.length; i++) { const fid = needFetch[i]; const farcasterUsername = farcasterUsernames[i]; resultMap.set(fid, farcasterUsername); } return resultMap; })(); for (let i = 0; i < needFetch.length; i++) { const fid = needFetch[i]; const fetchFarcasterUsernamePromise = (async () => { const resultMap = await fetchFarcasterUsernamesPromise; return resultMap.get(fid) ?? null; })(); this.farcasterUsernameQueryCache.set(fid, { fid, expirationTime: Date.now() + queryTimeout * 2, farcasterUsername: fetchFarcasterUsernamePromise, }); } return (async () => { const [resultMap, cacheResults] = await Promise.all([ fetchFarcasterUsernamesPromise, cacheResultsPromise, ]); for (let i = 0; i < needFetch.length; i++) { const fid = needFetch[i]; const farcasterUsername = resultMap.get(fid); const timeout = farcasterUsername === null ? failedQueryCacheTimeout : cacheTimeout; this.farcasterUsernameQueryCache.set(fid, { fid, expirationTime: Date.now() + timeout, farcasterUsername, }); } const results = []; for (let i = 0; i < fids.length; i++) { const cachedResult = cacheResults[i]; if (cachedResult) { results.push(cachedResult); } else { results.push(resultMap.get(fids[i])); } } return results; })(); } getCachedFarcasterUsernameEntryForFID( fid: string, ): ?FarcasterUsernameQueryCacheEntry { const cacheResult = this.farcasterUsernameQueryCache.get(fid); if (!cacheResult) { return undefined; } const { expirationTime } = cacheResult; if (expirationTime <= Date.now()) { this.farcasterUsernameQueryCache.delete(fid); return undefined; } return cacheResult; } getCachedFarcasterUsernameForFID(fid: string): ?string { const cacheResult = this.getCachedFarcasterUsernameEntryForFID(fid); if (!cacheResult) { return undefined; } const { farcasterUsername } = cacheResult; if (typeof farcasterUsername !== 'string') { return undefined; } return farcasterUsername; } } export { FCCache }; diff --git a/lib/utils/neynar-client.js b/lib/utils/neynar-client.js index ca2155747..4ff41da0b 100644 --- a/lib/utils/neynar-client.js +++ b/lib/utils/neynar-client.js @@ -1,335 +1,344 @@ // @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, }; +export type FarcasterUser = { + +username: string, + +pfpURL: string, +}; + 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( + async getFarcasterUsers( fids: $ReadOnlyArray, - ): Promise> { + ): Promise> { const fidsLeft = [...fids]; - const results: Array = []; + 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); + results.push( + neynarUser + ? { username: neynarUser.username, pfpURL: neynarUser.pfp_url } + : 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 };