diff --git a/lib/components/neynar-client-provider.react.js b/lib/components/neynar-client-provider.react.js index f5c8114fd..5f7cdb30f 100644 --- a/lib/components/neynar-client-provider.react.js +++ b/lib/components/neynar-client-provider.react.js @@ -1,44 +1,47 @@ // @flow import * as React from 'react'; +import { FCCache } from '../utils/fc-cache.js'; import { NeynarClient } from '../utils/neynar-client.js'; type NeynarClientContextType = { +client: NeynarClient, + +fcCache: FCCache, }; const NeynarClientContext: React.Context = React.createContext(); type Props = { +apiKey: ?string, +children: React.Node, }; function NeynarClientProvider(props: Props): React.Node { const { apiKey, children } = props; const neynarClient = React.useMemo(() => { if (!apiKey) { return null; } return new NeynarClient(apiKey); }, [apiKey]); const context = React.useMemo(() => { if (!neynarClient) { return null; } return { client: neynarClient, + fcCache: new FCCache(neynarClient), }; }, [neynarClient]); return ( {children} ); } export { NeynarClientContext, NeynarClientProvider }; diff --git a/lib/types/farcaster-types.js b/lib/types/farcaster-types.js index 0bdf98f2e..082285eda 100644 --- a/lib/types/farcaster-types.js +++ b/lib/types/farcaster-types.js @@ -1,29 +1,35 @@ // @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 NeynarUserWithViewerContext = { +export type NeynarUser = { +fid: number, + +username: string, + ... +}; + +export type NeynarUserWithViewerContext = $ReadOnly<{ + ...NeynarUser, +viewerContext: { +following: boolean, }, ... -}; +}>; export type NeynarChannel = { +id: string, +name: string, ... }; diff --git a/lib/utils/fc-cache.js b/lib/utils/fc-cache.js new file mode 100644 index 000000000..04c860587 --- /dev/null +++ b/lib/utils/fc-cache.js @@ -0,0 +1,152 @@ +// @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), + throwOnTimeout(`usernames for ${JSON.stringify(needFetch)}`), + ]); + } 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 caa0a65f6..9b6eb5ce6 100644 --- a/lib/utils/neynar-client.js +++ b/lib/utils/neynar-client.js @@ -1,188 +1,234 @@ // @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 FetchFollowedFarcasterChannelsResponse = { +channels: $ReadOnlyArray, +next: { +cursor: ?string, }, }; type FetchFarcasterChannelByNameResponse = { +channels: $ReadOnlyArray, }; +type FetchUsersResponse = { + +users: $ReadOnlyArray, +}; + 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; 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: FetchFollowedFarcasterChannelsResponse = 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; + } } export { NeynarClient };